Files
LehrerApp/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs
T
admin 86e114580f
CI / build-and-test (push) Canceled after 0s
Fix: Sync Log und verwaise Einträge
2026-09-07 17:09:38 +02:00

492 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
namespace LehrerApp.Desktop.ViewModels.Students;
/// Für die Schüler-Auswahl im Dokumentationsdialog, wenn er ohne festen Schüler geöffnet wird
/// (Gruppen-Tab, siehe GroupDocumentationTabViewModel).
public record StudentOption(Guid Id, string Name);
// ── Dokumentation: deutsche Anzeige für Typ/Status (5.1) ─────────────────────
public static class DocumentationTypeDisplay
{
public static string[] Options { get; } =
["Gespräch", "Vorkommnis", "Förderplan", "Fehlzeit", "Elternanruf", "Elternbrief", "Planung / Erinnerung"];
public static string Label(DocumentationType t) => t switch
{
DocumentationType.Conversation => "Gespräch",
DocumentationType.Incident => "Vorkommnis",
DocumentationType.SupportPlan => "Förderplan",
DocumentationType.Absence => "Fehlzeit",
DocumentationType.ParentCall => "Elternanruf",
DocumentationType.ParentLetter => "Elternbrief",
DocumentationType.Planning => "Planung / Erinnerung",
_ => "",
};
public static DocumentationType FromLabel(string label) => label switch
{
"Vorkommnis" => DocumentationType.Incident,
"Förderplan" => DocumentationType.SupportPlan,
"Fehlzeit" => DocumentationType.Absence,
"Elternanruf" => DocumentationType.ParentCall,
"Elternbrief" => DocumentationType.ParentLetter,
"Planung / Erinnerung" => DocumentationType.Planning,
_ => DocumentationType.Conversation,
};
}
// ── Dokumentation: Labels zur Nachverfolgung ─────────────────────────────────
public static class DocumentationTagDisplay
{
// Vorschläge für die AutoCompleteBox im Dialog — eigene Labels sind trotzdem frei möglich.
public static string[] Suggestions { get; } =
[
"Kritisch", "Nacharbeiten", "Mit JGL abklären", "Erkundigung einholen",
"Elterngespräch nötig", "Mit Schulleitung abklären", "Klassenkonferenz",
"Frist beachten", "Beobachten", "Erledigt",
];
public static string ColorHex(string tag) => tag switch
{
"Kritisch" or "Dringend" => "#E53935", // rot Priorität
"Erledigt" => "#43A047", // grün abgeschlossen
"Beobachten" or "Frist beachten" => "#1E88E5", // blau im Blick behalten
"Nacharbeiten" or "Mit JGL abklären" or "Erkundigung einholen"
or "Elterngespräch nötig" or "Mit Schulleitung abklären" or "Klassenkonferenz"
=> "#FB8C00", // orange Handlungsbedarf
_ => "#757575", // grau freies Label
};
}
public static class SupportStatusDisplay
{
public static string[] Options { get; } = ["Aktiv", "Abgeschlossen", "Pausiert"];
public static string Label(SupportStatus s) => s switch
{
SupportStatus.Completed => "Abgeschlossen",
SupportStatus.Paused => "Pausiert",
_ => "Aktiv",
};
public static SupportStatus FromLabel(string label) => label switch
{
"Abgeschlossen" => SupportStatus.Completed,
"Pausiert" => SupportStatus.Paused,
_ => SupportStatus.Active,
};
}
// ── Dialog: Dokumentationseintrag anlegen/bearbeiten (5.1.15.1.3, 5.3.1) ────
public partial class DocumentationDialogViewModel : ObservableObject
{
private readonly IAttachmentStorage _attachmentStorage;
private readonly Documentation? _editing;
private readonly Guid _studentId;
private readonly Guid? _contextGroupId;
private readonly List<string> _newlyUploadedStorageIds = [];
// Nur gesetzt, wenn der Dialog aus einem Gruppen-Kontext (5.1, Dokumentation-Tab der
// Lerngruppe) ohne festen Schüler geöffnet wird — vom Schüler-Tab aus (fester _studentId)
// bleibt die Liste leer und die Auswahl unsichtbar.
public List<StudentOption> StudentOptions { get; }
public bool CanPickStudent => StudentOptions.Count > 0;
[ObservableProperty] private StudentOption? _selectedStudent;
[ObservableProperty] private string _studentError = "";
[ObservableProperty] private string _typeName = DocumentationTypeDisplay.Options[0];
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
[ObservableProperty] private string _title = "";
[ObservableProperty] private string _content = "";
[ObservableProperty] private bool _isConfidential;
[ObservableProperty] private bool _excludeFromWebUntisSync;
[ObservableProperty] private string _newParticipant = "";
public ObservableCollection<string> Participants { get; } = [];
[ObservableProperty] private int _lessonCount = 1;
[ObservableProperty] private bool _absenceExcused;
[ObservableProperty] private string _absenceReason = "";
[ObservableProperty] private string _newMeasure = "";
public ObservableCollection<string> Measures { get; } = [];
[ObservableProperty] private string _reviewDateText = "";
[ObservableProperty] private string _supportStatusName = SupportStatusDisplay.Options[0];
// Elternanruf (nur Planung — Abhaken/Eindrücke laufen über ParentCallSessionDialog)
[ObservableProperty] private string _newCallPoint = "";
public ObservableCollection<string> CallPoints { get; } = [];
// Elternbrief
[ObservableProperty] private string _letterDraftContent = "";
[ObservableProperty] private string _letterSentDateText = "";
[ObservableProperty] private bool _letterResponseReceived;
[ObservableProperty] private string _letterResponseDateText = "";
[ObservableProperty] private string _letterResponseNote = "";
[ObservableProperty] private string _letterSentDateError = "";
[ObservableProperty] private string _letterResponseDateError = "";
// Anhänge (für alle Typen)
public ObservableCollection<AttachmentItem> Attachments { get; } = [];
[ObservableProperty] private string _attachmentError = "";
// Labels zur Nachverfolgung (für alle Typen)
[ObservableProperty] private string _newTag = "";
public ObservableCollection<string> Tags { get; } = [];
public string[] TagSuggestions => DocumentationTagDisplay.Suggestions;
[ObservableProperty] private string _titleError = "";
[ObservableProperty] private string _dateTextError = "";
[ObservableProperty] private string _reviewDateTextError = "";
public bool IsConversation => TypeName == "Gespräch";
public bool IsAbsence => TypeName == "Fehlzeit";
public bool IsSupportPlan => TypeName == "Förderplan";
public bool IsParentCall => TypeName == "Elternanruf";
public bool IsParentLetter => TypeName == "Elternbrief";
public string[] TypeOptions => DocumentationTypeDisplay.Options;
public string[] SupportStatusOptions => SupportStatusDisplay.Options;
public string DialogTitle => _editing is null ? "Dokumentation hinzufügen" : "Dokumentation bearbeiten";
public long MaxAttachmentSizeBytes => IAttachmentStorage.MaxSizeBytes;
public Documentation? Result { get; private set; }
public DocumentationDialogViewModel(Guid studentId, Documentation? editing, IAttachmentStorage attachmentStorage,
List<StudentOption>? studentOptions = null, Guid? contextGroupId = null)
{
_studentId = studentId;
_editing = editing;
_attachmentStorage = attachmentStorage;
_contextGroupId = contextGroupId;
StudentOptions = studentOptions ?? [];
if (CanPickStudent)
SelectedStudent = StudentOptions.FirstOrDefault(s => s.Id == studentId);
if (editing is null) return;
TypeName = DocumentationTypeDisplay.Label(editing.Type);
DateText = editing.Date.ToString("dd.MM.yyyy");
Title = editing.Title;
Content = editing.Content;
IsConfidential = editing.IsConfidential;
ExcludeFromWebUntisSync = editing.ExcludeFromWebUntisSync;
foreach (var p in editing.Participants) Participants.Add(p);
if (editing.AbsenceData is { } a)
{
LessonCount = a.LessonCount;
AbsenceExcused = a.Excused;
AbsenceReason = a.Reason ?? "";
}
if (editing.SupportData is { } s)
{
foreach (var m in s.Measures) Measures.Add(m);
ReviewDateText = s.ReviewDate?.ToString("dd.MM.yyyy") ?? "";
SupportStatusName = SupportStatusDisplay.Label(s.Status);
}
if (editing.ParentCallData is { } pc)
foreach (var point in pc.Points) CallPoints.Add(point.Text);
if (editing.ParentLetterData is { } pl)
{
LetterDraftContent = pl.DraftContent;
LetterSentDateText = pl.SentDate?.ToString("dd.MM.yyyy") ?? "";
LetterResponseReceived = pl.ResponseReceived;
LetterResponseDateText = pl.ResponseDate?.ToString("dd.MM.yyyy") ?? "";
LetterResponseNote = pl.ResponseNote;
}
foreach (var att in editing.Attachments)
Attachments.Add(new AttachmentItem(att.StorageId, att.FileName, att.SizeBytes, att.UploadedAt));
foreach (var tag in editing.Tags) Tags.Add(tag);
}
partial void OnTypeNameChanged(string value)
{
OnPropertyChanged(nameof(IsConversation));
OnPropertyChanged(nameof(IsAbsence));
OnPropertyChanged(nameof(IsSupportPlan));
OnPropertyChanged(nameof(IsParentCall));
OnPropertyChanged(nameof(IsParentLetter));
}
// ── Anhänge ────────────────────────────────────────────────────────────
public void AddAttachment(string fileName, Stream content)
{
AttachmentError = "";
if (content.Length > IAttachmentStorage.MaxSizeBytes)
{
AttachmentError = $"Datei zu groß (max. {IAttachmentStorage.MaxSizeBytes / 1024 / 1024} MB).";
return;
}
var storageId = _attachmentStorage.Upload(fileName, content);
_newlyUploadedStorageIds.Add(storageId);
Attachments.Add(new AttachmentItem(storageId, fileName, content.Length, DateTime.UtcNow));
}
public Stream? OpenAttachment(AttachmentItem item) => _attachmentStorage.OpenRead(item.StorageId);
[RelayCommand]
private void RemoveAttachment(AttachmentItem? item)
{
if (item is null) return;
_attachmentStorage.Delete(item.StorageId);
_newlyUploadedStorageIds.Remove(item.StorageId);
Attachments.Remove(item);
}
/// Vom Code-Behind beim Abbrechen aufgerufen: neu hochgeladene, nie gespeicherte Anhänge
/// wieder entfernen, damit keine verwaisten Blobs in der Datenbank zurückbleiben.
public void DiscardUnsavedAttachments()
{
foreach (var id in _newlyUploadedStorageIds) _attachmentStorage.Delete(id);
}
[RelayCommand]
private void AddParticipant()
{
if (string.IsNullOrWhiteSpace(NewParticipant)) return;
Participants.Add(NewParticipant.Trim());
NewParticipant = "";
}
[RelayCommand]
private void RemoveParticipant(string? p) { if (p is not null) Participants.Remove(p); }
[RelayCommand]
private void AddMeasure()
{
if (string.IsNullOrWhiteSpace(NewMeasure)) return;
Measures.Add(NewMeasure.Trim());
NewMeasure = "";
}
[RelayCommand]
private void RemoveMeasure(string? m) { if (m is not null) Measures.Remove(m); }
[RelayCommand]
private void AddCallPoint()
{
if (string.IsNullOrWhiteSpace(NewCallPoint)) return;
CallPoints.Add(NewCallPoint.Trim());
NewCallPoint = "";
}
[RelayCommand]
private void RemoveCallPoint(string? p) { if (p is not null) CallPoints.Remove(p); }
[RelayCommand]
private void AddTag()
{
if (string.IsNullOrWhiteSpace(NewTag)) return;
var tag = NewTag.Trim();
if (Tags.Contains(tag)) return;
Tags.Add(tag);
NewTag = "";
}
[RelayCommand]
private void RemoveTag(string? t) { if (t is not null) Tags.Remove(t); }
[RelayCommand]
private void Save()
{
TitleError = ""; DateTextError = ""; ReviewDateTextError = ""; StudentError = "";
LetterSentDateError = ""; LetterResponseDateError = "";
var valid = true;
if (CanPickStudent && SelectedStudent is null) { StudentError = "Bezug auswählen."; valid = false; }
if (!CanPickStudent && _studentId == Guid.Empty && _contextGroupId is null)
{
StudentError = "Die Schülerliste ist noch nicht geladen. Dialog schließen und erneut öffnen.";
valid = false;
}
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
{ DateTextError = "Format TT.MM.JJJJ."; valid = false; }
DateOnly? reviewDate = null;
if (IsSupportPlan && !string.IsNullOrWhiteSpace(ReviewDateText))
{
if (!DateOnly.TryParseExact(ReviewDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var r))
{ ReviewDateTextError = "Format TT.MM.JJJJ."; valid = false; }
else reviewDate = r;
}
DateOnly? sentDate = null;
if (IsParentLetter && !string.IsNullOrWhiteSpace(LetterSentDateText))
{
if (!DateOnly.TryParseExact(LetterSentDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var s))
{ LetterSentDateError = "Format TT.MM.JJJJ."; valid = false; }
else sentDate = s;
}
DateOnly? responseDate = null;
if (IsParentLetter && !string.IsNullOrWhiteSpace(LetterResponseDateText))
{
if (!DateOnly.TryParseExact(LetterResponseDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var r))
{ LetterResponseDateError = "Format TT.MM.JJJJ."; valid = false; }
else responseDate = r;
}
if (!valid) return;
var type = DocumentationTypeDisplay.FromLabel(TypeName);
var effectiveStudentId = CanPickStudent ? SelectedStudent!.Id : _studentId;
Result = _editing ?? new Documentation { StudentId = effectiveStudentId, GroupId = _contextGroupId };
Result.Type = type;
Result.Date = date;
Result.Title = Title.Trim();
Result.Content = (Content ?? "").Trim();
// Das bewusste Speichern im vollständigen Dialog schließt einen im Sitzplan erzeugten
// Schnellentwurf ab. Stunden- und Lesson-Bezug bleiben am bestehenden Objekt erhalten.
Result.IsDraft = false;
Result.IsConfidential = IsConfidential;
Result.ExcludeFromWebUntisSync = ExcludeFromWebUntisSync;
Result.Participants = type == DocumentationType.Conversation ? Participants.ToList() : [];
Result.AbsenceData = type == DocumentationType.Absence
? new AbsenceData
{
LessonCount = LessonCount,
Excused = AbsenceExcused,
Reason = string.IsNullOrWhiteSpace(AbsenceReason) ? null : AbsenceReason.Trim(),
}
: null;
Result.SupportData = type == DocumentationType.SupportPlan
? new SupportData
{
Measures = Measures.ToList(),
ReviewDate = reviewDate,
Status = SupportStatusDisplay.FromLabel(SupportStatusName),
}
: null;
if (type == DocumentationType.ParentCall)
{
var existingPoints = _editing?.ParentCallData?.Points ?? [];
Result.ParentCallData = new ParentCallData
{
// Punkte mit unverändertem Text behalten ihren Abhak-Status; neue/umbenannte
// Punkte starten offen — das Abhaken selbst läuft über ParentCallSessionDialog.
Points = CallPoints.Select(text =>
existingPoints.FirstOrDefault(p => p.Text == text) ?? new ParentCallPoint { Text = text }).ToList(),
Impressions = _editing?.ParentCallData?.Impressions ?? "",
IsConducted = _editing?.ParentCallData?.IsConducted ?? false,
ConductedDate = _editing?.ParentCallData?.ConductedDate,
};
}
else Result.ParentCallData = null;
Result.ParentLetterData = type == DocumentationType.ParentLetter
? new ParentLetterData
{
DraftContent = (LetterDraftContent ?? "").Trim(),
SentDate = sentDate,
ResponseReceived = LetterResponseReceived,
ResponseDate = responseDate,
ResponseNote = (LetterResponseNote ?? "").Trim(),
}
: null;
Result.Attachments = Attachments.Select(a => new DocumentAttachment
{
StorageId = a.StorageId, FileName = a.FileName, SizeBytes = a.SizeBytes, UploadedAt = a.UploadedAt,
}).ToList();
Result.Tags = Tags.ToList();
// Nach erfolgreichem Speichern sollen diese Anhänge NICHT mehr beim Abbrechen-Cleanup
// gelöscht werden — der Aufrufer speichert den Eintrag direkt im Anschluss an Save().
_newlyUploadedStorageIds.Clear();
}
}
public class AttachmentItem(string storageId, string fileName, long sizeBytes, DateTime uploadedAt)
{
public const string HazardAssessmentSuffix = ".gbu.json";
public string StorageId { get; } = storageId;
public string FileName { get; } = fileName;
public long SizeBytes { get; } = sizeBytes;
public DateTime UploadedAt { get; } = uploadedAt;
public string SizeDisplay => $"{SizeBytes / 1024.0:0} KB";
/// Strukturierter Gefährdungsbeurteilungs-Anhang (siehe HazardAssessment) statt einer
/// beliebigen hochgeladenen Datei — steuert im Anhang-Editor, ob "Öffnen"+"PDF" statt des
/// generischen "Speichern" angeboten wird.
public bool IsHazardAssessment => FileName.EndsWith(HazardAssessmentSuffix, StringComparison.OrdinalIgnoreCase);
}
public class TagChip(string text)
{
public string Text { get; } = text;
public string ColorHex { get; } = DocumentationTagDisplay.ColorHex(text);
}
// ── Listen-Eintrag mit Vertraulichkeits-Freigabe (5.1.3/5.4.1) ───────────────
public partial class DocumentationItem : ObservableObject
{
public Documentation Model { get; }
public string DateDisplay { get; }
public string TypeLabel { get; }
public bool IsConfidential { get; }
public bool IsParentCall { get; }
public bool HasAttachments { get; }
public bool IsDraft { get; }
public string StatusLabel { get; }
public List<TagChip> TagChips { get; }
/// Nur im Gruppen-Tab (5.1, GroupDocumentationTabViewModel) gefüllt — die Schüler-Detailansicht
/// zeigt ohnehin nur Einträge eines einzelnen Schülers und braucht den Namen nicht.
public string StudentName { get; }
/// True, wenn der Eintrag keiner Gruppe zugeordnet ist oder der aktuell angezeigten Gruppe
/// entspricht — false für Einträge aus einem anderen Unterricht desselben Schülers (werden im
/// Gruppen-Tab optisch abgesetzt statt komplett ausgeblendet, siehe Nutzer-Feedback).
public bool IsOwnGroup { get; }
/// Name der Gruppe, aus der ein nicht-eigener Eintrag stammt (nur gesetzt, wenn !IsOwnGroup).
public string OtherGroupLabel { get; }
/// Dimmt Einträge aus anderen Lerngruppen im Gruppen-Tab, ohne sie zu verstecken.
public double ContentOpacity => IsOwnGroup ? 1.0 : 0.55;
[ObservableProperty] private bool _isRevealed;
public DocumentationItem(Documentation d, string studentName = "", bool isOwnGroup = true, string otherGroupLabel = "")
{
Model = d;
DateDisplay = d.Date.ToString("dd.MM.yyyy");
TypeLabel = DocumentationTypeDisplay.Label(d.Type);
IsConfidential = d.IsConfidential;
IsRevealed = !d.IsConfidential;
IsParentCall = d.Type == DocumentationType.ParentCall;
HasAttachments = d.Attachments.Count > 0;
IsDraft = d.IsDraft;
StatusLabel = BuildStatusLabel(d);
TagChips = d.Tags.Select(t => new TagChip(t)).ToList();
StudentName = studentName;
IsOwnGroup = isOwnGroup;
OtherGroupLabel = otherGroupLabel;
}
private static string BuildStatusLabel(Documentation d) => d.Type switch
{
_ when d.IsDraft => "Nacharbeiten",
DocumentationType.ParentCall when d.ParentCallData is { IsConducted: true } pc =>
$"Durchgeführt am {pc.ConductedDate:dd.MM.yyyy}",
DocumentationType.ParentCall => "Noch nicht durchgeführt",
DocumentationType.ParentLetter when d.ParentLetterData is { } pl =>
pl.ResponseReceived ? "Rückmeldung erhalten"
: pl.SentDate.HasValue ? $"Versendet am {pl.SentDate:dd.MM.yyyy}"
: "Noch nicht versendet",
_ => "",
};
[RelayCommand] private void Reveal() => IsRevealed = true;
}