initial commit
This commit is contained in:
@@ -63,14 +63,15 @@ class BookingApi {
|
||||
addPair('come4', 'leave4');
|
||||
addPair('come5', 'leave5');
|
||||
|
||||
final absence =
|
||||
(row['code']?.toString().trim().isEmpty ?? true) ? null : row['code'].toString();
|
||||
final code = (row['code']?.toString().trim().isEmpty ?? true)
|
||||
? null
|
||||
: row['code'].toString().trim();
|
||||
|
||||
out.add(WorkDay(
|
||||
date: d,
|
||||
intervals: intervals,
|
||||
targetMinutes: target,
|
||||
absenceType: absence,
|
||||
code: code,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Lädt die tägliche Sollzeit (HH:MM:SS pro Wochentag) aus der API.
|
||||
/// Gibt eine Map<int,int> zurück: key = DateTime.weekday (1..7), value = Minuten.
|
||||
class DailyWorkingApi {
|
||||
final String host;
|
||||
final String path;
|
||||
final http.Client client;
|
||||
|
||||
const DailyWorkingApi({
|
||||
required this.client,
|
||||
this.host = 'api.windesign.at',
|
||||
this.path = '/workinghours.php',
|
||||
});
|
||||
|
||||
Future<Map<int, int>> getDailyMinutes() async {
|
||||
final uri = Uri.https(host, path, {
|
||||
'module': 'dailyworking',
|
||||
'function': 'getList',
|
||||
});
|
||||
|
||||
final res = await client
|
||||
.get(uri, headers: {'Accept': 'application/json'})
|
||||
.timeout(const Duration(seconds: 15));
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
throw Exception('HTTP ${res.statusCode}: ${res.body}');
|
||||
}
|
||||
|
||||
final map = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
final list = (map['entries'] as List?) ?? const [];
|
||||
if (list.isEmpty) return _defaultPlan();
|
||||
|
||||
final row = (list.first as Map).cast<String, dynamic>();
|
||||
|
||||
int parseHMS(String? s) {
|
||||
if (s == null) return 0;
|
||||
final parts = s.split(':');
|
||||
if (parts.length != 3) return 0;
|
||||
final h = int.tryParse(parts[0]) ?? 0;
|
||||
final m = int.tryParse(parts[1]) ?? 0;
|
||||
return h * 60 + m; // Sekunden ignoriert
|
||||
}
|
||||
|
||||
return {
|
||||
DateTime.monday: parseHMS(row['monday'] as String?),
|
||||
DateTime.tuesday: parseHMS(row['tuesday'] as String?),
|
||||
DateTime.wednesday: parseHMS(row['wednesday'] as String?),
|
||||
DateTime.thursday: parseHMS(row['thursday'] as String?),
|
||||
DateTime.friday: parseHMS(row['friday'] as String?),
|
||||
DateTime.saturday: parseHMS(row['saturday'] as String?),
|
||||
DateTime.sunday: parseHMS(row['sunday'] as String?),
|
||||
};
|
||||
}
|
||||
|
||||
Map<int, int> _defaultPlan() => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -4,14 +4,14 @@ import 'work_interval.dart';
|
||||
class WorkDay {
|
||||
final DateTime date;
|
||||
final List<WorkInterval> intervals; // bis zu 5
|
||||
final String? absenceType; // Urlaub, Krank, …
|
||||
final String? code; // GZ, K, U, SU, T oder null
|
||||
final int targetMinutes; // Soll
|
||||
|
||||
const WorkDay({
|
||||
required this.date,
|
||||
required this.intervals,
|
||||
required this.targetMinutes,
|
||||
this.absenceType,
|
||||
this.code,
|
||||
});
|
||||
|
||||
int get workedMinutes => intervals.fold(0, (sum, i) => sum + i.minutes);
|
||||
|
||||
+336
-163
@@ -1,7 +1,9 @@
|
||||
import 'dart:ui' show FontFeature;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../api/booking_api.dart';
|
||||
import '../api/daily_working_api.dart';
|
||||
import '../models/work_day.dart';
|
||||
import '../models/work_interval.dart';
|
||||
import '../utils/helpers.dart';
|
||||
@@ -15,8 +17,14 @@ class MonthlyView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MonthlyViewState extends State<MonthlyView> {
|
||||
int _uiVersion = 0;
|
||||
// Spaltenbreiten (Header & Body identisch -> exakte Ausrichtung)
|
||||
static const double _wDate = 120;
|
||||
static const double _wHoliday = 160;
|
||||
static const double _wTime = 84;
|
||||
static const double _wCode = 110;
|
||||
static const double _wNumber = 92;
|
||||
|
||||
// Edit/Fokus/Controller je Feld
|
||||
final Set<String> _invalidCells = <String>{};
|
||||
final Map<String, FocusNode> _focusNodes = {};
|
||||
final Map<String, TextEditingController> _controllers = {};
|
||||
@@ -25,13 +33,23 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
_controllers.putIfAbsent(key, () => TextEditingController(text: initial));
|
||||
|
||||
late final http.Client _client;
|
||||
late final BookingApi _api;
|
||||
late final BookingApi _bookingApi;
|
||||
late final DailyWorkingApi _dailyApi;
|
||||
|
||||
late DateTime _monthStart;
|
||||
List<WorkDay> _days = const [];
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
Map<String, String> _holidays = const {};
|
||||
Map<int, int> _dailyPlan = {
|
||||
DateTime.monday: 8 * 60,
|
||||
DateTime.tuesday: 8 * 60,
|
||||
DateTime.wednesday: 8 * 60,
|
||||
DateTime.thursday: 8 * 60,
|
||||
DateTime.friday: 8 * 60,
|
||||
DateTime.saturday: 0,
|
||||
DateTime.sunday: 0,
|
||||
};
|
||||
|
||||
late final ScrollController _hCtrl;
|
||||
late final ScrollController _vCtrl;
|
||||
@@ -44,7 +62,8 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
_hCtrl = ScrollController();
|
||||
_vCtrl = ScrollController();
|
||||
_client = http.Client();
|
||||
_api = BookingApi(client: _client);
|
||||
_bookingApi = BookingApi(client: _client);
|
||||
_dailyApi = DailyWorkingApi(client: _client);
|
||||
_loadMonth(_monthStart);
|
||||
}
|
||||
|
||||
@@ -69,15 +88,33 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
_days = const [];
|
||||
});
|
||||
try {
|
||||
final apiDays = await _api.getBookingList(m);
|
||||
final days = fillMonth(m, apiDays);
|
||||
final results = await Future.wait([
|
||||
_bookingApi.getBookingList(m),
|
||||
_dailyApi.getDailyMinutes(),
|
||||
]);
|
||||
|
||||
final apiDays = results[0] as List<WorkDay>;
|
||||
final plan = results[1] as Map<int, int>;
|
||||
|
||||
final filled = fillMonth(m, apiDays);
|
||||
|
||||
final withTargets = filled
|
||||
.map((d) => WorkDay(
|
||||
date: d.date,
|
||||
intervals: d.intervals,
|
||||
targetMinutes: plan[d.date.weekday] ?? d.targetMinutes,
|
||||
code: d.code,
|
||||
))
|
||||
.toList();
|
||||
|
||||
setState(() {
|
||||
_monthStart = DateTime(m.year, m.month, 1);
|
||||
_holidays = buildHolidayMapAT(_monthStart.year);
|
||||
_days = days;
|
||||
_days = withTargets;
|
||||
_dailyPlan = plan;
|
||||
_loading = false;
|
||||
_uiVersion++;
|
||||
});
|
||||
|
||||
_syncControllersWithDays();
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
@@ -87,38 +124,66 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
}
|
||||
}
|
||||
|
||||
double get _tableMinWidth =>
|
||||
_wDate + _wHoliday + (10 * _wTime) + _wCode + (4 * _wNumber) + _wDate;
|
||||
|
||||
// Override für Ist-Minuten nach Code
|
||||
int _workedFor(WorkDay d) {
|
||||
final c = d.code;
|
||||
if (c == 'U' || c == 'SU' || c == 'K' || c == 'T') {
|
||||
return d.targetMinutes; // Urlaub/Sonderurlaub/Krankenstand/Training -> Ist = Soll
|
||||
}
|
||||
return d.workedMinutes; // normal aus Intervallen
|
||||
}
|
||||
|
||||
Color? _rowColorFor(WorkDay d, {required Color? holidayBg, required Color? weekendBg}) {
|
||||
switch (d.code) {
|
||||
case 'GZ': return const Color(0xFFBFBFFF);
|
||||
case 'U': return const Color(0xFF7F7FFF);
|
||||
case 'SU': return const Color(0xFF7F7FFF);
|
||||
case 'K': return Colors.yellow;
|
||||
case 'T': return Colors.red;
|
||||
}
|
||||
// kein Code -> ggf. Feiertag/Wochenende
|
||||
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 columns = _buildColumns();
|
||||
final headerColumns = _buildColumns();
|
||||
final bodyColumns = _buildColumns(); // gleiche Struktur (Body blendet Header aus)
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final holidayBg = theme.colorScheme.secondaryContainer.withOpacity(0.45);
|
||||
final weekendBg = Colors.grey.withOpacity(0.30);
|
||||
|
||||
// Effektive Tage: live aus Editfeldern gelesen
|
||||
// Effektive Tage (inkl. Live-Edits)
|
||||
final effectiveDays = List<WorkDay>.generate(_days.length, (i) => _effectiveDay(i, _days[i]));
|
||||
|
||||
// kumulative Differenz
|
||||
// kumulative Differenz basierend auf Override- oder Intervall-Arbeitszeit
|
||||
final diffs = <int>[];
|
||||
for (final d in effectiveDays) {
|
||||
final worked = _workedFor(d);
|
||||
diffs.add(worked - d.targetMinutes);
|
||||
}
|
||||
final cumulative = <int>[];
|
||||
int sum = 0;
|
||||
for (final d in effectiveDays) {
|
||||
sum += d.diffMinutes;
|
||||
for (final diff in diffs) {
|
||||
sum += diff;
|
||||
cumulative.add(sum);
|
||||
}
|
||||
|
||||
final rows = List<DataRow>.generate(_days.length, (i) {
|
||||
final day = effectiveDays[i];
|
||||
final run = cumulative[i];
|
||||
final isHoliday = _holidays.containsKey(ymd(day.date));
|
||||
final isWeekend =
|
||||
day.date.weekday == DateTime.saturday || day.date.weekday == DateTime.sunday;
|
||||
return DataRow(
|
||||
// MaterialStateProperty -> WidgetStateProperty (Deprecation)
|
||||
color: WidgetStateProperty.resolveWith<Color?>((_) {
|
||||
if (isHoliday) return holidayBg;
|
||||
if (isWeekend) return weekendBg;
|
||||
return null;
|
||||
}),
|
||||
color: WidgetStateProperty.resolveWith<Color?>(
|
||||
(_) => _rowColorFor(day, holidayBg: holidayBg, weekendBg: weekendBg),
|
||||
),
|
||||
cells: _buildEditableCells(i, day, run),
|
||||
);
|
||||
});
|
||||
@@ -165,6 +230,34 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// FIXIERTE KOPFZEILE (nur horizontal scrollend)
|
||||
Scrollbar(
|
||||
controller: _hCtrl,
|
||||
notificationPredicate: (n) => n.metrics.axis == Axis.horizontal,
|
||||
thumbVisibility: true,
|
||||
child: SingleChildScrollView(
|
||||
controller: _hCtrl,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: _tableMinWidth),
|
||||
child: DataTableTheme(
|
||||
data: const DataTableThemeData(
|
||||
headingRowHeight: 42,
|
||||
columnSpacing: 20,
|
||||
headingTextStyle: TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
child: DataTable(
|
||||
showCheckboxColumn: false,
|
||||
columns: headerColumns,
|
||||
rows: const <DataRow>[], // nur Header anzeigen
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// BODY (horiz. & vert. Scroll, aber Header im Body ausgeblendet)
|
||||
Expanded(
|
||||
child: Scrollbar(
|
||||
controller: _hCtrl,
|
||||
@@ -175,7 +268,7 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 1300),
|
||||
constraints: BoxConstraints(minWidth: _tableMinWidth),
|
||||
child: Scrollbar(
|
||||
controller: _vCtrl,
|
||||
notificationPredicate: (n) => n.metrics.axis == Axis.vertical,
|
||||
@@ -184,15 +277,14 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
controller: _vCtrl,
|
||||
child: DataTableTheme(
|
||||
data: const DataTableThemeData(
|
||||
headingRowHeight: 42,
|
||||
headingRowHeight: 0, // Header hier ausblenden
|
||||
dataRowMinHeight: 44,
|
||||
dataRowMaxHeight: 54,
|
||||
columnSpacing: 20,
|
||||
headingTextStyle: TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
child: DataTable(
|
||||
showCheckboxColumn: false,
|
||||
columns: columns,
|
||||
columns: bodyColumns,
|
||||
rows: rows,
|
||||
),
|
||||
),
|
||||
@@ -206,43 +298,46 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
}
|
||||
|
||||
List<DataColumn> _buildColumns() {
|
||||
const center = TextAlign.center;
|
||||
const right = TextAlign.right;
|
||||
const left = TextAlign.left;
|
||||
|
||||
DataColumn c(String label, {TextAlign align = center, double? width}) => DataColumn(
|
||||
DataColumn c(String label, {Alignment align = Alignment.center, double? width}) => DataColumn(
|
||||
label: SizedBox(
|
||||
width: width ?? (align == center ? null : 120),
|
||||
width: width,
|
||||
child: Align(
|
||||
alignment: align == right
|
||||
? Alignment.centerRight
|
||||
: align == left
|
||||
? Alignment.centerLeft
|
||||
: Alignment.center,
|
||||
child: Text(label, textAlign: align),
|
||||
alignment: align,
|
||||
child: Text(label, textAlign: _toTextAlign(align)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return [
|
||||
// 0
|
||||
c('Datum', align: right),
|
||||
// 1
|
||||
c('Feiertag', align: left, width: 160),
|
||||
// 2..11 Zeitspalten
|
||||
c('Start 1'), c('Ende 1'),
|
||||
c('Start 2'), c('Ende 2'),
|
||||
c('Start 3'), c('Ende 3'),
|
||||
c('Start 4'), c('Ende 4'),
|
||||
c('Start 5'), c('Ende 5'),
|
||||
// 12..16 Kennzahlen
|
||||
c('Abwesenheitstype', align: left, width: 160),
|
||||
c('Soll', align: right),
|
||||
c('Ist', align: right),
|
||||
c('Differenz', align: right),
|
||||
c('Differenz gesamt', align: right),
|
||||
// 17
|
||||
c('Datum', align: left),
|
||||
// 0 Datum (rechtsbündig)
|
||||
c('Datum', align: Alignment.centerRight, width: _wDate),
|
||||
|
||||
// 1 Feiertag (links)
|
||||
c('Feiertag', align: Alignment.centerLeft, width: _wHoliday),
|
||||
|
||||
// 2..11 Zeitspalten (zentriert)
|
||||
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),
|
||||
|
||||
// 12 Code (zentriert)
|
||||
c('Code', align: Alignment.center, width: _wCode),
|
||||
|
||||
// 13..16 Kennzahlen (rechtsbündig)
|
||||
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),
|
||||
|
||||
// 17 Datum (linksbündig)
|
||||
c('Datum', align: Alignment.centerLeft, width: _wDate),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -264,103 +359,174 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
|
||||
final cells = <DataCell>[];
|
||||
|
||||
// 0: Datum Wochentag (rechts)
|
||||
cells.add(DataCell(Align(alignment: Alignment.centerRight, child: Text(leftLabel))));
|
||||
// 0: Datum (rechts)
|
||||
cells.add(DataCell(SizedBox(
|
||||
width: _wDate,
|
||||
child: Align(alignment: Alignment.centerRight, child: Text(leftLabel)),
|
||||
)));
|
||||
// 1: Feiertag-Name
|
||||
cells.add(DataCell(Text(hName)));
|
||||
cells.add(DataCell(SizedBox(
|
||||
width: _wHoliday,
|
||||
child: Align(alignment: Alignment.centerLeft, child: Text(hName)),
|
||||
)));
|
||||
|
||||
// 2..11: editierbare Zeiten
|
||||
// 2..11: editierbare 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));
|
||||
cells.add(DataCell(
|
||||
_timeField(
|
||||
key: ValueKey(keyS),
|
||||
controller: ctrlS,
|
||||
focusNode: fnS,
|
||||
invalid: isInvalid(slot, true),
|
||||
onChanged: (text) {
|
||||
final k = _cellKey(dayIndex, slot, true);
|
||||
final valid = text.isEmpty || _isValidHHMM(text);
|
||||
setState(() {
|
||||
if (valid) {
|
||||
_invalidCells.remove(k);
|
||||
cells.add(DataCell(SizedBox(
|
||||
width: _wTime,
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: _timeField(
|
||||
key: ValueKey(keyS),
|
||||
controller: ctrlS,
|
||||
focusNode: fnS,
|
||||
invalid: isInvalid(slot, true),
|
||||
onChanged: (text) {
|
||||
final k = _cellKey(dayIndex, slot, true);
|
||||
final valid = text.isEmpty || _isValidHHMM(text);
|
||||
setState(() {
|
||||
if (valid) {
|
||||
_invalidCells.remove(k);
|
||||
} else {
|
||||
_invalidCells.add(k);
|
||||
}
|
||||
});
|
||||
if (valid && (text.isEmpty || text.length == 5)) {
|
||||
_commitTime(dayIndex, slot, true, text);
|
||||
} else {
|
||||
_invalidCells.add(k);
|
||||
setState(() {}); // live neu berechnen
|
||||
}
|
||||
});
|
||||
if (valid && (text.isEmpty || text.length == 5)) {
|
||||
_commitTime(dayIndex, slot, true, text);
|
||||
} else {
|
||||
setState(() {}); // live neu berechnen, Fokus bleibt erhalten
|
||||
}
|
||||
},
|
||||
onSubmitted: (text) {
|
||||
if (text.isEmpty || _isValidHHMM(text)) {
|
||||
_commitTime(dayIndex, slot, true, text);
|
||||
}
|
||||
},
|
||||
},
|
||||
onSubmitted: (text) {
|
||||
if (text.isEmpty || _isValidHHMM(text)) {
|
||||
_commitTime(dayIndex, slot, true, text);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
onTap: () => fnS.requestFocus(),
|
||||
));
|
||||
)));
|
||||
|
||||
// Ende
|
||||
final keyE = 't_${dayIndex}_${slot}_e';
|
||||
final fnE = _nodeFor(keyE);
|
||||
final ctrlE = _controllerFor(keyE, slotText(slot, false));
|
||||
cells.add(DataCell(
|
||||
_timeField(
|
||||
key: ValueKey(keyE),
|
||||
controller: ctrlE,
|
||||
focusNode: fnE,
|
||||
invalid: isInvalid(slot, false),
|
||||
onChanged: (text) {
|
||||
final k = _cellKey(dayIndex, slot, false);
|
||||
final valid = text.isEmpty || _isValidHHMM(text);
|
||||
setState(() {
|
||||
if (valid) {
|
||||
_invalidCells.remove(k);
|
||||
cells.add(DataCell(SizedBox(
|
||||
width: _wTime,
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: _timeField(
|
||||
key: ValueKey(keyE),
|
||||
controller: ctrlE,
|
||||
focusNode: fnE,
|
||||
invalid: isInvalid(slot, false),
|
||||
onChanged: (text) {
|
||||
final k = _cellKey(dayIndex, slot, false);
|
||||
final valid = text.isEmpty || _isValidHHMM(text);
|
||||
setState(() {
|
||||
if (valid) {
|
||||
_invalidCells.remove(k);
|
||||
} else {
|
||||
_invalidCells.add(k);
|
||||
}
|
||||
});
|
||||
if (valid && (text.isEmpty || text.length == 5)) {
|
||||
_commitTime(dayIndex, slot, false, text);
|
||||
} else {
|
||||
_invalidCells.add(k);
|
||||
setState(() {}); // live neu berechnen
|
||||
}
|
||||
});
|
||||
if (valid && (text.isEmpty || text.length == 5)) {
|
||||
_commitTime(dayIndex, slot, false, text);
|
||||
} else {
|
||||
setState(() => _uiVersion++);
|
||||
}
|
||||
},
|
||||
onSubmitted: (text) {
|
||||
if (text.isEmpty || _isValidHHMM(text)) {
|
||||
_commitTime(dayIndex, slot, false, text);
|
||||
}
|
||||
},
|
||||
},
|
||||
onSubmitted: (text) {
|
||||
if (text.isEmpty || _isValidHHMM(text)) {
|
||||
_commitTime(dayIndex, slot, false, text);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
onTap: () => fnE.requestFocus(),
|
||||
));
|
||||
)));
|
||||
}
|
||||
|
||||
// 12..16: Kennzahlen
|
||||
// 12: Code (Dropdown) — zentriert (Kurzname geschlossen, Langname in Liste)
|
||||
cells.add(DataCell(SizedBox(
|
||||
width: _wCode,
|
||||
child: Align(alignment: Alignment.center, child: _codeDropdown(dayIndex, day)),
|
||||
)));
|
||||
|
||||
// 13..16: Kennzahlen (rechts) – mit Override
|
||||
final worked = _workedFor(day);
|
||||
final soll = minutesToHHMM(day.targetMinutes);
|
||||
final ist = minutesToHHMM(day.workedMinutes);
|
||||
final diff = minutesToSignedHHMM(day.diffMinutes);
|
||||
final ist = minutesToHHMM(worked);
|
||||
final diff = minutesToSignedHHMM(worked - day.targetMinutes);
|
||||
final diffSum = minutesToSignedHHMM(runningDiff);
|
||||
|
||||
cells.add(DataCell(Text(day.absenceType ?? '—')));
|
||||
cells.add(DataCell(Align(alignment: Alignment.centerRight, child: mono(soll))));
|
||||
cells.add(DataCell(Align(alignment: Alignment.centerRight, child: mono(ist))));
|
||||
cells.add(DataCell(Align(alignment: Alignment.centerRight, child: mono(diff))));
|
||||
cells.add(DataCell(Align(alignment: Alignment.centerRight, child: mono(diffSum))));
|
||||
cells.add(DataCell(SizedBox(
|
||||
width: _wNumber,
|
||||
child: Align(alignment: Alignment.centerRight, child: mono(soll)),
|
||||
)));
|
||||
cells.add(DataCell(SizedBox(
|
||||
width: _wNumber,
|
||||
child: Align(alignment: Alignment.centerRight, child: mono(ist)),
|
||||
)));
|
||||
cells.add(DataCell(SizedBox(
|
||||
width: _wNumber,
|
||||
child: Align(alignment: Alignment.centerRight, child: mono(diff)),
|
||||
)));
|
||||
cells.add(DataCell(SizedBox(
|
||||
width: _wNumber,
|
||||
child: Align(alignment: Alignment.centerRight, child: mono(diffSum)),
|
||||
)));
|
||||
|
||||
// 17: Wochentag Datum (links)
|
||||
cells.add(DataCell(Align(alignment: Alignment.centerLeft, child: Text(rightLabel))));
|
||||
// 17: Datum (links)
|
||||
cells.add(DataCell(SizedBox(
|
||||
width: _wDate,
|
||||
child: Align(alignment: Alignment.centerLeft, child: Text(rightLabel)),
|
||||
)));
|
||||
|
||||
assert(cells.length == 18, 'Row has ${cells.length} cells but expected 18.');
|
||||
return cells;
|
||||
}
|
||||
|
||||
Widget _codeDropdown(int dayIndex, WorkDay day) {
|
||||
final value = day.code; // null => kein Code (—)
|
||||
final values = <String?>[null, ...kAbsenceCodes];
|
||||
|
||||
return DropdownButton<String?>(
|
||||
isExpanded: true,
|
||||
value: value,
|
||||
// Langnamen in der aufgeklappten Liste:
|
||||
items: values.map((v) {
|
||||
final label = v == null ? '—' : codeLabel(v);
|
||||
return DropdownMenuItem<String?>(
|
||||
value: v,
|
||||
child: Text(label, textAlign: TextAlign.center),
|
||||
);
|
||||
}).toList(),
|
||||
// Kurznamen (zentriert) in der geschlossenen Anzeige:
|
||||
selectedItemBuilder: (context) {
|
||||
return values.map((v) {
|
||||
final shortText = v ?? '—';
|
||||
return Center(child: Text(shortText));
|
||||
}).toList();
|
||||
},
|
||||
onChanged: (newCode) {
|
||||
final d = _days[dayIndex];
|
||||
setState(() {
|
||||
final newDays = List<WorkDay>.from(_days);
|
||||
newDays[dayIndex] = WorkDay(
|
||||
date: d.date,
|
||||
intervals: d.intervals,
|
||||
targetMinutes: d.targetMinutes,
|
||||
code: newCode,
|
||||
);
|
||||
_days = newDays;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _isValidHHMM(String s) {
|
||||
if (s.length != 5 || s[2] != ':') return false;
|
||||
final h = int.tryParse(s.substring(0, 2));
|
||||
@@ -402,8 +568,8 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
newDays[dayIndex] = WorkDay(
|
||||
date: d.date,
|
||||
intervals: newIntervals,
|
||||
targetMinutes: d.targetMinutes,
|
||||
absenceType: d.absenceType,
|
||||
targetMinutes: _dailyPlan[d.date.weekday] ?? d.targetMinutes,
|
||||
code: d.code,
|
||||
);
|
||||
_days = newDays;
|
||||
});
|
||||
@@ -448,52 +614,59 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
}
|
||||
}
|
||||
|
||||
final target = _dailyPlan[base.date.weekday] ?? base.targetMinutes;
|
||||
|
||||
return WorkDay(
|
||||
date: base.date,
|
||||
intervals: intervals,
|
||||
targetMinutes: base.targetMinutes,
|
||||
absenceType: base.absenceType,
|
||||
targetMinutes: target,
|
||||
code: base.code,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- fehlende Methode: gemeinsames Zeitfeld-Widget ----
|
||||
Widget _timeField({
|
||||
required Key key,
|
||||
required TextEditingController controller,
|
||||
required bool invalid,
|
||||
required ValueChanged<String> onChanged,
|
||||
required ValueChanged<String> onSubmitted,
|
||||
FocusNode? focusNode,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final errorFill = theme.colorScheme.errorContainer.withOpacity(0.25);
|
||||
// rahmenlose Eingabefelder, nur dezente Füllung bei invalid
|
||||
Widget _timeField({
|
||||
required Key key,
|
||||
required TextEditingController controller,
|
||||
required bool invalid,
|
||||
required ValueChanged<String> onChanged,
|
||||
required ValueChanged<String> onSubmitted,
|
||||
FocusNode? focusNode,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final errorFill = theme.colorScheme.errorContainer.withOpacity(0.25);
|
||||
|
||||
return SizedBox(
|
||||
width: 84,
|
||||
child: TextFormField(
|
||||
key: key,
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontFeatures: [FontFeature.tabularFigures()]),
|
||||
keyboardType: TextInputType.datetime,
|
||||
inputFormatters: const [HHmmInputFormatter()],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
// dezente Hinterlegung nur wenn ungültig:
|
||||
filled: invalid,
|
||||
fillColor: invalid ? errorFill : null,
|
||||
// kein hintText mehr
|
||||
return SizedBox(
|
||||
width: _wTime,
|
||||
child: TextFormField(
|
||||
key: key,
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontFeatures: [FontFeature.tabularFigures()]),
|
||||
keyboardType: TextInputType.datetime,
|
||||
inputFormatters: const [HHmmInputFormatter()],
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 6, vertical: 6),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
).copyWith(
|
||||
filled: invalid,
|
||||
fillColor: invalid ? errorFill : null,
|
||||
),
|
||||
onChanged: onChanged,
|
||||
onFieldSubmitted: onSubmitted,
|
||||
),
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
class _MonthHeader extends StatelessWidget {
|
||||
|
||||
+24
-9
@@ -36,15 +36,6 @@ Widget mono(String s) =>
|
||||
String fmtTimeOfDay(TimeOfDay t) =>
|
||||
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
int minutesBetween(TimeOfDay a, TimeOfDay b) {
|
||||
final aMin = a.hour * 60 + a.minute;
|
||||
final bMin = b.hour * 60 + b.minute;
|
||||
final d = bMin - aMin;
|
||||
if (d <= 0) return 0;
|
||||
if (d >= 24 * 60) return 24 * 60;
|
||||
return d;
|
||||
}
|
||||
|
||||
String minutesToHHMM(int minutes) {
|
||||
final h = minutes ~/ 60;
|
||||
final m = minutes % 60;
|
||||
@@ -102,3 +93,27 @@ List<WorkDay> fillMonth(DateTime monthStart, List<WorkDay> existing) {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// "HH:MM:SS" → Minuten (Sekunden ignoriert). Ungültig => null.
|
||||
int? minutesFromHHMMSS(String? s) {
|
||||
if (s == null) return null;
|
||||
final parts = s.split(':');
|
||||
if (parts.length != 3) return null;
|
||||
final h = int.tryParse(parts[0]) ?? 0;
|
||||
final m = int.tryParse(parts[1]) ?? 0;
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
// Abwesenheitscodes und Labels
|
||||
const List<String> kAbsenceCodes = ['GZ', 'K', 'U', 'SU', 'T'];
|
||||
|
||||
String codeLabel(String? code) {
|
||||
switch (code) {
|
||||
case 'GZ': return 'Gleitzeit';
|
||||
case 'K': return 'Krankenstand';
|
||||
case 'U': return 'Urlaub';
|
||||
case 'SU': return 'Sonderurlaub';
|
||||
case 'T': return 'Training';
|
||||
default: return '—';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Tippt „HHMM“ und formatiert live zu „HH:MM“ (nur Ziffern erlaubt).
|
||||
class HHmmInputFormatter extends TextInputFormatter {
|
||||
const HHmmInputFormatter();
|
||||
|
||||
@@ -17,7 +18,7 @@ class HHmmInputFormatter extends TextInputFormatter {
|
||||
} else {
|
||||
final hh = digits.substring(0, 2);
|
||||
final mm = digits.substring(2);
|
||||
text = '$hh:$mm'; // ← Interpolation statt + ':' +
|
||||
text = '$hh:$mm';
|
||||
}
|
||||
|
||||
final offset = text.length;
|
||||
|
||||
Reference in New Issue
Block a user