diff --git a/lib/screens/monthly_view.dart b/lib/screens/monthly_view.dart index a142ba9..114590c 100644 --- a/lib/screens/monthly_view.dart +++ b/lib/screens/monthly_view.dart @@ -1,10 +1,7 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:ui' show FontFeature; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/foundation.dart' show kIsWeb; import '../api/booking_api.dart'; import '../api/daily_working_api.dart'; @@ -15,9 +12,6 @@ import '../utils/helpers.dart'; import '../utils/holidays_at.dart'; import '../utils/input_formatters.dart'; -// Plattform-Bridge (conditional import) -import '../platform/pdf_platform.dart' as platform_pdf; - class MonthlyView extends StatefulWidget { const MonthlyView({super.key}); @override @@ -33,9 +27,6 @@ class _MonthlyViewState extends State { static const double _wNumber = 76; static const double _fontSize = 12; - // API Base (nur Debug) - static const String _apiUrl = 'https://api.windesign.at/workinghours.php'; - // Codes, die Zeiten leeren/sperren static const Set _lockCodes = {'G', 'K', 'U', 'SU'}; @@ -68,30 +59,22 @@ class _MonthlyViewState extends State { DateTime.sunday: 0, }; - // Monatliche Startdaten + // Monatliche Startdaten (für kumulierte Differenz & Footer) MonthStart? _monthStartInfo; int _carryBaseMinutes = 0; - // Scroll-Controller + // Scroll-Controller (Header/Body synchron) late final ScrollController _hHeaderCtrl; late final ScrollController _hBodyCtrl; late final ScrollController _vCtrl; bool _syncingH = false; // Save-Status pro Zeile - final Map _saveTimers = {}; - final Set _savingRows = {}; - final Set _justSavedRows = {}; - final Map _rowSaveError = {}; - - // Doc-Existenz je Typ (für Buttons) - final Map _docExists = {'salery': false, 'timesheet': false}; - bool get _hasSalery => _docExists['salery'] == true; - bool get _hasTimesheet => _docExists['timesheet'] == true; - - String get _currentMonthIso => - '${_monthStart.year.toString().padLeft(4, '0')}-' - '${_monthStart.month.toString().padLeft(2, '0')}-01'; + final Map _saveTimers = {}; // rowIndex -> Timer + final Set _savingRows = {}; // Zeilen, die gerade speichern + final Set _justSavedRows = + {}; // Zeilen, die eben gespeichert haben (Häkchen kurz anzeigen) + final Map _rowSaveError = {}; // Zeilenfehler @override void initState() { @@ -106,7 +89,9 @@ class _MonthlyViewState extends State { _hBodyCtrl.addListener(() { if (_syncingH) return; _syncingH = true; - if (_hHeaderCtrl.hasClients) _hHeaderCtrl.jumpTo(_hBodyCtrl.offset); + if (_hHeaderCtrl.hasClients) { + _hHeaderCtrl.jumpTo(_hBodyCtrl.offset); + } _syncingH = false; }); @@ -135,8 +120,8 @@ class _MonthlyViewState extends State { } Future _loadMonth(DateTime m) async { - print( - '[monthly] LOAD month=${m.year}-${m.month.toString().padLeft(2, '0')}'); + // ignore: avoid_print + print('[monthly] LOAD month=${ymd(m)}'); setState(() { _loading = true; _error = null; @@ -144,55 +129,73 @@ class _MonthlyViewState extends State { }); try { final results = await Future.wait([ - _bookingApi.getBookingList(m), - _dailyApi.getDailyMinutes(), - _bookingApi.getMonthStart(m), + _bookingApi.getBookingList(m), // List + _dailyApi.getDailyMinutes(), // Map + _bookingApi.getMonthStart(m), // MonthStart ]); final apiDays = results[0] as List; final plan = results[1] as Map; final mStart = results[2] as MonthStart; - final holidayMap = buildHolidayMapAT(m.year); + // Nur Feiertage, KEINE Wochenenden in die Map aufnehmen: + final holidayMap = buildHolidayMapAT(m.year, includeWeekends: false); final filled = fillMonth(m, apiDays); + + // Debug: zeigen, was die Liste enthält (optional) + // ignore: avoid_print + // for (final d in filled) { print('[monthly] day ${ymd(d.date)} code=${d.code}'); } + final withTargets = filled.map((d) { final isHoliday = holidayMap.containsKey(ymd(d.date)); + // Feiertage haben immer Soll 0 final baseTarget = isHoliday ? 0 : (plan[d.date.weekday] ?? d.targetMinutes); + // U/SU/K ebenfalls Soll 0 final code = d.code; final target = (code == 'U' || code == 'SU' || code == 'K') ? 0 : baseTarget; return WorkDay( - date: d.date, - intervals: d.intervals, - targetMinutes: target, - code: code); + date: d.date, + intervals: d.intervals, + targetMinutes: target, + code: code, + ); }).toList(); + // --- Dedupe: pro Datum nur EIN Eintrag --- + final _counts = {}; + for (final d in withTargets) { + final k = ymd(d.date); + _counts[k] = (_counts[k] ?? 0) + 1; + } + final byDate = {}; + for (final d in withTargets) { + byDate[ymd(d.date)] = d; // spätere Einträge überschreiben frühere + } + final uniqueDays = byDate.values.toList() + ..sort((a, b) => a.date.compareTo(b.date)); + // --- Ende Dedupe --- + setState(() { _monthStart = DateTime(m.year, m.month, 1); _holidays = holidayMap; - _days = withTargets; + _days = uniqueDays; // deduplizierte Liste _dailyPlan = plan; _monthStartInfo = mStart; - _carryBaseMinutes = mStart.carryBaseMinutes; + _carryBaseMinutes = + mStart.carryBaseMinutes; // starthours + overtime + correction _loading = false; + + // Status-Maps leeren (neuer Monat) _savingRows.clear(); _justSavedRows.clear(); _rowSaveError.clear(); }); - print('[monthly] monthStart loaded for ${ymd(_monthStart)} ' - 'carryBase=${_carryBaseMinutes} ' - 'startvacation=${_monthStartInfo?.startVacationUnits ?? 0} ' - 'overtime=${_monthStartInfo?.overtimeMinutes ?? 0} ' - 'correction=${_monthStartInfo?.correctionMinutes ?? 0}'); - _syncControllersWithDays(); - await _refreshDocExists(); } catch (e) { - print('[monthly] LOAD ERROR: $e'); setState(() { _error = e.toString(); _loading = false; @@ -200,35 +203,20 @@ class _MonthlyViewState extends State { } } - Future _refreshDocExists() async { - try { - final sal = await _bookingApi.hasMonthlyPdf( - date: _currentMonthIso, type: 'salery'); - final ts = await _bookingApi.hasMonthlyPdf( - date: _currentMonthIso, type: 'timesheet'); - if (!mounted) return; - setState(() { - _docExists['salery'] = sal; - _docExists['timesheet'] = ts; - }); - } catch (e) { - debugPrint('hasMonthlyPdf error: $e'); - } - } - double get _tableMinWidth => _wDate + _wHoliday + (10 * _wTime) + _wCode + (4 * _wNumber) + _wDate; + // Ist-Override int _workedFor(WorkDay d) { switch (d.code) { case 'U': case 'SU': case 'K': - return 0; + return 0; // Ist = 0 case 'T': - return d.targetMinutes; + return d.targetMinutes; // Training = Soll default: - return d.workedMinutes; + return d.workedMinutes; // regulär per Intervals (inkl. Pausenregel) } } @@ -236,15 +224,15 @@ class _MonthlyViewState extends State { {required Color? holidayBg, required Color? weekendBg}) { switch (d.code) { case 'G': - return const Color(0xFFBFBFFF); + return const Color(0xFFBFBFFF); // Gleitzeit case 'U': - return const Color(0xFF7F7FFF); + return const Color(0xFF7F7FFF); // Urlaub case 'SU': - return const Color(0xFF7F7FFF); + return const Color(0xFF7F7FFF); // Sonderurlaub case 'K': - return Colors.yellow; + return Colors.yellow; // Krankenstand case 'T': - return Colors.red; + return Colors.red; // Training } final isHoliday = _holidays.containsKey(ymd(d.date)); final isWeekend = d.date.weekday == DateTime.saturday || @@ -262,9 +250,11 @@ class _MonthlyViewState extends State { final holidayBg = theme.colorScheme.secondaryContainer.withOpacity(0.45); final weekendBg = Colors.grey.withOpacity(0.30); + // Live-„Effective“-Tage (inkl. Eingabetexte + Sperrlogik) final effectiveDays = List.generate(_days.length, (i) => _effectiveDay(i, _days[i])); + // Tagesdifferenzen & kumuliert (Start mit Monatssaldo aus API) final diffs = []; int sollTotal = 0; int istTotal = 0; @@ -281,6 +271,7 @@ class _MonthlyViewState extends State { cumulative.add(sum); } + // Footer-Werte final monthLabel = monthTitle(_monthStart); final startVacation = _monthStartInfo?.startVacationUnits ?? 0; final usedVacation = _days.where((d) => d.code == 'U').length; @@ -309,101 +300,101 @@ class _MonthlyViewState extends State { ); }); - return Scaffold( - body: Column(children: [ - _MonthHeader( - month: _monthStart, - loading: _loading, - onPrev: () => - _loadMonth(DateTime(_monthStart.year, _monthStart.month - 1, 1)), - onNext: () => - _loadMonth(DateTime(_monthStart.year, _monthStart.month + 1, 1)), - onPickMonth: () async { - final picked = await showDatePicker( - context: context, - initialDate: _monthStart, - firstDate: DateTime(2000, 1, 1), - lastDate: DateTime(2100, 12, 31), - helpText: 'Monat wählen', - initialEntryMode: DatePickerEntryMode.calendarOnly, - ); - if (picked != null) - _loadMonth(DateTime(picked.year, picked.month, 1)); - }, - onReload: () => _loadMonth(_monthStart), - ), - if (_loading) const LinearProgressIndicator(minHeight: 2), - if (_error != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: MaterialBanner( - content: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text('Fehler beim Laden:', - style: TextStyle(fontSize: _fontSize)), - SelectableText(_error ?? '', - style: const TextStyle(fontSize: _fontSize)), - ], - ), - actions: [ - TextButton( - onPressed: () => _loadMonth(_monthStart), - child: const Text('Erneut versuchen')), + return Column(children: [ + _MonthHeader( + month: _monthStart, + loading: _loading, + onPrev: () => + _loadMonth(DateTime(_monthStart.year, _monthStart.month - 1, 1)), + onNext: () => + _loadMonth(DateTime(_monthStart.year, _monthStart.month + 1, 1)), + onPickMonth: () async { + final picked = await showDatePicker( + context: context, + initialDate: _monthStart, + firstDate: DateTime(2000, 1, 1), + lastDate: DateTime(2100, 12, 31), + helpText: 'Monat wählen', + initialEntryMode: DatePickerEntryMode.calendarOnly, + ); + if (picked != null) { + _loadMonth(DateTime(picked.year, picked.month, 1)); + } + }, + onReload: () => _loadMonth(_monthStart), + ), + if (_loading) const LinearProgressIndicator(minHeight: 2), + if (_error != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: MaterialBanner( + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Fehler beim Laden:', + style: TextStyle(fontSize: _fontSize)), + SelectableText(_error ?? '', + style: const TextStyle(fontSize: _fontSize)), ], ), + actions: [ + TextButton( + onPressed: () => _loadMonth(_monthStart), + child: const Text('Erneut versuchen'), + ), + ], ), + ), - // Kopfzeile - SingleChildScrollView( - controller: _hHeaderCtrl, - physics: const NeverScrollableScrollPhysics(), - scrollDirection: Axis.horizontal, - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: _tableMinWidth), - child: DataTableTheme( - data: DataTableThemeData( - headingRowHeight: 30, - columnSpacing: 10, - headingTextStyle: const TextStyle( - fontWeight: FontWeight.w700, fontSize: _fontSize), - ), - child: const _HeaderOnlyDataTable(), + // FIXIERTE KOPFZEILE (mitgeführt) + SingleChildScrollView( + controller: _hHeaderCtrl, + physics: const NeverScrollableScrollPhysics(), + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: _tableMinWidth), + child: DataTableTheme( + data: DataTableThemeData( + headingRowHeight: 30, + columnSpacing: 10, + headingTextStyle: const TextStyle( + fontWeight: FontWeight.w700, fontSize: _fontSize), ), + child: const _HeaderOnlyDataTable(), ), ), + ), - // Body - Expanded( - child: Scrollbar( + // BODY (scrollbar horiz/vert) + Expanded( + child: Scrollbar( + controller: _hBodyCtrl, + notificationPredicate: (n) => n.metrics.axis == Axis.horizontal, + thumbVisibility: true, + child: SingleChildScrollView( controller: _hBodyCtrl, - notificationPredicate: (n) => n.metrics.axis == Axis.horizontal, - thumbVisibility: true, - child: SingleChildScrollView( - controller: _hBodyCtrl, - padding: const EdgeInsets.only(bottom: 16), - scrollDirection: Axis.horizontal, - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: _tableMinWidth), - child: Scrollbar( + padding: const EdgeInsets.only(bottom: 16), + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: _tableMinWidth), + child: Scrollbar( + controller: _vCtrl, + notificationPredicate: (n) => n.metrics.axis == Axis.vertical, + thumbVisibility: true, + child: SingleChildScrollView( controller: _vCtrl, - notificationPredicate: (n) => n.metrics.axis == Axis.vertical, - thumbVisibility: true, - child: SingleChildScrollView( - controller: _vCtrl, - child: DataTableTheme( - data: const DataTableThemeData( - headingRowHeight: 0, - dataRowMinHeight: 30, - dataRowMaxHeight: 34, - columnSpacing: 10, - ), - child: DataTable( - showCheckboxColumn: false, - columns: bodyColumns, - rows: rows, - ), + child: DataTableTheme( + data: const DataTableThemeData( + headingRowHeight: 0, + dataRowMinHeight: 30, + dataRowMaxHeight: 34, + columnSpacing: 10, + ), + child: DataTable( + showCheckboxColumn: false, + columns: bodyColumns, + rows: rows, ), ), ), @@ -411,183 +402,36 @@ class _MonthlyViewState extends State { ), ), ), - - const Divider(height: 1), - _MonthlySummaryFooter( - month: _monthStart, - fontSize: _fontSize, - uebertragStartText: minutesToSignedHHMM(_carryBaseMinutes), - sollText: minutesToHHMM(sollTotal), - istText: minutesToHHMM(istTotal), - correctionText: minutesToSignedHHMM(correctionMin), - saldoText: minutesToSignedHHMM(istTotal - sollTotal), - paidOvertimeText: '—', - uebertragNextText: minutesToSignedHHMM(nextCarryMin), - restUrlaubText: '$startVacation', - urlaubUebertragText: '$vacationCarry', - countGleitzeit: codeCount['G'] ?? 0, - countKrank: codeCount['K'] ?? 0, - countUrlaub: codeCount['U'] ?? 0, - countSonderurlaub: codeCount['SU'] ?? 0, - countTraining: codeCount['T'] ?? 0, - monthLabel: monthLabel, - ), - const SizedBox(height: 8), - ]), - - // ====== Fußleiste mit PDF-Actions (farbig & (de)aktiv) ====== - bottomNavigationBar: SafeArea( - top: false, - child: Material( - elevation: 6, - color: Theme.of(context).colorScheme.surface, - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _docButtonRow(context, - label: 'Salary', type: 'salery', exists: _hasSalery), - const SizedBox(height: 8), - _docButtonRow(context, - label: 'Timesheet', - type: 'timesheet', - exists: _hasTimesheet), - ], - ), - ), - ), - ), - ); - } - - // ---------- Bottom bar helpers ---------- - - Widget _docButtonRow(BuildContext context, - {required String label, required String type, required bool exists}) { - final scheme = Theme.of(context).colorScheme; - final bg = exists ? scheme.primaryContainer : scheme.surfaceVariant; - final fg = exists ? scheme.onPrimaryContainer : scheme.onSurfaceVariant; - - return Container( - decoration: - BoxDecoration(color: bg, borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Text(label, - style: TextStyle(fontWeight: FontWeight.w600, color: fg)), - ), - const Spacer(), - _miniBtn(Icons.visibility, 'view', - exists ? () => _viewDoc(type) : null, fg), - const SizedBox(width: 8), - _miniBtn(Icons.download, 'download', - exists ? () => _downloadDoc(type) : null, fg), - const SizedBox(width: 8), - _miniBtn(Icons.upload_file, 'replace', () => _replaceDoc(type), fg), - const SizedBox(width: 8), - _miniBtn(Icons.delete_outline, 'delete', - exists ? () => _deleteDoc(type) : null, fg), - ], - ), - ); - } - - Widget _miniBtn( - IconData icon, String text, VoidCallback? onPressed, Color fg) { - return FilledButton.tonalIcon( - onPressed: onPressed, - icon: Icon(icon, size: 18), - label: Text(text), - style: FilledButton.styleFrom( - foregroundColor: onPressed == null ? fg.withOpacity(0.5) : fg, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - minimumSize: const Size(0, 36), ), - ); - } - - // ====== Aktionen (nutzen Plattform-Bridge) ====== - - Future _viewDoc(String type) async { - try { - final bytes = - await _bookingApi.getMonthlyPdf(date: _currentMonthIso, type: type); - final name = '${type}_$_currentMonthIso.pdf'; - await platform_pdf.platformViewPdf(context, bytes, filename: name); - } catch (e) { - _snack('Konnte $type nicht anzeigen: $e', isError: true); - } - } - - Future _downloadDoc(String type) async { - try { - final bytes = - await _bookingApi.getMonthlyPdf(date: _currentMonthIso, type: type); - final name = '${type}_$_currentMonthIso.pdf'; - await platform_pdf.platformDownloadPdf(bytes, filename: name); - _snack('Download gestartet'); - } catch (e) { - _snack('Download fehlgeschlagen: $e', isError: true); - } - } - - Future _replaceDoc(String type) async { - try { - final res = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['pdf'], - withData: true, - ); - if (res == null || res.files.isEmpty) return; - final f = res.files.single; - final bytes = f.bytes!; - await _bookingApi.uploadMonthlyPdf( - date: _currentMonthIso, - type: type, - bytes: bytes, - filename: f.name.isNotEmpty ? f.name : '$type.pdf', - ); - _snack('Dokument hochgeladen.'); - await _refreshDocExists(); - } catch (e) { - _snack('Upload fehlgeschlagen: $e', isError: true); - } - } - Future _deleteDoc(String type) async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Löschen bestätigen'), - content: Text( - 'Möchtest du das $type-PDF für $_currentMonthIso wirklich löschen?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Abbrechen')), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Löschen')), - ], + // Footer (zentriert) + const Divider(height: 1), + _MonthlySummaryFooter( + month: _monthStart, + fontSize: _fontSize, + // Linke Spalte + uebertragStartText: minutesToSignedHHMM(_carryBaseMinutes), + sollText: minutesToHHMM(sollTotal), + istText: minutesToHHMM(istTotal), + correctionText: minutesToSignedHHMM(correctionMin), + saldoText: minutesToSignedHHMM(istTotal - sollTotal), + paidOvertimeText: '—', + uebertragNextText: minutesToSignedHHMM(nextCarryMin), + restUrlaubText: '${_monthStartInfo?.startVacationUnits ?? 0}', + urlaubUebertragText: + '${(_monthStartInfo?.startVacationUnits ?? 0) - (_days.where((d) => d.code == "U").length)}', + // Rechte Spalte (Counts) + countGleitzeit: codeCount['G'] ?? 0, + countKrank: codeCount['K'] ?? 0, + countUrlaub: codeCount['U'] ?? 0, + countSonderurlaub: codeCount['SU'] ?? 0, + countTraining: codeCount['T'] ?? 0, + monthLabel: monthLabel, ), - ); - if (ok != true) return; - - try { - await _bookingApi.deleteMonthlyPdf(date: _currentMonthIso, type: type); - _snack('Gelöscht.'); - await _refreshDocExists(); - } catch (e) { - _snack('Löschen fehlgeschlagen: $e', isError: true); - } + const SizedBox(height: 8), + ]); } - // ---------- Tabellen-Logik (wie gehabt) ---------- - List _buildColumns() { DataColumn c(String label, {Alignment align = Alignment.center, double? width}) => @@ -596,9 +440,11 @@ class _MonthlyViewState extends State { width: width, child: Align( alignment: align, - child: Text(label, - textAlign: _toTextAlign(align), - style: const TextStyle(fontSize: _fontSize)), + child: Text( + label, + textAlign: _toTextAlign(align), + style: const TextStyle(fontSize: _fontSize), + ), ), ), ); @@ -627,8 +473,8 @@ class _MonthlyViewState extends State { List _buildEditableCells( int dayIndex, WorkDay day, int runningDiff) { - final leftLabel = rightDayLabel(day.date); - final rightLabel = leftDayLabel(day.date); + final leftLabel = rightDayLabel(day.date); // "Mo 01.09." + final rightLabel = leftDayLabel(day.date); // "01.09. Mo" final hName = _holidays[ymd(day.date)] ?? ''; final bool lockTimes = day.code != null && _lockCodes.contains(day.code); @@ -648,30 +494,41 @@ class _MonthlyViewState extends State { bool isInvalid(int slot, bool isStart) => _invalidCells.contains(_cellKey(dayIndex, slot, isStart)); + final cells = []; + // 0: Datum (rechtsbündig) cells.add(DataCell(SizedBox( width: _wDate, child: Align( - alignment: Alignment.centerRight, - child: Text(leftLabel, style: const TextStyle(fontSize: _fontSize))), + alignment: Alignment.centerRight, + child: Text(leftLabel, style: const TextStyle(fontSize: _fontSize)), + ), ))); + // 1: Feiertag + Save-Status-Icon (rechts) cells.add(DataCell(SizedBox( width: _wHoliday, child: Align( alignment: Alignment.centerLeft, - child: Row(children: [ - Expanded( - child: Text(hName, - style: const TextStyle(fontSize: _fontSize), - overflow: TextOverflow.ellipsis)), - const SizedBox(width: 4), - _rowStatusBadge(dayIndex), - ]), + child: Row( + children: [ + Expanded( + child: Text( + hName, + style: const TextStyle(fontSize: _fontSize), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 4), + _rowStatusBadge(dayIndex), // Spinner/Häkchen/Fehler + ], + ), ), ))); + // 2..11: Zeiten (zentriert) for (int slot = 0; slot < 5; slot++) { + // Start final keyS = 't_${dayIndex}_${slot}_s'; final fnS = _nodeFor(keyS); final ctrlS = _controllerFor(keyS, slotText(slot, true)); @@ -691,25 +548,29 @@ class _MonthlyViewState extends State { final k = _cellKey(dayIndex, slot, true); final valid = text.isEmpty || _isValidHHMM(text); setState(() { - if (valid) + if (valid) { _invalidCells.remove(k); - else + } else { _invalidCells.add(k); + } }); - if (valid && (text.isEmpty || text.length == 5)) + if (valid && (text.isEmpty || text.length == 5)) { _commitTime(dayIndex, slot, true, text); - else - setState(() {}); + } else { + setState(() {}); // Repaint (Fehlermarkierung) + } }, onSubmitted: (text) { if (lockTimes) return; - if (text.isEmpty || _isValidHHMM(text)) + if (text.isEmpty || _isValidHHMM(text)) { _commitTime(dayIndex, slot, true, text); + } }, ), ), ))); + // Ende final keyE = 't_${dayIndex}_${slot}_e'; final fnE = _nodeFor(keyE); final ctrlE = _controllerFor(keyE, slotText(slot, false)); @@ -729,26 +590,30 @@ class _MonthlyViewState extends State { final k = _cellKey(dayIndex, slot, false); final valid = text.isEmpty || _isValidHHMM(text); setState(() { - if (valid) + if (valid) { _invalidCells.remove(k); - else + } else { _invalidCells.add(k); + } }); - if (valid && (text.isEmpty || text.length == 5)) + if (valid && (text.isEmpty || text.length == 5)) { _commitTime(dayIndex, slot, false, text); - else + } else { setState(() {}); + } }, onSubmitted: (text) { if (lockTimes) return; - if (text.isEmpty || _isValidHHMM(text)) + if (text.isEmpty || _isValidHHMM(text)) { _commitTime(dayIndex, slot, false, text); + } }, ), ), ))); } + // 12: Code (Dropdown zentriert, am Wochenende/Feiertag gesperrt) cells.add(DataCell(SizedBox( width: _wCode, child: Align( @@ -757,6 +622,7 @@ class _MonthlyViewState extends State { ), ))); + // 13..16: Kennzahlen final worked = _workedFor(day); final soll = minutesToHHMM(day.targetMinutes); final ist = minutesToHHMM(worked); @@ -764,46 +630,54 @@ class _MonthlyViewState extends State { final diffSum = minutesToSignedHHMM(runningDiff); cells.add(DataCell(SizedBox( - width: _wNumber, - child: - Align(alignment: Alignment.centerRight, child: _monoSmall(soll))))); + width: _wNumber, + child: Align(alignment: Alignment.centerRight, child: _monoSmall(soll)), + ))); cells.add(DataCell(SizedBox( - width: _wNumber, - child: - Align(alignment: Alignment.centerRight, child: _monoSmall(ist))))); + width: _wNumber, + child: Align(alignment: Alignment.centerRight, child: _monoSmall(ist)), + ))); cells.add(DataCell(SizedBox( - width: _wNumber, - child: - Align(alignment: Alignment.centerRight, child: _monoSmall(diff))))); + width: _wNumber, + child: Align(alignment: Alignment.centerRight, child: _monoSmall(diff)), + ))); cells.add(DataCell(SizedBox( - width: _wNumber, - child: Align( - alignment: Alignment.centerRight, child: _monoSmall(diffSum))))); + width: _wNumber, + child: + Align(alignment: Alignment.centerRight, child: _monoSmall(diffSum)), + ))); + // 17: Datum (linksbündig) cells.add(DataCell(SizedBox( width: _wDate, child: Align( - alignment: Alignment.centerLeft, - child: Text(leftDayLabel(day.date), - style: const TextStyle(fontSize: _fontSize))), + alignment: Alignment.centerLeft, + child: Text(rightLabel, style: const TextStyle(fontSize: _fontSize)), + ), ))); - assert(cells.length == 18); + assert( + cells.length == 18, 'Row has ${cells.length} cells but expected 18.'); return cells; } + // kleines Mono-Text-Widget für Zahlen Widget _monoSmall(String s) => Text( s, style: const TextStyle( - fontSize: _fontSize, fontFeatures: [FontFeature.tabularFigures()]), + fontSize: _fontSize, + fontFeatures: [FontFeature.tabularFigures()], + ), ); + // kleiner Status-Badge pro Zeile Widget _rowStatusBadge(int row) { if (_savingRows.contains(row)) { return const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2)); + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ); } if (_rowSaveError.containsKey(row)) { return Icon(Icons.error_outline, @@ -816,25 +690,29 @@ class _MonthlyViewState extends State { } Widget _codeDropdown(int dayIndex, WorkDay day, {required bool disabled}) { - final value = day.code; - final values = [null, ...kAbsenceCodes]; + final value = day.code; // null => — + final values = [null, ...kAbsenceCodes]; // ['G','K','U','SU','T'] + return DropdownButton( isExpanded: true, value: value, items: values.map((v) { - final label = v == null ? '—' : codeLabel(v); + final label = v == null ? '—' : codeLabel(v); // langer Name im Dropdown return DropdownMenuItem( - value: v, - child: Text(label, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: _fontSize))); - }).toList(), - selectedItemBuilder: (context) => values.map((v) { - final shortText = v ?? '—'; - return Center( - child: - Text(shortText, style: const TextStyle(fontSize: _fontSize))); + value: v, + child: Text(label, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: _fontSize)), + ); }).toList(), + selectedItemBuilder: (context) { + return values.map((v) { + final shortText = v ?? '—'; // kurzer Name in der Tabelle + return Center( + child: + Text(shortText, style: const TextStyle(fontSize: _fontSize))); + }).toList(); + }, onChanged: disabled ? null : (newCode) { @@ -850,6 +728,7 @@ class _MonthlyViewState extends State { code: newCode, ); _days = newDays; + if (willLock) { for (int slot = 0; slot < 5; slot++) { final sKey = 't_${dayIndex}_${slot}_s'; @@ -861,6 +740,7 @@ class _MonthlyViewState extends State { } } }); + _scheduleSave(dayIndex); }, ); @@ -880,14 +760,17 @@ class _MonthlyViewState extends State { void _commitTime(int dayIndex, int slot, bool isStart, String text) { final t = text.trim().isEmpty ? null : parseTextHHMM(text); final d = _days[dayIndex]; + if (d.code != null && _lockCodes.contains(d.code)) return; final starts = List.filled(5, null); final ends = List.filled(5, null); + for (int i = 0; i < d.intervals.length && i < 5; i++) { starts[i] = d.intervals[i].start; ends[i] = d.intervals[i].end; } + if (isStart) { starts[slot] = t; } else { @@ -915,11 +798,10 @@ class _MonthlyViewState extends State { _scheduleSave(dayIndex); } + // Debounce für Tages-Save (damit nicht bei jedem Key sofort ein POST rausgeht) void _scheduleSave(int dayIndex) { _saveTimers[dayIndex]?.cancel(); _saveTimers[dayIndex] = Timer(const Duration(milliseconds: 500), () async { - print( - '[monthly] SAVE (debounced) row=$dayIndex date=${ymd(_days[dayIndex].date)}'); await _saveDay(dayIndex); }); } @@ -936,11 +818,9 @@ class _MonthlyViewState extends State { try { final effective = _effectiveDay(dayIndex, _days[dayIndex]); - final payload = _debugDayPayload(effective); - print('[api] POST $_apiUrl'); - print('[api] body=${jsonEncode(payload)}'); - await _bookingApi.saveDay(effective); + + // ⬇️ Folgemonat sofort mitschreiben (Monatsstart) await _saveMonthStartNow(); if (!mounted) return; @@ -948,6 +828,8 @@ class _MonthlyViewState extends State { _savingRows.remove(dayIndex); _justSavedRows.add(dayIndex); }); + + // Häkchen nach kurzer Zeit wieder ausblenden Timer(const Duration(milliseconds: 1200), () { if (!mounted) return; setState(() { @@ -962,18 +844,23 @@ class _MonthlyViewState extends State { }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Speichern fehlgeschlagen: $e'), - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 2)), + content: Text('Speichern fehlgeschlagen: $e'), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), ); } } Future _saveMonthStartNow() async { + // 1) Folgemonat bestimmen final nextMonth = DateTime(_monthStart.year, _monthStart.month + 1, 1); + + // 2) Effektive Tage (inkl. Eingabefelder berücksichtigen) final effectiveDays = List.generate(_days.length, (i) => _effectiveDay(i, _days[i])); + // 3) Saldo des Monats berechnen (Start + Summe(IST - SOLL)) int saldoMin = _carryBaseMinutes; for (final d in effectiveDays) { final worked = _workedFor(d); @@ -981,36 +868,33 @@ class _MonthlyViewState extends State { saldoMin += worked - target; } + // 4) Urlaubs-Übertrag berechnen final startVacation = _monthStartInfo?.startVacationUnits ?? 0; final usedVacation = _days.where((d) => d.code == 'U').length; final carryVacation = startVacation - usedVacation; + // 5) Overtime/Correction aus Startdaten übernehmen final overtime = _monthStartInfo?.overtimeMinutes ?? 0; final correction = _monthStartInfo?.correctionMinutes ?? 0; - final payload = { - 'module': 'monthlybooking', - 'function': 'saveStart', - 'date': - '${nextMonth.year.toString().padLeft(4, '0')}-${nextMonth.month.toString().padLeft(2, '0')}-01', - 'starthours': saldoMin, - 'startvacation': carryVacation, - 'overtime': overtime, - 'correction': correction, - }; - print('[api] POST $_apiUrl'); - print('[api] body=${jsonEncode(payload)}'); + // Debug + // ignore: avoid_print + print( + '[monthly] saveMonthStart next=$nextMonth saldo=$saldoMin vacation=$carryVacation'); try { await _bookingApi.saveMonthStart( - nextMonth, + nextMonth, // BookingApi: positional DateTime starthours: saldoMin, startvacation: carryVacation, overtime: overtime, correction: correction, ); + // ignore: avoid_print + print('[monthly] saveMonthStart OK'); } catch (e) { - debugPrint('monthlybooking/saveStart ERROR: $e'); + // ignore: avoid_print + print('[monthly] saveMonthStart ERROR: $e'); } } @@ -1044,22 +928,28 @@ class _MonthlyViewState extends State { WorkDay _effectiveDay(int row, WorkDay base) { final isHoliday = _holidays.containsKey(ymd(base.date)); + + // Basisziel (Tagesplan) final baseTarget = _dailyPlan[base.date.weekday] ?? base.targetMinutes; + // Zielzeit bestimmen int targetFor(WorkDay b) { if (isHoliday) return 0; if (b.code == 'U' || b.code == 'SU' || b.code == 'K') return 0; return baseTarget; } + // „Lock“-Pfad (G/K/U/SU: keine Zeiten) if (base.code != null && _lockCodes.contains(base.code)) { return WorkDay( - date: base.date, - intervals: const [], - targetMinutes: targetFor(base), - code: base.code); + date: base.date, + intervals: const [], + targetMinutes: targetFor(base), + code: base.code, + ); } + // Eingabetexte berücksichtigen TimeOfDay? baseSlot(WorkDay d, int slot, bool isStart) { if (slot >= d.intervals.length) return null; final iv = d.intervals[slot]; @@ -1079,16 +969,19 @@ class _MonthlyViewState extends State { final e = (eText != null && eText.isNotEmpty) ? parseTextHHMM(eText) : baseSlot(base, slot, false); + if (s != null && e != null) intervals.add(WorkInterval(s, e)); } return WorkDay( - date: base.date, - intervals: intervals, - targetMinutes: targetFor(base), - code: base.code); + date: base.date, + intervals: intervals, + targetMinutes: targetFor(base), + code: base.code, + ); } + // kompakte, rahmenlose Eingabefelder Widget _timeField({ required Key key, required TextEditingController controller, @@ -1110,7 +1003,9 @@ class _MonthlyViewState extends State { enabled: enabled, textAlign: TextAlign.center, style: const TextStyle( - fontFeatures: [FontFeature.tabularFigures()], fontSize: _fontSize), + fontFeatures: [FontFeature.tabularFigures()], + fontSize: _fontSize, + ), keyboardType: TextInputType.datetime, inputFormatters: const [HHmmInputFormatter()], decoration: const InputDecoration( @@ -1135,50 +1030,6 @@ class _MonthlyViewState extends State { if (a == Alignment.centerLeft) return TextAlign.left; return TextAlign.center; } - - Map _debugDayPayload(WorkDay day) { - final starts = List.filled(5, null); - final ends = List.filled(5, null); - for (int i = 0; i < day.intervals.length && i < 5; i++) { - starts[i] = fmtTimeOfDay(day.intervals[i].start); - ends[i] = fmtTimeOfDay(day.intervals[i].end); - } - final lock = _lockCodes.contains(day.code); - - final y = day.date.year.toString().padLeft(4, '0'); - final m = day.date.month.toString().padLeft(2, '0'); - final d = day.date.day.toString().padLeft(2, '0'); - - return { - 'module': 'booking', - 'function': 'saveDay', - 'date': '$y-$m-$d', - 'code': day.code, - 'come1': lock ? null : starts[0], - 'leave1': lock ? null : ends[0], - 'come2': lock ? null : starts[1], - 'leave2': lock ? null : ends[1], - 'come3': lock ? null : starts[2], - 'leave3': lock ? null : ends[2], - 'come4': lock ? null : starts[3], - 'leave4': lock ? null : ends[3], - 'come5': lock ? null : starts[4], - 'leave5': lock ? null : ends[4], - }; - } - - void _snack(String msg, - {bool isError = false, Duration duration = const Duration(seconds: 2)}) { - final theme = Theme.of(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(msg), - backgroundColor: isError ? theme.colorScheme.error : null, - behavior: SnackBarBehavior.floating, - duration: duration, - ), - ); - } } /// Kopfzeile als separate DataTable (fixiert) @@ -1190,7 +1041,10 @@ class _HeaderOnlyDataTable extends StatelessWidget { final state = context.findAncestorStateOfType<_MonthlyViewState>()!; final cols = state._buildColumns(); return DataTable( - showCheckboxColumn: false, columns: cols, rows: const []); + showCheckboxColumn: false, + columns: cols, + rows: const [], + ); } } @@ -1241,24 +1095,30 @@ class _MonthHeader extends StatelessWidget { } } -/// Footer (unverändert, nur hier vollständig) +/// Footer mit 2 Spalten und berechneten Werten (zentriert, responsiv) class _MonthlySummaryFooter extends StatelessWidget { final DateTime month; final double fontSize; - final String uebertragStartText; - final String sollText; - final String istText; - final String correctionText; - final String saldoText; - final String paidOvertimeText; - final String uebertragNextText; - final String restUrlaubText; - final String urlaubUebertragText; + + // Linke Spalte (Strings bereits formatiert) + final String uebertragStartText; // Startsaldo (Minuten → HH:MM) + final String sollText; // Summe Soll (HH:MM) + final String istText; // Summe Ist (HH:MM) + final String correctionText; // Correction (HH:MM, ±) + final String saldoText; // IST - SOLL (±HH:MM) + final String paidOvertimeText; // folgt später (—) + final String uebertragNextText; // letzter „Differenz gesamt“ (±HH:MM) + final String restUrlaubText; // startvacation (Zahl) + final String urlaubUebertragText; // startvacation - used + + // Rechte Spalte (Counts) final int countGleitzeit; final int countKrank; final int countUrlaub; final int countSonderurlaub; final int countTraining; + + // Label für aktuellen Monat (z. B. "September 2025") final String monthLabel; const _MonthlySummaryFooter({ @@ -1284,15 +1144,17 @@ class _MonthlySummaryFooter extends StatelessWidget { @override Widget build(BuildContext context) { + // Monatslabels: Vormonat für Überträge/Resturlaub, aktueller Monat für Soll/Ist final prev = DateTime(month.year, month.month - 1, 1); final prevLabel = monthTitle(prev); final curLabel = monthLabel; - const double leftValueWidth = 120; - const double rightValueWidth = 80; - const double colGap = 24; - const double rowGap = 2; - const double footerMaxWidth = 720; + // Layout-Konstanten + const double leftValueWidth = 120; // Werte-Breite links (rechtsbündig) + const double rightValueWidth = 80; // Werte-Breite rechts (rechtsbündig) + const double colGap = 24; // Abstand zwischen Spalten + const double rowGap = 2; // Zeilenabstand + const double footerMaxWidth = 720; // maximale Footer-Breite final labelStyle = TextStyle( fontSize: fontSize, color: Theme.of(context).colorScheme.onSurface); @@ -1302,6 +1164,7 @@ class _MonthlySummaryFooter extends StatelessWidget { color: Theme.of(context).colorScheme.onSurface, ); + // Linke Spalte: Feldname rechtsbündig, Wert rechtsbündig (fixe Breite) final leftItems = <(String label, String value)>[ ('Übertrag $prevLabel', uebertragStartText), ('SOLL Arbeitszeit ($curLabel)', sollText), @@ -1314,6 +1177,7 @@ class _MonthlySummaryFooter extends StatelessWidget { ('Übertrag Urlaub', urlaubUebertragText), ]; + // Rechte Spalte: links der Wert (rechtsbündig), rechts der Feldname final rightItems = <(String value, String label)>[ ('${countGleitzeit}', 'Gleitzeit'), ('${countKrank}', 'Krank'), @@ -1322,66 +1186,98 @@ class _MonthlySummaryFooter extends StatelessWidget { ('${countTraining}', 'Training'), ]; - Widget leftRow(String label, String value) => Padding( - padding: const EdgeInsets.symmetric(vertical: rowGap), - child: Row(children: [ + Widget leftRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: rowGap), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + // Label nimmt restliche Breite ein, rechtsbündig Expanded( - child: Align( - alignment: Alignment.centerRight, - child: Text(label, - style: labelStyle, - overflow: TextOverflow.fade, - softWrap: false, - maxLines: 1))), + child: Align( + alignment: Alignment.centerRight, + child: Text(label, + style: labelStyle, + overflow: TextOverflow.fade, + softWrap: false, + maxLines: 1), + ), + ), const SizedBox(width: 12), + // Wert mit fixer Breite, rechtsbündig SizedBox( - width: leftValueWidth, - child: Align( - alignment: Alignment.centerRight, - child: Text(value, - style: valueStyle, textAlign: TextAlign.right))), - ]), - ); + width: leftValueWidth, + child: Align( + alignment: Alignment.centerRight, + child: + Text(value, style: valueStyle, textAlign: TextAlign.right), + ), + ), + ], + ), + ); + } - Widget rightRow(String value, String label) => Padding( - padding: const EdgeInsets.symmetric(vertical: rowGap), - child: Row(children: [ + Widget rightRow(String value, String label) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: rowGap), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + // Wert (rechtsbündig) mit fixer Breite SizedBox( - width: rightValueWidth, - child: Align( - alignment: Alignment.centerRight, - child: Text(value, - style: valueStyle, textAlign: TextAlign.right))), + width: rightValueWidth, + child: Align( + alignment: Alignment.centerRight, + child: + Text(value, style: valueStyle, textAlign: TextAlign.right), + ), + ), const SizedBox(width: 12), + // Feldname links – nimmt restliche Breite Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: Text(label, - style: labelStyle, - overflow: TextOverflow.fade, - softWrap: false, - maxLines: 1))), - ]), - ); + child: Align( + alignment: Alignment.centerLeft, + child: Text(label, + style: labelStyle, + overflow: TextOverflow.fade, + softWrap: false, + maxLines: 1), + ), + ), + ], + ), + ); + } + // Zentrierter Footer-Container return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: footerMaxWidth), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Linke Spalte (flexibel) + Expanded( child: Column( - mainAxisSize: MainAxisSize.min, - children: leftItems.map((e) => leftRow(e.$1, e.$2)).toList(), - )), - const SizedBox(width: colGap), - Expanded( + mainAxisSize: MainAxisSize.min, + children: leftItems.map((e) => leftRow(e.$1, e.$2)).toList(), + ), + ), + SizedBox(width: colGap), + // Rechte Spalte (flexibel) + Expanded( child: Column( - mainAxisSize: MainAxisSize.min, - children: rightItems.map((e) => rightRow(e.$1, e.$2)).toList(), - )), - ]), + mainAxisSize: MainAxisSize.min, + children: + rightItems.map((e) => rightRow(e.$1, e.$2)).toList(), + ), + ), + ], + ), ), ), ); diff --git a/lib/utils/holidays_at.dart b/lib/utils/holidays_at.dart index 53f466b..a09fff7 100644 --- a/lib/utils/holidays_at.dart +++ b/lib/utils/holidays_at.dart @@ -1,50 +1,72 @@ -import '../utils/helpers.dart'; - -/// Sehr vereinfachte österreichische Feiertage (bundesweit). -/// Key = 'YYYY-MM-DD', Value = Name. -Map buildHolidayMapAT(int year) { - final Map m = {}; - - DateTime easter = _easterSunday(year); - DateTime easterMon = easter.add(const Duration(days: 1)); - DateTime ascension = easter.add(const Duration(days: 39)); - DateTime whitMon = easter.add(const Duration(days: 50)); - DateTime corpusChristi = easter.add(const Duration(days: 60)); - - void add(DateTime d, String name) => m[ymd(d)] = name; - - add(DateTime(year, 1, 1), 'Neujahr'); - add(DateTime(year, 1, 6), 'Heilige Drei Könige'); - add(easterMon, 'Ostermontag'); - add(DateTime(year, 5, 1), 'Staatsfeiertag'); - add(ascension, 'Christi Himmelfahrt'); - add(whitMon, 'Pfingstmontag'); - add(corpusChristi, 'Fronleichnam'); - add(DateTime(year, 8, 15), 'Mariä Himmelfahrt'); - add(DateTime(year, 10, 26), 'Nationalfeiertag'); - add(DateTime(year, 11, 1), 'Allerheiligen'); - add(DateTime(year, 12, 8), 'Mariä Empfängnis'); - add(DateTime(year, 12, 25), 'Christtag'); - add(DateTime(year, 12, 26), 'Stefanitag'); - - return m; -} +// lib/utils/holidays_at.dart +import 'helpers.dart'; // ymd(DateTime) -/// Gaußsche Osterformel (Gregorianisch) DateTime _easterSunday(int year) { final a = year % 19; final b = year ~/ 100; final c = year % 100; final d = b ~/ 4; final e = b % 4; - final f = (b + 8) ~/ 25; - final g = (b - f + 1) ~/ 3; + final f = ((b + 8) ~/ 25); + final g = ((b - f + 1) ~/ 3); final h = (19 * a + b - d - g + 15) % 30; final i = c ~/ 4; final k = c % 4; final l = (32 + 2 * e + 2 * i - h - k) % 7; - final m = (a + 11 * h + 22 * l) ~/ 451; - final month = (h + l - 7 * m + 114) ~/ 31; // 3=March, 4=April + final m = ((a + 11 * h + 22 * l) ~/ 451); + final month = ((h + l - 7 * m + 114) ~/ 31); final day = ((h + l - 7 * m + 114) % 31) + 1; return DateTime(year, month, day); } + +DateTime _addDays(DateTime d, int days) => DateTime(d.year, d.month, d.day + days); + +Map buildHolidayMapAT( + int year, { + bool includeWeekends = false, +}) { + final Map map = {}; + + // setzt nur, wenn noch nichts gesetzt wurde (echte Feiertage sollen Vorrang haben) + void putIfEmpty(DateTime date, String name) { + final key = ymd(date); + map.putIfAbsent(key, () => name); + } + + // Bewegliche Feiertage + final easter = _easterSunday(year); + // (Karfreitag ist in AT nicht generell arbeitsfrei – ggf. entfernen) + putIfEmpty(_addDays(easter, -2), 'Karfreitag'); + putIfEmpty(_addDays(easter, 1), 'Ostermontag'); + putIfEmpty(_addDays(easter, 39), 'Christi Himmelfahrt'); + putIfEmpty(_addDays(easter, 50), 'Pfingstmontag'); + putIfEmpty(_addDays(easter, 60), 'Fronleichnam'); + + // Feste Feiertage + putIfEmpty(DateTime(year, 1, 1), 'Neujahr'); + putIfEmpty(DateTime(year, 1, 6), 'Heilige Drei Könige'); + putIfEmpty(DateTime(year, 5, 1), 'Staatsfeiertag'); + putIfEmpty(DateTime(year, 8, 15), 'Mariä Himmelfahrt'); + putIfEmpty(DateTime(year, 10, 26), 'Nationalfeiertag'); + putIfEmpty(DateTime(year, 11, 1), 'Allerheiligen'); + putIfEmpty(DateTime(year, 12, 8), 'Mariä Empfängnis'); + putIfEmpty(DateTime(year, 12, 25), 'Christtag'); + putIfEmpty(DateTime(year, 12, 26), 'Stefanitag'); + + // Optional: Wochenenden **nur ergänzen**, aber niemals Feiertage überschreiben + if (includeWeekends) { + DateTime d = DateTime(year, 1, 1); + while (d.year == year) { + final wd = d.weekday; + if (wd == DateTime.saturday || wd == DateTime.sunday) { + final key = ymd(d); + if (!map.containsKey(key)) { + putIfEmpty(d, wd == DateTime.saturday ? 'Samstag' : 'Sonntag'); + } + } + d = d.add(const Duration(days: 1)); + } + } + + return map; +}