You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1286 lines
41 KiB
Dart

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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<MonthlyView> createState() => _MonthlyViewState();
}
class _MonthlyViewState extends State<MonthlyView> {
// 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<String> _lockCodes = {'G', 'K', 'U', 'SU'};
// Edit/Fokus/Controller je Feld
final Set<String> _invalidCells = <String>{};
final Map<String, FocusNode> _focusNodes = {};
final Map<String, TextEditingController> _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<WorkDay> _days = const [];
bool _loading = false;
String? _error;
Map<String, String> _holidays = const {};
Map<int, int> _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<int, Timer> _saveTimers = {}; // rowIndex -> Timer
final Set<int> _savingRows = {}; // Zeilen, die gerade speichern
final Set<int> _justSavedRows =
{}; // Zeilen, die eben gespeichert haben (Häkchen kurz anzeigen)
final Map<int, String> _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<void> _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<WorkDay>
_dailyApi.getDailyMinutes(), // Map<int,int>
_bookingApi.getMonthStart(m), // MonthStart
]);
final apiDays = results[0] as List<WorkDay>;
final plan = results[1] as Map<int, int>;
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 = <String, int>{};
for (final d in withTargets) {
final k = ymd(d.date);
_counts[k] = (_counts[k] ?? 0) + 1;
}
final byDate = <String, WorkDay>{};
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<WorkDay>.generate(_days.length, (i) => _effectiveDay(i, _days[i]));
// Tagesdifferenzen & kumuliert (Start mit Monatssaldo aus API)
final diffs = <int>[];
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>[];
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 = <String, int>{
'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<DataRow>.generate(_days.length, (i) {
final day = effectiveDays[i];
final run = cumulative[i];
return DataRow(
color: WidgetStateProperty.resolveWith<Color?>(
(_) => _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: <Widget>[
Text('Fehler beim Laden:',
style: TextStyle(fontSize: _fontSize)),
SelectableText(_error ?? '',
style: const TextStyle(fontSize: _fontSize)),
],
),
actions: <Widget>[
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<DataColumn> _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<DataCell> _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 = <DataCell>[];
// 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 = <String?>[null, ...kAbsenceCodes]; // ['G','K','U','SU','T']
return DropdownButton<String?>(
isExpanded: true,
value: value,
items: values.map((v) {
final label = v == null ? '' : codeLabel(v); // langer Name im Dropdown
return DropdownMenuItem<String?>(
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<WorkDay>.from(_days);
newDays[dayIndex] = WorkDay(
date: d.date,
intervals: willLock ? <WorkInterval>[] : 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<TimeOfDay?>.filled(5, null);
final ends = List<TimeOfDay?>.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 = <WorkInterval>[];
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<WorkDay>.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<void> _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<void> _saveMonthStartNow() async {
// 1) Folgemonat bestimmen
final nextMonth = DateTime(_monthStart.year, _monthStart.month + 1, 1);
// 2) Effektive Tage (inkl. Eingabefelder berücksichtigen)
final effectiveDays =
List<WorkDay>.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 = <WorkInterval>[];
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<String> onChanged,
required ValueChanged<String> 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 <DataRow>[],
);
}
}
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(),
),
),
],
),
),
),
);
}
}