footer
This commit is contained in:
+419
-90
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -17,7 +18,7 @@ class MonthlyView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MonthlyViewState extends State<MonthlyView> {
|
||||
// Kompaktere Layout-Parameter
|
||||
// Layout
|
||||
static const double _wDate = 96;
|
||||
static const double _wHoliday = 120;
|
||||
static const double _wTime = 60;
|
||||
@@ -25,17 +26,19 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
static const double _wNumber = 76;
|
||||
static const double _fontSize = 12;
|
||||
|
||||
// Codes, die Zeiten sperren/ leeren
|
||||
// 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());
|
||||
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;
|
||||
@@ -45,7 +48,7 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
Map<String, String> _holidays = const {};
|
||||
Map<int, int> _dailyPlan = {
|
||||
Map<int, int> _dailyPlan = const {
|
||||
DateTime.monday: 8 * 60,
|
||||
DateTime.tuesday: 8 * 60,
|
||||
DateTime.wednesday: 8 * 60,
|
||||
@@ -55,10 +58,14 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
DateTime.sunday: 0,
|
||||
};
|
||||
|
||||
// Scroll-Controller
|
||||
late final ScrollController _hHeaderCtrl; // Header (nur mitgeführt)
|
||||
late final ScrollController _hBodyCtrl; // Body (führend)
|
||||
late final ScrollController _vCtrl; // Body vertikal
|
||||
// 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;
|
||||
|
||||
@override
|
||||
@@ -71,7 +78,6 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
_hBodyCtrl = ScrollController();
|
||||
_vCtrl = ScrollController();
|
||||
|
||||
// Body scrollt -> Header folgt
|
||||
_hBodyCtrl.addListener(() {
|
||||
if (_syncingH) return;
|
||||
_syncingH = true;
|
||||
@@ -112,27 +118,35 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
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); // << neu
|
||||
|
||||
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();
|
||||
final withTargets = filled.map((d) {
|
||||
final isHoliday = holidayMap.containsKey(ymd(d.date)); // << neu
|
||||
final target =
|
||||
isHoliday ? 0 : (plan[d.date.weekday] ?? d.targetMinutes);
|
||||
return WorkDay(
|
||||
date: d.date,
|
||||
intervals: d.intervals,
|
||||
targetMinutes: target,
|
||||
code: d.code,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
setState(() {
|
||||
_monthStart = DateTime(m.year, m.month, 1);
|
||||
_holidays = buildHolidayMapAT(_monthStart.year);
|
||||
_holidays = holidayMap; // << aus holidayMap setzen
|
||||
_days = withTargets;
|
||||
_dailyPlan = plan;
|
||||
_monthStartInfo = mStart;
|
||||
_carryBaseMinutes = mStart.carryBaseMinutes;
|
||||
_loading = false;
|
||||
});
|
||||
|
||||
@@ -150,14 +164,20 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
|
||||
// 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;
|
||||
switch (d.code) {
|
||||
case 'U':
|
||||
case 'SU':
|
||||
case 'K': // neu: Krank => IST = 0
|
||||
return 0;
|
||||
case 'T':
|
||||
return d.targetMinutes;
|
||||
default:
|
||||
return d.workedMinutes;
|
||||
}
|
||||
return d.workedMinutes;
|
||||
}
|
||||
|
||||
Color? _rowColorFor(WorkDay d, {required Color? holidayBg, required Color? weekendBg}) {
|
||||
Color? _rowColorFor(WorkDay d,
|
||||
{required Color? holidayBg, required Color? weekendBg}) {
|
||||
switch (d.code) {
|
||||
case 'G':
|
||||
return const Color(0xFFBFBFFF); // Gleitzeit
|
||||
@@ -166,12 +186,13 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
case 'SU':
|
||||
return const Color(0xFF7F7FFF); // Sonderurlaub
|
||||
case 'K':
|
||||
return Colors.yellow; // Krankenstand
|
||||
return Colors.yellow; // Krankenstand
|
||||
case 'T':
|
||||
return Colors.red; // Training
|
||||
return Colors.red; // Training
|
||||
}
|
||||
final isHoliday = _holidays.containsKey(ymd(d.date));
|
||||
final isWeekend = d.date.weekday == DateTime.saturday || d.date.weekday == DateTime.sunday;
|
||||
final isWeekend = d.date.weekday == DateTime.saturday ||
|
||||
d.date.weekday == DateTime.sunday;
|
||||
if (isHoliday) return holidayBg;
|
||||
if (isWeekend) return weekendBg;
|
||||
return null;
|
||||
@@ -186,21 +207,45 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
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]));
|
||||
// Live-„Effective“-Tage (inkl. Eingabetexte + Sperrlogik)
|
||||
final effectiveDays =
|
||||
List<WorkDay>.generate(_days.length, (i) => _effectiveDay(i, _days[i]));
|
||||
|
||||
// diffs & kumuliert (mit Override)
|
||||
// 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 = 0;
|
||||
int sum = _carryBaseMinutes;
|
||||
for (final diff in diffs) {
|
||||
sum += diff;
|
||||
cumulative.add(sum);
|
||||
}
|
||||
|
||||
// Footer-Werte berechnen
|
||||
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];
|
||||
@@ -216,8 +261,10 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
_MonthHeader(
|
||||
month: _monthStart,
|
||||
loading: _loading,
|
||||
onPrev: () => _loadMonth(DateTime(_monthStart.year, _monthStart.month - 1, 1)),
|
||||
onNext: () => _loadMonth(DateTime(_monthStart.year, _monthStart.month + 1, 1)),
|
||||
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,
|
||||
@@ -242,20 +289,22 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text('Fehler beim Laden:', style: TextStyle(fontSize: _fontSize)),
|
||||
SelectableText(_error ?? '', style: const TextStyle(fontSize: _fontSize)),
|
||||
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)),
|
||||
child: const Text('Erneut versuchen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// FIXIERTE KOPFZEILE (mitgeführt, nicht interaktiv)
|
||||
// FIXIERTE KOPFZEILE (mitgeführt)
|
||||
SingleChildScrollView(
|
||||
controller: _hHeaderCtrl,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
@@ -266,16 +315,15 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
data: DataTableThemeData(
|
||||
headingRowHeight: 30,
|
||||
columnSpacing: 10,
|
||||
headingTextStyle: const TextStyle(fontWeight: FontWeight.w700).copyWith(
|
||||
fontSize: _fontSize,
|
||||
),
|
||||
headingTextStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w700, fontSize: _fontSize),
|
||||
),
|
||||
child: const _HeaderOnlyDataTable(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// BODY (horiz. & vert. Scroll; horizontale Scrollbar hier)
|
||||
// BODY (scrollbar horiz/vert)
|
||||
Expanded(
|
||||
child: Scrollbar(
|
||||
controller: _hBodyCtrl,
|
||||
@@ -295,7 +343,7 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
controller: _vCtrl,
|
||||
child: DataTableTheme(
|
||||
data: const DataTableThemeData(
|
||||
headingRowHeight: 0, // Header im Body ausblenden
|
||||
headingRowHeight: 0,
|
||||
dataRowMinHeight: 30,
|
||||
dataRowMaxHeight: 34,
|
||||
columnSpacing: 10,
|
||||
@@ -312,11 +360,38 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Footer
|
||||
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: '—', // folgt später
|
||||
uebertragNextText: minutesToSignedHHMM(nextCarryMin),
|
||||
restUrlaubText: '$startVacation',
|
||||
urlaubUebertragText: '$vacationCarry',
|
||||
// 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(
|
||||
DataColumn c(String label,
|
||||
{Alignment align = Alignment.center, double? width}) =>
|
||||
DataColumn(
|
||||
label: SizedBox(
|
||||
width: width,
|
||||
child: Align(
|
||||
@@ -352,18 +427,16 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
];
|
||||
}
|
||||
|
||||
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
|
||||
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)] ?? '';
|
||||
|
||||
// Sperre Zeiten, wenn Code in _lockCodes
|
||||
final bool lockTimes = day.code != null && _lockCodes.contains(day.code);
|
||||
|
||||
// Ist Wochenende/Feiertag => Code nicht änderbar
|
||||
final bool isHoliday = _holidays.containsKey(ymd(day.date));
|
||||
final bool isWeekend = day.date.weekday == DateTime.saturday || day.date.weekday == DateTime.sunday;
|
||||
final bool isWeekend = day.date.weekday == DateTime.saturday ||
|
||||
day.date.weekday == DateTime.sunday;
|
||||
final bool codeDisabled = isHoliday || isWeekend;
|
||||
|
||||
String slotText(int slot, bool isStart) {
|
||||
@@ -380,7 +453,7 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
|
||||
final cells = <DataCell>[];
|
||||
|
||||
// 0: Datum (rechts)
|
||||
// 0: Datum (rechtsbündig)
|
||||
cells.add(DataCell(SizedBox(
|
||||
width: _wDate,
|
||||
child: Align(
|
||||
@@ -397,7 +470,7 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
),
|
||||
)));
|
||||
|
||||
// 2..11: Zeiten (evtl. gesperrt & geleert)
|
||||
// 2..11: Zeiten
|
||||
for (int slot = 0; slot < 5; slot++) {
|
||||
// Start
|
||||
final keyS = 't_${dayIndex}_${slot}_s';
|
||||
@@ -484,7 +557,7 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
)));
|
||||
}
|
||||
|
||||
// 12: Code (Dropdown) — zentriert (disable bei Sa/So/Feiertag)
|
||||
// 12: Code (Dropdown, am Wochenende/Feiertag gesperrt)
|
||||
cells.add(DataCell(SizedBox(
|
||||
width: _wCode,
|
||||
child: Align(
|
||||
@@ -493,7 +566,7 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
),
|
||||
)));
|
||||
|
||||
// 13..16: Kennzahlen (mit Override)
|
||||
// 13..16: Kennzahlen
|
||||
final worked = _workedFor(day);
|
||||
final soll = minutesToHHMM(day.targetMinutes);
|
||||
final ist = minutesToHHMM(worked);
|
||||
@@ -514,10 +587,11 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
)));
|
||||
cells.add(DataCell(SizedBox(
|
||||
width: _wNumber,
|
||||
child: Align(alignment: Alignment.centerRight, child: _monoSmall(diffSum)),
|
||||
child:
|
||||
Align(alignment: Alignment.centerRight, child: _monoSmall(diffSum)),
|
||||
)));
|
||||
|
||||
// 17: Datum (links)
|
||||
// 17: Datum (linksbündig)
|
||||
cells.add(DataCell(SizedBox(
|
||||
width: _wDate,
|
||||
child: Align(
|
||||
@@ -526,7 +600,8 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
),
|
||||
)));
|
||||
|
||||
assert(cells.length == 18, 'Row has ${cells.length} cells but expected 18.');
|
||||
assert(
|
||||
cells.length == 18, 'Row has ${cells.length} cells but expected 18.');
|
||||
return cells;
|
||||
}
|
||||
|
||||
@@ -550,21 +625,25 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
final label = v == null ? '—' : codeLabel(v);
|
||||
return DropdownMenuItem<String?>(
|
||||
value: v,
|
||||
child: Text(label, textAlign: TextAlign.center, style: const TextStyle(fontSize: _fontSize)),
|
||||
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)));
|
||||
return Center(
|
||||
child:
|
||||
Text(shortText, style: const TextStyle(fontSize: _fontSize)));
|
||||
}).toList();
|
||||
},
|
||||
onChanged: disabled
|
||||
? null
|
||||
: (newCode) {
|
||||
final d = _days[dayIndex];
|
||||
// Wenn neuer Code ein Lock-Code ist: Zeiten löschen & sperren
|
||||
final bool willLock = newCode != null && _lockCodes.contains(newCode);
|
||||
final bool willLock =
|
||||
newCode != null && _lockCodes.contains(newCode);
|
||||
setState(() {
|
||||
final newDays = List<WorkDay>.from(_days);
|
||||
newDays[dayIndex] = WorkDay(
|
||||
@@ -576,14 +655,11 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
_days = newDays;
|
||||
|
||||
if (willLock) {
|
||||
// Controller leeren & Invalid-Flags löschen
|
||||
for (int slot = 0; slot < 5; slot++) {
|
||||
final sKey = 't_${dayIndex}_${slot}_s';
|
||||
final eKey = 't_${dayIndex}_${slot}_e';
|
||||
final cs = _controllers[sKey];
|
||||
final ce = _controllers[eKey];
|
||||
if (cs != null && cs.text.isNotEmpty) cs.text = '';
|
||||
if (ce != null && ce.text.isNotEmpty) ce.text = '';
|
||||
_controllers[sKey]?.text = '';
|
||||
_controllers[eKey]?.text = '';
|
||||
_invalidCells.remove(_cellKey(dayIndex, slot, true));
|
||||
_invalidCells.remove(_cellKey(dayIndex, slot, false));
|
||||
}
|
||||
@@ -608,7 +684,6 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
final t = text.trim().isEmpty ? null : parseTextHHMM(text);
|
||||
final d = _days[dayIndex];
|
||||
|
||||
// Wenn Code sperrt: ignorieren (Sicherheit)
|
||||
if (d.code != null && _lockCodes.contains(d.code)) return;
|
||||
|
||||
final starts = List<TimeOfDay?>.filled(5, null);
|
||||
@@ -662,19 +737,42 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
: '';
|
||||
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;
|
||||
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) {
|
||||
// Wenn Code sperrt, sind die Intervalle leer (unabhängig von Controller-Text)
|
||||
if (base.code != null && _lockCodes.contains(base.code)) {
|
||||
final target = _dailyPlan[base.date.weekday] ?? base.targetMinutes;
|
||||
return WorkDay(date: base.date, intervals: const [], targetMinutes: target, code: base.code);
|
||||
final isHoliday = _holidays.containsKey(ymd(base.date));
|
||||
|
||||
// Basisziel (Tagesplan)
|
||||
final baseTarget = _dailyPlan[base.date.weekday] ?? base.targetMinutes;
|
||||
|
||||
// Zielzeit bestimmen: Feiertag -> 0, sonst bei U/SU -> 0, sonst Tagesplan
|
||||
int targetFor(WorkDay b) {
|
||||
if (isHoliday) return 0;
|
||||
if (b.code == 'U' || b.code == 'SU' || b.code == 'K') {
|
||||
return 0; // <- neu: Soll = 0 bei U/SU
|
||||
}
|
||||
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];
|
||||
@@ -688,20 +786,20 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
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);
|
||||
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));
|
||||
}
|
||||
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,
|
||||
targetMinutes: targetFor(base), // <- neu angewendet
|
||||
code: base.code,
|
||||
);
|
||||
}
|
||||
@@ -757,8 +855,7 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimaler Header-Table, damit die Kopfzeile fixiert bleiben kann.
|
||||
/// Nutzt die gleichen Spalten wie der Body, aber ohne Rows.
|
||||
/// Kopfzeile als separate DataTable (fixiert)
|
||||
class _HeaderOnlyDataTable extends StatelessWidget {
|
||||
const _HeaderOnlyDataTable();
|
||||
|
||||
@@ -796,20 +893,252 @@ class _MonthHeader extends StatelessWidget {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: Row(children: [
|
||||
IconButton(onPressed: loading ? null : onPrev, icon: const Icon(Icons.chevron_left)),
|
||||
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,
|
||||
),
|
||||
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)),
|
||||
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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Metric {
|
||||
final String label;
|
||||
final String value;
|
||||
const _Metric(this.label, this.value);
|
||||
}
|
||||
|
||||
class _MetricList extends StatelessWidget {
|
||||
final List<_Metric> items;
|
||||
final TextStyle labelStyle;
|
||||
final TextStyle valueStyle;
|
||||
const _MetricList({
|
||||
required this.items,
|
||||
required this.labelStyle,
|
||||
required this.valueStyle,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: items
|
||||
.map((m) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(m.label, style: labelStyle)),
|
||||
const SizedBox(width: 12),
|
||||
Text(m.value, style: valueStyle),
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user