fix: Briefe Kontaktauswahl
CI / build-and-test (push) Waiting to run

This commit is contained in:
2026-09-16 01:49:46 +02:00
parent 03a5e0dd9c
commit ef3e9dbbb6
9 changed files with 163 additions and 18 deletions
@@ -191,6 +191,49 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
Assert.True(vm.Generate(output));
}
[Fact]
public void AddressPreview_FolgtDemGewaehltenKontaktUndAktualisiertSichBeimWechsel()
{
var student = new Student
{
FirstName = "Lena", LastName = "Beispiel",
Contacts =
[
new Contact { Name = "Frau Beispiel", Relation = "Mutter", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" },
new Contact { Name = "Herr Beispiel", Relation = "Vater", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" },
],
};
var vm = Build(student, StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
Assert.Equal(2, vm.Contacts.Count);
Assert.Contains("Frau Beispiel", vm.AddressPreview);
Assert.Contains("Erste Str. 1", vm.AddressPreview);
vm.SelectedContact = vm.Contacts.Single(c => c.Model.Name == "Herr Beispiel");
Assert.Contains("Herr Beispiel", vm.AddressPreview);
Assert.Contains("Zweite Str. 2", vm.AddressPreview);
Assert.DoesNotContain("Frau Beispiel", vm.AddressPreview);
}
[Fact]
public void SelectContactDialog_StartetBeimAktuellenKontaktUndAktualisiertVorschau()
{
var contacts = new List<LetterContactChoice>
{
new(new Contact { Name = "Frau Beispiel", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" }),
new(new Contact { Name = "Herr Beispiel", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" }),
};
var dialogVm = new SelectContactDialogViewModel(contacts, contacts[1]);
Assert.Same(contacts[1], dialogVm.SelectedContact);
Assert.Contains("Zweite Str. 2", dialogVm.AddressPreview);
dialogVm.SelectedContact = contacts[0];
Assert.Contains("Erste Str. 1", dialogVm.AddressPreview);
}
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
@@ -18,8 +18,7 @@ public static class LetterPlaceholderBuilder
string letterText, string teacherName, DrawingValue? attendanceCalendar = null,
DrawingValue? absenceDays = null)
{
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
var address = FormatAddress(contact);
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
{
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
@@ -36,6 +35,14 @@ public static class LetterPlaceholderBuilder
};
}
/// <summary>Mehrzeilige Anschrift (Name/Straße/PLZ Ort) für Adressvorschau im Dialog und
/// den <c>Contact.Address</c>-Platzhalter - eine Formatierung für beide Verwendungen.</summary>
public static string FormatAddress(Contact? contact)
{
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
return string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
}
public static bool IsEmpty(PlaceholderValue value) => value switch
{
TextValue x => string.IsNullOrWhiteSpace(x.Value),
@@ -37,9 +37,14 @@ public static class StudentAttendanceCalendarDrawingBuilder
AttendanceCalendarSize.Large => 1f,
_ => .85f,
};
var contentWidth = width * scale;
// Die Breite richtet sich nach der DRAWBOX-Deklaration im Layout-Skript (fest, unabhängig
// von der Größenwahl) - nur Schrift/Zellenhöhe skalieren mit "Größe". Würde die Breite mit
// skalieren, würde "Groß" (scale=1) exakt die Skript-Box ausfüllen, "Klein"/"Normal" aber
// nur einen Teil davon - und eine größere Skalierung als 1 liefe über die Box hinaus und
// würde am rechten Rand abgeschnitten (SVG overflow="hidden").
var contentWidth = width;
var cellHeight = 10 * scale;
var monthGapX = 6 * scale;
var monthGapX = 10f;
var first = options.NormalizedStartMonth;
var monthCount = options.NormalizedMonthCount;
var monthWidth = (contentWidth - monthGapX * (monthCount - 1)) / monthCount;
@@ -112,8 +117,10 @@ public static class StudentAbsenceDayListDrawingBuilder
AttendanceCalendarSize.Large => 1f,
_ => .88f,
};
var contentWidth = width * scale;
var rowHeight = 9 * scale;
// Breite bleibt an die DRAWBOX-Deklaration im Layout-Skript gebunden (siehe
// StudentAttendanceCalendarDrawingBuilder) - nur Zeilenhöhe/Schrift skalieren mit "Größe".
var contentWidth = width;
var rowHeight = 12 * scale;
var start = options.NormalizedStartMonth;
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
var rows = absences
@@ -130,17 +137,17 @@ public static class StudentAbsenceDayListDrawingBuilder
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"));
y += 10 * scale + 2 * scale;
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#CBD5E1", .4f, "#F3F4F6"));
commands.Add(new DrawStringEx(2 * scale, y + scale, rowHeight - scale, 31 * scale, "Datum",
commands.Add(new DrawStringEx(2, y + scale, rowHeight - scale, 31, "Datum",
DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
commands.Add(new DrawStringEx(36 * scale, y + scale, rowHeight - scale, 75 * scale, "Umfang",
commands.Add(new DrawStringEx(36, y + scale, rowHeight - scale, 75, "Umfang",
DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
commands.Add(new DrawStringEx(113 * scale, y + scale, rowHeight - scale, 55 * scale, "Status",
commands.Add(new DrawStringEx(113, y + scale, rowHeight - scale, 55, "Status",
DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
y += rowHeight;
if (rows.Count == 0)
{
commands.Add(new DrawStringEx(2 * scale, y + 2 * scale, rowHeight, contentWidth - 4 * scale,
commands.Add(new DrawStringEx(2, y + 2 * scale, rowHeight, contentWidth - 4,
"Keine Fehltage im gewählten Zeitraum", DrawingTextAlignment.AlignLeft, 8 * scale,
Color: "#6B7280", Italic: true));
y += rowHeight + 3 * scale;
@@ -163,12 +170,12 @@ public static class StudentAbsenceDayListDrawingBuilder
: row.FriendlyStatusLabel;
var statusColor = row.IsUnexcused ? "#C62828" : "#2E7D32";
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#E5E7EB", .3f, fill));
commands.Add(new DrawStringEx(2 * scale, y + scale, rowHeight - scale, 31 * scale,
commands.Add(new DrawStringEx(2, y + scale, rowHeight - scale, 31,
row.Date.ToString("dd.MM.yyyy"), DrawingTextAlignment.AlignLeft, 7.5f * scale,
Color: "#374151"));
commands.Add(new DrawStringEx(36 * scale, y + scale, rowHeight - scale, 75 * scale,
commands.Add(new DrawStringEx(36, y + scale, rowHeight - scale, 75,
extent, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151"));
commands.Add(new DrawStringEx(113 * scale, y + scale, rowHeight - scale, 55 * scale,
commands.Add(new DrawStringEx(113, y + scale, rowHeight - scale, 55,
status, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: statusColor,
Bold: row.IsUnexcused));
y += rowHeight;
@@ -44,6 +44,8 @@ public partial class CreateLetterDialogViewModel : ObservableObject
public bool HasCustomPlaceholders => CustomPlaceholders.Count > 0;
public bool HasNoTemplates => Templates.Count == 0;
public bool HasNoContacts => Contacts.Count == 0;
public string AddressPreview => LetterPlaceholderBuilder.FormatAddress(SelectedContact?.Model);
public bool HasAddressPreview => !string.IsNullOrWhiteSpace(AddressPreview);
public bool UsesAttendanceAdvancedContent => UsesAttendanceCalendar || UsesAbsenceDayList;
public string SuggestedFileName => SanitizeFileName(
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf");
@@ -82,6 +84,8 @@ public partial class CreateLetterDialogViewModel : ObservableObject
partial void OnSelectedContactChanged(LetterContactChoice? value)
{
Anrede = value?.Model.LetterSalutation ?? "";
OnPropertyChanged(nameof(AddressPreview));
OnPropertyChanged(nameof(HasAddressPreview));
RefreshValidation();
}
partial void OnAnredeChanged(string value) => RefreshValidation();
@@ -0,0 +1,25 @@
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Students;
public partial class SelectContactDialogViewModel : ObservableObject
{
[ObservableProperty] private LetterContactChoice? _selectedContact;
public IReadOnlyList<LetterContactChoice> Contacts { get; }
public string AddressPreview => LetterPlaceholderBuilder.FormatAddress(SelectedContact?.Model);
public bool HasAddressPreview => !string.IsNullOrWhiteSpace(AddressPreview);
public SelectContactDialogViewModel(IReadOnlyList<LetterContactChoice> contacts, LetterContactChoice? current)
{
Contacts = contacts;
SelectedContact = current ?? contacts.FirstOrDefault();
}
partial void OnSelectedContactChanged(LetterContactChoice? value)
{
OnPropertyChanged(nameof(AddressPreview));
OnPropertyChanged(nameof(HasAddressPreview));
}
}
@@ -26,11 +26,17 @@
Classes="emptyhint" IsVisible="{Binding HasNoTemplates}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Kontakt *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Contacts}" SelectedItem="{Binding SelectedContact}"
DisplayMemberBinding="{Binding Display}" HorizontalAlignment="Stretch"/>
<TextBlock Text="Kein aktueller Kontakt vorhanden." Classes="emptyhint"
IsVisible="{Binding HasNoContacts}"/>
<TextBlock Text="Anschrift *" FontSize="12" Opacity="0.7"/>
<Border BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" CornerRadius="6" Padding="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding AddressPreview}" TextWrapping="Wrap"
VerticalAlignment="Center" IsVisible="{Binding HasAddressPreview}"/>
<TextBlock Grid.Column="0" Text="Kein aktueller Kontakt vorhanden." Classes="emptyhint"
VerticalAlignment="Center" IsVisible="{Binding HasNoContacts}"/>
<Button Grid.Column="1" Content="Kontakt wechseln …" Click="OnSelectContact"
IsEnabled="{Binding !HasNoContacts}" VerticalAlignment="Center"/>
</Grid>
</Border>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Anrede *" FontSize="12" Opacity="0.7"/>
@@ -30,6 +30,14 @@ public partial class CreateLetterDialog : Window
if (DataContext is CreateLetterDialogViewModel vm) await ConfigureAttendanceCalendar(vm);
}
private async void OnSelectContact(object? sender, RoutedEventArgs e)
{
if (DataContext is not CreateLetterDialogViewModel vm) return;
var dialogVm = new SelectContactDialogViewModel(vm.Contacts, vm.SelectedContact);
var dialog = new SelectContactDialog { DataContext = dialogVm };
if (await dialog.ShowDialog<bool>(this)) vm.SelectedContact = dialogVm.SelectedContact;
}
private async Task<bool> ConfigureAttendanceCalendar(CreateLetterDialogViewModel vm)
{
var configVm = new AttendanceCalendarConfigurationViewModel(vm.GetAttendanceCalendarOptions());
@@ -0,0 +1,33 @@
<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.SelectContactDialog"
x:DataType="vm:SelectContactDialogViewModel"
Title="Kontakt wählen" Width="380" Height="440"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="24">
<TextBlock Grid.Row="0" Text="Kontakt für Anrede und Anschrift" Classes="dialogtitle" Margin="0,0,0,12"/>
<ListBox Grid.Row="1" ItemsSource="{Binding Contacts}" SelectedItem="{Binding SelectedContact}"
BorderThickness="1">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:LetterContactChoice">
<TextBlock Text="{Binding Display}" Padding="4,2"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Border Grid.Row="2" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" CornerRadius="6"
Padding="10" Margin="0,12,0,0" IsVisible="{Binding HasAddressPreview}">
<TextBlock Text="{Binding AddressPreview}" TextWrapping="Wrap" FontSize="12"/>
</Border>
<Grid Grid.Row="3" ColumnDefinitions="*,10,*" Margin="0,16,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Übernehmen" HorizontalAlignment="Stretch" Click="OnApply"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,12 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Students;
public partial class SelectContactDialog : Window
{
public SelectContactDialog() => InitializeComponent();
private void OnApply(object? sender, RoutedEventArgs e) => Close(true);
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}