|
|
|
|
@ -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 filled = fillMonth(m, apiDays);
|
|
|
|
|
final holidayMap = buildHolidayMapAT(m.year); // << neu
|
|
|
|
|
|
|
|
|
|
final withTargets = filled
|
|
|
|
|
.map((d) => WorkDay(
|
|
|
|
|
date: d.date,
|
|
|
|
|
intervals: d.intervals,
|
|
|
|
|
targetMinutes: plan[d.date.weekday] ?? d.targetMinutes,
|
|
|
|
|
code: d.code,
|
|
|
|
|
))
|
|
|
|
|
.toList();
|
|
|
|
|
final filled = fillMonth(m, apiDays);
|
|
|
|
|
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)
|
|
|
|
|
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)) {
|
|
|
|
|
final target = _dailyPlan[base.date.weekday] ?? base.targetMinutes;
|
|
|
|
|
return WorkDay(date: base.date, intervals: const [], targetMinutes: target, code: 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(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|