import 'dart:async'; import 'dart:ui' show FontFeature; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import '../api/booking_api.dart'; import '../api/daily_working_api.dart'; import '../models/work_day.dart'; import '../models/work_interval.dart'; import '../models/month_start.dart'; import '../utils/helpers.dart'; import '../utils/holidays_at.dart'; import '../utils/input_formatters.dart'; class MonthlyView extends StatefulWidget { const MonthlyView({super.key}); @override State createState() => _MonthlyViewState(); } class _MonthlyViewState extends State { // Layout static const double _wDate = 96; static const double _wHoliday = 120; static const double _wTime = 60; static const double _wCode = 70; static const double _wNumber = 76; static const double _fontSize = 12; // Codes, die Zeiten leeren/sperren static const Set _lockCodes = {'G', 'K', 'U', 'SU'}; // Edit/Fokus/Controller je Feld final Set _invalidCells = {}; final Map _focusNodes = {}; final Map _controllers = {}; FocusNode _nodeFor(String key) => _focusNodes.putIfAbsent(key, () => FocusNode()); TextEditingController _controllerFor(String key, String initial) => _controllers.putIfAbsent(key, () => TextEditingController(text: initial)); // API / State late final http.Client _client; late final BookingApi _bookingApi; late final DailyWorkingApi _dailyApi; late DateTime _monthStart; List _days = const []; bool _loading = false; String? _error; Map _holidays = const {}; Map _dailyPlan = const { DateTime.monday: 8 * 60, DateTime.tuesday: 8 * 60, DateTime.wednesday: 8 * 60, DateTime.thursday: 8 * 60, DateTime.friday: 8 * 60, DateTime.saturday: 0, DateTime.sunday: 0, }; // Monatliche Startdaten (für kumulierte Differenz & Footer) MonthStart? _monthStartInfo; int _carryBaseMinutes = 0; // 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 = {}; // 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() { super.initState(); final now = DateTime.now(); _monthStart = DateTime(now.year, now.month, 1); _hHeaderCtrl = ScrollController(); _hBodyCtrl = ScrollController(); _vCtrl = ScrollController(); _hBodyCtrl.addListener(() { if (_syncingH) return; _syncingH = true; if (_hHeaderCtrl.hasClients) { _hHeaderCtrl.jumpTo(_hBodyCtrl.offset); } _syncingH = false; }); _client = http.Client(); _bookingApi = BookingApi(client: _client); _dailyApi = DailyWorkingApi(client: _client); _loadMonth(_monthStart); } @override void dispose() { for (final n in _focusNodes.values) { n.dispose(); } for (final c in _controllers.values) { c.dispose(); } for (final t in _saveTimers.values) { t.cancel(); } _hHeaderCtrl.dispose(); _hBodyCtrl.dispose(); _vCtrl.dispose(); _client.close(); super.dispose(); } Future _loadMonth(DateTime m) async { // ignore: avoid_print print('[monthly] LOAD month=${ymd(m)}'); setState(() { _loading = true; _error = null; _days = const []; }); try { final results = await Future.wait([ _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; // 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, ); }).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 = uniqueDays; // deduplizierte Liste _dailyPlan = plan; _monthStartInfo = mStart; _carryBaseMinutes = mStart.carryBaseMinutes; // starthours + overtime + correction _loading = false; // Status-Maps leeren (neuer Monat) _savingRows.clear(); _justSavedRows.clear(); _rowSaveError.clear(); }); _syncControllersWithDays(); } catch (e) { setState(() { _error = e.toString(); _loading = false; }); } } 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; // Ist = 0 case 'T': return d.targetMinutes; // Training = Soll default: return d.workedMinutes; // regulär per Intervals (inkl. Pausenregel) } } Color? _rowColorFor(WorkDay d, {required Color? holidayBg, required Color? weekendBg}) { switch (d.code) { case 'G': return const Color(0xFFBFBFFF); // Gleitzeit case 'U': return const Color(0xFF7F7FFF); // Urlaub case 'SU': return const Color(0xFF7F7FFF); // Sonderurlaub case 'K': return Colors.yellow; // Krankenstand case 'T': return Colors.red; // Training } final isHoliday = _holidays.containsKey(ymd(d.date)); final isWeekend = d.date.weekday == DateTime.saturday || d.date.weekday == DateTime.sunday; if (isHoliday) return holidayBg; if (isWeekend) return weekendBg; return null; } @override Widget build(BuildContext context) { final bodyColumns = _buildColumns(); final theme = Theme.of(context); 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; for (final d in effectiveDays) { final worked = _workedFor(d); diffs.add(worked - d.targetMinutes); sollTotal += d.targetMinutes; istTotal += worked; } final cumulative = []; int sum = _carryBaseMinutes; for (final diff in diffs) { sum += diff; cumulative.add(sum); } // Footer-Werte final monthLabel = monthTitle(_monthStart); final startVacation = _monthStartInfo?.startVacationUnits ?? 0; final usedVacation = _days.where((d) => d.code == 'U').length; final vacationCarry = startVacation - usedVacation; final codeCount = { 'G': _days.where((d) => d.code == 'G').length, 'K': _days.where((d) => d.code == 'K').length, 'U': usedVacation, 'SU': _days.where((d) => d.code == 'SU').length, 'T': _days.where((d) => d.code == 'T').length, }; final correctionMin = _monthStartInfo?.correctionMinutes ?? 0; final nextCarryMin = cumulative.isNotEmpty ? cumulative.last : _carryBaseMinutes; final rows = List.generate(_days.length, (i) { final day = effectiveDays[i]; final run = cumulative[i]; return DataRow( color: WidgetStateProperty.resolveWith( (_) => _rowColorFor(day, holidayBg: holidayBg, weekendBg: weekendBg), ), cells: _buildEditableCells(i, day, run), ); }); 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'), ), ], ), ), // 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 (scrollbar horiz/vert) Expanded( child: Scrollbar( 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( 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, ), ), ), ), ), ), ), ), // 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, ), const SizedBox(height: 8), ]); } List _buildColumns() { DataColumn c(String label, {Alignment align = Alignment.center, double? width}) => DataColumn( label: SizedBox( width: width, child: Align( alignment: align, child: Text( label, textAlign: _toTextAlign(align), style: const TextStyle(fontSize: _fontSize), ), ), ), ); return [ c('Datum', align: Alignment.centerRight, width: _wDate), c('Feiertag', align: Alignment.centerLeft, width: _wHoliday), c('Start 1', align: Alignment.center, width: _wTime), c('Ende 1', align: Alignment.center, width: _wTime), c('Start 2', align: Alignment.center, width: _wTime), c('Ende 2', align: Alignment.center, width: _wTime), c('Start 3', align: Alignment.center, width: _wTime), c('Ende 3', align: Alignment.center, width: _wTime), c('Start 4', align: Alignment.center, width: _wTime), c('Ende 4', align: Alignment.center, width: _wTime), c('Start 5', align: Alignment.center, width: _wTime), c('Ende 5', align: Alignment.center, width: _wTime), c('Code', align: Alignment.center, width: _wCode), c('Soll', align: Alignment.centerRight, width: _wNumber), c('Ist', align: Alignment.centerRight, width: _wNumber), c('Differenz', align: Alignment.centerRight, width: _wNumber), c('Differenz gesamt', align: Alignment.centerRight, width: _wNumber), c('Datum', align: Alignment.centerLeft, width: _wDate), ]; } List _buildEditableCells( int dayIndex, WorkDay day, int runningDiff) { 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); final bool isHoliday = _holidays.containsKey(ymd(day.date)); final bool isWeekend = day.date.weekday == DateTime.saturday || day.date.weekday == DateTime.sunday; final bool codeDisabled = isHoliday || isWeekend; String slotText(int slot, bool isStart) { if (lockTimes) return ''; if (slot < day.intervals.length) { final p = day.intervals[slot]; return fmtTimeOfDay(isStart ? p.start : p.end); } return ''; } 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)), ), ))); // 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), // 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)); if (lockTimes && ctrlS.text.isNotEmpty) ctrlS.text = ''; cells.add(DataCell(SizedBox( width: _wTime, child: Align( alignment: Alignment.center, child: _timeField( key: ValueKey(keyS), controller: ctrlS, focusNode: fnS, enabled: !lockTimes, invalid: isInvalid(slot, true), onChanged: (text) { if (lockTimes) return; final k = _cellKey(dayIndex, slot, true); final valid = text.isEmpty || _isValidHHMM(text); setState(() { if (valid) { _invalidCells.remove(k); } else { _invalidCells.add(k); } }); if (valid && (text.isEmpty || text.length == 5)) { _commitTime(dayIndex, slot, true, text); } else { setState(() {}); // Repaint (Fehlermarkierung) } }, onSubmitted: (text) { if (lockTimes) return; 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)); if (lockTimes && ctrlE.text.isNotEmpty) ctrlE.text = ''; cells.add(DataCell(SizedBox( width: _wTime, child: Align( alignment: Alignment.center, child: _timeField( key: ValueKey(keyE), controller: ctrlE, focusNode: fnE, enabled: !lockTimes, invalid: isInvalid(slot, false), onChanged: (text) { if (lockTimes) return; final k = _cellKey(dayIndex, slot, false); final valid = text.isEmpty || _isValidHHMM(text); setState(() { if (valid) { _invalidCells.remove(k); } else { _invalidCells.add(k); } }); if (valid && (text.isEmpty || text.length == 5)) { _commitTime(dayIndex, slot, false, text); } else { setState(() {}); } }, onSubmitted: (text) { if (lockTimes) return; 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( alignment: Alignment.center, child: _codeDropdown(dayIndex, day, disabled: codeDisabled), ), ))); // 13..16: Kennzahlen final worked = _workedFor(day); final soll = minutesToHHMM(day.targetMinutes); final ist = minutesToHHMM(worked); final diff = minutesToSignedHHMM(worked - day.targetMinutes); final diffSum = minutesToSignedHHMM(runningDiff); cells.add(DataCell(SizedBox( width: _wNumber, child: Align(alignment: Alignment.centerRight, child: _monoSmall(soll)), ))); cells.add(DataCell(SizedBox( width: _wNumber, child: Align(alignment: Alignment.centerRight, child: _monoSmall(ist)), ))); cells.add(DataCell(SizedBox( width: _wNumber, child: Align(alignment: Alignment.centerRight, child: _monoSmall(diff)), ))); cells.add(DataCell(SizedBox( 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(rightLabel, style: const TextStyle(fontSize: _fontSize)), ), ))); 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()], ), ); // kleiner Status-Badge pro Zeile Widget _rowStatusBadge(int row) { if (_savingRows.contains(row)) { return const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ); } if (_rowSaveError.containsKey(row)) { return Icon(Icons.error_outline, size: 14, color: Theme.of(context).colorScheme.error); } if (_justSavedRows.contains(row)) { return const Icon(Icons.check_circle, size: 14, color: Colors.green); } return const SizedBox(width: 14, height: 14); } Widget _codeDropdown(int dayIndex, WorkDay day, {required bool disabled}) { 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); // langer Name im Dropdown return DropdownMenuItem( 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) { final d = _days[dayIndex]; final bool willLock = newCode != null && _lockCodes.contains(newCode); setState(() { final newDays = List.from(_days); newDays[dayIndex] = WorkDay( date: d.date, intervals: willLock ? [] : d.intervals, targetMinutes: _dailyPlan[d.date.weekday] ?? d.targetMinutes, code: newCode, ); _days = newDays; if (willLock) { for (int slot = 0; slot < 5; slot++) { final sKey = 't_${dayIndex}_${slot}_s'; final eKey = 't_${dayIndex}_${slot}_e'; _controllers[sKey]?.text = ''; _controllers[eKey]?.text = ''; _invalidCells.remove(_cellKey(dayIndex, slot, true)); _invalidCells.remove(_cellKey(dayIndex, slot, false)); } } }); _scheduleSave(dayIndex); }, ); } bool _isValidHHMM(String s) { if (s.length != 5 || s[2] != ':') return false; final h = int.tryParse(s.substring(0, 2)); final m = int.tryParse(s.substring(3, 5)); if (h == null || m == null) return false; return h >= 0 && h <= 23 && m >= 0 && m <= 59; } String _cellKey(int dayIndex, int slot, bool isStart) => 'd${dayIndex}_s${slot}_${isStart ? 'b' : 'e'}'; 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 { ends[slot] = t; } final newIntervals = []; for (int i = 0; i < 5; i++) { final s = starts[i]; final e = ends[i]; if (s != null && e != null) newIntervals.add(WorkInterval(s, e)); } setState(() { final newDays = List.from(_days); newDays[dayIndex] = WorkDay( date: d.date, intervals: newIntervals, targetMinutes: _dailyPlan[d.date.weekday] ?? d.targetMinutes, code: d.code, ); _days = newDays; }); _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 { await _saveDay(dayIndex); }); } Future _saveDay(int dayIndex) async { _saveTimers[dayIndex]?.cancel(); _saveTimers.remove(dayIndex); setState(() { _savingRows.add(dayIndex); _rowSaveError.remove(dayIndex); _justSavedRows.remove(dayIndex); }); try { final effective = _effectiveDay(dayIndex, _days[dayIndex]); await _bookingApi.saveDay(effective); // ⬇️ Folgemonat sofort mitschreiben (Monatsstart) await _saveMonthStartNow(); if (!mounted) return; setState(() { _savingRows.remove(dayIndex); _justSavedRows.add(dayIndex); }); // Häkchen nach kurzer Zeit wieder ausblenden Timer(const Duration(milliseconds: 1200), () { if (!mounted) return; setState(() { _justSavedRows.remove(dayIndex); }); }); } catch (e) { if (!mounted) return; setState(() { _savingRows.remove(dayIndex); _rowSaveError[dayIndex] = e.toString(); }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( 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); final target = d.targetMinutes; 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; // Debug // ignore: avoid_print print( '[monthly] saveMonthStart next=$nextMonth saldo=$saldoMin vacation=$carryVacation'); try { await _bookingApi.saveMonthStart( nextMonth, // BookingApi: positional DateTime starthours: saldoMin, startvacation: carryVacation, overtime: overtime, correction: correction, ); // ignore: avoid_print print('[monthly] saveMonthStart OK'); } catch (e) { // ignore: avoid_print print('[monthly] saveMonthStart ERROR: $e'); } } void _syncControllersWithDays() { for (int i = 0; i < _days.length; i++) { final lock = _days[i].code != null && _lockCodes.contains(_days[i].code); for (int slot = 0; slot < 5; slot++) { final sKey = 't_${i}_${slot}_s'; final eKey = 't_${i}_${slot}_e'; final sText = lock ? '' : (slot < _days[i].intervals.length) ? fmtTimeOfDay(_days[i].intervals[slot].start) : ''; final eText = lock ? '' : (slot < _days[i].intervals.length) ? fmtTimeOfDay(_days[i].intervals[slot].end) : ''; final cs = _controllers[sKey]; final ce = _controllers[eKey]; if (cs != null && cs.text != sText && !(_focusNodes[sKey]?.hasFocus ?? false)) cs.text = sText; if (ce != null && ce.text != eText && !(_focusNodes[eKey]?.hasFocus ?? false)) ce.text = eText; } } } 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, ); } // Eingabetexte berücksichtigen TimeOfDay? baseSlot(WorkDay d, int slot, bool isStart) { if (slot >= d.intervals.length) return null; final iv = d.intervals[slot]; return isStart ? iv.start : iv.end; } final intervals = []; for (int slot = 0; slot < 5; slot++) { final sKey = 't_${row}_${slot}_s'; final eKey = 't_${row}_${slot}_e'; final sText = _controllers[sKey]?.text; final eText = _controllers[eKey]?.text; final s = (sText != null && sText.isNotEmpty) ? parseTextHHMM(sText) : baseSlot(base, slot, true); 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, ); } // kompakte, rahmenlose Eingabefelder Widget _timeField({ required Key key, required TextEditingController controller, required bool invalid, required ValueChanged onChanged, required ValueChanged onSubmitted, bool enabled = true, FocusNode? focusNode, }) { final theme = Theme.of(context); final errorFill = theme.colorScheme.errorContainer.withOpacity(0.25); return SizedBox( width: _wTime, child: TextFormField( key: key, controller: controller, focusNode: focusNode, enabled: enabled, textAlign: TextAlign.center, style: const TextStyle( fontFeatures: [FontFeature.tabularFigures()], fontSize: _fontSize, ), keyboardType: TextInputType.datetime, inputFormatters: const [HHmmInputFormatter()], decoration: const InputDecoration( isDense: true, contentPadding: EdgeInsets.symmetric(horizontal: 4, vertical: 2), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, disabledBorder: InputBorder.none, ).copyWith( filled: invalid, fillColor: invalid ? errorFill : null, ), onChanged: onChanged, onFieldSubmitted: onSubmitted, ), ); } TextAlign _toTextAlign(Alignment a) { if (a == Alignment.centerRight) return TextAlign.right; if (a == Alignment.centerLeft) return TextAlign.left; return TextAlign.center; } } /// Kopfzeile als separate DataTable (fixiert) class _HeaderOnlyDataTable extends StatelessWidget { const _HeaderOnlyDataTable(); @override Widget build(BuildContext context) { final state = context.findAncestorStateOfType<_MonthlyViewState>()!; final cols = state._buildColumns(); return DataTable( showCheckboxColumn: false, columns: cols, rows: const [], ); } } class _MonthHeader extends StatelessWidget { final DateTime month; final VoidCallback onPrev; final VoidCallback onNext; final VoidCallback onPickMonth; final VoidCallback onReload; final bool loading; const _MonthHeader({ required this.month, required this.onPrev, required this.onNext, required this.onPickMonth, required this.onReload, this.loading = false, super.key, }); @override Widget build(BuildContext context) { final title = monthTitle(month); return Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), child: Row(children: [ IconButton( onPressed: loading ? null : onPrev, icon: const Icon(Icons.chevron_left)), Expanded( child: TextButton.icon( onPressed: loading ? null : onPickMonth, icon: const Icon(Icons.calendar_month), label: Text(title, style: Theme.of(context) .textTheme .titleLarge ?.copyWith(fontSize: 16)), ), ), IconButton( onPressed: loading ? null : onReload, icon: const Icon(Icons.refresh)), IconButton( onPressed: loading ? null : onNext, icon: const Icon(Icons.chevron_right)), ]), ); } } /// Footer mit 2 Spalten und berechneten Werten (zentriert, responsiv) class _MonthlySummaryFooter extends StatelessWidget { final DateTime month; final double fontSize; // 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({ required this.month, required this.fontSize, required this.uebertragStartText, required this.sollText, required this.istText, required this.correctionText, required this.saldoText, required this.paidOvertimeText, required this.uebertragNextText, required this.restUrlaubText, required this.urlaubUebertragText, required this.countGleitzeit, required this.countKrank, required this.countUrlaub, required this.countSonderurlaub, required this.countTraining, required this.monthLabel, super.key, }); @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; // 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); final valueStyle = TextStyle( fontSize: fontSize, fontFeatures: const [FontFeature.tabularFigures()], 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), ('IST Arbeitszeit ($curLabel)', istText), ('Correction', correctionText), ('Saldo', saldoText), ('ausbezahlte Überstunden', paidOvertimeText), ('Übertrag nächster Monat', uebertragNextText), ('Resturlaub $prevLabel', restUrlaubText), ('Übertrag Urlaub', urlaubUebertragText), ]; // Rechte Spalte: links der Wert (rechtsbündig), rechts der Feldname final rightItems = <(String value, String label)>[ ('${countGleitzeit}', 'Gleitzeit'), ('${countKrank}', 'Krank'), ('${countUrlaub}', 'Urlaub'), ('${countSonderurlaub}', 'Sonderurlaub'), ('${countTraining}', 'Training'), ]; 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), ), ), 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), ), ), ], ), ); } 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), ), ), 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), ), ), ], ), ); } // Zentrierter Footer-Container return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: footerMaxWidth), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), 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(), ), ), SizedBox(width: colGap), // Rechte Spalte (flexibel) Expanded( child: Column( mainAxisSize: MainAxisSize.min, children: rightItems.map((e) => rightRow(e.$1, e.$2)).toList(), ), ), ], ), ), ), ); } }