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.

729 lines
23 KiB
Dart

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 '../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> {
// Kompaktere Layout-Parameter
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;
// 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));
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 = {
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,
};
late final ScrollController _hCtrl;
late final ScrollController _vCtrl;
@override
void initState() {
super.initState();
final now = DateTime.now();
_monthStart = DateTime(now.year, now.month, 1);
_hCtrl = ScrollController();
_vCtrl = ScrollController();
_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();
}
_hCtrl.dispose();
_vCtrl.dispose();
_client.close();
super.dispose();
}
Future<void> _loadMonth(DateTime m) async {
setState(() {
_loading = true;
_error = null;
_days = const [];
});
try {
final results = await Future.wait([
_bookingApi.getBookingList(m),
_dailyApi.getDailyMinutes(),
]);
final apiDays = results[0] as List<WorkDay>;
final plan = results[1] as Map<int, int>;
final filled = fillMonth(m, apiDays);
final withTargets = filled
.map((d) => WorkDay(
date: d.date,
intervals: d.intervals,
targetMinutes: plan[d.date.weekday] ?? d.targetMinutes,
code: d.code,
))
.toList();
setState(() {
_monthStart = DateTime(m.year, m.month, 1);
_holidays = buildHolidayMapAT(_monthStart.year);
_days = withTargets;
_dailyPlan = plan;
_loading = false;
});
_syncControllersWithDays();
} catch (e) {
setState(() {
_error = e.toString();
_loading = false;
});
}
}
double get _tableMinWidth =>
_wDate + _wHoliday + (10 * _wTime) + _wCode + (4 * _wNumber) + _wDate;
// Ist-Override für U/SU/K/T
int _workedFor(WorkDay d) {
final c = d.code;
if (c == 'U' || c == 'SU' || c == 'K' || c == 'T') {
return d.targetMinutes;
}
return d.workedMinutes;
}
Color? _rowColorFor(WorkDay d, {required Color? holidayBg, required Color? weekendBg}) {
switch (d.code) {
case 'GZ':
return const Color(0xFFBFBFFF); // 0xBFBFFF mit vollem Alpha
case 'U':
return const Color(0xFF7F7FFF); // 0x7F7FFF mit vollem Alpha
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 headerColumns = _buildColumns();
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]));
// diffs & kumuliert (mit Override)
final diffs = <int>[];
for (final d in effectiveDays) {
final worked = _workedFor(d);
diffs.add(worked - d.targetMinutes);
}
final cumulative = <int>[];
int sum = 0;
for (final diff in diffs) {
sum += diff;
cumulative.add(sum);
}
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: Text('Erneut versuchen', style: const TextStyle(fontSize: _fontSize)),
),
],
),
),
// FIXIERTE KOPFZEILE
Scrollbar(
controller: _hCtrl,
notificationPredicate: (n) => n.metrics.axis == Axis.horizontal,
thumbVisibility: true,
child: SingleChildScrollView(
controller: _hCtrl,
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: _tableMinWidth),
child: DataTableTheme(
data: DataTableThemeData(
headingRowHeight: 30,
columnSpacing: 10,
headingTextStyle: const TextStyle(fontWeight: FontWeight.w700).copyWith(
fontSize: _fontSize,
),
),
child: DataTable(
showCheckboxColumn: false,
columns: headerColumns,
rows: const <DataRow>[],
),
),
),
),
),
// BODY
Expanded(
child: Scrollbar(
controller: _hCtrl,
notificationPredicate: (n) => n.metrics.axis == Axis.horizontal,
thumbVisibility: true,
child: SingleChildScrollView(
controller: _hCtrl,
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,
),
),
),
),
),
),
),
),
]);
}
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) {
// Gewünscht: links Wochentag zuerst, rechts Datum zuerst.
final leftLabel = rightDayLabel(day.date); // Wochentag Datum
final rightLabel = leftDayLabel(day.date); // Datum Wochentag
final hName = _holidays[ymd(day.date)] ?? '';
String slotText(int slot, bool isStart) {
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 (rechts)
cells.add(DataCell(SizedBox(
width: _wDate,
child: Align(
alignment: Alignment.centerRight,
child: Text(leftLabel, style: const TextStyle(fontSize: _fontSize)),
),
)));
// 1: Feiertag
cells.add(DataCell(SizedBox(
width: _wHoliday,
child: Align(
alignment: Alignment.centerLeft,
child: Text(hName, style: const TextStyle(fontSize: _fontSize)),
),
)));
// 2..11: Zeiten
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));
cells.add(DataCell(SizedBox(
width: _wTime,
child: Align(
alignment: Alignment.center,
child: _timeField(
key: ValueKey(keyS),
controller: ctrlS,
focusNode: fnS,
invalid: isInvalid(slot, true),
onChanged: (text) {
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 (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));
cells.add(DataCell(SizedBox(
width: _wTime,
child: Align(
alignment: Alignment.center,
child: _timeField(
key: ValueKey(keyE),
controller: ctrlE,
focusNode: fnE,
invalid: isInvalid(slot, false),
onChanged: (text) {
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 (text.isEmpty || _isValidHHMM(text)) {
_commitTime(dayIndex, slot, false, text);
}
},
),
),
)));
}
// 12: Code
cells.add(DataCell(SizedBox(
width: _wCode,
child: Align(alignment: Alignment.center, child: _codeDropdown(dayIndex, day)),
)));
// 13..16: Kennzahlen (mit Override)
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 (links)
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;
}
Widget _codeDropdown(int dayIndex, WorkDay day) {
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) {
return values.map((v) {
final shortText = v ?? '';
return Center(child: Text(shortText, style: const TextStyle(fontSize: _fontSize)));
}).toList();
},
onChanged: (newCode) {
final d = _days[dayIndex];
setState(() {
final newDays = List<WorkDay>.from(_days);
newDays[dayIndex] = WorkDay(
date: d.date,
intervals: d.intervals,
targetMinutes: d.targetMinutes,
code: newCode,
);
_days = newDays;
});
},
);
}
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];
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;
});
}
void _syncControllersWithDays() {
for (int i = 0; i < _days.length; i++) {
for (int slot = 0; slot < 5; slot++) {
final sKey = 't_${i}_${slot}_s';
final eKey = 't_${i}_${slot}_e';
final sText =
(slot < _days[i].intervals.length) ? fmtTimeOfDay(_days[i].intervals[slot].start) : '';
final eText =
(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) {
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));
}
}
final target = _dailyPlan[base.date.weekday] ?? base.targetMinutes;
return WorkDay(
date: base.date,
intervals: intervals,
targetMinutes: target,
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,
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,
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,
).copyWith(
filled: invalid,
fillColor: invalid ? errorFill : null,
),
onChanged: onChanged,
onFieldSubmitted: onSubmitted,
),
);
}
Widget _monoSmall(String s) => Text(
s,
style: const TextStyle(
fontSize: _fontSize,
fontFeatures: [FontFeature.tabularFigures()],
),
);
TextAlign _toTextAlign(Alignment a) {
if (a == Alignment.centerRight) return TextAlign.right;
if (a == Alignment.centerLeft) return TextAlign.left;
return TextAlign.center;
}
}
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)),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
),
),
),
IconButton(onPressed: loading ? null : onReload, icon: const Icon(Icons.refresh)),
IconButton(onPressed: loading ? null : onNext, icon: const Icon(Icons.chevron_right)),
]),
);
}
}