This commit is contained in:
2025-09-19 13:53:50 +02:00
parent f212f9feb3
commit 1e90222d36
11 changed files with 751 additions and 333 deletions
+58 -53
View File
@@ -1,81 +1,86 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:flutter/material.dart';
import '../models/work_day.dart'; import '../models/work_day.dart';
import '../models/work_interval.dart'; import '../models/work_interval.dart';
import '../models/month_start.dart';
import '../utils/helpers.dart'; import '../utils/helpers.dart';
class BookingApi { class BookingApi {
final String host;
final String path;
final http.Client client; final http.Client client;
BookingApi({required this.client});
const BookingApi({ /// Monatliche Buchungen holen (für YYYY-MM)
required this.client,
this.host = 'api.windesign.at',
this.path = '/workinghours.php',
});
Future<List<WorkDay>> getBookingList(DateTime monthStart) async { Future<List<WorkDay>> getBookingList(DateTime monthStart) async {
final y = monthStart.year.toString().padLeft(4, '0'); final y = monthStart.year.toString().padLeft(4, '0');
final m = monthStart.month.toString().padLeft(2, '0'); final m = monthStart.month.toString().padLeft(2, '0');
final uri = Uri.https(host, path, { final uri = Uri.parse(
'module': 'booking', 'https://api.windesign.at/workinghours.php?module=booking&function=getList&date=$y-$m',
'function': 'getList', );
'date': '$y-$m',
});
http.Response res;
try {
res = await client
.get(uri, headers: {'Accept': 'application/json'})
.timeout(const Duration(seconds: 15));
} on http.ClientException catch (e) {
throw Exception('ClientException: ${e.message} (uri=$uri)');
} on Object catch (e) {
throw Exception('Network error: $e (uri=$uri)');
}
final res = await client.get(uri);
if (res.statusCode != 200) { if (res.statusCode != 200) {
throw Exception('HTTP ${res.statusCode}: ${res.body}'); throw Exception('booking/getList failed: ${res.statusCode} ${res.body}');
} }
final map = jsonDecode(res.body) as Map<String, dynamic>; final map = jsonDecode(res.body) as Map<String, dynamic>;
final List items = map['bookings'] ?? []; if (map['error'] == true) {
final out = <WorkDay>[]; throw Exception('booking/getList error: ${map['errmsg']}');
}
final list = (map['bookings'] as List?) ?? const [];
final items = <WorkDay>[];
for (final row in items) { for (final e in list) {
final d = DateTime.parse(row['bookingDay'] as String); final row = e as Map<String, dynamic>;
final isWeekend = d.weekday == DateTime.saturday || d.weekday == DateTime.sunday; final dayStr = (row['bookingDay'] as String?) ?? '';
final target = isWeekend ? 0 : 8 * 60; final date = DateTime.tryParse(dayStr);
if (date == null) continue;
String? code = (row['code'] as String?)?.trim();
if (code != null && code.isEmpty) code = null;
final starts = <String?>[
row['come1'] as String?, row['come2'] as String?,
row['come3'] as String?, row['come4'] as String?, row['come5'] as String?,
];
final ends = <String?>[
row['leave1'] as String?, row['leave2'] as String?,
row['leave3'] as String?, row['leave4'] as String?, row['leave5'] as String?,
];
final intervals = <WorkInterval>[]; final intervals = <WorkInterval>[];
TimeOfDay? p(dynamic v) => parseHHMM(v); for (int i = 0; i < 5; i++) {
void addPair(String a, String b) { final s = parseDbTime(starts[i]);
final s = p(row[a]); final e2 = parseDbTime(ends[i]);
final e = p(row[b]); if (s != null && e2 != null) {
if (s != null && e != null) intervals.add(WorkInterval(s, e)); intervals.add(WorkInterval(s, e2));
}
} }
addPair('come1', 'leave1');
addPair('come2', 'leave2');
addPair('come3', 'leave3');
addPair('come4', 'leave4');
addPair('come5', 'leave5');
final code = (row['code']?.toString().trim().isEmpty ?? true) items.add(WorkDay(
? null date: DateTime(date.year, date.month, date.day),
: row['code'].toString().trim();
out.add(WorkDay(
date: d,
intervals: intervals, intervals: intervals,
targetMinutes: target, targetMinutes: 0, // wird im UI mit dem Tagesplan ersetzt
code: code, code: code,
)); ));
} }
out.sort((a, b) => a.date.compareTo(b.date)); return items;
return out; }
/// Monatliche Startdaten (Startsaldo, Vacation, Overtime, Correction)
Future<MonthStart> getMonthStart(DateTime monthStart) async {
final y = monthStart.year.toString().padLeft(4, '0');
final m = monthStart.month.toString().padLeft(2, '0');
final uri = Uri.parse(
'https://api.windesign.at/workinghours.php'
'?module=monthlybooking&function=getList&date=$y-$m-01',
);
final res = await client.get(uri);
if (res.statusCode != 200) {
throw Exception('monthlybooking/getList failed: ${res.statusCode} ${res.body}');
}
final map = jsonDecode(res.body) as Map<String, dynamic>;
if (map['error'] == true) {
throw Exception('monthlybooking/getList error: ${map['errmsg']}');
}
return MonthStart.fromJson(map);
} }
} }
+28 -47
View File
@@ -1,66 +1,47 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; 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 { class DailyWorkingApi {
final String host;
final String path;
final http.Client client; final http.Client client;
DailyWorkingApi({required this.client});
const DailyWorkingApi({ /// Liefert Map weekday(1..7) -> Minuten (int)
required this.client,
this.host = 'api.windesign.at',
this.path = '/workinghours.php',
});
Future<Map<int, int>> getDailyMinutes() async { Future<Map<int, int>> getDailyMinutes() async {
final uri = Uri.https(host, path, { final uri = Uri.parse(
'module': 'dailyworking', 'https://api.windesign.at/workinghours.php?module=dailyworking&function=getList',
'function': 'getList', );
}); final res = await client.get(uri);
final res = await client
.get(uri, headers: {'Accept': 'application/json'})
.timeout(const Duration(seconds: 15));
if (res.statusCode != 200) { if (res.statusCode != 200) {
throw Exception('HTTP ${res.statusCode}: ${res.body}'); throw Exception('dailyworking/getList failed: ${res.statusCode} ${res.body}');
} }
final map = jsonDecode(res.body) as Map<String, dynamic>; final map = jsonDecode(res.body) as Map<String, dynamic>;
final list = (map['entries'] as List?) ?? const []; if (map['error'] == true) {
if (list.isEmpty) return _defaultPlan(); throw Exception('dailyworking/getList error: ${map['errmsg']}');
}
final row = (list.first as Map).cast<String, dynamic>(); final entries = (map['entries'] as List?) ?? const [];
if (entries.isEmpty) {
int parseHMS(String? s) { return {
if (s == null) return 0; 1: 8 * 60, 2: 8 * 60, 3: 8 * 60, 4: 8 * 60, 5: 8 * 60, 6: 0, 7: 0,
};
}
final row = entries.first as Map<String, dynamic>;
int parseHHMMSS(String? s) {
if (s == null || s.isEmpty) return 0;
final parts = s.split(':'); final parts = s.split(':');
if (parts.length != 3) return 0; if (parts.length < 2) return 0;
final h = int.tryParse(parts[0]) ?? 0; final h = int.tryParse(parts[0]) ?? 0;
final m = int.tryParse(parts[1]) ?? 0; final m = int.tryParse(parts[1]) ?? 0;
return h * 60 + m; // Sekunden ignoriert return h * 60 + m;
} }
return { return {
DateTime.monday: parseHMS(row['monday'] as String?), 1: parseHHMMSS(row['monday'] as String?),
DateTime.tuesday: parseHMS(row['tuesday'] as String?), 2: parseHHMMSS(row['tuesday'] as String?),
DateTime.wednesday: parseHMS(row['wednesday'] as String?), 3: parseHHMMSS(row['wednesday'] as String?),
DateTime.thursday: parseHMS(row['thursday'] as String?), 4: parseHHMMSS(row['thursday'] as String?),
DateTime.friday: parseHMS(row['friday'] as String?), 5: parseHHMMSS(row['friday'] as String?),
DateTime.saturday: parseHMS(row['saturday'] as String?), 6: parseHHMMSS(row['saturday'] as String?),
DateTime.sunday: parseHMS(row['sunday'] as String?), 7: parseHHMMSS(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,
};
} }
+32 -12
View File
@@ -1,25 +1,45 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'screens/home.dart'; import 'screens/monthly_view.dart';
void main() => runApp(const ZeitschreibungApp()); void main() {
runApp(const WorkingHoursApp());
}
class ZeitschreibungApp extends StatelessWidget { class WorkingHoursApp extends StatelessWidget {
const ZeitschreibungApp({super.key}); const WorkingHoursApp({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'Zeitschreibung', title: 'Working Hours',
theme: ThemeData( theme: ThemeData(
colorSchemeSeed: const Color(0xFF3B82F6),
useMaterial3: true, useMaterial3: true,
inputDecorationTheme: const InputDecorationTheme( colorSchemeSeed: const Color(0xFF4B7BE5),
border: OutlineInputBorder(), visualDensity: VisualDensity.compact,
isDense: true, ),
), home: const _Home(),
);
}
}
class _Home extends StatelessWidget {
const _Home();
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 1,
child: Scaffold(
appBar: AppBar(
title: const Text('Zeitschreibung'),
bottom: const TabBar(tabs: [
Tab(text: 'Monat'),
]),
),
body: const TabBarView(children: [
MonthlyView(),
]),
), ),
home: const HomeScreen(),
debugShowCheckedModeBanner: false,
); );
} }
} }
+35
View File
@@ -0,0 +1,35 @@
class MonthStart {
final DateTime month; // YYYY-MM-01
final int startMinutes; // starthours (in Minuten)
final int startVacationUnits; // startvacation (z.B. Tage)
final int overtimeMinutes; // overtime (Minuten)
final int correctionMinutes; // correction (Minuten)
const MonthStart({
required this.month,
required this.startMinutes,
required this.startVacationUnits,
required this.overtimeMinutes,
required this.correctionMinutes,
});
/// Basis für kumulierte Differenz
int get carryBaseMinutes => startMinutes + overtimeMinutes + correctionMinutes;
static MonthStart fromJson(Map<String, dynamic> json) {
final bookings = (json['bookings'] as List?) ?? const [];
final row = bookings.isNotEmpty ? (bookings.first as Map<String, dynamic>) : const <String, dynamic>{};
final dateStr = (json['date'] as String?) ?? '1970-01-01';
final dt = DateTime.tryParse(dateStr) ?? DateTime(1970, 1, 1);
int _toInt(dynamic v) => v is num ? v.toInt() : int.tryParse('$v') ?? 0;
return MonthStart(
month: DateTime(dt.year, dt.month, 1),
startMinutes: _toInt(row['starthours']),
startVacationUnits: _toInt(row['startvacation']),
overtimeMinutes: _toInt(row['overtime']),
correctionMinutes: _toInt(row['correction']),
);
}
}
+6 -5
View File
@@ -1,11 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../utils/work_time.dart';
import 'work_interval.dart'; import 'work_interval.dart';
class WorkDay { class WorkDay {
final DateTime date; final DateTime date;
final List<WorkInterval> intervals; // bis zu 5 final List<WorkInterval> intervals;
final String? code; // GZ, K, U, SU, T oder null final int targetMinutes;
final int targetMinutes; // Soll final String? code; // 'G','U','SU','K','T' oder null
const WorkDay({ const WorkDay({
required this.date, required this.date,
@@ -14,6 +15,6 @@ class WorkDay {
this.code, this.code,
}); });
int get workedMinutes => intervals.fold(0, (sum, i) => sum + i.minutes); /// Effektive Ist-Zeit mit 30-Minuten-Pausenregel (>6h) und Lückenanrechnung
int get diffMinutes => workedMinutes - targetMinutes; int get workedMinutes => effectiveWorkedMinutes(intervals);
} }
-10
View File
@@ -3,15 +3,5 @@ import 'package:flutter/material.dart';
class WorkInterval { class WorkInterval {
final TimeOfDay start; final TimeOfDay start;
final TimeOfDay end; final TimeOfDay end;
const WorkInterval(this.start, this.end); const WorkInterval(this.start, this.end);
int get minutes {
final aMin = start.hour * 60 + start.minute;
final bMin = end.hour * 60 + end.minute;
final d = bMin - aMin;
if (d <= 0) return 0;
if (d >= 24 * 60) return 24 * 60;
return d;
}
} }
+419 -90
View File
@@ -6,6 +6,7 @@ import '../api/booking_api.dart';
import '../api/daily_working_api.dart'; import '../api/daily_working_api.dart';
import '../models/work_day.dart'; import '../models/work_day.dart';
import '../models/work_interval.dart'; import '../models/work_interval.dart';
import '../models/month_start.dart';
import '../utils/helpers.dart'; import '../utils/helpers.dart';
import '../utils/holidays_at.dart'; import '../utils/holidays_at.dart';
import '../utils/input_formatters.dart'; import '../utils/input_formatters.dart';
@@ -17,7 +18,7 @@ class MonthlyView extends StatefulWidget {
} }
class _MonthlyViewState extends State<MonthlyView> { class _MonthlyViewState extends State<MonthlyView> {
// Kompaktere Layout-Parameter // Layout
static const double _wDate = 96; static const double _wDate = 96;
static const double _wHoliday = 120; static const double _wHoliday = 120;
static const double _wTime = 60; static const double _wTime = 60;
@@ -25,17 +26,19 @@ class _MonthlyViewState extends State<MonthlyView> {
static const double _wNumber = 76; static const double _wNumber = 76;
static const double _fontSize = 12; static const double _fontSize = 12;
// Codes, die Zeiten sperren/ leeren // Codes, die Zeiten leeren/sperren
static const Set<String> _lockCodes = {'G', 'K', 'U', 'SU'}; static const Set<String> _lockCodes = {'G', 'K', 'U', 'SU'};
// Edit/Fokus/Controller je Feld // Edit/Fokus/Controller je Feld
final Set<String> _invalidCells = <String>{}; final Set<String> _invalidCells = <String>{};
final Map<String, FocusNode> _focusNodes = {}; final Map<String, FocusNode> _focusNodes = {};
final Map<String, TextEditingController> _controllers = {}; 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) => TextEditingController _controllerFor(String key, String initial) =>
_controllers.putIfAbsent(key, () => TextEditingController(text: initial)); _controllers.putIfAbsent(key, () => TextEditingController(text: initial));
// API / State
late final http.Client _client; late final http.Client _client;
late final BookingApi _bookingApi; late final BookingApi _bookingApi;
late final DailyWorkingApi _dailyApi; late final DailyWorkingApi _dailyApi;
@@ -45,7 +48,7 @@ class _MonthlyViewState extends State<MonthlyView> {
bool _loading = false; bool _loading = false;
String? _error; String? _error;
Map<String, String> _holidays = const {}; Map<String, String> _holidays = const {};
Map<int, int> _dailyPlan = { Map<int, int> _dailyPlan = const {
DateTime.monday: 8 * 60, DateTime.monday: 8 * 60,
DateTime.tuesday: 8 * 60, DateTime.tuesday: 8 * 60,
DateTime.wednesday: 8 * 60, DateTime.wednesday: 8 * 60,
@@ -55,10 +58,14 @@ class _MonthlyViewState extends State<MonthlyView> {
DateTime.sunday: 0, DateTime.sunday: 0,
}; };
// Scroll-Controller // Monatliche Startdaten (für kumulierte Differenz & Footer)
late final ScrollController _hHeaderCtrl; // Header (nur mitgeführt) MonthStart? _monthStartInfo;
late final ScrollController _hBodyCtrl; // Body (führend) int _carryBaseMinutes = 0;
late final ScrollController _vCtrl; // Body vertikal
// Scroll-Controller (Header/Body synchron)
late final ScrollController _hHeaderCtrl;
late final ScrollController _hBodyCtrl;
late final ScrollController _vCtrl;
bool _syncingH = false; bool _syncingH = false;
@override @override
@@ -71,7 +78,6 @@ class _MonthlyViewState extends State<MonthlyView> {
_hBodyCtrl = ScrollController(); _hBodyCtrl = ScrollController();
_vCtrl = ScrollController(); _vCtrl = ScrollController();
// Body scrollt -> Header folgt
_hBodyCtrl.addListener(() { _hBodyCtrl.addListener(() {
if (_syncingH) return; if (_syncingH) return;
_syncingH = true; _syncingH = true;
@@ -112,27 +118,35 @@ class _MonthlyViewState extends State<MonthlyView> {
final results = await Future.wait([ final results = await Future.wait([
_bookingApi.getBookingList(m), _bookingApi.getBookingList(m),
_dailyApi.getDailyMinutes(), _dailyApi.getDailyMinutes(),
_bookingApi.getMonthStart(m),
]); ]);
final apiDays = results[0] as List<WorkDay>; final apiDays = results[0] as List<WorkDay>;
final plan = results[1] as Map<int, int>; 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 filled = fillMonth(m, apiDays);
final withTargets = filled.map((d) {
final withTargets = filled final isHoliday = holidayMap.containsKey(ymd(d.date)); // << neu
.map((d) => WorkDay( final target =
date: d.date, isHoliday ? 0 : (plan[d.date.weekday] ?? d.targetMinutes);
intervals: d.intervals, return WorkDay(
targetMinutes: plan[d.date.weekday] ?? d.targetMinutes, date: d.date,
code: d.code, intervals: d.intervals,
)) targetMinutes: target,
.toList(); code: d.code,
);
}).toList();
setState(() { setState(() {
_monthStart = DateTime(m.year, m.month, 1); _monthStart = DateTime(m.year, m.month, 1);
_holidays = buildHolidayMapAT(_monthStart.year); _holidays = holidayMap; // << aus holidayMap setzen
_days = withTargets; _days = withTargets;
_dailyPlan = plan; _dailyPlan = plan;
_monthStartInfo = mStart;
_carryBaseMinutes = mStart.carryBaseMinutes;
_loading = false; _loading = false;
}); });
@@ -150,14 +164,20 @@ class _MonthlyViewState extends State<MonthlyView> {
// Ist-Override für U/SU/K/T // Ist-Override für U/SU/K/T
int _workedFor(WorkDay d) { int _workedFor(WorkDay d) {
final c = d.code; switch (d.code) {
if (c == 'U' || c == 'SU' || c == 'K' || c == 'T') { case 'U':
return d.targetMinutes; 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) { switch (d.code) {
case 'G': case 'G':
return const Color(0xFFBFBFFF); // Gleitzeit return const Color(0xFFBFBFFF); // Gleitzeit
@@ -166,12 +186,13 @@ class _MonthlyViewState extends State<MonthlyView> {
case 'SU': case 'SU':
return const Color(0xFF7F7FFF); // Sonderurlaub return const Color(0xFF7F7FFF); // Sonderurlaub
case 'K': case 'K':
return Colors.yellow; // Krankenstand return Colors.yellow; // Krankenstand
case 'T': case 'T':
return Colors.red; // Training return Colors.red; // Training
} }
final isHoliday = _holidays.containsKey(ymd(d.date)); 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 (isHoliday) return holidayBg;
if (isWeekend) return weekendBg; if (isWeekend) return weekendBg;
return null; return null;
@@ -186,21 +207,45 @@ class _MonthlyViewState extends State<MonthlyView> {
final holidayBg = theme.colorScheme.secondaryContainer.withOpacity(0.45); final holidayBg = theme.colorScheme.secondaryContainer.withOpacity(0.45);
final weekendBg = Colors.grey.withOpacity(0.30); 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>[]; final diffs = <int>[];
int sollTotal = 0;
int istTotal = 0;
for (final d in effectiveDays) { for (final d in effectiveDays) {
final worked = _workedFor(d); final worked = _workedFor(d);
diffs.add(worked - d.targetMinutes); diffs.add(worked - d.targetMinutes);
sollTotal += d.targetMinutes;
istTotal += worked;
} }
final cumulative = <int>[]; final cumulative = <int>[];
int sum = 0; int sum = _carryBaseMinutes;
for (final diff in diffs) { for (final diff in diffs) {
sum += diff; sum += diff;
cumulative.add(sum); 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 rows = List<DataRow>.generate(_days.length, (i) {
final day = effectiveDays[i]; final day = effectiveDays[i];
final run = cumulative[i]; final run = cumulative[i];
@@ -216,8 +261,10 @@ class _MonthlyViewState extends State<MonthlyView> {
_MonthHeader( _MonthHeader(
month: _monthStart, month: _monthStart,
loading: _loading, loading: _loading,
onPrev: () => _loadMonth(DateTime(_monthStart.year, _monthStart.month - 1, 1)), onPrev: () =>
onNext: () => _loadMonth(DateTime(_monthStart.year, _monthStart.month + 1, 1)), _loadMonth(DateTime(_monthStart.year, _monthStart.month - 1, 1)),
onNext: () =>
_loadMonth(DateTime(_monthStart.year, _monthStart.month + 1, 1)),
onPickMonth: () async { onPickMonth: () async {
final picked = await showDatePicker( final picked = await showDatePicker(
context: context, context: context,
@@ -242,20 +289,22 @@ class _MonthlyViewState extends State<MonthlyView> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
Text('Fehler beim Laden:', style: TextStyle(fontSize: _fontSize)), Text('Fehler beim Laden:',
SelectableText(_error ?? '', style: const TextStyle(fontSize: _fontSize)), style: TextStyle(fontSize: _fontSize)),
SelectableText(_error ?? '',
style: const TextStyle(fontSize: _fontSize)),
], ],
), ),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
onPressed: () => _loadMonth(_monthStart), 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( SingleChildScrollView(
controller: _hHeaderCtrl, controller: _hHeaderCtrl,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
@@ -266,16 +315,15 @@ class _MonthlyViewState extends State<MonthlyView> {
data: DataTableThemeData( data: DataTableThemeData(
headingRowHeight: 30, headingRowHeight: 30,
columnSpacing: 10, columnSpacing: 10,
headingTextStyle: const TextStyle(fontWeight: FontWeight.w700).copyWith( headingTextStyle: const TextStyle(
fontSize: _fontSize, fontWeight: FontWeight.w700, fontSize: _fontSize),
),
), ),
child: const _HeaderOnlyDataTable(), child: const _HeaderOnlyDataTable(),
), ),
), ),
), ),
// BODY (horiz. & vert. Scroll; horizontale Scrollbar hier) // BODY (scrollbar horiz/vert)
Expanded( Expanded(
child: Scrollbar( child: Scrollbar(
controller: _hBodyCtrl, controller: _hBodyCtrl,
@@ -295,7 +343,7 @@ class _MonthlyViewState extends State<MonthlyView> {
controller: _vCtrl, controller: _vCtrl,
child: DataTableTheme( child: DataTableTheme(
data: const DataTableThemeData( data: const DataTableThemeData(
headingRowHeight: 0, // Header im Body ausblenden headingRowHeight: 0,
dataRowMinHeight: 30, dataRowMinHeight: 30,
dataRowMaxHeight: 34, dataRowMaxHeight: 34,
columnSpacing: 10, 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() { 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( label: SizedBox(
width: width, width: width,
child: Align( child: Align(
@@ -352,18 +427,16 @@ class _MonthlyViewState extends State<MonthlyView> {
]; ];
} }
List<DataCell> _buildEditableCells(int dayIndex, WorkDay day, int runningDiff) { List<DataCell> _buildEditableCells(
// Gewünscht: links Wochentag zuerst, rechts Datum zuerst. int dayIndex, WorkDay day, int runningDiff) {
final leftLabel = rightDayLabel(day.date); // Wochentag Datum final leftLabel = rightDayLabel(day.date); // "Mo 01.09."
final rightLabel = leftDayLabel(day.date); // Datum Wochentag final rightLabel = leftDayLabel(day.date); // "01.09. Mo"
final hName = _holidays[ymd(day.date)] ?? ''; final hName = _holidays[ymd(day.date)] ?? '';
// Sperre Zeiten, wenn Code in _lockCodes
final bool lockTimes = day.code != null && _lockCodes.contains(day.code); 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 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; final bool codeDisabled = isHoliday || isWeekend;
String slotText(int slot, bool isStart) { String slotText(int slot, bool isStart) {
@@ -380,7 +453,7 @@ class _MonthlyViewState extends State<MonthlyView> {
final cells = <DataCell>[]; final cells = <DataCell>[];
// 0: Datum (rechts) // 0: Datum (rechtsbündig)
cells.add(DataCell(SizedBox( cells.add(DataCell(SizedBox(
width: _wDate, width: _wDate,
child: Align( 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++) { for (int slot = 0; slot < 5; slot++) {
// Start // Start
final keyS = 't_${dayIndex}_${slot}_s'; 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( cells.add(DataCell(SizedBox(
width: _wCode, width: _wCode,
child: Align( child: Align(
@@ -493,7 +566,7 @@ class _MonthlyViewState extends State<MonthlyView> {
), ),
))); )));
// 13..16: Kennzahlen (mit Override) // 13..16: Kennzahlen
final worked = _workedFor(day); final worked = _workedFor(day);
final soll = minutesToHHMM(day.targetMinutes); final soll = minutesToHHMM(day.targetMinutes);
final ist = minutesToHHMM(worked); final ist = minutesToHHMM(worked);
@@ -514,10 +587,11 @@ class _MonthlyViewState extends State<MonthlyView> {
))); )));
cells.add(DataCell(SizedBox( cells.add(DataCell(SizedBox(
width: _wNumber, 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( cells.add(DataCell(SizedBox(
width: _wDate, width: _wDate,
child: Align( 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; return cells;
} }
@@ -550,21 +625,25 @@ class _MonthlyViewState extends State<MonthlyView> {
final label = v == null ? '' : codeLabel(v); final label = v == null ? '' : codeLabel(v);
return DropdownMenuItem<String?>( return DropdownMenuItem<String?>(
value: v, 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(), }).toList(),
selectedItemBuilder: (context) { selectedItemBuilder: (context) {
return values.map((v) { return values.map((v) {
final shortText = 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(); }).toList();
}, },
onChanged: disabled onChanged: disabled
? null ? null
: (newCode) { : (newCode) {
final d = _days[dayIndex]; final d = _days[dayIndex];
// Wenn neuer Code ein Lock-Code ist: Zeiten löschen & sperren final bool willLock =
final bool willLock = newCode != null && _lockCodes.contains(newCode); newCode != null && _lockCodes.contains(newCode);
setState(() { setState(() {
final newDays = List<WorkDay>.from(_days); final newDays = List<WorkDay>.from(_days);
newDays[dayIndex] = WorkDay( newDays[dayIndex] = WorkDay(
@@ -576,14 +655,11 @@ class _MonthlyViewState extends State<MonthlyView> {
_days = newDays; _days = newDays;
if (willLock) { if (willLock) {
// Controller leeren & Invalid-Flags löschen
for (int slot = 0; slot < 5; slot++) { for (int slot = 0; slot < 5; slot++) {
final sKey = 't_${dayIndex}_${slot}_s'; final sKey = 't_${dayIndex}_${slot}_s';
final eKey = 't_${dayIndex}_${slot}_e'; final eKey = 't_${dayIndex}_${slot}_e';
final cs = _controllers[sKey]; _controllers[sKey]?.text = '';
final ce = _controllers[eKey]; _controllers[eKey]?.text = '';
if (cs != null && cs.text.isNotEmpty) cs.text = '';
if (ce != null && ce.text.isNotEmpty) ce.text = '';
_invalidCells.remove(_cellKey(dayIndex, slot, true)); _invalidCells.remove(_cellKey(dayIndex, slot, true));
_invalidCells.remove(_cellKey(dayIndex, slot, false)); _invalidCells.remove(_cellKey(dayIndex, slot, false));
} }
@@ -608,7 +684,6 @@ class _MonthlyViewState extends State<MonthlyView> {
final t = text.trim().isEmpty ? null : parseTextHHMM(text); final t = text.trim().isEmpty ? null : parseTextHHMM(text);
final d = _days[dayIndex]; final d = _days[dayIndex];
// Wenn Code sperrt: ignorieren (Sicherheit)
if (d.code != null && _lockCodes.contains(d.code)) return; if (d.code != null && _lockCodes.contains(d.code)) return;
final starts = List<TimeOfDay?>.filled(5, null); final starts = List<TimeOfDay?>.filled(5, null);
@@ -662,19 +737,42 @@ class _MonthlyViewState extends State<MonthlyView> {
: ''; : '';
final cs = _controllers[sKey]; final cs = _controllers[sKey];
final ce = _controllers[eKey]; final ce = _controllers[eKey];
if (cs != null && cs.text != sText && !(_focusNodes[sKey]?.hasFocus ?? false)) cs.text = sText; if (cs != null &&
if (ce != null && ce.text != eText && !(_focusNodes[eKey]?.hasFocus ?? false)) ce.text = eText; 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) { 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));
if (base.code != null && _lockCodes.contains(base.code)) {
final target = _dailyPlan[base.date.weekday] ?? base.targetMinutes; // Basisziel (Tagesplan)
return WorkDay(date: base.date, intervals: const [], targetMinutes: target, code: base.code); 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) { TimeOfDay? baseSlot(WorkDay d, int slot, bool isStart) {
if (slot >= d.intervals.length) return null; if (slot >= d.intervals.length) return null;
final iv = d.intervals[slot]; final iv = d.intervals[slot];
@@ -688,20 +786,20 @@ class _MonthlyViewState extends State<MonthlyView> {
final sText = _controllers[sKey]?.text; final sText = _controllers[sKey]?.text;
final eText = _controllers[eKey]?.text; final eText = _controllers[eKey]?.text;
final s = (sText != null && sText.isNotEmpty) ? parseTextHHMM(sText) : baseSlot(base, slot, true); final s = (sText != null && sText.isNotEmpty)
final e = (eText != null && eText.isNotEmpty) ? parseTextHHMM(eText) : baseSlot(base, slot, false); ? parseTextHHMM(sText)
: baseSlot(base, slot, true);
final e = (eText != null && eText.isNotEmpty)
? parseTextHHMM(eText)
: baseSlot(base, slot, false);
if (s != null && e != null) { if (s != null && e != null) intervals.add(WorkInterval(s, e));
intervals.add(WorkInterval(s, e));
}
} }
final target = _dailyPlan[base.date.weekday] ?? base.targetMinutes;
return WorkDay( return WorkDay(
date: base.date, date: base.date,
intervals: intervals, intervals: intervals,
targetMinutes: target, targetMinutes: targetFor(base), // <- neu angewendet
code: base.code, code: base.code,
); );
} }
@@ -757,8 +855,7 @@ class _MonthlyViewState extends State<MonthlyView> {
} }
} }
/// Minimaler Header-Table, damit die Kopfzeile fixiert bleiben kann. /// Kopfzeile als separate DataTable (fixiert)
/// Nutzt die gleichen Spalten wie der Body, aber ohne Rows.
class _HeaderOnlyDataTable extends StatelessWidget { class _HeaderOnlyDataTable extends StatelessWidget {
const _HeaderOnlyDataTable(); const _HeaderOnlyDataTable();
@@ -796,20 +893,252 @@ class _MonthHeader extends StatelessWidget {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0), padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: Row(children: [ 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( Expanded(
child: TextButton.icon( child: TextButton.icon(
onPressed: loading ? null : onPickMonth, onPressed: loading ? null : onPickMonth,
icon: const Icon(Icons.calendar_month), icon: const Icon(Icons.calendar_month),
label: Text(title, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontSize: 16)), label: Text(title,
style: TextButton.styleFrom( style: Theme.of(context)
foregroundColor: Theme.of(context).colorScheme.onSurface, .textTheme
), .titleLarge
?.copyWith(fontSize: 16)),
), ),
), ),
IconButton(onPressed: loading ? null : onReload, icon: const Icon(Icons.refresh)), IconButton(
IconButton(onPressed: loading ? null : onNext, icon: const Icon(Icons.chevron_right)), 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(),
);
}
}
+73 -93
View File
@@ -1,119 +1,99 @@
import 'dart:ui' show FontFeature;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/work_day.dart'; import '../models/work_day.dart';
String monthTitle(DateTime m) { /// Kurzcode-Liste & Label
const months = [ const List<String> kAbsenceCodes = ['G', 'K', 'U', 'SU', 'T'];
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
];
return '${months[m.month - 1]} ${m.year}';
}
String leftDayLabel(DateTime d) => '${ddmm(d)} ${weekdayShort(d)}'; String codeLabel(String code) {
String rightDayLabel(DateTime d) => '${weekdayShort(d)} ${ddmm(d)}'; switch (code) {
case 'G': return 'Gleitzeit';
String weekdayShort(DateTime d) { case 'K': return 'Krankenstand';
switch (d.weekday) { case 'U': return 'Urlaub';
case DateTime.monday: return 'Mo'; case 'SU': return 'Sonderurlaub';
case DateTime.tuesday: return 'Di'; case 'T': return 'Training';
case DateTime.wednesday: return 'Mi'; default: return code;
case DateTime.thursday: return 'Do';
case DateTime.friday: return 'Fr';
case DateTime.saturday: return 'Sa';
case DateTime.sunday: return 'So';
default: return '';
} }
} }
String ddmm(DateTime d) => /// Zeit-Helfer
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}'; TimeOfDay? parseDbTime(String? hhmmss) {
if (hhmmss == null || hhmmss.isEmpty) return null;
Widget mono(String s) => final parts = hhmmss.split(':');
Text(s, style: const TextStyle(fontFeatures: [FontFeature.tabularFigures()]));
String fmtTimeOfDay(TimeOfDay t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
String minutesToHHMM(int minutes) {
final h = minutes ~/ 60;
final m = minutes % 60;
return '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}';
}
String minutesToSignedHHMM(int minutes) {
final sign = minutes < 0 ? '-' : '+';
final absMin = minutes.abs();
final h = absMin ~/ 60;
final m = absMin % 60;
return '$sign${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}';
}
TimeOfDay? parseHHMM(dynamic v) {
if (v == null) return null;
final s = v.toString().trim();
if (s.isEmpty) return null;
final parts = s.split(':');
if (parts.length < 2) return null; if (parts.length < 2) return null;
final h = int.tryParse(parts[0]) ?? 0; final h = int.tryParse(parts[0]) ?? 0;
final m = int.tryParse(parts[1]) ?? 0; final m = int.tryParse(parts[1]) ?? 0;
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
return TimeOfDay(hour: h, minute: m); return TimeOfDay(hour: h, minute: m);
} }
String fmtTimeOfDay(TimeOfDay? t) {
if (t == null) return '';
final hh = t.hour.toString().padLeft(2, '0');
final mm = t.minute.toString().padLeft(2, '0');
return '$hh:$mm';
}
TimeOfDay? parseTextHHMM(String s) { TimeOfDay? parseTextHHMM(String s) {
final t = s.trim(); if (s.length != 5 || s[2] != ':') return null;
if (t.length != 5 || t[2] != ':') return null; final h = int.tryParse(s.substring(0, 2));
final h = int.tryParse(t.substring(0, 2)); final m = int.tryParse(s.substring(3, 5));
final m = int.tryParse(t.substring(3, 5));
if (h == null || m == null) return null; if (h == null || m == null) return null;
if (h < 0 || h > 23 || m < 0 || m > 59) return null; if (h < 0 || h > 23 || m < 0 || m > 59) return null;
return TimeOfDay(hour: h, minute: m); return TimeOfDay(hour: h, minute: m);
} }
String minutesToHHMM(int minutes) {
final sign = minutes < 0 ? '-' : '';
final m = minutes.abs();
final hh = (m ~/ 60).toString().padLeft(2, '0');
final mm = (m % 60).toString().padLeft(2, '0');
return '$sign$hh:$mm';
}
String minutesToSignedHHMM(int minutes) => minutesToHHMM(minutes);
/// Datums-Formatierungen
String ymd(DateTime d) => String ymd(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
// Füllt alle Tage des Monats auf const _weekdayShort = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
List<WorkDay> fillMonth(DateTime monthStart, List<WorkDay> existing) {
final map = <String, WorkDay>{for (final w in existing) ymd(w.date): w}; String _shortWeekday(DateTime d) => _weekdayShort[(d.weekday - 1) % 7];
final nextMonth = DateTime(monthStart.year, monthStart.month + 1, 1); String _shortDate(DateTime d) =>
final out = <WorkDay>[]; '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.';
for (DateTime d = monthStart; d.isBefore(nextMonth); d = d.add(const Duration(days: 1))) {
final key = ymd(d); /// Links in der Tabelle (Tag vor Datum)
final wd = map[key]; String rightDayLabel(DateTime d) => '${_shortWeekday(d)} ${_shortDate(d)}';
if (wd != null) {
out.add(wd); /// Rechts in der Tabelle (Datum vor Tag)
String leftDayLabel(DateTime d) => '${_shortDate(d)} ${_shortWeekday(d)}';
const _monthNames = [
'Jänner', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
];
String monthTitle(DateTime m) => '${_monthNames[m.month - 1]} ${m.year}';
/// Füllt den kompletten Monat mit leeren Tagen, ersetzt vorhandene Einträge
List<WorkDay> fillMonth(DateTime monthStart, List<WorkDay> fromApi) {
final m0 = DateTime(monthStart.year, monthStart.month, 1);
final m1 = DateTime(monthStart.year, monthStart.month + 1, 1);
final byDay = <String, WorkDay>{for (final d in fromApi) ymd(d.date): d};
final days = <WorkDay>[];
for (DateTime d = m0; d.isBefore(m1); d = d.add(const Duration(days: 1))) {
final k = ymd(d);
if (byDay.containsKey(k)) {
final v = byDay[k]!;
days.add(WorkDay(
date: d,
intervals: v.intervals,
targetMinutes: v.targetMinutes,
code: v.code,
));
} else { } else {
final isWeekend = d.weekday == DateTime.saturday || d.weekday == DateTime.sunday; days.add(WorkDay(date: d, intervals: const [], targetMinutes: 0, code: null));
final target = isWeekend ? 0 : 8 * 60;
out.add(WorkDay(date: d, intervals: const [], targetMinutes: target));
} }
} }
return out; return days;
}
/// "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 = ['G', 'K', 'U', 'SU', 'T'];
String codeLabel(String? code) {
switch (code) {
case 'G': return 'Gleitzeit';
case 'K': return 'Krankenstand';
case 'U': return 'Urlaub';
case 'SU': return 'Sonderurlaub';
case 'T': return 'Training';
default: return '';
}
} }
+15 -7
View File
@@ -1,12 +1,25 @@
import '../utils/helpers.dart'; import '../utils/helpers.dart';
/// Sehr vereinfachte österreichische Feiertage (bundesweit).
/// Key = 'YYYY-MM-DD', Value = Name.
Map<String, String> buildHolidayMapAT(int year) { Map<String, String> buildHolidayMapAT(int year) {
final Map<String, String> m = {}; final Map<String, String> m = {};
DateTime easter = _easterSunday(year);
DateTime easterMon = easter.add(const Duration(days: 1));
DateTime ascension = easter.add(const Duration(days: 39));
DateTime whitMon = easter.add(const Duration(days: 50));
DateTime corpusChristi = easter.add(const Duration(days: 60));
void add(DateTime d, String name) => m[ymd(d)] = name; void add(DateTime d, String name) => m[ymd(d)] = name;
add(DateTime(year, 1, 1), 'Neujahr'); add(DateTime(year, 1, 1), 'Neujahr');
add(DateTime(year, 1, 6), 'Heilige Drei Könige'); add(DateTime(year, 1, 6), 'Heilige Drei Könige');
add(easterMon, 'Ostermontag');
add(DateTime(year, 5, 1), 'Staatsfeiertag'); add(DateTime(year, 5, 1), 'Staatsfeiertag');
add(ascension, 'Christi Himmelfahrt');
add(whitMon, 'Pfingstmontag');
add(corpusChristi, 'Fronleichnam');
add(DateTime(year, 8, 15), 'Mariä Himmelfahrt'); add(DateTime(year, 8, 15), 'Mariä Himmelfahrt');
add(DateTime(year, 10, 26), 'Nationalfeiertag'); add(DateTime(year, 10, 26), 'Nationalfeiertag');
add(DateTime(year, 11, 1), 'Allerheiligen'); add(DateTime(year, 11, 1), 'Allerheiligen');
@@ -14,15 +27,10 @@ Map<String, String> buildHolidayMapAT(int year) {
add(DateTime(year, 12, 25), 'Christtag'); add(DateTime(year, 12, 25), 'Christtag');
add(DateTime(year, 12, 26), 'Stefanitag'); add(DateTime(year, 12, 26), 'Stefanitag');
final easter = _easterSunday(year);
add(easter.add(const Duration(days: 1)), 'Ostermontag');
add(easter.add(const Duration(days: 39)), 'Christi Himmelfahrt');
add(easter.add(const Duration(days: 50)), 'Pfingstmontag');
add(easter.add(const Duration(days: 60)), 'Fronleichnam');
return m; return m;
} }
/// Gaußsche Osterformel (Gregorianisch)
DateTime _easterSunday(int year) { DateTime _easterSunday(int year) {
final a = year % 19; final a = year % 19;
final b = year ~/ 100; final b = year ~/ 100;
@@ -36,7 +44,7 @@ DateTime _easterSunday(int year) {
final k = c % 4; final k = c % 4;
final l = (32 + 2 * e + 2 * i - h - k) % 7; final l = (32 + 2 * e + 2 * i - h - k) % 7;
final m = (a + 11 * h + 22 * l) ~/ 451; final m = (a + 11 * h + 22 * l) ~/ 451;
final month = (h + l - 7 * m + 114) ~/ 31; // 3=March, 4=April final month = (h + l - 7 * m + 114) ~/ 31; // 3=March, 4=April
final day = ((h + l - 7 * m + 114) % 31) + 1; final day = ((h + l - 7 * m + 114) % 31) + 1;
return DateTime(year, month, day); return DateTime(year, month, day);
} }
+46 -16
View File
@@ -1,27 +1,57 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
/// Tippt „HHMM“ und formatiert live zu „HH:MM“ (nur Ziffern erlaubt). /// Formatiert Eingaben als HH:mm (max. 5 Zeichen),
/// lässt nur Ziffern und optional ":" an Pos 2 zu.
/// Fügt bei Bedarf ":" automatisch ein.
class HHmmInputFormatter extends TextInputFormatter { class HHmmInputFormatter extends TextInputFormatter {
const HHmmInputFormatter(); const HHmmInputFormatter();
@override @override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { TextEditingValue formatEditUpdate(
String digits = newValue.text.replaceAll(RegExp(r'[^0-9]'), ''); TextEditingValue oldValue,
if (digits.isEmpty) { TextEditingValue newValue,
return const TextEditingValue(text: '', selection: TextSelection.collapsed(offset: 0)); ) {
} var text = newValue.text;
if (digits.length > 4) digits = digits.substring(0, 4);
String text; // nur Ziffern und ":" erlauben
if (digits.length <= 2) { final filtered = StringBuffer();
text = digits; for (int i = 0; i < text.length; i++) {
} else { final c = text[i];
final hh = digits.substring(0, 2); if ((c.codeUnitAt(0) >= 48 && c.codeUnitAt(0) <= 57) || c == ':') {
final mm = digits.substring(2); filtered.write(c);
text = '$hh:$mm'; }
}
text = filtered.toString();
// Länge begrenzen
if (text.length > 5) text = text.substring(0, 5);
// ":" automatisch einfügen
if (text.length >= 3) {
if (text[2] != ':') {
text = text.replaceRange(2, 2, ':');
}
} }
final offset = text.length; // nur ein ":" erlauben, und nur an Stelle 2
return TextEditingValue(text: text, selection: TextSelection.collapsed(offset: offset)); if (text.contains(':')) {
final idx = text.indexOf(':');
if (idx != 2) {
text = text.replaceAll(':', '');
if (text.length >= 2) {
text = '${text.substring(0, 2)}:${text.substring(2)}';
}
} else {
// weitere ":" entfernen
final rest = text.substring(3).replaceAll(':', '');
text = text.substring(0, 3) + rest;
}
}
// Cursor ans Ende
return TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
);
} }
} }
+39
View File
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import '../models/work_interval.dart';
int _toMinutes(TimeOfDay t) => t.hour * 60 + t.minute;
/// Effektive Arbeitsminuten eines Tages nach Pausenregel:
/// - <= 6h Arbeit: keine Pflichtpause
/// - > 6h Arbeit: 30 Min Pflichtpause, reduziert um die Summe der Lücken
/// zwischen Intervallen (maximal bis 0 reduzierbar).
int effectiveWorkedMinutes(
List<WorkInterval> intervals, {
int requiredBreakOver6h = 30,
}) {
if (intervals.isEmpty) return 0;
// sortieren
final sorted = [...intervals]
..sort((a, b) => _toMinutes(a.start).compareTo(_toMinutes(b.start)));
// Arbeitszeit summieren
int worked = 0;
for (final iv in sorted) {
final w = _toMinutes(iv.end) - _toMinutes(iv.start);
if (w > 0) worked += w;
}
if (worked <= 6 * 60) return worked;
// Lücken summieren
int gaps = 0;
for (var i = 0; i < sorted.length - 1; i++) {
final gap = _toMinutes(sorted[i + 1].start) - _toMinutes(sorted[i].end);
if (gap > 0) gaps += gap;
}
final extraBreak = (requiredBreakOver6h - gaps).clamp(0, requiredBreakOver6h);
final effective = worked - extraBreak;
return effective < 0 ? 0 : effective;
}