feat: add student master data import

This commit is contained in:
2026-08-17 13:12:03 +02:00
parent 9286bfa3b5
commit 37f4fee574
18 changed files with 2359 additions and 13 deletions
@@ -0,0 +1,149 @@
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Core.Importing;
using LehrerApp.Core.Services;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Students;
public partial class StudentImportDialogViewModel : ObservableObject
{
private readonly StudentImportService _service;
private readonly StudentImportPreview _preview;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _error = "";
public string FileName { get; }
public string FormatName => _preview.FormatName;
public int TotalStudents => _preview.Entries.Count;
public int NewStudents => _preview.Entries.Count(entry =>
entry.AutomaticResolution?.Kind == StudentImportResolutionKind.CreateNew);
public int ExistingStudents => _preview.Entries.Count(entry =>
entry.AutomaticResolution?.Kind == StudentImportResolutionKind.UseExisting);
public int StudentsToSupplement => _preview.Entries.Count(entry => entry.FieldsToSupplement.Count > 0);
public string Summary =>
$"{TotalStudents} erkannt · {NewStudents} neu · {ExistingStudents} eindeutig vorhanden · "
+ $"{StudentsToSupplement} mit Ergänzungen";
public string GroupSummary { get; }
public ObservableCollection<StudentImportMessageItem> Messages { get; } = [];
public ObservableCollection<StudentImportConflictItem> Conflicts { get; } = [];
public bool HasMessages => Messages.Count > 0;
public bool HasConflicts => Conflicts.Count > 0;
public bool IsReadyWithoutConflicts => !HasConflicts && _preview.CanApply;
public bool HasBlockingErrors => !_preview.CanApply;
public bool CanApply => _preview.CanApply && !IsBusy;
public StudentImportApplyResult? Result { get; private set; }
public StudentImportDialogViewModel(
StudentImportService service,
StudentImportPreview preview,
string fileName)
{
_service = service;
_preview = preview;
FileName = fileName;
foreach (var message in preview.Messages)
Messages.Add(new StudentImportMessageItem(message));
foreach (var conflict in preview.Conflicts)
Conflicts.Add(new StudentImportConflictItem(conflict));
var automaticGroups = preview.GroupAssignments.Count(group => group.AutomaticGroupId is not null);
var unresolvedGroups = preview.GroupAssignments.Count(group => group.ConflictId is not null);
var unassignedGroups = preview.GroupAssignments.Count - automaticGroups - unresolvedGroups;
GroupSummary = preview.GroupAssignments.Count == 0
? "Die Datei enthält keine Klassenangabe."
: $"Lerngruppen: {automaticGroups} automatisch · {unresolvedGroups} zu entscheiden"
+ (unassignedGroups > 0 ? $" · {unassignedGroups} nicht zugeordnet" : "");
}
partial void OnIsBusyChanged(bool value) => OnPropertyChanged(nameof(CanApply));
public IReadOnlyList<ImportDecision> BuildDecisions() => Conflicts
.Select(conflict => new ImportDecision(conflict.Id, conflict.SelectedOption.Id))
.ToList()
.AsReadOnly();
public async Task<bool> TryApplyAsync()
{
Error = "";
if (!_preview.CanApply)
{
Error = "Der Import enthält Fehler und kann nicht angewendet werden.";
return false;
}
IsBusy = true;
try
{
var decisions = BuildDecisions();
Result = await Task.Run(async () =>
await _service.ApplyAsync(_preview, decisions).ConfigureAwait(false));
return true;
}
catch (InvalidOperationException ex)
{
Error = ex.Message;
return false;
}
finally
{
IsBusy = false;
}
}
}
public sealed class StudentImportMessageItem
{
public string Message { get; }
public string SourceReference { get; }
public string Display => SourceReference.Length == 0 ? Message : $"{SourceReference}: {Message}";
public string Foreground { get; }
public StudentImportMessageItem(ImportMessage message)
{
Message = message.Message;
SourceReference = message.SourceReference ?? "";
Foreground = message.Severity switch
{
ImportMessageSeverity.Error => "#C62828",
ImportMessageSeverity.Warning => "#B06A00",
_ => "#2563EB",
};
}
}
public partial class StudentImportConflictItem : ObservableObject
{
[ObservableProperty] private StudentImportConflictOptionItem _selectedOption;
public string Id { get; }
public string Title { get; }
public string Description { get; }
public string ImportedValue { get; }
public string ExistingValue { get; }
public string SourceReference { get; }
public IReadOnlyList<StudentImportConflictOptionItem> Options { get; }
public StudentImportConflictItem(ImportConflict conflict)
{
Id = conflict.Id;
Title = conflict.Title;
Description = conflict.Description;
ImportedValue = conflict.ImportedValue;
ExistingValue = conflict.ExistingValue ?? "";
SourceReference = conflict.SourceReference ?? "";
Options = conflict.Options
.Select(option => new StudentImportConflictOptionItem(
option.Id, option.Label, option.Description ?? ""))
.ToList()
.AsReadOnly();
_selectedOption = Options.First(option => option.Id == conflict.DefaultOptionId);
}
}
public sealed record StudentImportConflictOptionItem(
string Id,
string Label,
string Description);
@@ -572,14 +572,16 @@ public class ContactItem
public string Relation { get; }
public string LetterSalutation { get; }
public string? Phone { get; }
public string? MobilePhone { get; }
public string? Email { get; }
public string Address { get; }
public bool HasPhone => !string.IsNullOrEmpty(Phone);
public bool HasPhone => !string.IsNullOrEmpty(Phone) || !string.IsNullOrEmpty(MobilePhone);
public bool HasEmail => !string.IsNullOrEmpty(Email);
public bool HasAddress => !string.IsNullOrEmpty(Address);
public bool IsInvalid => Model.InvalidSince.HasValue;
public bool IsValid => !IsInvalid;
public string PhoneDisplay => Phone ?? "";
public string PhoneDisplay => string.Join(" · ", new[] { MobilePhone, Phone }
.Where(value => !string.IsNullOrWhiteSpace(value)));
public string EmailDisplay => Email ?? "";
public string StatusText => IsInvalid
? $"Ungültig seit {Model.InvalidSince:dd.MM.yyyy} · {InvalidReasonText(Model.InvalidReason)}"
@@ -595,9 +597,10 @@ public class ContactItem
Relation = c.Relation;
LetterSalutation = c.LetterSalutation ?? "";
Phone = c.Phone;
MobilePhone = c.MobilePhone;
Email = c.Email;
Address = FormatAddress(c);
CallCommand = new RelayCommand(() => OpenUri($"tel:{Phone}"), () => HasPhone);
CallCommand = new RelayCommand(() => OpenUri($"tel:{MobilePhone ?? Phone}"), () => HasPhone);
MailCommand = new RelayCommand(() => OpenUri($"mailto:{Email}"), () => HasEmail);
}
@@ -696,6 +699,7 @@ public partial class ContactEntryViewModel : ObservableObject
[ObservableProperty] private string _relation = "";
[ObservableProperty] private string _letterSalutation = "";
[ObservableProperty] private string _phone = "";
[ObservableProperty] private string _mobilePhone = "";
[ObservableProperty] private string _email = "";
[ObservableProperty] private string _street = "";
[ObservableProperty] private string _postalCode = "";
@@ -711,6 +715,7 @@ public partial class ContactEntryViewModel : ObservableObject
Relation = Relation.Trim(),
LetterSalutation = string.IsNullOrWhiteSpace(LetterSalutation) ? null : LetterSalutation.Trim(),
Phone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.Trim(),
MobilePhone = string.IsNullOrWhiteSpace(MobilePhone) ? null : MobilePhone.Trim(),
Email = string.IsNullOrWhiteSpace(Email) ? null : Email.Trim(),
Street = string.IsNullOrWhiteSpace(Street) ? null : Street.Trim(),
PostalCode = string.IsNullOrWhiteSpace(PostalCode) ? null : PostalCode.Trim(),
@@ -726,6 +731,7 @@ public partial class ContactEditDialogViewModel : ObservableObject
[ObservableProperty] private string _relation = "Elternteil";
[ObservableProperty] private string _letterSalutation = "";
[ObservableProperty] private string _phone = "";
[ObservableProperty] private string _mobilePhone = "";
[ObservableProperty] private string _email = "";
[ObservableProperty] private string _street = "";
[ObservableProperty] private string _postalCode = "";
@@ -753,6 +759,7 @@ public partial class ContactEditDialogViewModel : ObservableObject
Relation = source.Relation;
LetterSalutation = source.LetterSalutation ?? "";
Phone = source.Phone ?? "";
MobilePhone = source.MobilePhone ?? "";
Email = source.Email ?? "";
Street = source.Street ?? "";
PostalCode = source.PostalCode ?? "";
@@ -804,6 +811,7 @@ public partial class ContactEditDialogViewModel : ObservableObject
Relation = Relation.Trim(),
LetterSalutation = NullIfEmpty(LetterSalutation),
Phone = NullIfEmpty(Phone),
MobilePhone = NullIfEmpty(MobilePhone),
Email = NullIfEmpty(Email),
Street = NullIfEmpty(Street),
PostalCode = NullIfEmpty(PostalCode),