diff --git a/android/app/build/intermediates/assets/debug/mergeDebugAssets/flutter_assets/kernel_blob.bin b/android/app/build/intermediates/assets/debug/mergeDebugAssets/flutter_assets/kernel_blob.bin index 2e678e3..10908fa 100644 Binary files a/android/app/build/intermediates/assets/debug/mergeDebugAssets/flutter_assets/kernel_blob.bin and b/android/app/build/intermediates/assets/debug/mergeDebugAssets/flutter_assets/kernel_blob.bin differ diff --git a/android/app/build/intermediates/compressed_assets/debug/compressDebugAssets/out/assets/flutter_assets/kernel_blob.bin.jar b/android/app/build/intermediates/compressed_assets/debug/compressDebugAssets/out/assets/flutter_assets/kernel_blob.bin.jar index 93ae0f7..50e0639 100644 Binary files a/android/app/build/intermediates/compressed_assets/debug/compressDebugAssets/out/assets/flutter_assets/kernel_blob.bin.jar and b/android/app/build/intermediates/compressed_assets/debug/compressDebugAssets/out/assets/flutter_assets/kernel_blob.bin.jar differ diff --git a/android/app/build/intermediates/flutter/debug/flutter_assets/kernel_blob.bin b/android/app/build/intermediates/flutter/debug/flutter_assets/kernel_blob.bin index 2e678e3..10908fa 100644 Binary files a/android/app/build/intermediates/flutter/debug/flutter_assets/kernel_blob.bin and b/android/app/build/intermediates/flutter/debug/flutter_assets/kernel_blob.bin differ diff --git a/android/app/build/intermediates/incremental/packageDebug/tmp/debug/dex-renamer-state.txt b/android/app/build/intermediates/incremental/packageDebug/tmp/debug/dex-renamer-state.txt index dce4c9a..acf1923 100644 --- a/android/app/build/intermediates/incremental/packageDebug/tmp/debug/dex-renamer-state.txt +++ b/android/app/build/intermediates/incremental/packageDebug/tmp/debug/dex-renamer-state.txt @@ -1,4 +1,4 @@ -#Mon Oct 13 17:28:53 CEST 2025 +#Thu Oct 16 11:43:56 CEST 2025 path.4=1/classes.dex path.3=11/classes.dex path.2=0/classes.dex diff --git a/android/app/build/outputs/apk/debug/app-debug.apk b/android/app/build/outputs/apk/debug/app-debug.apk index 46cc0be..eeda7f8 100644 Binary files a/android/app/build/outputs/apk/debug/app-debug.apk and b/android/app/build/outputs/apk/debug/app-debug.apk differ diff --git a/android/app/build/outputs/flutter-apk/app-debug.apk b/android/app/build/outputs/flutter-apk/app-debug.apk index 46cc0be..eeda7f8 100644 Binary files a/android/app/build/outputs/flutter-apk/app-debug.apk and b/android/app/build/outputs/flutter-apk/app-debug.apk differ diff --git a/lib/screens/monthly_view.dart b/lib/screens/monthly_view.dart index 114590c..95eb301 100644 --- a/lib/screens/monthly_view.dart +++ b/lib/screens/monthly_view.dart @@ -1,7 +1,10 @@ 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'; @@ -12,6 +15,9 @@ 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 @@ -27,6 +33,9 @@ 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'}; @@ -59,22 +68,30 @@ class _MonthlyViewState extends State { DateTime.sunday: 0, }; - // Monatliche Startdaten (für kumulierte Differenz & Footer) + // Monatliche Startdaten MonthStart? _monthStartInfo; int _carryBaseMinutes = 0; - // Scroll-Controller (Header/Body synchron) + // Scroll-Controller 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 + 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'; @override void initState() { @@ -89,9 +106,7 @@ 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; }); @@ -120,8 +135,8 @@ class _MonthlyViewState extends State { } Future _loadMonth(DateTime m) async { - // ignore: avoid_print - print('[monthly] LOAD month=${ymd(m)}'); + print( + '[monthly] LOAD month=${m.year}-${m.month.toString().padLeft(2, '0')}'); setState(() { _loading = true; _error = null; @@ -129,73 +144,77 @@ class _MonthlyViewState extends State { }); try { final results = await Future.wait([ - _bookingApi.getBookingList(m), // List - _dailyApi.getDailyMinutes(), // Map - _bookingApi.getMonthStart(m), // MonthStart + _bookingApi.getBookingList(m), + _dailyApi.getDailyMinutes(), + _bookingApi.getMonthStart(m), ]); 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: + // ➜ Nur FEIERTAGE, KEINE Wochenenden markieren final holidayMap = buildHolidayMapAT(m.year, includeWeekends: false); + // Monatsliste füllen (API + Lücken) 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}'); } - + // Sollzeiten je Tag auf Basis Feiertage/Code setzen 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 = {}; + // ➜ DEDUPE pro Datum (falls API doppelte Tage liefert) + final counts = {}; for (final d in withTargets) { final k = ymd(d.date); - _counts[k] = (_counts[k] ?? 0) + 1; + counts[k] = (counts[k] ?? 0) + 1; } + counts.forEach((k, c) { + if (c > 1) { + print('[monthly] DUPLICATE day detected: $k ($c×) – keeping last'); + } + }); final byDate = {}; for (final d in withTargets) { - byDate[ymd(d.date)] = d; // spätere Einträge überschreiben frühere + byDate[ymd(d.date)] = d; // späterer gewinnt } 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 + _days = uniqueDays; // << dedupliziert _dailyPlan = plan; _monthStartInfo = mStart; - _carryBaseMinutes = - mStart.carryBaseMinutes; // starthours + overtime + correction + _carryBaseMinutes = mStart.carryBaseMinutes; _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; @@ -203,20 +222,35 @@ 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; // Ist = 0 + return 0; case 'T': - return d.targetMinutes; // Training = Soll + return d.targetMinutes; default: - return d.workedMinutes; // regulär per Intervals (inkl. Pausenregel) + return d.workedMinutes; } } @@ -224,15 +258,15 @@ class _MonthlyViewState extends State { {required Color? holidayBg, required Color? weekendBg}) { switch (d.code) { case 'G': - return const Color(0xFFBFBFFF); // Gleitzeit + return const Color(0xFFBFBFFF); case 'U': - return const Color(0xFF7F7FFF); // Urlaub + return const Color(0xFF7F7FFF); case 'SU': - return const Color(0xFF7F7FFF); // Sonderurlaub + return const Color(0xFF7F7FFF); case 'K': - return Colors.yellow; // Krankenstand + return Colors.yellow; case 'T': - return Colors.red; // Training + return Colors.red; } final isHoliday = _holidays.containsKey(ymd(d.date)); final isWeekend = d.date.weekday == DateTime.saturday || @@ -250,11 +284,9 @@ 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; @@ -271,7 +303,6 @@ 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; @@ -300,101 +331,101 @@ class _MonthlyViewState extends State { ); }); - 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)), + 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')), ], ), - 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), ), - ], - ), - ), - - // 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(), ), - child: const _HeaderOnlyDataTable(), ), ), - ), - // BODY (scrollbar horiz/vert) - Expanded( - child: Scrollbar( - controller: _hBodyCtrl, - notificationPredicate: (n) => n.metrics.axis == Axis.horizontal, - thumbVisibility: true, - child: SingleChildScrollView( + // Body + Expanded( + child: Scrollbar( 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( + 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, - child: DataTableTheme( - data: const DataTableThemeData( - headingRowHeight: 0, - dataRowMinHeight: 30, - dataRowMaxHeight: 34, - columnSpacing: 10, - ), - child: DataTable( - showCheckboxColumn: false, - columns: bodyColumns, - rows: rows, + 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, + ), ), ), ), @@ -402,36 +433,183 @@ class _MonthlyViewState extends State { ), ), ), - ), - // 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 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), + ], + ), + ), + ), ), - const SizedBox(height: 8), - ]); + ); } + // ---------- 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')), + ], + ), + ); + 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); + } + } + + // ---------- Tabellen-Logik (wie gehabt) ---------- + List _buildColumns() { DataColumn c(String label, {Alignment align = Alignment.center, double? width}) => @@ -440,11 +618,9 @@ 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)), ), ), ); @@ -473,8 +649,8 @@ class _MonthlyViewState extends State { 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 leftLabel = rightDayLabel(day.date); + final rightLabel = leftDayLabel(day.date); final hName = _holidays[ymd(day.date)] ?? ''; final bool lockTimes = day.code != null && _lockCodes.contains(day.code); @@ -494,41 +670,30 @@ 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), // Spinner/Häkchen/Fehler - ], - ), + child: Row(children: [ + Expanded( + child: Text(hName, + style: const TextStyle(fontSize: _fontSize), + overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + _rowStatusBadge(dayIndex), + ]), ), ))); - // 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)); @@ -548,29 +713,25 @@ 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(() {}); // Repaint (Fehlermarkierung) - } + else + setState(() {}); }, 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)); @@ -590,30 +751,26 @@ 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( @@ -622,7 +779,6 @@ class _MonthlyViewState extends State { ), ))); - // 13..16: Kennzahlen final worked = _workedFor(day); final soll = minutesToHHMM(day.targetMinutes); final ist = minutesToHHMM(worked); @@ -630,54 +786,46 @@ 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(rightLabel, style: const TextStyle(fontSize: _fontSize)), - ), + alignment: Alignment.centerLeft, + child: Text(leftDayLabel(day.date), + style: const TextStyle(fontSize: _fontSize))), ))); - assert( - cells.length == 18, 'Row has ${cells.length} cells but expected 18.'); + assert(cells.length == 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, @@ -690,29 +838,25 @@ class _MonthlyViewState extends State { } Widget _codeDropdown(int dayIndex, WorkDay day, {required bool disabled}) { - final value = day.code; // null => — - final values = [null, ...kAbsenceCodes]; // ['G','K','U','SU','T'] - + final value = day.code; + final values = [null, ...kAbsenceCodes]; return DropdownButton( isExpanded: true, value: value, items: values.map((v) { - final label = v == null ? '—' : codeLabel(v); // langer Name im Dropdown + final label = v == null ? '—' : codeLabel(v); return DropdownMenuItem( - value: v, - child: Text(label, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: _fontSize)), - ); + 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))); }).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) { @@ -728,7 +872,6 @@ class _MonthlyViewState extends State { code: newCode, ); _days = newDays; - if (willLock) { for (int slot = 0; slot < 5; slot++) { final sKey = 't_${dayIndex}_${slot}_s'; @@ -740,7 +883,6 @@ class _MonthlyViewState extends State { } } }); - _scheduleSave(dayIndex); }, ); @@ -760,17 +902,14 @@ 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 { @@ -798,10 +937,11 @@ 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); }); } @@ -818,9 +958,11 @@ class _MonthlyViewState extends State { try { final effective = _effectiveDay(dayIndex, _days[dayIndex]); - await _bookingApi.saveDay(effective); + final payload = _debugDayPayload(effective); + print('[api] POST $_apiUrl'); + print('[api] body=${jsonEncode(payload)}'); - // ⬇️ Folgemonat sofort mitschreiben (Monatsstart) + await _bookingApi.saveDay(effective); await _saveMonthStartNow(); if (!mounted) return; @@ -828,8 +970,6 @@ 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(() { @@ -844,23 +984,18 @@ 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); @@ -868,33 +1003,36 @@ 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; - // Debug - // ignore: avoid_print - print( - '[monthly] saveMonthStart next=$nextMonth saldo=$saldoMin vacation=$carryVacation'); + 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)}'); try { await _bookingApi.saveMonthStart( - nextMonth, // BookingApi: positional DateTime + nextMonth, 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'); + debugPrint('monthlybooking/saveStart ERROR: $e'); } } @@ -928,28 +1066,22 @@ 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]; @@ -969,19 +1101,16 @@ 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, @@ -1003,9 +1132,7 @@ 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( @@ -1030,6 +1157,50 @@ 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) @@ -1041,10 +1212,7 @@ 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 []); } } @@ -1095,30 +1263,24 @@ class _MonthHeader extends StatelessWidget { } } -/// Footer mit 2 Spalten und berechneten Werten (zentriert, responsiv) +/// Footer (unverändert, nur hier vollständig) 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 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; 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({ @@ -1144,17 +1306,15 @@ 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; - // 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 + const double leftValueWidth = 120; + const double rightValueWidth = 80; + const double colGap = 24; + const double rowGap = 2; + const double footerMaxWidth = 720; final labelStyle = TextStyle( fontSize: fontSize, color: Theme.of(context).colorScheme.onSurface); @@ -1164,7 +1324,6 @@ 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), @@ -1177,7 +1336,6 @@ 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'), @@ -1186,98 +1344,66 @@ class _MonthlySummaryFooter extends StatelessWidget { ('${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 + Widget leftRow(String label, String value) => Padding( + padding: const EdgeInsets.symmetric(vertical: rowGap), + child: Row(children: [ 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) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: rowGap), - child: Row( - mainAxisSize: MainAxisSize.max, - children: [ - // Wert (rechtsbündig) mit fixer Breite + Widget rightRow(String value, String label) => Padding( + padding: const EdgeInsets.symmetric(vertical: rowGap), + child: Row(children: [ 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( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Linke Spalte (flexibel) - Expanded( + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Expanded( child: Column( - mainAxisSize: MainAxisSize.min, - children: leftItems.map((e) => leftRow(e.$1, e.$2)).toList(), - ), - ), - SizedBox(width: colGap), - // Rechte Spalte (flexibel) - Expanded( + mainAxisSize: MainAxisSize.min, + children: leftItems.map((e) => leftRow(e.$1, e.$2)).toList(), + )), + const SizedBox(width: colGap), + 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(), + )), + ]), ), ), );