Schülerberichte und erweiterte Deckblatt-Bausteine ergänzen
CI / build-and-test (push) Canceled after 0s
CI / build-and-test (push) Canceled after 0s
This commit is contained in:
1 parent
336869e75f
commit
e2f019667a
31 files changed
+961
-14
No files matched your search
@@ -262,6 +262,54 @@ public sealed class ExamCoverSheetTests : IDisposable
|
|||||||
Assert.Equal("HED", new TeacherSettingsService(_directory).LoadAbbreviation());
|
Assert.Equal("HED", new TeacherSettingsService(_directory).LoadAbbreviation());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(RepeatGradeRule.Better, "bessere Note")]
|
||||||
|
[InlineData(RepeatGradeRule.Repeat, "Note der Wiederholungsarbeit")]
|
||||||
|
[InlineData(RepeatGradeRule.Original, "Note der ersten Arbeit")]
|
||||||
|
public void Wiederholungsbaustein_ZeigtDieGespeicherteBewertungsregel(RepeatGradeRule rule, string expected)
|
||||||
|
{
|
||||||
|
var original = new Exam { GroupId = _group.Id, Title = "Erste Arbeit", Date = _exam.Date.AddDays(-14) };
|
||||||
|
_exam.RepeatOfExamId = original.Id; _exam.RepeatGradeRule = rule;
|
||||||
|
var template = new LoadedTemplate(new TemplateManifest(), new LayoutParser().Parse(
|
||||||
|
"PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 240 $Exam.RepeatInfo fit=content"), new Dictionary<string, byte[]>());
|
||||||
|
var drawing = GradeReportDrawingBuilder.Repeat(_exam, original, new ExamResult { Grade = "4" }, template);
|
||||||
|
var text = string.Join(" ", drawing.Commands.OfType<DrawStringEx>().Select(c => c.Text));
|
||||||
|
Assert.Contains(expected, text);
|
||||||
|
Assert.Contains("Note der ersten Arbeit: 4", text);
|
||||||
|
_exam.IsMakeUp = true;
|
||||||
|
text = string.Join(" ", GradeReportDrawingBuilder.Repeat(_exam, original, null, template).Commands.OfType<DrawStringEx>().Select(c => c.Text));
|
||||||
|
Assert.Contains("Nachschreibarbeit", text);
|
||||||
|
Assert.Contains("ersetzt die versäumte", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Zwischenstandsbaustein_VerlangtDurchgangUndAlleAusgewaehltenSchueler()
|
||||||
|
{
|
||||||
|
var store = Store();
|
||||||
|
var source = Path.Combine(_directory, "zwischenstand.lavorlage");
|
||||||
|
TemplatePackage.Create(source, new TemplateManifest { Id = "zwischenstand", Name = "Zwischenstand",
|
||||||
|
Metadata = new() { [TemplateMetadataKeys.ReportType] = TemplateReportTypes.ExamCover },
|
||||||
|
Placeholders = [new(ReportTextDrawing.InterimGrades, PlaceholderType.Drawing)] },
|
||||||
|
"PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 240 $Student.InterimGrades fit=content", new Dictionary<string, byte[]>());
|
||||||
|
store.Import(source);
|
||||||
|
var interims = new FakeInterimGrades();
|
||||||
|
interims.Save(new() { StudentId = _anna.Id, GroupId = _group.Id, Date = _exam.Date, Occasion = "Sprechtag", OverallValue = "2" });
|
||||||
|
var vm = new ExamCoverSheetDialogViewModel(_exam, _group, "Chemie", new FakeStudents([_anna, _ben]),
|
||||||
|
new FakeMemberships([]), _results, new GradingService(), [store], new QuestTemplateRenderer(), interims: interims);
|
||||||
|
var output = Path.Combine(_directory, "zwischenstand.pdf");
|
||||||
|
Assert.True(vm.UsesInterimGrades);
|
||||||
|
Assert.False(await vm.GenerateAsync(output));
|
||||||
|
Assert.Contains("auswählen", vm.StatusMessage);
|
||||||
|
vm.SelectedInterimRun = vm.InterimRuns.Single();
|
||||||
|
Assert.False(await vm.GenerateAsync(output));
|
||||||
|
Assert.Contains(_ben.FullName, vm.StatusMessage);
|
||||||
|
Assert.False(File.Exists(output));
|
||||||
|
vm.Students.Single(s => s.Model.Id == _ben.Id).IsIncluded = false;
|
||||||
|
Assert.True(await vm.GenerateAsync(output));
|
||||||
|
using var document = UglyToad.PdfPig.PdfDocument.Open(output);
|
||||||
|
Assert.Contains("Sprechtag", document.GetPage(1).Text);
|
||||||
|
}
|
||||||
|
|
||||||
private static string Normalize(string text) => text.Replace(" ", "");
|
private static string Normalize(string text) => text.Replace(" ", "");
|
||||||
private static List<float> Widths(DrawingValue drawing) =>
|
private static List<float> Widths(DrawingValue drawing) =>
|
||||||
drawing.Commands.OfType<DrawRectangle>().Select(r => r.Width).Distinct().Order().ToList();
|
drawing.Commands.OfType<DrawRectangle>().Select(r => r.Width).Distinct().Order().ToList();
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
using LehrerApp.Templating;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class StudentReportTests
|
||||||
|
{
|
||||||
|
private readonly Student _student = new() { FirstName = "Erika", LastName = "Beispiel" };
|
||||||
|
private readonly LearningGroup _group = new() { Name = "9 Ch G", SchoolYear = "2026/27" };
|
||||||
|
private readonly DateOnly _date = new(2026, 10, 1);
|
||||||
|
private readonly FakeDocumentation _docs = new();
|
||||||
|
private readonly FakeEntries _entries = new();
|
||||||
|
private readonly FakeAspects _aspects = new();
|
||||||
|
private readonly List<ParticipationSession> _sessions = [];
|
||||||
|
|
||||||
|
private StudentReportService Service() => new(new FakeSessions(_sessions), _entries, _aspects,
|
||||||
|
new FakeExams([]), new FakeResults(), new FakeGrades(), new FakeMemberships([]),
|
||||||
|
new FakeCompetencyDomains(), _docs, new GradingService(), new CompetencyAnalysisService());
|
||||||
|
|
||||||
|
private Dictionary<string, PlaceholderValue> Build(StudentReportOptions? options = null) => Service().Build(
|
||||||
|
_student, _group, StudentReportTemplate.Create(), options ?? new(_date, _date.AddMonths(1), null, IncludeInterim: false));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dokumentation_IstAufSchuelerGruppeZeitraumUndFreigabeBegrenzt()
|
||||||
|
{
|
||||||
|
Documentation Doc(string title) => new() { StudentId = _student.Id, GroupId = _group.Id, Date = _date, Title = title };
|
||||||
|
_docs.Add(Doc("sichtbar"));
|
||||||
|
var confidential = Doc("vertraulich"); confidential.IsConfidential = true; _docs.Add(confidential);
|
||||||
|
var draft = Doc("Entwurf"); draft.IsDraft = true; _docs.Add(draft);
|
||||||
|
var deleted = Doc("gelöscht"); deleted.IsDeleted = true; _docs.Add(deleted);
|
||||||
|
var otherGroup = Doc("fremdeGruppe"); otherGroup.GroupId = Guid.NewGuid(); _docs.Add(otherGroup);
|
||||||
|
var otherStudent = Doc("fremderSchüler"); otherStudent.StudentId = Guid.NewGuid(); _docs.Add(otherStudent);
|
||||||
|
var outside = Doc("außerhalb"); outside.Date = _date.AddDays(-1); _docs.Add(outside);
|
||||||
|
|
||||||
|
var text = Text((DrawingValue)Build()[StudentReportTemplate.Documentation]);
|
||||||
|
Assert.Contains("sichtbar", text);
|
||||||
|
foreach (var hidden in new[] { "vertraulich", "Entwurf", "gelöscht", "fremdeGruppe", "fremderSchüler", "außerhalb" })
|
||||||
|
Assert.DoesNotContain(hidden, text);
|
||||||
|
var included = Build(new(_date, _date, null, IncludeInterim: false, IncludeConfidential: true));
|
||||||
|
Assert.Contains("[Vertraulich]", Text((DrawingValue)included[StudentReportTemplate.Documentation]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Zwischenstand_MussZumSchuelerZurGruppeUndZumZeitraumPassen()
|
||||||
|
{
|
||||||
|
var interim = new InterimGrade { StudentId = _student.Id, GroupId = _group.Id, Date = _date,
|
||||||
|
Occasion = "Sprechtag", OralValue = "2", WrittenValue = "3", OverallValue = "2-3" };
|
||||||
|
var values = Build(new(_date, _date, interim));
|
||||||
|
Assert.Contains("2-3", Text((DrawingValue)values[ReportTextDrawing.InterimGrades]));
|
||||||
|
interim.StudentId = Guid.NewGuid();
|
||||||
|
Assert.Throws<InvalidDataException>(() => Build(new(_date, _date, interim)));
|
||||||
|
interim.StudentId = _student.Id; interim.GroupId = Guid.NewGuid();
|
||||||
|
Assert.Throws<InvalidDataException>(() => Build(new(_date, _date, interim)));
|
||||||
|
interim.GroupId = _group.Id; interim.Date = _date.AddDays(1);
|
||||||
|
Assert.Throws<InvalidDataException>(() => Build(new(_date, _date, interim)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AbgewaehlteBausteineBleibenLeer()
|
||||||
|
{
|
||||||
|
var values = Build(new(_date, _date, null, false, false, false, false, false));
|
||||||
|
Assert.All(StudentReportTemplate.Drawings, name => Assert.Empty(((DrawingValue)values[name]).Commands));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Hausaufgaben_AltdatenUndFremdeSitzungen()
|
||||||
|
{
|
||||||
|
var session = new ParticipationSession { GroupId = _group.Id, Date = _date };
|
||||||
|
_sessions.Add(session);
|
||||||
|
_entries.Add(new() { StudentId = _student.Id, SessionId = session.Id, HomeworkMissing = true });
|
||||||
|
_entries.Add(new() { StudentId = _student.Id, SessionId = Guid.NewGuid(), Homework = HomeworkStatus.Completed });
|
||||||
|
var text = Text((DrawingValue)Build()[StudentReportTemplate.Homework]);
|
||||||
|
Assert.Contains("Nicht gemacht", text);
|
||||||
|
Assert.DoesNotContain("Hausaufgabe gemacht", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Standardbericht_RenderbarMitGraphUndMehrseitigerDokumentation()
|
||||||
|
{
|
||||||
|
_aspects.Save(new() { Key = "quality", Label = "Qualität", Weight = 1 });
|
||||||
|
for (var i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
var session = new ParticipationSession { GroupId = _group.Id, Date = _date.AddDays(i) };
|
||||||
|
_sessions.Add(session);
|
||||||
|
_entries.Add(new() { StudentId = _student.Id, SessionId = session.Id,
|
||||||
|
Ratings = [new() { Key = "quality", Value = i - 2 }], Homework = HomeworkStatus.Completed });
|
||||||
|
}
|
||||||
|
for (var i = 0; i < 55; i++) _docs.Add(new() { StudentId = _student.Id, GroupId = _group.Id,
|
||||||
|
Date = _date, Title = $"Eintrag {i:D2}", Content = "Lange Beobachtung mit einem ausführlichen Text über die regelmäßige Mitarbeit im Unterricht und die Lernfortschritte." });
|
||||||
|
var pdf = new QuestTemplateRenderer().RenderToPdf(StudentReportTemplate.Create(), new ReportProvider(Build()));
|
||||||
|
using var document = UglyToad.PdfPig.PdfDocument.Open(pdf);
|
||||||
|
Assert.True(document.NumberOfPages > 1);
|
||||||
|
Assert.All(document.GetPages(), page => Assert.Contains(_student.FullName.Replace(" ", ""), page.Text.Replace(" ", "")));
|
||||||
|
var text = string.Concat(document.GetPages().Select(p => p.Text)).Replace(" ", "");
|
||||||
|
Assert.Contains(_student.FullName.Replace(" ", ""), text);
|
||||||
|
Assert.Contains("Lerngruppe:9ChG", text);
|
||||||
|
Assert.Contains("GewichteteMitarbeit", text);
|
||||||
|
Assert.Contains("Eintrag54", text);
|
||||||
|
Assert.Contains("Lernfortschritte.", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dialog_UngueltigeDatumsangabenErzeugenKeineDatei()
|
||||||
|
{
|
||||||
|
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var vm = new StudentReportDialogViewModel(_student,
|
||||||
|
new FakeMemberships([new() { StudentId = _student.Id, GroupId = _group.Id }]),
|
||||||
|
new FakeGroups([_group]), new FakeInterimGrades(), Service(), new QuestTemplateRenderer(),
|
||||||
|
new SchoolYearService(), [new TemplateStore(directory)]);
|
||||||
|
vm.From = "31.02.2026";
|
||||||
|
var output = Path.Combine(directory, "bericht.pdf");
|
||||||
|
Assert.False(await vm.GenerateAsync(output));
|
||||||
|
Assert.False(File.Exists(output));
|
||||||
|
Assert.Contains("TT.MM.JJJJ", vm.StatusMessage);
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ReportProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
|
||||||
|
{ public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values; }
|
||||||
|
|
||||||
|
private static string Text(DrawingValue drawing) => string.Join(" ", drawing.Commands.OfType<DrawStringEx>().Select(c => c.Text));
|
||||||
|
}
|
||||||
@@ -238,6 +238,7 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<GroupMembershipTools>();
|
services.AddSingleton<GroupMembershipTools>();
|
||||||
services.AddSingleton<LetterTemplateTools>();
|
services.AddSingleton<LetterTemplateTools>();
|
||||||
services.AddSingleton<StudentAttendanceCalendarService>();
|
services.AddSingleton<StudentAttendanceCalendarService>();
|
||||||
|
services.AddSingleton<StudentReportService>();
|
||||||
services.AddSingleton<CompetencyTools>();
|
services.AddSingleton<CompetencyTools>();
|
||||||
services.AddSingleton<GroupTools>();
|
services.AddSingleton<GroupTools>();
|
||||||
services.AddSingleton<ParticipationTools>();
|
services.AddSingleton<ParticipationTools>();
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ public static class ExamCoverPlaceholderBuilder
|
|||||||
public static Dictionary<string, PlaceholderValue> Build(Exam exam, LearningGroup group, string subjectName,
|
public static Dictionary<string, PlaceholderValue> Build(Exam exam, LearningGroup group, string subjectName,
|
||||||
Student student, ExamResult? result, bool fillResults, GradingService grading, LoadedTemplate template,
|
Student student, ExamResult? result, bool fillResults, GradingService grading, LoadedTemplate template,
|
||||||
DateOnly today, Niveau? studentNiveau = null, string teacherAbbreviation = "",
|
DateOnly today, Niveau? studentNiveau = null, string teacherAbbreviation = "",
|
||||||
string? title = null)
|
string? title = null, Exam? original = null, ExamResult? originalResult = null,
|
||||||
|
InterimGrade? interim = null)
|
||||||
{
|
{
|
||||||
var tasks = exam.Tasks.OrderBy(t => t.Nr).ToList();
|
var tasks = exam.Tasks.OrderBy(t => t.Nr).ToList();
|
||||||
var variant = result?.Variant ?? ExamVariant.A;
|
var variant = result?.Variant ?? ExamVariant.A;
|
||||||
@@ -62,6 +63,10 @@ public static class ExamCoverPlaceholderBuilder
|
|||||||
ExamCoverSheetService.Columns(tasks, variant, filled ? result!.Points : null),
|
ExamCoverSheetService.Columns(tasks, variant, filled ? result!.Points : null),
|
||||||
box.Width, box.MillimeterScale)
|
box.Width, box.MillimeterScale)
|
||||||
: new DrawingValue([], 0);
|
: new DrawingValue([], 0);
|
||||||
|
values[ReportTextDrawing.RepeatInfo] = DrawingBoxLocator.Find(template, ReportTextDrawing.RepeatInfo) is null
|
||||||
|
? new DrawingValue([], 0) : GradeReportDrawingBuilder.Repeat(exam, original, originalResult, template);
|
||||||
|
values[ReportTextDrawing.InterimGrades] = interim is null
|
||||||
|
? new DrawingValue([], 0) : GradeReportDrawingBuilder.Interim(interim, template);
|
||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ public static class ExamCoverSheetDialogs
|
|||||||
App.Services.GetRequiredService<GradingService>(),
|
App.Services.GetRequiredService<GradingService>(),
|
||||||
[App.Services.GetRequiredService<TemplateStore>(), App.Services.GetRequiredService<WorksheetTemplateStore>().Store],
|
[App.Services.GetRequiredService<TemplateStore>(), App.Services.GetRequiredService<WorksheetTemplateStore>().Store],
|
||||||
App.Services.GetRequiredService<ITemplateBatchRenderer>(),
|
App.Services.GetRequiredService<ITemplateBatchRenderer>(),
|
||||||
App.Services.GetRequiredService<TeacherSettingsService>().LoadAbbreviation());
|
App.Services.GetRequiredService<TeacherSettingsService>().LoadAbbreviation(),
|
||||||
|
App.Services.GetRequiredService<IExamRepository>(),
|
||||||
|
App.Services.GetRequiredService<IInterimGradeRepository>());
|
||||||
await new ExamCoverSheetDialog { DataContext = vm }.ShowDialog(owner);
|
await new ExamCoverSheetDialog { DataContext = vm }.ShowDialog(owner);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Templating;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
public static class GradeReportDrawingBuilder
|
||||||
|
{
|
||||||
|
public static DrawingValue Interim(InterimGrade grade, LoadedTemplate template) =>
|
||||||
|
Text(template, ReportTextDrawing.InterimGrades, "Vorläufiger Notenzwischenstand",
|
||||||
|
[
|
||||||
|
$"Stichtag: {grade.Date:dd.MM.yyyy} · {grade.Occasion}",
|
||||||
|
$"Mündlich: {Value(grade.OralValue)}",
|
||||||
|
$"Schriftlich: {Value(grade.WrittenValue)}",
|
||||||
|
$"Vorläufige Zeugnisnote: {Value(grade.OverallValue)}",
|
||||||
|
"Dieser Zwischenstand ist keine festgeschriebene Zeugnisnote.",
|
||||||
|
]);
|
||||||
|
|
||||||
|
public static DrawingValue Repeat(Exam exam, Exam? original, ExamResult? originalResult,
|
||||||
|
LoadedTemplate template)
|
||||||
|
{
|
||||||
|
if (exam.RepeatOfExamId is null) return new DrawingValue([], 0);
|
||||||
|
if (original is null || original.Id != exam.RepeatOfExamId || original.GroupId != exam.GroupId)
|
||||||
|
throw new InvalidDataException("Die verknüpfte erste Arbeit ist nicht verfügbar.");
|
||||||
|
var rule = exam.IsMakeUp ? "Die Nachschreibarbeit ersetzt die versäumte erste Arbeit. Liegen beide Noten vor, zählt die erste Arbeit."
|
||||||
|
: exam.RepeatGradeRule switch
|
||||||
|
{
|
||||||
|
RepeatGradeRule.Repeat => "Für die Bewertung zählt die Note der Wiederholungsarbeit.",
|
||||||
|
RepeatGradeRule.Original => "Für die Bewertung zählt die Note der ersten Arbeit.",
|
||||||
|
_ => "Für die Bewertung zählt die bessere Note aus beiden Arbeiten.",
|
||||||
|
};
|
||||||
|
var grade = originalResult?.Grade;
|
||||||
|
if (string.IsNullOrWhiteSpace(grade) && ExamCoverSheetService.IsCorrected(originalResult))
|
||||||
|
grade = new GradingService().CalculateGrade(originalResult!.TotalPoints,
|
||||||
|
ExamScoringService.Maximum(original.Tasks, originalResult.Variant), original.GradingKey);
|
||||||
|
var firstGrade = originalResult is { Absent: true } ? "abwesend" : Value(grade);
|
||||||
|
return Text(template, ReportTextDrawing.RepeatInfo,
|
||||||
|
exam.IsMakeUp ? "Nachschreibarbeit" : "Wiederholungsarbeit",
|
||||||
|
[$"Erste Arbeit: {original.Title} vom {original.Date:dd.MM.yyyy}",
|
||||||
|
$"Note der ersten Arbeit: {firstGrade}", rule]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DrawingValue Text(LoadedTemplate template, string name, string title, IEnumerable<string> paragraphs)
|
||||||
|
{
|
||||||
|
if (DrawingBoxLocator.Find(template, name) is not { } box) return new DrawingValue([], 0);
|
||||||
|
return ReportTextDrawing.Build(title, paragraphs, box.Width, box.MillimeterScale, box.Height, box.IsFixed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Value(string? value) => string.IsNullOrWhiteSpace(value) ? "nicht erfasst" : value;
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ public class LetterTemplateTools(
|
|||||||
public List<LetterTemplateDto> ListLetterTemplates()
|
public List<LetterTemplateDto> ListLetterTemplates()
|
||||||
{
|
{
|
||||||
var result = new List<LetterTemplateDto>();
|
var result = new List<LetterTemplateDto>();
|
||||||
foreach (var installed in templates.GetTemplates().Where(x => !x.IsExamCover))
|
foreach (var installed in templates.GetTemplates().Where(x => !x.IsExamCover && !x.IsStudentReport))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
using LehrerApp.Templating;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
public sealed record StudentReportOptions(DateOnly From, DateOnly To, InterimGrade? Interim,
|
||||||
|
bool IncludeInterim = true, bool IncludeParticipation = true, bool IncludeCompetencies = true,
|
||||||
|
bool IncludeHomework = true, bool IncludeDocumentation = true, bool IncludeConfidential = false,
|
||||||
|
string Comment = "");
|
||||||
|
|
||||||
|
/// <summary>Stellt die vorhandenen Bewertungen eines Schülers für eine Lerngruppe zusammen.
|
||||||
|
/// Die Berechnungen stammen aus denselben Diensten wie die interaktiven Auswertungen.</summary>
|
||||||
|
public sealed class StudentReportService(
|
||||||
|
IParticipationSessionRepository sessions, IParticipationRepository entries,
|
||||||
|
IParticipationAspectRepository aspects, IExamRepository exams, IExamResultRepository results,
|
||||||
|
IGradeRepository grades, IGroupMembershipRepository memberships,
|
||||||
|
ICompetencyDomainRepository domains, IDocumentationRepository documentation,
|
||||||
|
GradingService grading, CompetencyAnalysisService competencies)
|
||||||
|
{
|
||||||
|
public Dictionary<string, PlaceholderValue> Build(Student student, LearningGroup group,
|
||||||
|
LoadedTemplate template, StudentReportOptions options)
|
||||||
|
{
|
||||||
|
if (options.From > options.To) throw new InvalidDataException("Das Ende des Berichtszeitraums liegt vor dem Beginn.");
|
||||||
|
var values = LetterPlaceholderBuilder.BuildStandardValues(student, null, group, DateOnly.FromDateTime(DateTime.Today), "", "");
|
||||||
|
values["Report.Period"] = new TextValue($"{options.From:dd.MM.yyyy} - {options.To:dd.MM.yyyy}");
|
||||||
|
values["Report.Comment"] = new MultilineValue(options.Comment);
|
||||||
|
foreach (var name in StudentReportTemplate.Drawings) values[name] = new DrawingValue([], 0);
|
||||||
|
if (options.IncludeInterim && Uses(ReportTextDrawing.InterimGrades))
|
||||||
|
{
|
||||||
|
var interim = options.Interim;
|
||||||
|
if (interim is null || interim.StudentId != student.Id || interim.GroupId != group.Id
|
||||||
|
|| interim.Date < options.From || interim.Date > options.To)
|
||||||
|
throw new InvalidDataException("Bitte einen gespeicherten Zwischenstand dieses Schülers im Berichtszeitraum auswählen oder den Baustein abwählen.");
|
||||||
|
values[ReportTextDrawing.InterimGrades] = GradeReportDrawingBuilder.Interim(interim, template);
|
||||||
|
}
|
||||||
|
|
||||||
|
var membership = memberships.GetByStudentAndGroup(student.Id, group.Id);
|
||||||
|
bool Within(DateOnly date) => date >= options.From && date <= options.To
|
||||||
|
&& (membership is null || GroupMembershipService.IsActiveOn(membership, date));
|
||||||
|
var groupSessions = sessions.GetByGroup(group.Id).Where(s => Within(s.Date)).OrderBy(s => s.Date).ToList();
|
||||||
|
var bySession = groupSessions.ToDictionary(s => s.Id);
|
||||||
|
var studentEntries = entries.GetByStudent(student.Id).Where(e => bySession.ContainsKey(e.SessionId)).ToList();
|
||||||
|
var applicableAspects = aspects.GetDefaults().Concat(aspects.GetByGroup(group.Id))
|
||||||
|
.GroupBy(a => a.Key).Select(g => g.Last()).ToList();
|
||||||
|
|
||||||
|
if (options.IncludeParticipation && Uses(StudentReportTemplate.Participation))
|
||||||
|
{
|
||||||
|
var timeline = new ParticipationTimelineBuilder(sessions, entries, aspects, exams, results, grades,
|
||||||
|
membership is null ? new Dictionary<Guid, GroupMembership>() : new() { [student.Id] = membership },
|
||||||
|
grading, group.Id, group.GradingSystem);
|
||||||
|
var points = timeline.BuildPoints(student.Id).Where(p => !p.IsExam && Within(p.Date)).ToList();
|
||||||
|
List<ReportChartSeries> series = [new("Gewichtete Mitarbeit", "#6C5CE7",
|
||||||
|
points.Where(p => p.WeightedValue.HasValue).Select(p => new ReportChartPoint(p.Date, p.WeightedValue!.Value)).ToList())];
|
||||||
|
string[] colors = ["#2E86DE", "#E67E22", "#16A085", "#C0392B"];
|
||||||
|
foreach (var (aspect, index) in applicableAspects.Select((a, i) => (a, i)))
|
||||||
|
series.Add(new(aspect.Label, colors[index % colors.Length],
|
||||||
|
points.Where(p => p.AspectValues.ContainsKey(aspect.Key)).Select(p => new ReportChartPoint(p.Date,
|
||||||
|
ParticipationRatingScale.Normalize(aspect.ValueType, p.AspectValues[aspect.Key], aspect.MaxPoints))).ToList()));
|
||||||
|
var box = DrawingBoxLocator.Find(template, StudentReportTemplate.Participation)!.Value;
|
||||||
|
var drawing = ParticipationReportDrawing.Build(series, options.From, options.To, box.Width, box.MillimeterScale);
|
||||||
|
if (drawing.ContentHeight > box.Height)
|
||||||
|
throw new InvalidDataException("Der Mitarbeitsgraph benötigt eine höhere Zeichenbox in der Vorlage.");
|
||||||
|
values[StudentReportTemplate.Participation] = drawing;
|
||||||
|
}
|
||||||
|
if (options.IncludeCompetencies && Uses(StudentReportTemplate.Competencies))
|
||||||
|
{
|
||||||
|
var groupExams = exams.GetByGroup(group.Id).Where(e => Within(e.Date)).ToList();
|
||||||
|
var examIds = groupExams.Select(e => e.Id).ToHashSet();
|
||||||
|
var scores = competencies.Analyze(domains.GetAll(), [], groupExams,
|
||||||
|
results.GetByStudent(student.Id).Where(r => examIds.Contains(r.ExamId)).ToList(),
|
||||||
|
groupSessions, studentEntries, applicableAspects);
|
||||||
|
var lines = scores.Where(s => s.StudentScores.ContainsKey(student.Id)).Select(s =>
|
||||||
|
{
|
||||||
|
var score = s.StudentScores[student.Id];
|
||||||
|
return $"{s.Code} {s.Description}: {score.Percent.ToString("0", System.Globalization.CultureInfo.GetCultureInfo("de-DE"))} % ({score.EvidenceCount} Bewertungen)";
|
||||||
|
}).ToList();
|
||||||
|
Add(StudentReportTemplate.Competencies, "Kompetenzen", lines, "Keine Kompetenzbewertungen im gewählten Zeitraum.");
|
||||||
|
}
|
||||||
|
if (options.IncludeHomework && Uses(StudentReportTemplate.Homework))
|
||||||
|
Add(StudentReportTemplate.Homework, "Hausaufgaben", studentEntries.Where(e => HomeworkDisplay.Effective(e) is not null)
|
||||||
|
.OrderBy(e => bySession[e.SessionId].Date).Select(e =>
|
||||||
|
$"{bySession[e.SessionId].Date:dd.MM.yyyy}: {HomeworkDisplay.Label(HomeworkDisplay.Effective(e))}").ToList(),
|
||||||
|
"Keine Hausaufgabenkontrollen im gewählten Zeitraum.");
|
||||||
|
if (options.IncludeDocumentation && Uses(StudentReportTemplate.Documentation))
|
||||||
|
{
|
||||||
|
var lines = documentation.GetByStudent(student.Id)
|
||||||
|
.Where(d => !d.IsDeleted && !d.IsDraft && (options.IncludeConfidential || !d.IsConfidential)
|
||||||
|
&& d.GroupId == group.Id && Within(d.Date) && d.Concerns(student.Id))
|
||||||
|
.OrderBy(d => d.Date).Select(d =>
|
||||||
|
$"{d.Date:dd.MM.yyyy} · {DocumentationTypeDisplay.Label(d.Type)}: {d.Title}"
|
||||||
|
+ (d.IsConfidential ? " [Vertraulich]" : "") + "\n" + d.Content).ToList();
|
||||||
|
lines.AddRange(studentEntries.Where(e => !string.IsNullOrWhiteSpace(e.Note)).OrderBy(e => bySession[e.SessionId].Date)
|
||||||
|
.Select(e => $"{bySession[e.SessionId].Date:dd.MM.yyyy} · Unterrichtsnotiz: {e.Note}"));
|
||||||
|
Add(StudentReportTemplate.Documentation, "Klassenbucheinträge und Unterrichtsnotizen", lines,
|
||||||
|
"Keine freigegebenen Einträge mit Bezug zu dieser Lerngruppe im gewählten Zeitraum.");
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
|
||||||
|
bool Uses(string name) => DrawingBoxLocator.Find(template, name) is not null;
|
||||||
|
void Add(string name, string title, List<string> lines, string empty) =>
|
||||||
|
values[name] = GradeReportDrawingBuilder.Text(template, name, title, lines.Count > 0 ? lines : [empty]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,17 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject
|
|||||||
private readonly GradingService _grading;
|
private readonly GradingService _grading;
|
||||||
private readonly TemplateStore _loader;
|
private readonly TemplateStore _loader;
|
||||||
private readonly ITemplateBatchRenderer _renderer;
|
private readonly ITemplateBatchRenderer _renderer;
|
||||||
|
private readonly Exam? _original;
|
||||||
|
private readonly Dictionary<Guid, ExamResult> _originalResults;
|
||||||
|
[ObservableProperty] private ReportInterimRunChoice? _selectedInterimRun;
|
||||||
|
[ObservableProperty] private bool _usesInterimGrades;
|
||||||
|
public ObservableCollection<ReportInterimRunChoice> InterimRuns { get; } = [];
|
||||||
|
|
||||||
|
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value)
|
||||||
|
{
|
||||||
|
UsesInterimGrades = value is not null &&
|
||||||
|
DrawingBoxLocator.Find(_loader.Load(value.Model), ReportTextDrawing.InterimGrades) is not null;
|
||||||
|
}
|
||||||
|
|
||||||
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
|
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
|
||||||
[ObservableProperty] private bool _fillResults;
|
[ObservableProperty] private bool _fillResults;
|
||||||
@@ -50,12 +61,19 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject
|
|||||||
public ExamCoverSheetDialogViewModel(Exam exam, LearningGroup group, string subjectName,
|
public ExamCoverSheetDialogViewModel(Exam exam, LearningGroup group, string subjectName,
|
||||||
IStudentRepository students, IGroupMembershipRepository memberships, IExamResultRepository results,
|
IStudentRepository students, IGroupMembershipRepository memberships, IExamResultRepository results,
|
||||||
GradingService grading, IReadOnlyList<TemplateStore> templateStores, ITemplateBatchRenderer renderer,
|
GradingService grading, IReadOnlyList<TemplateStore> templateStores, ITemplateBatchRenderer renderer,
|
||||||
string teacherAbbreviation = "")
|
string teacherAbbreviation = "", IExamRepository? exams = null, IInterimGradeRepository? interims = null)
|
||||||
{
|
{
|
||||||
_teacherAbbreviation = teacherAbbreviation;
|
_teacherAbbreviation = teacherAbbreviation;
|
||||||
_coverTitle = exam.Title;
|
_coverTitle = exam.Title;
|
||||||
_exam = exam; _group = group; _subjectName = subjectName; _grading = grading; _renderer = renderer;
|
_exam = exam; _group = group; _subjectName = subjectName; _grading = grading; _renderer = renderer;
|
||||||
_loader = templateStores[0];
|
_loader = templateStores[0];
|
||||||
|
_original = exam.RepeatOfExamId is { } originalId ? exams?.GetById(originalId) : null;
|
||||||
|
_originalResults = exam.RepeatOfExamId is { } firstId
|
||||||
|
? results.GetByExam(firstId).ToDictionary(r => r.StudentId) : [];
|
||||||
|
foreach (var run in (interims?.GetByGroup(group.Id) ?? [])
|
||||||
|
.GroupBy(g => (g.Date, g.Occasion)).OrderByDescending(g => g.Key.Date).ThenBy(g => g.Key.Occasion))
|
||||||
|
InterimRuns.Add(new(run.Key.Date, run.Key.Occasion, run.ToList()));
|
||||||
|
// Bewusste Auswahl statt stillschweigend den neuesten Zwischenstand zu drucken.
|
||||||
foreach (var template in templateStores.SelectMany(x => x.GetTemplates()).Where(x => x.IsExamCover)
|
foreach (var template in templateStores.SelectMany(x => x.GetTemplates()).Where(x => x.IsExamCover)
|
||||||
.DistinctBy(x => x.Id).OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase))
|
.DistinctBy(x => x.Id).OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase))
|
||||||
Templates.Add(new(template));
|
Templates.Add(new(template));
|
||||||
@@ -87,10 +105,20 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var loaded = _loader.Load(SelectedTemplate.Model);
|
var loaded = _loader.Load(SelectedTemplate.Model);
|
||||||
|
if (UsesInterimGrades && SelectedInterimRun is null)
|
||||||
|
{ StatusMessage = "Bitte einen gespeicherten Notenzwischenstand auswählen."; return false; }
|
||||||
|
if (UsesInterimGrades)
|
||||||
|
{
|
||||||
|
var missing = included.Where(s => !SelectedInterimRun!.Grades.Any(g => g.StudentId == s.Model.Id)).ToList();
|
||||||
|
if (missing.Count > 0)
|
||||||
|
{ StatusMessage = "Im gewählten Zwischenstand fehlen: " + string.Join(", ", missing.Select(s => s.FullName)) + ". Bitte Schüler abwählen oder einen anderen Durchgang wählen."; return false; }
|
||||||
|
}
|
||||||
var today = DateOnly.FromDateTime(DateTime.Now);
|
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||||
var data = included.Select(s => (ITemplateDataProvider)new LetterDataProvider(
|
var data = included.Select(s => (ITemplateDataProvider)new LetterDataProvider(
|
||||||
ExamCoverPlaceholderBuilder.Build(_exam, _group, _subjectName, s.Model, s.Result, FillResults,
|
ExamCoverPlaceholderBuilder.Build(_exam, _group, _subjectName, s.Model, s.Result, FillResults,
|
||||||
_grading, loaded, today, s.Niveau, TeacherAbbreviation, CoverTitle))).ToList();
|
_grading, loaded, today, s.Niveau, TeacherAbbreviation, CoverTitle,
|
||||||
|
_original, _originalResults.GetValueOrDefault(s.Model.Id),
|
||||||
|
UsesInterimGrades ? SelectedInterimRun!.Grades.Single(g => g.StudentId == s.Model.Id) : null))).ToList();
|
||||||
ReportProgress(0, included.Count);
|
ReportProgress(0, included.Count);
|
||||||
IsGenerating = true;
|
IsGenerating = true;
|
||||||
var progress = new Progress<int>(done => ReportProgress(done, included.Count));
|
var progress = new Progress<int>(done => ReportProgress(done, included.Count));
|
||||||
@@ -110,7 +138,7 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject
|
|||||||
StatusMessage = message;
|
StatusMessage = message;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
|
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException or QuestPDF.Drawing.Exceptions.DocumentLayoutException)
|
||||||
{ StatusMessage = $"Deckblätter konnten nicht erzeugt werden: {ex.Message}"; return false; }
|
{ StatusMessage = $"Deckblätter konnten nicht erzeugt werden: {ex.Message}"; return false; }
|
||||||
finally { IsGenerating = false; }
|
finally { IsGenerating = false; }
|
||||||
}
|
}
|
||||||
@@ -146,3 +174,8 @@ public partial class ExamCoverStudentChoice : ObservableObject
|
|||||||
_isIncluded = result is not { Absent: true };
|
_isIncluded = result is not { Absent: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record ReportInterimRunChoice(DateOnly Date, string Occasion, IReadOnlyList<InterimGrade> Grades)
|
||||||
|
{
|
||||||
|
public string Label => $"{Date:dd.MM.yyyy} · {Occasion}";
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ public partial class PersonalizeWorksheetDialogViewModel : ObservableObject
|
|||||||
IStudentRepository students, WorksheetTemplateStore templates, ITemplateRenderer renderer)
|
IStudentRepository students, WorksheetTemplateStore templates, ITemplateRenderer renderer)
|
||||||
{
|
{
|
||||||
_group = group; _templates = templates; _renderer = renderer;
|
_group = group; _templates = templates; _renderer = renderer;
|
||||||
foreach (var template in templates.Store.GetTemplates().Where(x => !x.IsExamCover)) Templates.Add(new(template));
|
foreach (var template in templates.Store.GetTemplates().Where(x => !x.IsExamCover && !x.IsStudentReport)) Templates.Add(new(template));
|
||||||
SelectedTemplate = Templates.FirstOrDefault();
|
SelectedTemplate = Templates.FirstOrDefault();
|
||||||
foreach (var id in studentIds)
|
foreach (var id in studentIds)
|
||||||
if (students.GetById(id) is { } student) Students.Add(new(student));
|
if (students.GetById(id) is { } student) Students.Add(new(student));
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
|||||||
_attendanceCalendarFactory = attendanceCalendarFactory;
|
_attendanceCalendarFactory = attendanceCalendarFactory;
|
||||||
_absenceDayListFactory = absenceDayListFactory;
|
_absenceDayListFactory = absenceDayListFactory;
|
||||||
_attendanceDataRefresher = attendanceDataRefresher;
|
_attendanceDataRefresher = attendanceDataRefresher;
|
||||||
foreach (var template in templates.GetTemplates().Where(x => !x.IsExamCover)) Templates.Add(new(template));
|
foreach (var template in templates.GetTemplates().Where(x => !x.IsExamCover && !x.IsStudentReport)) Templates.Add(new(template));
|
||||||
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) Contacts.Add(new(contact));
|
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) Contacts.Add(new(contact));
|
||||||
foreach (var membership in memberships.GetByStudent(student.Id))
|
foreach (var membership in memberships.GetByStudent(student.Id))
|
||||||
if (groups.GetById(membership.GroupId) is { } group) Groups.Add(new(group));
|
if (groups.GetById(membership.GroupId) is { } group) Groups.Add(new(group));
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Templating;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
|
||||||
|
public partial class StudentReportDialogViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly Student _student;
|
||||||
|
private readonly StudentReportService _reports;
|
||||||
|
private readonly ITemplateRenderer _renderer;
|
||||||
|
private readonly IInterimGradeRepository _interims;
|
||||||
|
private readonly SchoolYearService _schoolYears;
|
||||||
|
private readonly TemplateStore _loader;
|
||||||
|
public string StudentName => _student.FullName;
|
||||||
|
public string SuggestedFileName => string.Concat($"Schülerbericht_{_student.LastName}_{_student.FirstName}.pdf"
|
||||||
|
.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));
|
||||||
|
public ObservableCollection<LetterGroupChoice> Groups { get; } = [];
|
||||||
|
public ObservableCollection<StudentReportTemplateChoice> Templates { get; } = [];
|
||||||
|
public ObservableCollection<StudentReportInterimChoice> Interims { get; } = [];
|
||||||
|
[ObservableProperty] private LetterGroupChoice? _selectedGroup;
|
||||||
|
[ObservableProperty] private StudentReportTemplateChoice? _selectedTemplate;
|
||||||
|
[ObservableProperty] private StudentReportInterimChoice? _selectedInterim;
|
||||||
|
[ObservableProperty] private string _from = "";
|
||||||
|
[ObservableProperty] private string _to = DateTime.Today.ToString("dd.MM.yyyy");
|
||||||
|
[ObservableProperty] private bool _includeInterim = true;
|
||||||
|
[ObservableProperty] private bool _includeParticipation = true;
|
||||||
|
[ObservableProperty] private bool _includeCompetencies = true;
|
||||||
|
[ObservableProperty] private bool _includeHomework = true;
|
||||||
|
[ObservableProperty] private bool _includeDocumentation = true;
|
||||||
|
[ObservableProperty] private bool _includeConfidential;
|
||||||
|
[ObservableProperty] private string _comment = "";
|
||||||
|
[ObservableProperty] private string _statusMessage = "";
|
||||||
|
[ObservableProperty, NotifyPropertyChangedFor(nameof(CanGenerate))] private bool _isGenerating;
|
||||||
|
public bool CanGenerate => !IsGenerating && SelectedGroup is not null && SelectedTemplate is not null;
|
||||||
|
|
||||||
|
public StudentReportDialogViewModel(Student student, IGroupMembershipRepository memberships,
|
||||||
|
IGroupRepository groups, IInterimGradeRepository interims, StudentReportService reports,
|
||||||
|
ITemplateRenderer renderer, SchoolYearService schoolYears, IReadOnlyList<TemplateStore> stores)
|
||||||
|
{
|
||||||
|
_student = student; _interims = interims; _reports = reports; _renderer = renderer;
|
||||||
|
_schoolYears = schoolYears; _loader = stores[0];
|
||||||
|
Templates.Add(new(null));
|
||||||
|
foreach (var template in stores.SelectMany(s => s.GetTemplates()).Where(t => t.IsStudentReport)
|
||||||
|
.DistinctBy(t => t.PackagePath).OrderBy(t => t.Name)) Templates.Add(new(template));
|
||||||
|
foreach (var membership in memberships.GetByStudent(student.Id))
|
||||||
|
if (groups.GetById(membership.GroupId) is { } group && !Groups.Any(g => g.Model.Id == group.Id))
|
||||||
|
Groups.Add(new(group));
|
||||||
|
SelectedTemplate = Templates[0];
|
||||||
|
SelectedGroup = Groups.OrderByDescending(g => g.Model.SchoolYear).FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedGroupChanged(LetterGroupChoice? value)
|
||||||
|
{
|
||||||
|
Interims.Clear(); SelectedInterim = null;
|
||||||
|
if (value is not null)
|
||||||
|
{
|
||||||
|
From = _schoolYears.SchoolYearStart(value.Model.SchoolYear).ToString("dd.MM.yyyy");
|
||||||
|
To = _schoolYears.SchoolYearEnd(value.Model.SchoolYear).ToString("dd.MM.yyyy");
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
if (_schoolYears.SchoolYearEnd(value.Model.SchoolYear) > today) To = today.ToString("dd.MM.yyyy");
|
||||||
|
foreach (var grade in _interims.GetByStudentAndGroup(_student.Id, value.Model.Id)
|
||||||
|
.OrderByDescending(g => g.Date).ThenBy(g => g.Occasion)) Interims.Add(new(grade));
|
||||||
|
}
|
||||||
|
OnPropertyChanged(nameof(CanGenerate));
|
||||||
|
}
|
||||||
|
partial void OnSelectedTemplateChanged(StudentReportTemplateChoice? value) => OnPropertyChanged(nameof(CanGenerate));
|
||||||
|
|
||||||
|
public async Task<bool> GenerateAsync(string outputPath)
|
||||||
|
{
|
||||||
|
if (!CanGenerate) return false;
|
||||||
|
StatusMessage = "";
|
||||||
|
if (!DateOnly.TryParseExact(From, "dd.MM.yyyy", CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out var from)
|
||||||
|
|| !DateOnly.TryParseExact(To, "dd.MM.yyyy", CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out var to))
|
||||||
|
{ StatusMessage = "Bitte Beginn und Ende als TT.MM.JJJJ eingeben."; return false; }
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var template = SelectedTemplate!.Model is { } installed ? _loader.Load(installed) : StudentReportTemplate.Create();
|
||||||
|
// Datenbankzugriffe auf dem UI-Thread; nur das Setzen und Schreiben läuft im Hintergrund.
|
||||||
|
var values = _reports.Build(_student, SelectedGroup!.Model, template,
|
||||||
|
new(from, to, SelectedInterim?.Model, IncludeInterim, IncludeParticipation, IncludeCompetencies,
|
||||||
|
IncludeHomework, IncludeDocumentation, IncludeConfidential, Comment));
|
||||||
|
IsGenerating = true;
|
||||||
|
await Task.Run(() => File.WriteAllBytes(outputPath, _renderer.RenderToPdf(template, new LetterDataProvider(values))));
|
||||||
|
StatusMessage = "Schülerbericht erzeugt.";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException or QuestPDF.Drawing.Exceptions.DocumentLayoutException)
|
||||||
|
{ StatusMessage = $"Der Schülerbericht konnte nicht erzeugt werden: {ex.Message}"; return false; }
|
||||||
|
finally { IsGenerating = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record StudentReportTemplateChoice(InstalledTemplate? Model)
|
||||||
|
{
|
||||||
|
public string Name => Model?.Name ?? "Schülerbericht Standard";
|
||||||
|
}
|
||||||
|
public sealed record StudentReportInterimChoice(InterimGrade Model)
|
||||||
|
{
|
||||||
|
public string Label => $"{Model.Date:dd.MM.yyyy} · {Model.Occasion}";
|
||||||
|
}
|
||||||
@@ -276,6 +276,7 @@ public partial class StudentDetailViewModel : ObservableObject
|
|||||||
public Func<Documentation, string, Task<Documentation?>>? OnConductParentCall { get; set; }
|
public Func<Documentation, string, Task<Documentation?>>? OnConductParentCall { get; set; }
|
||||||
public Func<Student, Task<StudentManagementResult>>? OnManageStudent { get; set; }
|
public Func<Student, Task<StudentManagementResult>>? OnManageStudent { get; set; }
|
||||||
public Func<Task>? OnCreateLetter { get; set; }
|
public Func<Task>? OnCreateLetter { get; set; }
|
||||||
|
public Func<Task>? OnCreateStudentReport { get; set; }
|
||||||
public Action? OnReturnToStudentList { get; set; }
|
public Action? OnReturnToStudentList { get; set; }
|
||||||
|
|
||||||
partial void OnIsStudentActiveChanged(bool value) => OnPropertyChanged(nameof(StudentStatus));
|
partial void OnIsStudentActiveChanged(bool value) => OnPropertyChanged(nameof(StudentStatus));
|
||||||
@@ -455,6 +456,12 @@ public partial class StudentDetailViewModel : ObservableObject
|
|||||||
|
|
||||||
[RelayCommand] private void StartEdit() => IsEditing = true;
|
[RelayCommand] private void StartEdit() => IsEditing = true;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task CreateStudentReport()
|
||||||
|
{
|
||||||
|
if (Student is not null && OnCreateStudentReport is not null) await OnCreateStudentReport();
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task CreateLetter()
|
private async Task CreateLetter()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
x:Class="LehrerApp.Desktop.Views.Groups.ExamCoverSheetDialog"
|
x:Class="LehrerApp.Desktop.Views.Groups.ExamCoverSheetDialog"
|
||||||
x:DataType="vm:ExamCoverSheetDialogViewModel"
|
x:DataType="vm:ExamCoverSheetDialogViewModel"
|
||||||
Title="Deckblätter drucken"
|
Title="Deckblätter drucken"
|
||||||
Width="460" Height="680"
|
Width="460" Height="740" MinWidth="440" MinHeight="500"
|
||||||
CanResize="False" WindowStartupLocation="CenterOwner">
|
CanResize="True" WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,Auto,*,Auto,Auto" Margin="24">
|
<Grid RowDefinitions="Auto,Auto,*,Auto,Auto" Margin="24">
|
||||||
|
|
||||||
@@ -29,6 +29,14 @@
|
|||||||
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding TeacherAbbreviation}" PlaceholderText="z. B. MON HED"
|
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding TeacherAbbreviation}" PlaceholderText="z. B. MON HED"
|
||||||
ToolTip.Tip="Vorbelegt mit dem eigenen Kürzel aus den Einstellungen. Änderungen gelten nur für diesen Druck."/>
|
ToolTip.Tip="Vorbelegt mit dem eigenen Kürzel aus den Einstellungen. Änderungen gelten nur für diesen Druck."/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<StackPanel Spacing="4" IsVisible="{Binding UsesInterimGrades}">
|
||||||
|
<TextBlock Text="Gespeicherter Notenzwischenstand" FontWeight="SemiBold"/>
|
||||||
|
<ComboBox ItemsSource="{Binding InterimRuns}" SelectedItem="{Binding SelectedInterimRun}"
|
||||||
|
DisplayMemberBinding="{Binding Label}" HorizontalAlignment="Stretch"
|
||||||
|
PlaceholderText="Datum und Anlass auswählen"/>
|
||||||
|
<TextBlock Text="Für alle ausgewählten Schüler muss in diesem Durchgang ein Zwischenstand vorliegen."
|
||||||
|
TextWrapping="Wrap" FontSize="12" Opacity="0.65"/>
|
||||||
|
</StackPanel>
|
||||||
<CheckBox Content="Punkte und Note eintragen" IsChecked="{Binding FillResults}"
|
<CheckBox Content="Punkte und Note eintragen" IsChecked="{Binding FillResults}"
|
||||||
ToolTip.Tip="Aus: Blanko-Deckblatt mit Namen und leerer Punktetabelle zum Ausfüllen von Hand."/>
|
ToolTip.Tip="Aus: Blanko-Deckblatt mit Namen und leerer Punktetabelle zum Ausfüllen von Hand."/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Top">
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Top">
|
||||||
<TextBlock Text="{Binding StudentStatus}" VerticalAlignment="Center" Opacity="0.65"/>
|
<TextBlock Text="{Binding StudentStatus}" VerticalAlignment="Center" Opacity="0.65"/>
|
||||||
|
<Button Content="Schülerbericht" Command="{Binding CreateStudentReportCommand}"
|
||||||
|
IsVisible="{Binding !IsEditing}"/>
|
||||||
<Button Content="PDF-Brief" Command="{Binding CreateLetterCommand}"
|
<Button Content="PDF-Brief" Command="{Binding CreateLetterCommand}"
|
||||||
IsVisible="{Binding !IsEditing}"/>
|
IsVisible="{Binding !IsEditing}"/>
|
||||||
<Button Content="Bearbeiten" Command="{Binding StartEditCommand}"
|
<Button Content="Bearbeiten" Command="{Binding StartEditCommand}"
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public partial class StudentDetailView : UserControl
|
|||||||
vm.OnConductParentCall = ShowParentCallSessionDialog;
|
vm.OnConductParentCall = ShowParentCallSessionDialog;
|
||||||
vm.OnManageStudent = ShowManageStudentDialog;
|
vm.OnManageStudent = ShowManageStudentDialog;
|
||||||
vm.OnCreateLetter = ShowCreateLetterDialog;
|
vm.OnCreateLetter = ShowCreateLetterDialog;
|
||||||
|
vm.OnCreateStudentReport = ShowStudentReportDialog;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Contact?> ShowContactDialog(Contact? contact)
|
private async Task<Contact?> ShowContactDialog(Contact? contact)
|
||||||
@@ -58,6 +59,22 @@ public partial class StudentDetailView : UserControl
|
|||||||
await LetterDialogs.ShowCreateLetterDialogAsync(owner, student);
|
await LetterDialogs.ShowCreateLetterDialogAsync(owner, student);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ShowStudentReportDialog()
|
||||||
|
{
|
||||||
|
if (TopLevel.GetTopLevel(this) is not Window owner ||
|
||||||
|
DataContext is not StudentDetailViewModel { Student: { } student }) return;
|
||||||
|
var vm = new StudentReportDialogViewModel(student,
|
||||||
|
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||||
|
App.Services.GetRequiredService<IGroupRepository>(),
|
||||||
|
App.Services.GetRequiredService<IInterimGradeRepository>(),
|
||||||
|
App.Services.GetRequiredService<StudentReportService>(),
|
||||||
|
App.Services.GetRequiredService<LehrerApp.Templating.ITemplateRenderer>(),
|
||||||
|
App.Services.GetRequiredService<LehrerApp.Core.Services.SchoolYearService>(),
|
||||||
|
[App.Services.GetRequiredService<LehrerApp.Templating.TemplateStore>(),
|
||||||
|
App.Services.GetRequiredService<LehrerApp.Templating.WorksheetTemplateStore>().Store]);
|
||||||
|
await new StudentReportDialog { DataContext = vm }.ShowDialog(owner);
|
||||||
|
}
|
||||||
|
|
||||||
private void ShowAddressViewer(ContactItem contact)
|
private void ShowAddressViewer(ContactItem contact)
|
||||||
{
|
{
|
||||||
if (!contact.HasAddress) return;
|
if (!contact.HasAddress) return;
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<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.StudentReportDialog"
|
||||||
|
x:DataType="vm:StudentReportDialogViewModel"
|
||||||
|
Title="Schülerbericht" Width="560" Height="760" MinWidth="460" MinHeight="500"
|
||||||
|
WindowStartupLocation="CenterOwner">
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto" Margin="24">
|
||||||
|
<StackPanel Spacing="4" Margin="0,0,0,16">
|
||||||
|
<TextBlock Text="Schülerbericht" Classes="dialogtitle"/>
|
||||||
|
<TextBlock Text="{Binding StudentName}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<ScrollViewer Grid.Row="1">
|
||||||
|
<StackPanel Spacing="12" IsEnabled="{Binding !IsGenerating}">
|
||||||
|
<TextBlock Text="Lerngruppe und Schuljahr" FontWeight="SemiBold"/>
|
||||||
|
<ComboBox ItemsSource="{Binding Groups}" SelectedItem="{Binding SelectedGroup}"
|
||||||
|
DisplayMemberBinding="{Binding Display}" HorizontalAlignment="Stretch"/>
|
||||||
|
<TextBlock Text="Vorlage" FontWeight="SemiBold"/>
|
||||||
|
<ComboBox ItemsSource="{Binding Templates}" SelectedItem="{Binding SelectedTemplate}"
|
||||||
|
DisplayMemberBinding="{Binding Name}" HorizontalAlignment="Stretch"/>
|
||||||
|
<Grid ColumnDefinitions="*,12,*">
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Von (TT.MM.JJJJ)"/>
|
||||||
|
<TextBox Text="{Binding From}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
|
<TextBlock Text="Bis (TT.MM.JJJJ)"/>
|
||||||
|
<TextBox Text="{Binding To}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
<CheckBox Content="Notenzwischenstand" IsChecked="{Binding IncludeInterim}"/>
|
||||||
|
<ComboBox ItemsSource="{Binding Interims}" SelectedItem="{Binding SelectedInterim}"
|
||||||
|
DisplayMemberBinding="{Binding Label}" HorizontalAlignment="Stretch"
|
||||||
|
PlaceholderText="Gespeicherten Zwischenstand auswählen" IsVisible="{Binding IncludeInterim}"/>
|
||||||
|
<CheckBox Content="Mitarbeitsgraph mit gewichteter Mitarbeit und Einzelaspekten" IsChecked="{Binding IncludeParticipation}"/>
|
||||||
|
<CheckBox Content="Kompetenzbewertungen" IsChecked="{Binding IncludeCompetencies}"/>
|
||||||
|
<CheckBox Content="Hausaufgabenkontrollen" IsChecked="{Binding IncludeHomework}"/>
|
||||||
|
<CheckBox Content="Klassenbucheinträge und Unterrichtsnotizen" IsChecked="{Binding IncludeDocumentation}"/>
|
||||||
|
<CheckBox Content="Vertrauliche Dokumentation einschließen" IsChecked="{Binding IncludeConfidential}"
|
||||||
|
IsVisible="{Binding IncludeDocumentation}"/>
|
||||||
|
<TextBlock Text="Dokumentation wird auf die gewählte Lerngruppe begrenzt. Entwürfe werden ausgelassen."
|
||||||
|
TextWrapping="Wrap" FontSize="12" Opacity="0.65"/>
|
||||||
|
<TextBlock Text="Ergänzender Text" FontWeight="SemiBold"/>
|
||||||
|
<TextBox Text="{Binding Comment}" AcceptsReturn="True" TextWrapping="Wrap" MinHeight="90"/>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
<StackPanel Grid.Row="2" Spacing="10" Margin="0,12,0,0">
|
||||||
|
<ProgressBar IsVisible="{Binding IsGenerating}" IsIndeterminate="True" Height="6"/>
|
||||||
|
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap"/>
|
||||||
|
<Grid ColumnDefinitions="*,12,*">
|
||||||
|
<Button Content="Schließen" Click="OnClose" IsEnabled="{Binding !IsGenerating}" HorizontalAlignment="Stretch"/>
|
||||||
|
<Button Grid.Column="2" Content="PDF erzeugen…" Click="OnGenerate" IsEnabled="{Binding CanGenerate}" HorizontalAlignment="Stretch"/>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Views.Students;
|
||||||
|
|
||||||
|
public partial class StudentReportDialog : Window
|
||||||
|
{
|
||||||
|
public StudentReportDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
Closing += (_, e) => e.Cancel = DataContext is StudentReportDialogViewModel { IsGenerating: true };
|
||||||
|
}
|
||||||
|
private void OnClose(object? sender, RoutedEventArgs e) => Close();
|
||||||
|
private async void OnGenerate(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not StudentReportDialogViewModel vm) return;
|
||||||
|
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||||
|
{
|
||||||
|
Title = "Schülerbericht speichern", SuggestedFileName = vm.SuggestedFileName,
|
||||||
|
DefaultExtension = "pdf", FileTypeChoices = [new FilePickerFileType("PDF") { Patterns = ["*.pdf"] }],
|
||||||
|
});
|
||||||
|
if (file is null) return;
|
||||||
|
if (await vm.GenerateAsync(file.Path.LocalPath))
|
||||||
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(file.Path.LocalPath) { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,23 @@ namespace LehrerApp.TemplateDesigner.Tests;
|
|||||||
|
|
||||||
public sealed class ExamCoverDesignerTests
|
public sealed class ExamCoverDesignerTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Schuelerbericht_EinrichtenUndSonderinhalte_RendernMitBeispieldaten()
|
||||||
|
{
|
||||||
|
var vm = new DesignerViewModel();
|
||||||
|
vm.Reset();
|
||||||
|
vm.SetUpStudentReport();
|
||||||
|
vm.SetUpStudentReport();
|
||||||
|
vm.LayoutSource = StudentReportTemplate.Layout;
|
||||||
|
Assert.Equal(TemplateReportTypes.StudentReport, vm.BuildLoaded().Manifest.Metadata[TemplateMetadataKeys.ReportType]);
|
||||||
|
Assert.Equal(StudentReportTemplate.Create().Manifest.Placeholders.Count, vm.Placeholders.Count);
|
||||||
|
using var pdf = UglyToad.PdfPig.PdfDocument.Open(vm.RenderCurrentPdf());
|
||||||
|
var text = string.Concat(pdf.GetPages().Select(p => p.Text)).Replace(" ", "");
|
||||||
|
Assert.Contains("GewichteteMitarbeit", text);
|
||||||
|
Assert.Contains("Hausaufgaben", text);
|
||||||
|
Assert.Contains("VorläufigeZeugnisnote", text);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SetUpExamCover_SetztVorlagentypUndErgaenztFehlendePlatzhalter()
|
public void SetUpExamCover_SetztVorlagentypUndErgaenztFehlendePlatzhalter()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -180,10 +180,31 @@ public partial class DesignerViewModel : ObservableObject
|
|||||||
/// später in der Hauptapp.</summary>
|
/// später in der Hauptapp.</summary>
|
||||||
private static ITemplateDataProvider FitSampleData(LoadedTemplate template, ITemplateDataProvider provider)
|
private static ITemplateDataProvider FitSampleData(LoadedTemplate template, ITemplateDataProvider provider)
|
||||||
{
|
{
|
||||||
var values = provider.GetValues();
|
var values = new Dictionary<string, PlaceholderValue>(provider.GetValues(), StringComparer.Ordinal);
|
||||||
if (values.GetValueOrDefault(ExamPointsTableDrawing.PlaceholderName) is not DrawingValue) return provider;
|
if (values.GetValueOrDefault(ExamPointsTableDrawing.PlaceholderName) is DrawingValue)
|
||||||
return new DesignerDataProvider(new Dictionary<string, PlaceholderValue>(values, StringComparer.Ordinal)
|
values[ExamPointsTableDrawing.PlaceholderName] = ExamCoverPlaceholders.SampleTable(template);
|
||||||
{ [ExamPointsTableDrawing.PlaceholderName] = ExamCoverPlaceholders.SampleTable(template) });
|
foreach (var name in StudentReportTemplate.Drawings.Append(ReportTextDrawing.RepeatInfo))
|
||||||
|
if (values.GetValueOrDefault(name) is DrawingValue)
|
||||||
|
values[name] = StudentReportTemplate.SampleDrawing(name, DrawingBoxLocator.Find(template, name));
|
||||||
|
return new DesignerDataProvider(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetUpStudentReport()
|
||||||
|
{
|
||||||
|
var reportType = MetadataItems.FirstOrDefault(x => x.Name.Trim().Equals(TemplateMetadataKeys.ReportType, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (reportType is null) MetadataItems.Add(new(TemplateMetadataKeys.ReportType, TemplateReportTypes.StudentReport));
|
||||||
|
else reportType.Value = TemplateReportTypes.StudentReport;
|
||||||
|
var conflicts = new List<string>();
|
||||||
|
foreach (var known in StudentReportTemplate.Create().Manifest.Placeholders)
|
||||||
|
{
|
||||||
|
var existing = Placeholders.FirstOrDefault(p => p.Name.Trim() == known.Name);
|
||||||
|
if (existing is null) Placeholders.Add(new(known.Name, known.Type, false,
|
||||||
|
known.Name == "Report.Period" ? "01.08.2025 - 31.01.2026" : known.Name == "Student.Name" ? "Erika Beispiel" : known.Name == "Group.Name" ? "9 Ch G" : ""));
|
||||||
|
else if (existing.Type != known.Type) conflicts.Add(known.Name);
|
||||||
|
}
|
||||||
|
CanExport = false;
|
||||||
|
SetStatus(conflicts.Count == 0 ? "Als Schülerbericht eingerichtet. Bausteine stehen unter LehrerApp-Sonderinhalte."
|
||||||
|
: "Abweichender Platzhaltertyp: " + string.Join(", ", conflicts), conflicts.Count > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsExamCover => MetadataItems.Any(x =>
|
public bool IsExamCover => MetadataItems.Any(x =>
|
||||||
@@ -796,6 +817,24 @@ public static class SpecialContentCatalog
|
|||||||
new("Fehltage nach Datum", "Student.AbsenceDays",
|
new("Fehltage nach Datum", "Student.AbsenceDays",
|
||||||
"Chronologische Liste mit Datum, Fehlzeit oder ganzem Fehltag sowie Entschuldigungsstatus.",
|
"Chronologische Liste mit Datum, Fehlzeit oder ganzem Fehltag sowie Entschuldigungsstatus.",
|
||||||
"FLOWDRAWBOX", "170", "140"),
|
"FLOWDRAWBOX", "170", "140"),
|
||||||
|
new("Wiederholungsarbeit", ReportTextDrawing.RepeatInfo,
|
||||||
|
"Erste Arbeit, bisherige Note und Bewertungsregel; wird bei normalen Arbeiten leer gelassen.",
|
||||||
|
"FLOWDRAWBOX", "165", "150", "fit=content", InContentFlow: true),
|
||||||
|
new("Notenzwischenstand", ReportTextDrawing.InterimGrades,
|
||||||
|
"Mündlich, schriftlich und vorläufige Zeugnisnote aus einem beim Druck gewählten gespeicherten Durchgang.",
|
||||||
|
"FLOWDRAWBOX", "165", "150", "fit=content", InContentFlow: true),
|
||||||
|
new("Mitarbeitsgraph (Schülerbericht)", StudentReportTemplate.Participation,
|
||||||
|
"Gewichtete Mitarbeit und Einzelaspekte im gewählten Zeitraum auf der gemeinsamen -2 bis +2-Achse.",
|
||||||
|
"FLOWDRAWBOX", "170", "230", "fit=content", InContentFlow: true),
|
||||||
|
new("Kompetenzen (Schülerbericht)", StudentReportTemplate.Competencies,
|
||||||
|
"Kompetenzbewertungen aus Klausuren und Mitarbeit im gewählten Zeitraum.",
|
||||||
|
"FLOWDRAWBOX", "170", "230", "fit=content", InContentFlow: true),
|
||||||
|
new("Hausaufgaben (Schülerbericht)", StudentReportTemplate.Homework,
|
||||||
|
"Hausaufgabenkontrollen mit Datum und aktuellem Nachreichstatus.",
|
||||||
|
"FLOWDRAWBOX", "170", "230", "fit=content", InContentFlow: true),
|
||||||
|
new("Klassenbucheinträge (Schülerbericht)", StudentReportTemplate.Documentation,
|
||||||
|
"Freigegebene Dokumentation und Unterrichtsnotizen für einen Schüler und eine Lerngruppe.",
|
||||||
|
"FLOWDRAWBOX", "170", "230", "fit=content", InContentFlow: true),
|
||||||
new("Punktetabelle (Klausur-Deckblatt)", ExamPointsTableDrawing.PlaceholderName,
|
new("Punktetabelle (Klausur-Deckblatt)", ExamPointsTableDrawing.PlaceholderName,
|
||||||
"Aufgabe / Max. Punktzahl / Punktzahl mit festen Spaltenbreiten; bei vielen Aufgaben folgt ein weiterer Block, und die Elemente darunter im Content-Flow wandern mit. Die Breite bestimmt, wie viele Aufgaben nebeneinander passen (165 = sechs).",
|
"Aufgabe / Max. Punktzahl / Punktzahl mit festen Spaltenbreiten; bei vielen Aufgaben folgt ein weiterer Block, und die Elemente darunter im Content-Flow wandern mit. Die Breite bestimmt, wie viele Aufgaben nebeneinander passen (165 = sechs).",
|
||||||
"FLOWDRAWBOX", "165", "150", "fit=content", InContentFlow: true),
|
"FLOWDRAWBOX", "165", "150", "fit=content", InContentFlow: true),
|
||||||
@@ -824,7 +863,12 @@ public static class SpecialContentCatalog
|
|||||||
new DrawString(42, 27, "Ganzer Fehltag", 7, "#374151"),
|
new DrawString(42, 27, "Ganzer Fehltag", 7, "#374151"),
|
||||||
new DrawString(116, 27, "Entschuldigt", 7, "#2E7D32"),
|
new DrawString(116, 27, "Entschuldigt", 7, "#2E7D32"),
|
||||||
], 38),
|
], 38),
|
||||||
|
ReportTextDrawing.RepeatInfo => ReportTextDrawing.Build("Wiederholungsarbeit",
|
||||||
|
["Erste Arbeit: Atombau vom 18.12.2025", "Note der ersten Arbeit: 4", "Für die Bewertung zählt die bessere Note aus beiden Arbeiten."]),
|
||||||
|
ReportTextDrawing.InterimGrades => ReportTextDrawing.Build("Vorläufiger Notenzwischenstand",
|
||||||
|
["Stichtag: 15.01.2026 · Sprechtag", "Mündlich: 2", "Schriftlich: 3", "Vorläufige Zeugnisnote: 2-3"]),
|
||||||
ExamPointsTableDrawing.PlaceholderName => ExamPointsTableDrawing.Build(ExamCoverPlaceholders.SampleColumns, 165),
|
ExamPointsTableDrawing.PlaceholderName => ExamPointsTableDrawing.Build(ExamCoverPlaceholders.SampleColumns, 165),
|
||||||
|
_ when StudentReportTemplate.Drawings.Contains(placeholderName) => StudentReportTemplate.SampleDrawing(placeholderName),
|
||||||
_ => DesignerPlaceholder.GenericSampleDrawing(),
|
_ => DesignerPlaceholder.GenericSampleDrawing(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@
|
|||||||
</DataGrid.Columns>
|
</DataGrid.Columns>
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
<Button Content="Markierten Metadateneintrag entfernen" HorizontalAlignment="Left" Click="OnRemoveMetadata"/>
|
<Button Content="Markierten Metadateneintrag entfernen" HorizontalAlignment="Left" Click="OnRemoveMetadata"/>
|
||||||
|
<Button Content="Als Schülerbericht einrichten" HorizontalAlignment="Left" Click="OnSetUpStudentReport"/>
|
||||||
<Button Content="Als Klausur-Deckblatt einrichten" HorizontalAlignment="Left" Click="OnSetUpExamCover"
|
<Button Content="Als Klausur-Deckblatt einrichten" HorizontalAlignment="Left" Click="OnSetUpExamCover"
|
||||||
ToolTip.Tip="Setzt report-type=exam-cover und legt alle Platzhalter an, die die LehrerApp für Deckblätter befüllt (Exam.*, Result.*, …) — mit Beispielwerten für die Vorschau."/>
|
ToolTip.Tip="Setzt report-type=exam-cover und legt alle Platzhalter an, die die LehrerApp für Deckblätter befüllt (Exam.*, Result.*, …) — mit Beispielwerten für die Vorschau."/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ public partial class MainWindow : Window
|
|||||||
if (_viewModel.SelectedMetadata is not { } selected) return;
|
if (_viewModel.SelectedMetadata is not { } selected) return;
|
||||||
_viewModel.MetadataItems.Remove(selected); _viewModel.SelectedMetadata = null; _viewModel.CanExport = false;
|
_viewModel.MetadataItems.Remove(selected); _viewModel.SelectedMetadata = null; _viewModel.CanExport = false;
|
||||||
}
|
}
|
||||||
|
private void OnSetUpStudentReport(object? sender, RoutedEventArgs e) => _viewModel.SetUpStudentReport();
|
||||||
private void OnSetUpExamCover(object? sender, RoutedEventArgs e) => _viewModel.SetUpExamCover();
|
private void OnSetUpExamCover(object? sender, RoutedEventArgs e) => _viewModel.SetUpExamCover();
|
||||||
private void OnRemovePlaceholder(object? sender, RoutedEventArgs e) => _viewModel.RemoveSelectedPlaceholder();
|
private void OnRemovePlaceholder(object? sender, RoutedEventArgs e) => _viewModel.RemoveSelectedPlaceholder();
|
||||||
private void OnBoldSelection(object? sender, RoutedEventArgs e) => WrapPlaceholderSelection("[b]", "[/b]");
|
private void OnBoldSelection(object? sender, RoutedEventArgs e) => WrapPlaceholderSelection("[b]", "[/b]");
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using LehrerApp.Templating;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Templating.Tests;
|
||||||
|
|
||||||
|
public sealed class ReportDrawingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void LangeTexte_WerdenNurZwischenZeilenAufSeitenVerteilt()
|
||||||
|
{
|
||||||
|
var drawing = ReportTextDrawing.Build("Überschrift", [new string('W', 300)], 55, 1, 17);
|
||||||
|
var lines = drawing.Commands.OfType<DrawStringEx>().ToList();
|
||||||
|
Assert.True(drawing.ContentHeight > 17);
|
||||||
|
Assert.All(lines, line => Assert.True(line.Y % 17 + line.Height <= 17.001f));
|
||||||
|
Assert.Equal(new string('W', 300), string.Concat(lines.Skip(1).Select(line => line.Text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FesteBox_MeldetUeberlaufStattAbzuschneiden()
|
||||||
|
{
|
||||||
|
Assert.Throws<InvalidDataException>(() => ReportTextDrawing.Build("Titel", [new string('W', 300)], 55, 1, 17, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TexteUndGraphen_BeachtenDieMassEinheit()
|
||||||
|
{
|
||||||
|
var scale = 72f / 25.4f;
|
||||||
|
var mm = ReportTextDrawing.Build("Titel", ["Eine Beobachtung zum Lernfortschritt."], 170);
|
||||||
|
var pt = ReportTextDrawing.Build("Titel", ["Eine Beobachtung zum Lernfortschritt."], 170 * scale, scale, 240 * scale);
|
||||||
|
Assert.Equal(mm.ContentHeight * scale, pt.ContentHeight, 2);
|
||||||
|
var from = new DateOnly(2026, 10, 1);
|
||||||
|
ReportChartSeries[] series = [new("Qualität", "#123456", [new(from, 1), new(from.AddDays(1), -1)])];
|
||||||
|
var chartMm = ParticipationReportDrawing.Build(series, from, from.AddDays(1));
|
||||||
|
var chartPt = ParticipationReportDrawing.Build(series, from, from.AddDays(1), 170 * scale, scale);
|
||||||
|
Assert.Equal(chartMm.ContentHeight * scale, chartPt.ContentHeight, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -117,6 +117,8 @@ public static class ExamCoverPlaceholders
|
|||||||
new("Result.Points", PlaceholderType.Text, "52,5", "Erreichte Punkte, als Blanko eine Linie"),
|
new("Result.Points", PlaceholderType.Text, "52,5", "Erreichte Punkte, als Blanko eine Linie"),
|
||||||
new("Result.Percent", PlaceholderType.Text, "85 %", "Erreichter Anteil, als Blanko leer"),
|
new("Result.Percent", PlaceholderType.Text, "85 %", "Erreichter Anteil, als Blanko leer"),
|
||||||
new("Result.Grade", PlaceholderType.Text, "2", "Note, als Blanko eine Linie"),
|
new("Result.Grade", PlaceholderType.Text, "2", "Note, als Blanko eine Linie"),
|
||||||
|
new(ReportTextDrawing.RepeatInfo, PlaceholderType.Drawing, "", "Informationen zur ersten Arbeit und Bewertungsregel der Wiederholung"),
|
||||||
|
new(ReportTextDrawing.InterimGrades, PlaceholderType.Drawing, "", "Gespeicherter Zwischenstand: mündlich, schriftlich, vorläufige Zeugnisnote"),
|
||||||
new(ExamPointsTableDrawing.PlaceholderName, PlaceholderType.Drawing, "",
|
new(ExamPointsTableDrawing.PlaceholderName, PlaceholderType.Drawing, "",
|
||||||
"Punktetabelle (Aufgabe / Max. Punktzahl / Punktzahl)"),
|
"Punktetabelle (Aufgabe / Max. Punktzahl / Punktzahl)"),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -226,6 +226,18 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
|||||||
canvas.Layers(layers =>
|
canvas.Layers(layers =>
|
||||||
{
|
{
|
||||||
layers.PrimaryLayer().Background(Colors.White);
|
layers.PrimaryLayer().Background(Colors.White);
|
||||||
|
// Ohne eigene Folgeseitenvorlage ist derselbe Hintergrund auf jeder Seite
|
||||||
|
// vorgesehen. Zwei zustandsbehaftete ShowOnce/SkipOnce-Layer für dieselben
|
||||||
|
// Elemente können sich beim Paginieren gegenseitig verdrängen.
|
||||||
|
if (ReferenceEquals(first, continuation))
|
||||||
|
{
|
||||||
|
foreach (var element in first.Elements)
|
||||||
|
{
|
||||||
|
var current = element;
|
||||||
|
layers.Layer().Repeat().Element(root => RenderElement(root, current, template, values));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
foreach (var element in first.Elements)
|
foreach (var element in first.Elements)
|
||||||
{
|
{
|
||||||
var current = element;
|
var current = element;
|
||||||
|
|||||||
@@ -316,3 +316,39 @@ Höhe ins Elementformular. Im Bearbeitungsmodus lassen sich vorhandene Elemente
|
|||||||
außer einzeiligem `TEXT` - am rechten unteren Anfasser skalieren. Rasterfang und Pfeiltasten sind
|
außer einzeiligem `TEXT` - am rechten unteren Anfasser skalieren. Rasterfang und Pfeiltasten sind
|
||||||
für Feinkorrekturen verfügbar. Änderungen werden in die ursprüngliche DSL-Zeile zurückgeschrieben,
|
für Feinkorrekturen verfügbar. Änderungen werden in die ursprüngliche DSL-Zeile zurückgeschrieben,
|
||||||
wobei Inhalte, Platzhalter, Formatangaben und Attribute erhalten bleiben.
|
wobei Inhalte, Platzhalter, Formatangaben und Attribute erhalten bleiben.
|
||||||
|
|
||||||
|
|
||||||
|
### Weitere Deckblatt- und Schülerbericht-Bausteine
|
||||||
|
|
||||||
|
Die Sonderinhalte im TemplateDesigner umfassen zusätzlich:
|
||||||
|
|
||||||
|
| Platzhalter (Drawing) | Inhalt |
|
||||||
|
| --- | --- |
|
||||||
|
| `Exam.RepeatInfo` | Erste Arbeit, bisherige Note und Regel der Wiederholung; bei normalen Arbeiten leer |
|
||||||
|
| `Student.InterimGrades` | Gespeicherter Zwischenstand mit Stichtag/Anlass, mündlich, schriftlich und vorläufiger Zeugnisnote |
|
||||||
|
| `Student.ParticipationChart` | Gewichtete Mitarbeit und Einzelaspekte auf der gemeinsamen -2 bis +2-Achse |
|
||||||
|
| `Student.Competencies` | Kompetenzbewertungen aus Klausuren und Mitarbeit |
|
||||||
|
| `Student.Homework` | Hausaufgabenkontrollen mit Datum und aktuellem Nachreichstatus |
|
||||||
|
| `Student.Documentation` | Dokumentation und Unterrichtsnotizen der gewählten Lerngruppe |
|
||||||
|
|
||||||
|
`Exam.RepeatInfo` und `Student.InterimGrades` sind in Klausur-Deckblättern verfügbar.
|
||||||
|
Ein Zwischenstand wird im Druckdialog über Datum und Anlass gewählt; fehlt ein ausgewählter
|
||||||
|
Schüler in diesem Durchgang, wird der Druck mit einem Hinweis verhindert.
|
||||||
|
|
||||||
|
Für Schülerberichte im Schülerdetail gibt es eine eingebaute Standardvorlage und importierbare
|
||||||
|
Pakete mit `report-type=student-report` (in einer der beiden vorhandenen Vorlagenbibliotheken).
|
||||||
|
Im Designer setzt „Als Schülerbericht einrichten“ den Typ und ergänzt die benötigten Platzhalter;
|
||||||
|
die Sonderinhalte werden danach nach Bedarf in den Content-Flow eingefügt. Es werden außerdem
|
||||||
|
`Student.Name`, `Group.Name`, `Report.Period` (Text) und `Report.Comment` (Multiline) befüllt.
|
||||||
|
Es gibt keine erforderliche Kontaktadresse: der Bericht ist für den Austausch mit Kollegen gedacht.
|
||||||
|
|
||||||
|
Für lange Inhalte `FLOWDRAWBOX … fit=content` verwenden. Ihre Höhe muss einschließlich `gap`
|
||||||
|
in den Flow-Slot passen; die Standardvorlage nutzt 230 mm Inhaltshöhe in einem 240-mm-Slot.
|
||||||
|
Feste `DRAWBOX`-Textblöcke melden Platzmangel. Der Mitarbeitsgraph braucht mindestens 50 mm
|
||||||
|
Breite und ausreichend Höhe für seine Legende. Die Textbausteine beachten die Maßeinheit der Vorlage.
|
||||||
|
|
||||||
|
Der Bericht filtert auf Schüler, Gruppe und Zeitraum. Gelöschte Dokumentation und Entwürfe werden
|
||||||
|
nicht übernommen; vertrauliche Dokumentation ist im Dialog zuschaltbar. Hausaufgabenstatus und
|
||||||
|
Dokumentation entsprechen dem aktuellen gespeicherten Zustand. Notenzwischenstände werden aus
|
||||||
|
gespeicherten Durchgängen übernommen und nicht beim Druck neu berechnet. KI-Texterzeugung ist
|
||||||
|
noch nicht angebunden; ergänzende Texte lassen sich im Berichtsdialog eingeben.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
namespace LehrerApp.Templating;
|
||||||
|
|
||||||
|
/// <summary>Lesbare, umbrechende Textblöcke für Berichtsbausteine. Jede Zeile ist ein eigener
|
||||||
|
/// Zeichenbefehl, damit FLOWDRAWBOX auch lange Berichte an Zeilengrenzen aufteilen kann.</summary>
|
||||||
|
public static class ReportTextDrawing
|
||||||
|
{
|
||||||
|
public const string RepeatInfo = "Exam.RepeatInfo";
|
||||||
|
public const string InterimGrades = "Student.InterimGrades";
|
||||||
|
|
||||||
|
public static DrawingValue Build(string title, IEnumerable<string> paragraphs,
|
||||||
|
float width = 170, float scale = 1, float pageHeight = 240, bool fixedBox = false)
|
||||||
|
{
|
||||||
|
if (!float.IsFinite(width) || !float.IsFinite(scale) || !float.IsFinite(pageHeight)
|
||||||
|
|| scale <= 0 || width < 20 * scale || pageHeight < 12 * scale)
|
||||||
|
throw new InvalidDataException("Der Berichtsbaustein benötigt mindestens 20 × 12 mm Platz.");
|
||||||
|
var commands = new List<DrawingCommand>();
|
||||||
|
var lineHeight = 5f * scale;
|
||||||
|
var y = 0f;
|
||||||
|
// Konservative Zeichenbreiten in Gevierten der Standardschrift. Breite Buchstaben
|
||||||
|
// bekommen mehr Raum; auch einzelne überlange Wörter werden umgebrochen.
|
||||||
|
var available = (width - 2 * scale) / (10 * 25.4f / 72 * scale);
|
||||||
|
static float Advance(char c) => char.IsWhiteSpace(c) ? .4f
|
||||||
|
: c is 'm' or 'w' or 'M' or 'W' ? 1.1f
|
||||||
|
: c > 255 ? 1.1f : char.IsUpper(c) ? 1f : .7f;
|
||||||
|
void Line(string text, bool bold)
|
||||||
|
{
|
||||||
|
var offset = y % pageHeight;
|
||||||
|
if (offset + lineHeight > pageHeight + .001f) y += pageHeight - offset;
|
||||||
|
if (fixedBox && y + lineHeight > pageHeight + .001f)
|
||||||
|
throw new InvalidDataException("Der Berichtsbaustein passt nicht in die feste DRAWBOX. Bitte FLOWDRAWBOX verwenden oder die Box vergrößern.");
|
||||||
|
commands.Add(new DrawStringEx(scale, y, lineHeight, width - 2 * scale, text,
|
||||||
|
DrawingTextAlignment.AlignLeft, 10, Bold: bold));
|
||||||
|
y += lineHeight;
|
||||||
|
}
|
||||||
|
foreach (var paragraph in new[] { title }.Concat(paragraphs))
|
||||||
|
{
|
||||||
|
foreach (var original in paragraph.Replace("\r\n", "\n").Split('\n'))
|
||||||
|
{
|
||||||
|
var remaining = original.Trim();
|
||||||
|
if (remaining.Length == 0) { Line("", false); continue; }
|
||||||
|
while (remaining.Length > 0)
|
||||||
|
{
|
||||||
|
var length = 0;
|
||||||
|
var used = 0f;
|
||||||
|
while (length < remaining.Length && used + Advance(remaining[length]) <= available)
|
||||||
|
used += Advance(remaining[length++]);
|
||||||
|
length = Math.Max(1, length);
|
||||||
|
var cut = length;
|
||||||
|
if (length < remaining.Length)
|
||||||
|
{
|
||||||
|
var space = remaining.LastIndexOf(' ', length - 1, length);
|
||||||
|
if (space > 0) cut = space;
|
||||||
|
}
|
||||||
|
Line(remaining[..cut], paragraph == title);
|
||||||
|
remaining = remaining[cut..].TrimStart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new DrawingValue(commands, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
namespace LehrerApp.Templating;
|
||||||
|
|
||||||
|
public static class StudentReportTemplate
|
||||||
|
{
|
||||||
|
public const string Participation = "Student.ParticipationChart";
|
||||||
|
public const string Competencies = "Student.Competencies";
|
||||||
|
public const string Homework = "Student.Homework";
|
||||||
|
public const string Documentation = "Student.Documentation";
|
||||||
|
public static IReadOnlyList<string> Drawings { get; } =
|
||||||
|
[ReportTextDrawing.InterimGrades, Participation, Competencies, Homework, Documentation];
|
||||||
|
|
||||||
|
public const string Layout = """
|
||||||
|
PAGE 210 297 mm
|
||||||
|
#pragma format-version 3
|
||||||
|
#pragma page-template first
|
||||||
|
TEXT 20 12 "Schülerbericht: $Student.Name" size=15
|
||||||
|
TEXT 20 22 "Lerngruppe: $Group.Name · $Report.Period" size=10
|
||||||
|
#pragma flow-slot body x=20 y=34 w=170 h=240
|
||||||
|
#pragma end-page-template
|
||||||
|
#pragma content-flow body
|
||||||
|
FLOWDRAWBOX $Student.InterimGrades w=170 h=230 fit=content gap=5
|
||||||
|
FLOWDRAWBOX $Student.ParticipationChart w=170 h=230 fit=content gap=5
|
||||||
|
FLOWDRAWBOX $Student.Competencies w=170 h=230 fit=content gap=5
|
||||||
|
FLOWDRAWBOX $Student.Homework w=170 h=230 fit=content gap=5
|
||||||
|
FLOWDRAWBOX $Student.Documentation w=170 h=230 fit=content gap=5
|
||||||
|
FLOWBOX $Report.Comment w=170 size=10
|
||||||
|
#pragma end-content-flow
|
||||||
|
""";
|
||||||
|
|
||||||
|
public static DrawingValue SampleDrawing(string name, DeclaredDrawingBox? declared = null)
|
||||||
|
{
|
||||||
|
var box = declared ?? new DeclaredDrawingBox(170, 240, 1, false);
|
||||||
|
var from = new DateOnly(2026, 1, 1);
|
||||||
|
if (name == Participation)
|
||||||
|
return ParticipationReportDrawing.Build([new("Gewichtete Mitarbeit", "#6C5CE7",
|
||||||
|
[new(from, -.5), new(from.AddDays(7), .5), new(from.AddDays(14), 1.2)])], from, from.AddDays(20), box.Width, box.MillimeterScale);
|
||||||
|
var (title, text) = name switch
|
||||||
|
{
|
||||||
|
ReportTextDrawing.RepeatInfo => ("Wiederholungsarbeit", new[] { "Erste Arbeit: Atombau vom 18.12.2025", "Note der ersten Arbeit: 4", "Für die Bewertung zählt die bessere Note aus beiden Arbeiten." }),
|
||||||
|
ReportTextDrawing.InterimGrades => ("Vorläufiger Notenzwischenstand", new[] { "Stichtag: 15.01.2026 · Sprechtag", "Mündlich: 2", "Schriftlich: 3", "Vorläufige Zeugnisnote: 2-3" }),
|
||||||
|
Competencies => ("Kompetenzen", new[] { "CH1 Aufbau der Atome beschreiben: 75 % (4 Bewertungen)" }),
|
||||||
|
Homework => ("Hausaufgaben", new[] { "12.01.2026: Hausaufgabe gemacht", "14.01.2026: Hausaufgabe nachgereicht" }),
|
||||||
|
_ => ("Klassenbucheinträge und Unterrichtsnotizen", new[] { "15.01.2026 · Gespräch: Lernentwicklung", "Erika arbeitet inzwischen regelmäßig mit." }),
|
||||||
|
};
|
||||||
|
return ReportTextDrawing.Build(title, text, box.Width, box.MillimeterScale, box.Height, box.IsFixed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LoadedTemplate Create() => new(new TemplateManifest
|
||||||
|
{
|
||||||
|
Id = "student-report-standard", Name = "Schülerbericht Standard",
|
||||||
|
Metadata = new() { [TemplateMetadataKeys.ReportType] = TemplateReportTypes.StudentReport },
|
||||||
|
Placeholders = new[]
|
||||||
|
{
|
||||||
|
new PlaceholderDefinition("Student.Name", PlaceholderType.Text),
|
||||||
|
new PlaceholderDefinition("Group.Name", PlaceholderType.Text),
|
||||||
|
new PlaceholderDefinition("Report.Period", PlaceholderType.Text),
|
||||||
|
new PlaceholderDefinition("Report.Comment", PlaceholderType.Multiline),
|
||||||
|
}.Concat(Drawings.Select(n => new PlaceholderDefinition(n, PlaceholderType.Drawing))).ToList(),
|
||||||
|
}, new LayoutParser().Parse(Layout), new Dictionary<string, byte[]>());
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ReportChartPoint(DateOnly Date, double Value);
|
||||||
|
public sealed record ReportChartSeries(string Label, string Color, IReadOnlyList<ReportChartPoint> Points);
|
||||||
|
|
||||||
|
public static class ParticipationReportDrawing
|
||||||
|
{
|
||||||
|
public static DrawingValue Build(IReadOnlyList<ReportChartSeries> series, DateOnly from, DateOnly to,
|
||||||
|
float width = 170, float scale = 1)
|
||||||
|
{
|
||||||
|
if (from > to || width < 50 * scale || scale <= 0 || !float.IsFinite(width) || !float.IsFinite(scale))
|
||||||
|
throw new InvalidDataException("Der Mitarbeitsgraph benötigt mindestens 50 mm Breite und einen gültigen Zeitraum.");
|
||||||
|
if (series.All(s => s.Points.Count == 0))
|
||||||
|
return ReportTextDrawing.Build("Mitarbeitsentwicklung", ["Keine bewerteten Sitzungen im gewählten Zeitraum."], width, scale);
|
||||||
|
var commands = new List<DrawingCommand>();
|
||||||
|
void Text(float x, float y, string text, string color = "#334155", bool bold = false) =>
|
||||||
|
commands.Add(new DrawString(x * scale, y * scale, text, 9, color, Bold: bold));
|
||||||
|
var right = width / scale - 4;
|
||||||
|
const float left = 12, top = 14, height = 40;
|
||||||
|
Text(1, 0, "Mitarbeitsentwicklung (-2 bis +2)", bold: true);
|
||||||
|
for (var value = -2; value <= 2; value++)
|
||||||
|
{
|
||||||
|
var y = top + (2 - value) / 4f * height;
|
||||||
|
Text(1, y - 2, value.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
commands.Add(new DrawLine(left * scale, y * scale, right * scale, y * scale, "#CBD5E1", .2f * scale));
|
||||||
|
}
|
||||||
|
var days = Math.Max(1, to.DayNumber - from.DayNumber);
|
||||||
|
foreach (var item in series)
|
||||||
|
{
|
||||||
|
(float X, float Y)? previous = null;
|
||||||
|
foreach (var point in item.Points.Where(p => p.Date >= from && p.Date <= to).OrderBy(p => p.Date))
|
||||||
|
{
|
||||||
|
var x = (left + (point.Date.DayNumber - from.DayNumber) / (float)days * (right - left)) * scale;
|
||||||
|
var y = (top + (2 - (float)Math.Clamp(point.Value, -2, 2)) / 4 * height) * scale;
|
||||||
|
if (previous is { } prev) commands.Add(new DrawLine(prev.X, prev.Y, x, y, item.Color, .5f * scale));
|
||||||
|
commands.Add(new DrawCircle(x, y, .8f * scale, item.Color, .2f * scale, item.Color));
|
||||||
|
previous = (x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(left, 57, from.ToString("dd.MM.yyyy"));
|
||||||
|
Text(Math.Max(left, right - 26), 57, to.ToString("dd.MM.yyyy"));
|
||||||
|
var legendY = 64f;
|
||||||
|
foreach (var item in series)
|
||||||
|
{
|
||||||
|
// Jede Kurve bekommt eine eigene beschriftete Legendenzeile.
|
||||||
|
commands.Add(new DrawLine(left * scale, (legendY + 2) * scale, (left + 6) * scale, (legendY + 2) * scale, item.Color, .6f * scale));
|
||||||
|
var legend = ReportTextDrawing.Build("", [item.Label], (right - left - 10) * scale, scale);
|
||||||
|
// Legendentexte konservativ umbrechen, auch bei individuell langen Aspektnamen.
|
||||||
|
foreach (var line in legend.Commands.OfType<DrawStringEx>().Skip(1))
|
||||||
|
commands.Add(line with { X = (left + 9) * scale, Y = line.Y - 5 * scale + legendY * scale, Color = item.Color });
|
||||||
|
legendY += legend.ContentHeight / scale - 5;
|
||||||
|
}
|
||||||
|
return new DrawingValue(commands, (legendY + 2) * scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ public static class TemplateReportTypes
|
|||||||
{
|
{
|
||||||
public const string Letter = "letter";
|
public const string Letter = "letter";
|
||||||
public const string ExamCover = "exam-cover";
|
public const string ExamCover = "exam-cover";
|
||||||
|
public const string StudentReport = "student-report";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class TemplateMetadataText
|
public static class TemplateMetadataText
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ public static class TemplatePackage
|
|||||||
public sealed record InstalledTemplate(string Id, string Name, string Description, string PackagePath,
|
public sealed record InstalledTemplate(string Id, string Name, string Description, string PackagePath,
|
||||||
DateTime ImportedAtUtc, string ReportType = "")
|
DateTime ImportedAtUtc, string ReportType = "")
|
||||||
{
|
{
|
||||||
|
public bool IsStudentReport => ReportType.Equals(TemplateReportTypes.StudentReport, StringComparison.OrdinalIgnoreCase);
|
||||||
public bool IsExamCover => ReportType.Equals(TemplateReportTypes.ExamCover, StringComparison.OrdinalIgnoreCase);
|
public bool IsExamCover => ReportType.Equals(TemplateReportTypes.ExamCover, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4914,6 +4914,27 @@ beides vor dem ersten produktiven Zwei-Geräte-Einsatz empfehlenswert nachzuhole
|
|||||||
`ExamCoverSheetTests` hält Katalog und `ExamCoverPlaceholderBuilder` zusammen. Beim Öffnen
|
`ExamCoverSheetTests` hält Katalog und `ExamCoverPlaceholderBuilder` zusammen. Beim Öffnen
|
||||||
einer Deckblatt-Vorlage bekommen bekannte Platzhalter sprechende Beispielwerte statt
|
einer Deckblatt-Vorlage bekommen bekannte Platzhalter sprechende Beispielwerte statt
|
||||||
"Beispielwert".
|
"Beispielwert".
|
||||||
|
- [x] **11.5e** Erweiterte Deckblatt-Bausteine ([Gitea #1](https://git.science-teaching.de/admin/LehrerApp/issues/1)):
|
||||||
|
`Exam.RepeatInfo` zeigt die verknüpfte erste Arbeit, die bisherige Note und die Bewertungsregel
|
||||||
|
(inklusive Nachschreibarbeiten). `Student.InterimGrades` druckt mündlich/schriftlich/vorläufige
|
||||||
|
Zeugnisnote aus einem im Deckblatt-Dialog ausdrücklich ausgewählten gespeicherten Durchgang.
|
||||||
|
Fehlende Zwischenstände ausgewählter Schüler blockieren den Druck mit Namensliste.
|
||||||
|
Beide Inhalte sind im TemplateDesigner als Sonderinhalt mit echten Beispieldaten verfügbar.
|
||||||
|
- [~] **11.5f** Schülerbericht für Kollegen ([Gitea #2](https://git.science-teaching.de/admin/LehrerApp/issues/2)):
|
||||||
|
Im Schülerdetail über „Schülerbericht“: Lerngruppe, Zeitraum und einzelne Bausteine wählen;
|
||||||
|
Standardvorlage ohne Import oder eigene `.lavorlage` mit `report-type=student-report`.
|
||||||
|
Enthalten sind gespeicherter Notenzwischenstand, Mitarbeitsgraph (gewichtete Mitarbeit und
|
||||||
|
Einzelaspekte aus derselben Berechnung wie der Assistent), Kompetenzbewertungen,
|
||||||
|
Hausaufgabenkontrollen und Dokumentation/Unterrichtsnotizen. Der Bericht bezieht sich auf
|
||||||
|
genau einen Schüler und eine Lerngruppe; Dokumentationsentwürfe und gelöschte Einträge bleiben
|
||||||
|
ausgeschlossen, vertrauliche Dokumentation wird nur auf ausdrückliche Auswahl übernommen.
|
||||||
|
Lange Texte laufen über mehrere Seiten, feste Zeichenboxen melden Platzmangel statt Text
|
||||||
|
still abzuschneiden. Der TemplateDesigner bietet alle Bausteine und „Als Schülerbericht
|
||||||
|
einrichten“. Ergänzende Texte können manuell eingegeben werden.
|
||||||
|
**Noch offen/optional:** KI-generierte Textbausteine; hierfür enthält Issue #2 bisher nur eine
|
||||||
|
Idee, keinen festgelegten Prompt oder Übernahmeablauf. Hausaufgabenstatus und Dokumentation
|
||||||
|
zeigen ihren aktuellen gespeicherten Zustand im gewählten Zeitraum, keinen historischen
|
||||||
|
Änderungsstand zu einem vergangenen Stichtag.
|
||||||
- [ ] **11.6** Vollständiger Datenexport eines Schuljahres (Archivierung).
|
- [ ] **11.6** Vollständiger Datenexport eines Schuljahres (Archivierung).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in new issue
Block a user