|
|
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(),
|
|
|
),
|
|
|
),
|
|
|
],
|
|
|
),
|
|
|
),
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
}
|