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.

1390 lines
45 KiB
Dart

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';
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';
// Plattform-Bridge (conditional import)
import '../platform/pdf_platform.dart' as platform_pdf;
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;
// API Base (nur Debug)
static const String _apiUrl = 'https://api.windesign.at/workinghours.php';
// 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
MonthStart? _monthStartInfo;
int _carryBaseMinutes = 0;
// Scroll-Controller
late final ScrollController _hHeaderCtrl;
late final ScrollController _hBodyCtrl;
late final ScrollController _vCtrl;
bool _syncingH = false;
// Save-Status pro Zeile
final Map<int, Timer> _saveTimers = {};
final Set<int> _savingRows = {};
final Set<int> _justSavedRows = {};
final Map<int, String> _rowSaveError = {};
// Doc-Existenz je Typ (für Buttons)
final Map<String, bool> _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() {
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 {
print(
'[monthly] LOAD month=${m.year}-${m.month.toString().padLeft(2, '0')}');
setState(() {
_loading = true;
_error = null;
_days = const [];
});
try {
final results = await Future.wait([
_bookingApi.getBookingList(m),
_dailyApi.getDailyMinutes(),
_bookingApi.getMonthStart(m),
]);
final apiDays = results[0] as List<WorkDay>;
final plan = results[1] as Map<int, int>;
final mStart = results[2] as MonthStart;
final holidayMap = buildHolidayMapAT(m.year);
final filled = fillMonth(m, apiDays);
final withTargets = filled.map((d) {
final isHoliday = holidayMap.containsKey(ymd(d.date));
final baseTarget =
isHoliday ? 0 : (plan[d.date.weekday] ?? d.targetMinutes);
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();
setState(() {
_monthStart = DateTime(m.year, m.month, 1);
_holidays = holidayMap;
_days = withTargets;
_dailyPlan = plan;
_monthStartInfo = mStart;
_carryBaseMinutes = mStart.carryBaseMinutes;
_loading = false;
_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;
});
}
}
Future<void> _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;
int _workedFor(WorkDay d) {
switch (d.code) {
case 'U':
case 'SU':
case 'K':
return 0;
case 'T':
return d.targetMinutes;
default:
return d.workedMinutes;
}
}
Color? _rowColorFor(WorkDay d,
{required Color? holidayBg, required Color? weekendBg}) {
switch (d.code) {
case 'G':
return const Color(0xFFBFBFFF);
case 'U':
return const Color(0xFF7F7FFF);
case 'SU':
return const Color(0xFF7F7FFF);
case 'K':
return Colors.yellow;
case 'T':
return Colors.red;
}
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);
final effectiveDays =
List<WorkDay>.generate(_days.length, (i) => _effectiveDay(i, _days[i]));
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);
}
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 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: <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')),
],
),
),
// Kopfzeile
SingleChildScrollView(
controller: _hHeaderCtrl,
physics: const NeverScrollableScrollPhysics(),
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: _tableMinWidth),
child: DataTableTheme(
data: DataTableThemeData(
headingRowHeight: 30,
columnSpacing: 10,
headingTextStyle: const TextStyle(
fontWeight: FontWeight.w700, fontSize: _fontSize),
),
child: const _HeaderOnlyDataTable(),
),
),
),
// Body
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,
),
),
),
),
),
),
),
),
const Divider(height: 1),
_MonthlySummaryFooter(
month: _monthStart,
fontSize: _fontSize,
uebertragStartText: minutesToSignedHHMM(_carryBaseMinutes),
sollText: minutesToHHMM(sollTotal),
istText: minutesToHHMM(istTotal),
correctionText: minutesToSignedHHMM(correctionMin),
saldoText: minutesToSignedHHMM(istTotal - sollTotal),
paidOvertimeText: '',
uebertragNextText: minutesToSignedHHMM(nextCarryMin),
restUrlaubText: '$startVacation',
urlaubUebertragText: '$vacationCarry',
countGleitzeit: codeCount['G'] ?? 0,
countKrank: codeCount['K'] ?? 0,
countUrlaub: codeCount['U'] ?? 0,
countSonderurlaub: codeCount['SU'] ?? 0,
countTraining: codeCount['T'] ?? 0,
monthLabel: monthLabel,
),
const SizedBox(height: 8),
]),
// ====== Fußleiste mit PDF-Actions (farbig & (de)aktiv) ======
bottomNavigationBar: SafeArea(
top: false,
child: Material(
elevation: 6,
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_docButtonRow(context,
label: 'Salary', type: 'salery', exists: _hasSalery),
const SizedBox(height: 8),
_docButtonRow(context,
label: 'Timesheet',
type: 'timesheet',
exists: _hasTimesheet),
],
),
),
),
),
);
}
// ---------- Bottom bar helpers ----------
Widget _docButtonRow(BuildContext context,
{required String label, required String type, required bool exists}) {
final scheme = Theme.of(context).colorScheme;
final bg = exists ? scheme.primaryContainer : scheme.surfaceVariant;
final fg = exists ? scheme.onPrimaryContainer : scheme.onSurfaceVariant;
return Container(
decoration:
BoxDecoration(color: bg, borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(label,
style: TextStyle(fontWeight: FontWeight.w600, color: fg)),
),
const Spacer(),
_miniBtn(Icons.visibility, 'view',
exists ? () => _viewDoc(type) : null, fg),
const SizedBox(width: 8),
_miniBtn(Icons.download, 'download',
exists ? () => _downloadDoc(type) : null, fg),
const SizedBox(width: 8),
_miniBtn(Icons.upload_file, 'replace', () => _replaceDoc(type), fg),
const SizedBox(width: 8),
_miniBtn(Icons.delete_outline, 'delete',
exists ? () => _deleteDoc(type) : null, fg),
],
),
);
}
Widget _miniBtn(
IconData icon, String text, VoidCallback? onPressed, Color fg) {
return FilledButton.tonalIcon(
onPressed: onPressed,
icon: Icon(icon, size: 18),
label: Text(text),
style: FilledButton.styleFrom(
foregroundColor: onPressed == null ? fg.withOpacity(0.5) : fg,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
minimumSize: const Size(0, 36),
),
);
}
// ====== Aktionen (nutzen Plattform-Bridge) ======
Future<void> _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<void> _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<void> _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<void> _deleteDoc(String type) async {
final ok = await showDialog<bool>(
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<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);
final rightLabel = leftDayLabel(day.date);
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>[];
cells.add(DataCell(SizedBox(
width: _wDate,
child: Align(
alignment: Alignment.centerRight,
child: Text(leftLabel, style: const TextStyle(fontSize: _fontSize))),
)));
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),
]),
),
)));
for (int slot = 0; slot < 5; slot++) {
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(() {});
},
onSubmitted: (text) {
if (lockTimes) return;
if (text.isEmpty || _isValidHHMM(text))
_commitTime(dayIndex, slot, true, text);
},
),
),
)));
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);
},
),
),
)));
}
cells.add(DataCell(SizedBox(
width: _wCode,
child: Align(
alignment: Alignment.center,
child: _codeDropdown(dayIndex, day, disabled: codeDisabled),
),
)));
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)))));
cells.add(DataCell(SizedBox(
width: _wDate,
child: Align(
alignment: Alignment.centerLeft,
child: Text(leftDayLabel(day.date),
style: const TextStyle(fontSize: _fontSize))),
)));
assert(cells.length == 18);
return cells;
}
Widget _monoSmall(String s) => Text(
s,
style: const TextStyle(
fontSize: _fontSize, fontFeatures: [FontFeature.tabularFigures()]),
);
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;
final values = <String?>[null, ...kAbsenceCodes];
return DropdownButton<String?>(
isExpanded: true,
value: value,
items: values.map((v) {
final label = v == null ? '' : codeLabel(v);
return DropdownMenuItem<String?>(
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(),
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);
}
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);
});
}
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]);
final payload = _debugDayPayload(effective);
print('[api] POST $_apiUrl');
print('[api] body=${jsonEncode(payload)}');
await _bookingApi.saveDay(effective);
await _saveMonthStartNow();
if (!mounted) return;
setState(() {
_savingRows.remove(dayIndex);
_justSavedRows.add(dayIndex);
});
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 {
final nextMonth = DateTime(_monthStart.year, _monthStart.month + 1, 1);
final effectiveDays =
List<WorkDay>.generate(_days.length, (i) => _effectiveDay(i, _days[i]));
int saldoMin = _carryBaseMinutes;
for (final d in effectiveDays) {
final worked = _workedFor(d);
final target = d.targetMinutes;
saldoMin += worked - target;
}
final startVacation = _monthStartInfo?.startVacationUnits ?? 0;
final usedVacation = _days.where((d) => d.code == 'U').length;
final carryVacation = startVacation - usedVacation;
final overtime = _monthStartInfo?.overtimeMinutes ?? 0;
final correction = _monthStartInfo?.correctionMinutes ?? 0;
final payload = {
'module': 'monthlybooking',
'function': 'saveStart',
'date':
'${nextMonth.year.toString().padLeft(4, '0')}-${nextMonth.month.toString().padLeft(2, '0')}-01',
'starthours': saldoMin,
'startvacation': carryVacation,
'overtime': overtime,
'correction': correction,
};
print('[api] POST $_apiUrl');
print('[api] body=${jsonEncode(payload)}');
try {
await _bookingApi.saveMonthStart(
nextMonth,
starthours: saldoMin,
startvacation: carryVacation,
overtime: overtime,
correction: correction,
);
} catch (e) {
debugPrint('monthlybooking/saveStart 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));
final baseTarget = _dailyPlan[base.date.weekday] ?? base.targetMinutes;
int targetFor(WorkDay b) {
if (isHoliday) return 0;
if (b.code == 'U' || b.code == 'SU' || b.code == 'K') return 0;
return baseTarget;
}
if (base.code != null && _lockCodes.contains(base.code)) {
return WorkDay(
date: base.date,
intervals: const [],
targetMinutes: targetFor(base),
code: base.code);
}
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);
}
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;
}
Map<String, dynamic> _debugDayPayload(WorkDay day) {
final starts = List<String?>.filled(5, null);
final ends = List<String?>.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)
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 (unverändert, nur hier vollständig)
class _MonthlySummaryFooter extends StatelessWidget {
final DateTime month;
final double fontSize;
final String uebertragStartText;
final String sollText;
final String istText;
final String correctionText;
final String saldoText;
final String paidOvertimeText;
final String uebertragNextText;
final String restUrlaubText;
final String urlaubUebertragText;
final int countGleitzeit;
final int countKrank;
final int countUrlaub;
final int countSonderurlaub;
final int countTraining;
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) {
final prev = DateTime(month.year, month.month - 1, 1);
final prevLabel = monthTitle(prev);
final curLabel = monthLabel;
const double leftValueWidth = 120;
const double rightValueWidth = 80;
const double colGap = 24;
const double rowGap = 2;
const double footerMaxWidth = 720;
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,
);
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),
];
final rightItems = <(String value, String label)>[
('${countGleitzeit}', 'Gleitzeit'),
('${countKrank}', 'Krank'),
('${countUrlaub}', 'Urlaub'),
('${countSonderurlaub}', 'Sonderurlaub'),
('${countTraining}', 'Training'),
];
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))),
const SizedBox(width: 12),
SizedBox(
width: leftValueWidth,
child: Align(
alignment: Alignment.centerRight,
child: Text(value,
style: valueStyle, textAlign: TextAlign.right))),
]),
);
Widget rightRow(String value, String label) => Padding(
padding: const EdgeInsets.symmetric(vertical: rowGap),
child: Row(children: [
SizedBox(
width: rightValueWidth,
child: Align(
alignment: Alignment.centerRight,
child: Text(value,
style: valueStyle, textAlign: TextAlign.right))),
const SizedBox(width: 12),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Text(label,
style: labelStyle,
overflow: TextOverflow.fade,
softWrap: false,
maxLines: 1))),
]),
);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: footerMaxWidth),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Expanded(
child: Column(
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(),
)),
]),
),
),
);
}
}