footer
This commit is contained in:
+58
-53
@@ -1,81 +1,86 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/work_day.dart';
|
||||
import '../models/work_interval.dart';
|
||||
import '../models/month_start.dart';
|
||||
import '../utils/helpers.dart';
|
||||
|
||||
class BookingApi {
|
||||
final String host;
|
||||
final String path;
|
||||
final http.Client client;
|
||||
BookingApi({required this.client});
|
||||
|
||||
const BookingApi({
|
||||
required this.client,
|
||||
this.host = 'api.windesign.at',
|
||||
this.path = '/workinghours.php',
|
||||
});
|
||||
|
||||
/// Monatliche Buchungen holen (für YYYY-MM)
|
||||
Future<List<WorkDay>> getBookingList(DateTime monthStart) async {
|
||||
final y = monthStart.year.toString().padLeft(4, '0');
|
||||
final m = monthStart.month.toString().padLeft(2, '0');
|
||||
final uri = Uri.https(host, path, {
|
||||
'module': 'booking',
|
||||
'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 uri = Uri.parse(
|
||||
'https://api.windesign.at/workinghours.php?module=booking&function=getList&date=$y-$m',
|
||||
);
|
||||
|
||||
final res = await client.get(uri);
|
||||
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 List items = map['bookings'] ?? [];
|
||||
final out = <WorkDay>[];
|
||||
if (map['error'] == true) {
|
||||
throw Exception('booking/getList error: ${map['errmsg']}');
|
||||
}
|
||||
final list = (map['bookings'] as List?) ?? const [];
|
||||
final items = <WorkDay>[];
|
||||
|
||||
for (final row in items) {
|
||||
final d = DateTime.parse(row['bookingDay'] as String);
|
||||
final isWeekend = d.weekday == DateTime.saturday || d.weekday == DateTime.sunday;
|
||||
final target = isWeekend ? 0 : 8 * 60;
|
||||
for (final e in list) {
|
||||
final row = e as Map<String, dynamic>;
|
||||
final dayStr = (row['bookingDay'] as String?) ?? '';
|
||||
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>[];
|
||||
TimeOfDay? p(dynamic v) => parseHHMM(v);
|
||||
void addPair(String a, String b) {
|
||||
final s = p(row[a]);
|
||||
final e = p(row[b]);
|
||||
if (s != null && e != null) intervals.add(WorkInterval(s, e));
|
||||
for (int i = 0; i < 5; i++) {
|
||||
final s = parseDbTime(starts[i]);
|
||||
final e2 = parseDbTime(ends[i]);
|
||||
if (s != null && e2 != null) {
|
||||
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)
|
||||
? null
|
||||
: row['code'].toString().trim();
|
||||
|
||||
out.add(WorkDay(
|
||||
date: d,
|
||||
items.add(WorkDay(
|
||||
date: DateTime(date.year, date.month, date.day),
|
||||
intervals: intervals,
|
||||
targetMinutes: target,
|
||||
targetMinutes: 0, // wird im UI mit dem Tagesplan ersetzt
|
||||
code: code,
|
||||
));
|
||||
}
|
||||
|
||||
out.sort((a, b) => a.date.compareTo(b.date));
|
||||
return out;
|
||||
return items;
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +1,47 @@
|
||||
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;
|
||||
DailyWorkingApi({required this.client});
|
||||
|
||||
const DailyWorkingApi({
|
||||
required this.client,
|
||||
this.host = 'api.windesign.at',
|
||||
this.path = '/workinghours.php',
|
||||
});
|
||||
|
||||
/// Liefert Map weekday(1..7) -> Minuten (int)
|
||||
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));
|
||||
|
||||
final uri = Uri.parse(
|
||||
'https://api.windesign.at/workinghours.php?module=dailyworking&function=getList',
|
||||
);
|
||||
final res = await client.get(uri);
|
||||
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 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;
|
||||
if (map['error'] == true) {
|
||||
throw Exception('dailyworking/getList error: ${map['errmsg']}');
|
||||
}
|
||||
final entries = (map['entries'] as List?) ?? const [];
|
||||
if (entries.isEmpty) {
|
||||
return {
|
||||
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(':');
|
||||
if (parts.length != 3) return 0;
|
||||
if (parts.length < 2) return 0;
|
||||
final h = int.tryParse(parts[0]) ?? 0;
|
||||
final m = int.tryParse(parts[1]) ?? 0;
|
||||
return h * 60 + m; // Sekunden ignoriert
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
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?),
|
||||
1: parseHHMMSS(row['monday'] as String?),
|
||||
2: parseHHMMSS(row['tuesday'] as String?),
|
||||
3: parseHHMMSS(row['wednesday'] as String?),
|
||||
4: parseHHMMSS(row['thursday'] as String?),
|
||||
5: parseHHMMSS(row['friday'] as String?),
|
||||
6: parseHHMMSS(row['saturday'] 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
@@ -1,25 +1,45 @@
|
||||
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 {
|
||||
const ZeitschreibungApp({super.key});
|
||||
class WorkingHoursApp extends StatelessWidget {
|
||||
const WorkingHoursApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Zeitschreibung',
|
||||
title: 'Working Hours',
|
||||
theme: ThemeData(
|
||||
colorSchemeSeed: const Color(0xFF3B82F6),
|
||||
useMaterial3: true,
|
||||
inputDecorationTheme: const InputDecorationTheme(
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
colorSchemeSeed: const Color(0xFF4B7BE5),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/work_time.dart';
|
||||
import 'work_interval.dart';
|
||||
|
||||
class WorkDay {
|
||||
final DateTime date;
|
||||
final List<WorkInterval> intervals; // bis zu 5
|
||||
final String? code; // GZ, K, U, SU, T oder null
|
||||
final int targetMinutes; // Soll
|
||||
final List<WorkInterval> intervals;
|
||||
final int targetMinutes;
|
||||
final String? code; // 'G','U','SU','K','T' oder null
|
||||
|
||||
const WorkDay({
|
||||
required this.date,
|
||||
@@ -14,6 +15,6 @@ class WorkDay {
|
||||
this.code,
|
||||
});
|
||||
|
||||
int get workedMinutes => intervals.fold(0, (sum, i) => sum + i.minutes);
|
||||
int get diffMinutes => workedMinutes - targetMinutes;
|
||||
/// Effektive Ist-Zeit mit 30-Minuten-Pausenregel (>6h) und Lückenanrechnung
|
||||
int get workedMinutes => effectiveWorkedMinutes(intervals);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,5 @@ import 'package:flutter/material.dart';
|
||||
class WorkInterval {
|
||||
final TimeOfDay start;
|
||||
final TimeOfDay 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
@@ -6,6 +6,7 @@ import '../api/booking_api.dart';
|
||||
import '../api/daily_working_api.dart';
|
||||
import '../models/work_day.dart';
|
||||
import '../models/work_interval.dart';
|
||||
import '../models/month_start.dart';
|
||||
import '../utils/helpers.dart';
|
||||
import '../utils/holidays_at.dart';
|
||||
import '../utils/input_formatters.dart';
|
||||
@@ -17,7 +18,7 @@ class MonthlyView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MonthlyViewState extends State<MonthlyView> {
|
||||
// Kompaktere Layout-Parameter
|
||||
// Layout
|
||||
static const double _wDate = 96;
|
||||
static const double _wHoliday = 120;
|
||||
static const double _wTime = 60;
|
||||
@@ -25,17 +26,19 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
static const double _wNumber = 76;
|
||||
static const double _fontSize = 12;
|
||||
|
||||
// Codes, die Zeiten sperren/ leeren
|
||||
// Codes, die Zeiten leeren/sperren
|
||||
static const Set<String> _lockCodes = {'G', 'K', 'U', 'SU'};
|
||||
|
||||
// Edit/Fokus/Controller je Feld
|
||||
final Set<String> _invalidCells = <String>{};
|
||||
final Map<String, FocusNode> _focusNodes = {};
|
||||
final Map<String, TextEditingController> _controllers = {};
|
||||
FocusNode _nodeFor(String key) => _focusNodes.putIfAbsent(key, () => FocusNode());
|
||||
FocusNode _nodeFor(String key) =>
|
||||
_focusNodes.putIfAbsent(key, () => FocusNode());
|
||||
TextEditingController _controllerFor(String key, String initial) =>
|
||||
_controllers.putIfAbsent(key, () => TextEditingController(text: initial));
|
||||
|
||||
// API / State
|
||||
late final http.Client _client;
|
||||
late final BookingApi _bookingApi;
|
||||
late final DailyWorkingApi _dailyApi;
|
||||
@@ -45,7 +48,7 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
Map<String, String> _holidays = const {};
|
||||
Map<int, int> _dailyPlan = {
|
||||
Map<int, int> _dailyPlan = const {
|
||||
DateTime.monday: 8 * 60,
|
||||
DateTime.tuesday: 8 * 60,
|
||||
DateTime.wednesday: 8 * 60,
|
||||
@@ -55,10 +58,14 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
DateTime.sunday: 0,
|
||||
};
|
||||
|
||||
// Scroll-Controller
|
||||
late final ScrollController _hHeaderCtrl; // Header (nur mitgeführt)
|
||||
late final ScrollController _hBodyCtrl; // Body (führend)
|
||||
late final ScrollController _vCtrl; // Body vertikal
|
||||
// Monatliche Startdaten (für kumulierte Differenz & Footer)
|
||||
MonthStart? _monthStartInfo;
|
||||
int _carryBaseMinutes = 0;
|
||||
|
||||
// Scroll-Controller (Header/Body synchron)
|
||||
late final ScrollController _hHeaderCtrl;
|
||||
late final ScrollController _hBodyCtrl;
|
||||
late final ScrollController _vCtrl;
|
||||
bool _syncingH = false;
|
||||
|
||||
@override
|
||||
@@ -71,7 +78,6 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
_hBodyCtrl = ScrollController();
|
||||
_vCtrl = ScrollController();
|
||||
|
||||
// Body scrollt -> Header folgt
|
||||
_hBodyCtrl.addListener(() {
|
||||
if (_syncingH) return;
|
||||
_syncingH = true;
|
||||
@@ -112,27 +118,35 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
final results = await Future.wait([
|
||||
_bookingApi.getBookingList(m),
|
||||
_dailyApi.getDailyMinutes(),
|
||||
_bookingApi.getMonthStart(m),
|
||||
]);
|
||||
|
||||
final apiDays = results[0] as List<WorkDay>;
|
||||
final plan = results[1] as Map<int, int>;
|
||||
final mStart = results[2] as MonthStart;
|
||||
|
||||
final holidayMap = buildHolidayMapAT(m.year); // << neu
|
||||
|
||||
final filled = fillMonth(m, apiDays);
|
||||
|
||||
final withTargets = filled
|
||||
.map((d) => WorkDay(
|
||||
date: d.date,
|
||||
intervals: d.intervals,
|
||||
targetMinutes: plan[d.date.weekday] ?? d.targetMinutes,
|
||||
code: d.code,
|
||||
))
|
||||
.toList();
|
||||
final withTargets = filled.map((d) {
|
||||
final isHoliday = holidayMap.containsKey(ymd(d.date)); // << neu
|
||||
final target =
|
||||
isHoliday ? 0 : (plan[d.date.weekday] ?? d.targetMinutes);
|
||||
return WorkDay(
|
||||
date: d.date,
|
||||
intervals: d.intervals,
|
||||
targetMinutes: target,
|
||||
code: d.code,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
setState(() {
|
||||
_monthStart = DateTime(m.year, m.month, 1);
|
||||
_holidays = buildHolidayMapAT(_monthStart.year);
|
||||
_holidays = holidayMap; // << aus holidayMap setzen
|
||||
_days = withTargets;
|
||||
_dailyPlan = plan;
|
||||
_monthStartInfo = mStart;
|
||||
_carryBaseMinutes = mStart.carryBaseMinutes;
|
||||
_loading = false;
|
||||
});
|
||||
|
||||
@@ -150,14 +164,20 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
|
||||
// Ist-Override für U/SU/K/T
|
||||
int _workedFor(WorkDay d) {
|
||||
final c = d.code;
|
||||
if (c == 'U' || c == 'SU' || c == 'K' || c == 'T') {
|
||||
return d.targetMinutes;
|
||||
switch (d.code) {
|
||||
case 'U':
|
||||
case 'SU':
|
||||
case 'K': // neu: Krank => IST = 0
|
||||
return 0;
|
||||
case 'T':
|
||||
return d.targetMinutes;
|
||||
default:
|
||||
return d.workedMinutes;
|
||||
}
|
||||
return d.workedMinutes;
|
||||
}
|
||||
|
||||
Color? _rowColorFor(WorkDay d, {required Color? holidayBg, required Color? weekendBg}) {
|
||||
Color? _rowColorFor(WorkDay d,
|
||||
{required Color? holidayBg, required Color? weekendBg}) {
|
||||
switch (d.code) {
|
||||
case 'G':
|
||||
return const Color(0xFFBFBFFF); // Gleitzeit
|
||||
@@ -166,12 +186,13 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
case 'SU':
|
||||
return const Color(0xFF7F7FFF); // Sonderurlaub
|
||||
case 'K':
|
||||
return Colors.yellow; // Krankenstand
|
||||
return Colors.yellow; // Krankenstand
|
||||
case 'T':
|
||||
return Colors.red; // Training
|
||||
return Colors.red; // Training
|
||||
}
|
||||
final isHoliday = _holidays.containsKey(ymd(d.date));
|
||||
final isWeekend = d.date.weekday == DateTime.saturday || d.date.weekday == DateTime.sunday;
|
||||
final isWeekend = d.date.weekday == DateTime.saturday ||
|
||||
d.date.weekday == DateTime.sunday;
|
||||
if (isHoliday) return holidayBg;
|
||||
if (isWeekend) return weekendBg;
|
||||
return null;
|
||||
@@ -186,21 +207,45 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
final holidayBg = theme.colorScheme.secondaryContainer.withOpacity(0.45);
|
||||
final weekendBg = Colors.grey.withOpacity(0.30);
|
||||
|
||||
final effectiveDays = List<WorkDay>.generate(_days.length, (i) => _effectiveDay(i, _days[i]));
|
||||
// Live-„Effective“-Tage (inkl. Eingabetexte + Sperrlogik)
|
||||
final effectiveDays =
|
||||
List<WorkDay>.generate(_days.length, (i) => _effectiveDay(i, _days[i]));
|
||||
|
||||
// diffs & kumuliert (mit Override)
|
||||
// Tagesdifferenzen & kumuliert (Start mit Monatssaldo aus API)
|
||||
final diffs = <int>[];
|
||||
int sollTotal = 0;
|
||||
int istTotal = 0;
|
||||
for (final d in effectiveDays) {
|
||||
final worked = _workedFor(d);
|
||||
diffs.add(worked - d.targetMinutes);
|
||||
sollTotal += d.targetMinutes;
|
||||
istTotal += worked;
|
||||
}
|
||||
final cumulative = <int>[];
|
||||
int sum = 0;
|
||||
int sum = _carryBaseMinutes;
|
||||
for (final diff in diffs) {
|
||||
sum += diff;
|
||||
cumulative.add(sum);
|
||||
}
|
||||
|
||||
// Footer-Werte berechnen
|
||||
final monthLabel = monthTitle(_monthStart);
|
||||
final startVacation = _monthStartInfo?.startVacationUnits ?? 0;
|
||||
final usedVacation = _days.where((d) => d.code == 'U').length;
|
||||
final vacationCarry = startVacation - usedVacation;
|
||||
|
||||
final codeCount = <String, int>{
|
||||
'G': _days.where((d) => d.code == 'G').length,
|
||||
'K': _days.where((d) => d.code == 'K').length,
|
||||
'U': usedVacation,
|
||||
'SU': _days.where((d) => d.code == 'SU').length,
|
||||
'T': _days.where((d) => d.code == 'T').length,
|
||||
};
|
||||
|
||||
final correctionMin = _monthStartInfo?.correctionMinutes ?? 0;
|
||||
final nextCarryMin =
|
||||
cumulative.isNotEmpty ? cumulative.last : _carryBaseMinutes;
|
||||
|
||||
final rows = List<DataRow>.generate(_days.length, (i) {
|
||||
final day = effectiveDays[i];
|
||||
final run = cumulative[i];
|
||||
@@ -216,8 +261,10 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
_MonthHeader(
|
||||
month: _monthStart,
|
||||
loading: _loading,
|
||||
onPrev: () => _loadMonth(DateTime(_monthStart.year, _monthStart.month - 1, 1)),
|
||||
onNext: () => _loadMonth(DateTime(_monthStart.year, _monthStart.month + 1, 1)),
|
||||
onPrev: () =>
|
||||
_loadMonth(DateTime(_monthStart.year, _monthStart.month - 1, 1)),
|
||||
onNext: () =>
|
||||
_loadMonth(DateTime(_monthStart.year, _monthStart.month + 1, 1)),
|
||||
onPickMonth: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
@@ -242,20 +289,22 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text('Fehler beim Laden:', style: TextStyle(fontSize: _fontSize)),
|
||||
SelectableText(_error ?? '', style: const TextStyle(fontSize: _fontSize)),
|
||||
Text('Fehler beim Laden:',
|
||||
style: TextStyle(fontSize: _fontSize)),
|
||||
SelectableText(_error ?? '',
|
||||
style: const TextStyle(fontSize: _fontSize)),
|
||||
],
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => _loadMonth(_monthStart),
|
||||
child: Text('Erneut versuchen', style: const TextStyle(fontSize: _fontSize)),
|
||||
child: const Text('Erneut versuchen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// FIXIERTE KOPFZEILE (mitgeführt, nicht interaktiv)
|
||||
// FIXIERTE KOPFZEILE (mitgeführt)
|
||||
SingleChildScrollView(
|
||||
controller: _hHeaderCtrl,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
@@ -266,16 +315,15 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
data: DataTableThemeData(
|
||||
headingRowHeight: 30,
|
||||
columnSpacing: 10,
|
||||
headingTextStyle: const TextStyle(fontWeight: FontWeight.w700).copyWith(
|
||||
fontSize: _fontSize,
|
||||
),
|
||||
headingTextStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w700, fontSize: _fontSize),
|
||||
),
|
||||
child: const _HeaderOnlyDataTable(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// BODY (horiz. & vert. Scroll; horizontale Scrollbar hier)
|
||||
// BODY (scrollbar horiz/vert)
|
||||
Expanded(
|
||||
child: Scrollbar(
|
||||
controller: _hBodyCtrl,
|
||||
@@ -295,7 +343,7 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
controller: _vCtrl,
|
||||
child: DataTableTheme(
|
||||
data: const DataTableThemeData(
|
||||
headingRowHeight: 0, // Header im Body ausblenden
|
||||
headingRowHeight: 0,
|
||||
dataRowMinHeight: 30,
|
||||
dataRowMaxHeight: 34,
|
||||
columnSpacing: 10,
|
||||
@@ -312,11 +360,38 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Footer
|
||||
const Divider(height: 1),
|
||||
_MonthlySummaryFooter(
|
||||
month: _monthStart,
|
||||
fontSize: _fontSize,
|
||||
// Linke Spalte
|
||||
uebertragStartText: minutesToSignedHHMM(_carryBaseMinutes),
|
||||
sollText: minutesToHHMM(sollTotal),
|
||||
istText: minutesToHHMM(istTotal),
|
||||
correctionText: minutesToSignedHHMM(correctionMin),
|
||||
saldoText: minutesToSignedHHMM(istTotal - sollTotal),
|
||||
paidOvertimeText: '—', // folgt später
|
||||
uebertragNextText: minutesToSignedHHMM(nextCarryMin),
|
||||
restUrlaubText: '$startVacation',
|
||||
urlaubUebertragText: '$vacationCarry',
|
||||
// Rechte Spalte (Counts)
|
||||
countGleitzeit: codeCount['G'] ?? 0,
|
||||
countKrank: codeCount['K'] ?? 0,
|
||||
countUrlaub: codeCount['U'] ?? 0,
|
||||
countSonderurlaub: codeCount['SU'] ?? 0,
|
||||
countTraining: codeCount['T'] ?? 0,
|
||||
monthLabel: monthLabel,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
]);
|
||||
}
|
||||
|
||||
List<DataColumn> _buildColumns() {
|
||||
DataColumn c(String label, {Alignment align = Alignment.center, double? width}) => DataColumn(
|
||||
DataColumn c(String label,
|
||||
{Alignment align = Alignment.center, double? width}) =>
|
||||
DataColumn(
|
||||
label: SizedBox(
|
||||
width: width,
|
||||
child: Align(
|
||||
@@ -352,18 +427,16 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
];
|
||||
}
|
||||
|
||||
List<DataCell> _buildEditableCells(int dayIndex, WorkDay day, int runningDiff) {
|
||||
// Gewünscht: links Wochentag zuerst, rechts Datum zuerst.
|
||||
final leftLabel = rightDayLabel(day.date); // Wochentag Datum
|
||||
final rightLabel = leftDayLabel(day.date); // Datum Wochentag
|
||||
List<DataCell> _buildEditableCells(
|
||||
int dayIndex, WorkDay day, int runningDiff) {
|
||||
final leftLabel = rightDayLabel(day.date); // "Mo 01.09."
|
||||
final rightLabel = leftDayLabel(day.date); // "01.09. Mo"
|
||||
final hName = _holidays[ymd(day.date)] ?? '';
|
||||
|
||||
// Sperre Zeiten, wenn Code in _lockCodes
|
||||
final bool lockTimes = day.code != null && _lockCodes.contains(day.code);
|
||||
|
||||
// Ist Wochenende/Feiertag => Code nicht änderbar
|
||||
final bool isHoliday = _holidays.containsKey(ymd(day.date));
|
||||
final bool isWeekend = day.date.weekday == DateTime.saturday || day.date.weekday == DateTime.sunday;
|
||||
final bool isWeekend = day.date.weekday == DateTime.saturday ||
|
||||
day.date.weekday == DateTime.sunday;
|
||||
final bool codeDisabled = isHoliday || isWeekend;
|
||||
|
||||
String slotText(int slot, bool isStart) {
|
||||
@@ -380,7 +453,7 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
|
||||
final cells = <DataCell>[];
|
||||
|
||||
// 0: Datum (rechts)
|
||||
// 0: Datum (rechtsbündig)
|
||||
cells.add(DataCell(SizedBox(
|
||||
width: _wDate,
|
||||
child: Align(
|
||||
@@ -397,7 +470,7 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
),
|
||||
)));
|
||||
|
||||
// 2..11: Zeiten (evtl. gesperrt & geleert)
|
||||
// 2..11: Zeiten
|
||||
for (int slot = 0; slot < 5; slot++) {
|
||||
// Start
|
||||
final keyS = 't_${dayIndex}_${slot}_s';
|
||||
@@ -484,7 +557,7 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
)));
|
||||
}
|
||||
|
||||
// 12: Code (Dropdown) — zentriert (disable bei Sa/So/Feiertag)
|
||||
// 12: Code (Dropdown, am Wochenende/Feiertag gesperrt)
|
||||
cells.add(DataCell(SizedBox(
|
||||
width: _wCode,
|
||||
child: Align(
|
||||
@@ -493,7 +566,7 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
),
|
||||
)));
|
||||
|
||||
// 13..16: Kennzahlen (mit Override)
|
||||
// 13..16: Kennzahlen
|
||||
final worked = _workedFor(day);
|
||||
final soll = minutesToHHMM(day.targetMinutes);
|
||||
final ist = minutesToHHMM(worked);
|
||||
@@ -514,10 +587,11 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
)));
|
||||
cells.add(DataCell(SizedBox(
|
||||
width: _wNumber,
|
||||
child: Align(alignment: Alignment.centerRight, child: _monoSmall(diffSum)),
|
||||
child:
|
||||
Align(alignment: Alignment.centerRight, child: _monoSmall(diffSum)),
|
||||
)));
|
||||
|
||||
// 17: Datum (links)
|
||||
// 17: Datum (linksbündig)
|
||||
cells.add(DataCell(SizedBox(
|
||||
width: _wDate,
|
||||
child: Align(
|
||||
@@ -526,7 +600,8 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
),
|
||||
)));
|
||||
|
||||
assert(cells.length == 18, 'Row has ${cells.length} cells but expected 18.');
|
||||
assert(
|
||||
cells.length == 18, 'Row has ${cells.length} cells but expected 18.');
|
||||
return cells;
|
||||
}
|
||||
|
||||
@@ -550,21 +625,25 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
final label = v == null ? '—' : codeLabel(v);
|
||||
return DropdownMenuItem<String?>(
|
||||
value: v,
|
||||
child: Text(label, textAlign: TextAlign.center, style: const TextStyle(fontSize: _fontSize)),
|
||||
child: Text(label,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: _fontSize)),
|
||||
);
|
||||
}).toList(),
|
||||
selectedItemBuilder: (context) {
|
||||
return values.map((v) {
|
||||
final shortText = v ?? '—';
|
||||
return Center(child: Text(shortText, style: const TextStyle(fontSize: _fontSize)));
|
||||
return Center(
|
||||
child:
|
||||
Text(shortText, style: const TextStyle(fontSize: _fontSize)));
|
||||
}).toList();
|
||||
},
|
||||
onChanged: disabled
|
||||
? null
|
||||
: (newCode) {
|
||||
final d = _days[dayIndex];
|
||||
// Wenn neuer Code ein Lock-Code ist: Zeiten löschen & sperren
|
||||
final bool willLock = newCode != null && _lockCodes.contains(newCode);
|
||||
final bool willLock =
|
||||
newCode != null && _lockCodes.contains(newCode);
|
||||
setState(() {
|
||||
final newDays = List<WorkDay>.from(_days);
|
||||
newDays[dayIndex] = WorkDay(
|
||||
@@ -576,14 +655,11 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
_days = newDays;
|
||||
|
||||
if (willLock) {
|
||||
// Controller leeren & Invalid-Flags löschen
|
||||
for (int slot = 0; slot < 5; slot++) {
|
||||
final sKey = 't_${dayIndex}_${slot}_s';
|
||||
final eKey = 't_${dayIndex}_${slot}_e';
|
||||
final cs = _controllers[sKey];
|
||||
final ce = _controllers[eKey];
|
||||
if (cs != null && cs.text.isNotEmpty) cs.text = '';
|
||||
if (ce != null && ce.text.isNotEmpty) ce.text = '';
|
||||
_controllers[sKey]?.text = '';
|
||||
_controllers[eKey]?.text = '';
|
||||
_invalidCells.remove(_cellKey(dayIndex, slot, true));
|
||||
_invalidCells.remove(_cellKey(dayIndex, slot, false));
|
||||
}
|
||||
@@ -608,7 +684,6 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
final t = text.trim().isEmpty ? null : parseTextHHMM(text);
|
||||
final d = _days[dayIndex];
|
||||
|
||||
// Wenn Code sperrt: ignorieren (Sicherheit)
|
||||
if (d.code != null && _lockCodes.contains(d.code)) return;
|
||||
|
||||
final starts = List<TimeOfDay?>.filled(5, null);
|
||||
@@ -662,19 +737,42 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
: '';
|
||||
final cs = _controllers[sKey];
|
||||
final ce = _controllers[eKey];
|
||||
if (cs != null && cs.text != sText && !(_focusNodes[sKey]?.hasFocus ?? false)) cs.text = sText;
|
||||
if (ce != null && ce.text != eText && !(_focusNodes[eKey]?.hasFocus ?? false)) ce.text = eText;
|
||||
if (cs != null &&
|
||||
cs.text != sText &&
|
||||
!(_focusNodes[sKey]?.hasFocus ?? false)) cs.text = sText;
|
||||
if (ce != null &&
|
||||
ce.text != eText &&
|
||||
!(_focusNodes[eKey]?.hasFocus ?? false)) ce.text = eText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WorkDay _effectiveDay(int row, WorkDay base) {
|
||||
// Wenn Code sperrt, sind die Intervalle leer (unabhängig von Controller-Text)
|
||||
if (base.code != null && _lockCodes.contains(base.code)) {
|
||||
final target = _dailyPlan[base.date.weekday] ?? base.targetMinutes;
|
||||
return WorkDay(date: base.date, intervals: const [], targetMinutes: target, code: base.code);
|
||||
final isHoliday = _holidays.containsKey(ymd(base.date));
|
||||
|
||||
// Basisziel (Tagesplan)
|
||||
final baseTarget = _dailyPlan[base.date.weekday] ?? base.targetMinutes;
|
||||
|
||||
// Zielzeit bestimmen: Feiertag -> 0, sonst bei U/SU -> 0, sonst Tagesplan
|
||||
int targetFor(WorkDay b) {
|
||||
if (isHoliday) return 0;
|
||||
if (b.code == 'U' || b.code == 'SU' || b.code == 'K') {
|
||||
return 0; // <- neu: Soll = 0 bei U/SU
|
||||
}
|
||||
return baseTarget;
|
||||
}
|
||||
|
||||
// „Lock“-Pfad (G/K/U/SU: keine Zeiten)
|
||||
if (base.code != null && _lockCodes.contains(base.code)) {
|
||||
return WorkDay(
|
||||
date: base.date,
|
||||
intervals: const [],
|
||||
targetMinutes: targetFor(base),
|
||||
code: base.code,
|
||||
);
|
||||
}
|
||||
|
||||
// Eingabetexte berücksichtigen
|
||||
TimeOfDay? baseSlot(WorkDay d, int slot, bool isStart) {
|
||||
if (slot >= d.intervals.length) return null;
|
||||
final iv = d.intervals[slot];
|
||||
@@ -688,20 +786,20 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
final sText = _controllers[sKey]?.text;
|
||||
final eText = _controllers[eKey]?.text;
|
||||
|
||||
final s = (sText != null && sText.isNotEmpty) ? parseTextHHMM(sText) : baseSlot(base, slot, true);
|
||||
final e = (eText != null && eText.isNotEmpty) ? parseTextHHMM(eText) : baseSlot(base, slot, false);
|
||||
final s = (sText != null && sText.isNotEmpty)
|
||||
? parseTextHHMM(sText)
|
||||
: baseSlot(base, slot, true);
|
||||
final e = (eText != null && eText.isNotEmpty)
|
||||
? parseTextHHMM(eText)
|
||||
: baseSlot(base, slot, false);
|
||||
|
||||
if (s != null && e != null) {
|
||||
intervals.add(WorkInterval(s, e));
|
||||
}
|
||||
if (s != null && e != null) intervals.add(WorkInterval(s, e));
|
||||
}
|
||||
|
||||
final target = _dailyPlan[base.date.weekday] ?? base.targetMinutes;
|
||||
|
||||
return WorkDay(
|
||||
date: base.date,
|
||||
intervals: intervals,
|
||||
targetMinutes: target,
|
||||
targetMinutes: targetFor(base), // <- neu angewendet
|
||||
code: base.code,
|
||||
);
|
||||
}
|
||||
@@ -757,8 +855,7 @@ class _MonthlyViewState extends State<MonthlyView> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimaler Header-Table, damit die Kopfzeile fixiert bleiben kann.
|
||||
/// Nutzt die gleichen Spalten wie der Body, aber ohne Rows.
|
||||
/// Kopfzeile als separate DataTable (fixiert)
|
||||
class _HeaderOnlyDataTable extends StatelessWidget {
|
||||
const _HeaderOnlyDataTable();
|
||||
|
||||
@@ -796,20 +893,252 @@ class _MonthHeader extends StatelessWidget {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: Row(children: [
|
||||
IconButton(onPressed: loading ? null : onPrev, icon: const Icon(Icons.chevron_left)),
|
||||
IconButton(
|
||||
onPressed: loading ? null : onPrev,
|
||||
icon: const Icon(Icons.chevron_left)),
|
||||
Expanded(
|
||||
child: TextButton.icon(
|
||||
onPressed: loading ? null : onPickMonth,
|
||||
icon: const Icon(Icons.calendar_month),
|
||||
label: Text(title, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontSize: 16)),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
label: Text(title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
IconButton(onPressed: loading ? null : onReload, icon: const Icon(Icons.refresh)),
|
||||
IconButton(onPressed: loading ? null : onNext, icon: const Icon(Icons.chevron_right)),
|
||||
IconButton(
|
||||
onPressed: loading ? null : onReload,
|
||||
icon: const Icon(Icons.refresh)),
|
||||
IconButton(
|
||||
onPressed: loading ? null : onNext,
|
||||
icon: const Icon(Icons.chevron_right)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Footer mit 2 Spalten und berechneten Werten (zentriert, responsiv)
|
||||
class _MonthlySummaryFooter extends StatelessWidget {
|
||||
final DateTime month;
|
||||
final double fontSize;
|
||||
|
||||
// Linke Spalte (Strings bereits formatiert)
|
||||
final String uebertragStartText; // Startsaldo (Minuten → HH:MM)
|
||||
final String sollText; // Summe Soll (HH:MM)
|
||||
final String istText; // Summe Ist (HH:MM)
|
||||
final String correctionText; // Correction (HH:MM, ±)
|
||||
final String saldoText; // IST - SOLL (±HH:MM)
|
||||
final String paidOvertimeText; // folgt später (—)
|
||||
final String uebertragNextText; // letzter „Differenz gesamt“ (±HH:MM)
|
||||
final String restUrlaubText; // startvacation (Zahl)
|
||||
final String urlaubUebertragText; // startvacation - used
|
||||
|
||||
// Rechte Spalte (Counts)
|
||||
final int countGleitzeit;
|
||||
final int countKrank;
|
||||
final int countUrlaub;
|
||||
final int countSonderurlaub;
|
||||
final int countTraining;
|
||||
|
||||
// Label für aktuellen Monat (z. B. "September 2025")
|
||||
final String monthLabel;
|
||||
|
||||
const _MonthlySummaryFooter({
|
||||
required this.month,
|
||||
required this.fontSize,
|
||||
required this.uebertragStartText,
|
||||
required this.sollText,
|
||||
required this.istText,
|
||||
required this.correctionText,
|
||||
required this.saldoText,
|
||||
required this.paidOvertimeText,
|
||||
required this.uebertragNextText,
|
||||
required this.restUrlaubText,
|
||||
required this.urlaubUebertragText,
|
||||
required this.countGleitzeit,
|
||||
required this.countKrank,
|
||||
required this.countUrlaub,
|
||||
required this.countSonderurlaub,
|
||||
required this.countTraining,
|
||||
required this.monthLabel,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Monatslabels: Vormonat für Überträge/Resturlaub, aktueller Monat für Soll/Ist
|
||||
final prev = DateTime(month.year, month.month - 1, 1);
|
||||
final prevLabel = monthTitle(prev);
|
||||
final curLabel = monthLabel;
|
||||
|
||||
// Layout-Konstanten
|
||||
const double leftValueWidth = 120; // Werte-Breite links (rechtsbündig)
|
||||
const double rightValueWidth = 80; // Werte-Breite rechts (rechtsbündig)
|
||||
const double colGap = 24; // Abstand zwischen Spalten
|
||||
const double rowGap = 2; // Zeilenabstand
|
||||
const double footerMaxWidth = 720; // maximale Footer-Breite
|
||||
|
||||
final labelStyle = TextStyle(
|
||||
fontSize: fontSize, color: Theme.of(context).colorScheme.onSurface);
|
||||
final valueStyle = TextStyle(
|
||||
fontSize: fontSize,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
);
|
||||
|
||||
// Linke Spalte: Feldname rechtsbündig, Wert rechtsbündig (fixe Breite)
|
||||
final leftItems = <(String label, String value)>[
|
||||
('Übertrag $prevLabel', uebertragStartText),
|
||||
('SOLL Arbeitszeit ($curLabel)', sollText),
|
||||
('IST Arbeitszeit ($curLabel)', istText),
|
||||
('Correction', correctionText),
|
||||
('Saldo', saldoText),
|
||||
('ausbezahlte Überstunden', paidOvertimeText),
|
||||
('Übertrag nächster Monat', uebertragNextText),
|
||||
('Resturlaub $prevLabel', restUrlaubText),
|
||||
('Übertrag Urlaub', urlaubUebertragText),
|
||||
];
|
||||
|
||||
// Rechte Spalte: links der Wert (rechtsbündig), rechts der Feldname
|
||||
final rightItems = <(String value, String label)>[
|
||||
('${countGleitzeit}', 'Gleitzeit'),
|
||||
('${countKrank}', 'Krank'),
|
||||
('${countUrlaub}', 'Urlaub'),
|
||||
('${countSonderurlaub}', 'Sonderurlaub'),
|
||||
('${countTraining}', 'Training'),
|
||||
];
|
||||
|
||||
Widget leftRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: rowGap),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
// Label nimmt restliche Breite ein, rechtsbündig
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(label,
|
||||
style: labelStyle,
|
||||
overflow: TextOverflow.fade,
|
||||
softWrap: false,
|
||||
maxLines: 1),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Wert mit fixer Breite, rechtsbündig
|
||||
SizedBox(
|
||||
width: leftValueWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child:
|
||||
Text(value, style: valueStyle, textAlign: TextAlign.right),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget rightRow(String value, String label) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: rowGap),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
// Wert (rechtsbündig) mit fixer Breite
|
||||
SizedBox(
|
||||
width: rightValueWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child:
|
||||
Text(value, style: valueStyle, textAlign: TextAlign.right),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Feldname links – nimmt restliche Breite
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(label,
|
||||
style: labelStyle,
|
||||
overflow: TextOverflow.fade,
|
||||
softWrap: false,
|
||||
maxLines: 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Zentrierter Footer-Container
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: footerMaxWidth),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Linke Spalte (flexibel)
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: leftItems.map((e) => leftRow(e.$1, e.$2)).toList(),
|
||||
),
|
||||
),
|
||||
SizedBox(width: colGap),
|
||||
// Rechte Spalte (flexibel)
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children:
|
||||
rightItems.map((e) => rightRow(e.$1, e.$2)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Metric {
|
||||
final String label;
|
||||
final String value;
|
||||
const _Metric(this.label, this.value);
|
||||
}
|
||||
|
||||
class _MetricList extends StatelessWidget {
|
||||
final List<_Metric> items;
|
||||
final TextStyle labelStyle;
|
||||
final TextStyle valueStyle;
|
||||
const _MetricList({
|
||||
required this.items,
|
||||
required this.labelStyle,
|
||||
required this.valueStyle,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: items
|
||||
.map((m) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(m.label, style: labelStyle)),
|
||||
const SizedBox(width: 12),
|
||||
Text(m.value, style: valueStyle),
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+73
-93
@@ -1,119 +1,99 @@
|
||||
import 'dart:ui' show FontFeature;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/work_day.dart';
|
||||
|
||||
String monthTitle(DateTime m) {
|
||||
const months = [
|
||||
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
|
||||
];
|
||||
return '${months[m.month - 1]} ${m.year}';
|
||||
}
|
||||
/// Kurzcode-Liste & Label
|
||||
const List<String> kAbsenceCodes = ['G', 'K', 'U', 'SU', 'T'];
|
||||
|
||||
String leftDayLabel(DateTime d) => '${ddmm(d)} ${weekdayShort(d)}';
|
||||
String rightDayLabel(DateTime d) => '${weekdayShort(d)} ${ddmm(d)}';
|
||||
|
||||
String weekdayShort(DateTime d) {
|
||||
switch (d.weekday) {
|
||||
case DateTime.monday: return 'Mo';
|
||||
case DateTime.tuesday: return 'Di';
|
||||
case DateTime.wednesday: return 'Mi';
|
||||
case DateTime.thursday: return 'Do';
|
||||
case DateTime.friday: return 'Fr';
|
||||
case DateTime.saturday: return 'Sa';
|
||||
case DateTime.sunday: return 'So';
|
||||
default: return '';
|
||||
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 code;
|
||||
}
|
||||
}
|
||||
|
||||
String ddmm(DateTime d) =>
|
||||
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}';
|
||||
|
||||
Widget mono(String s) =>
|
||||
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(':');
|
||||
/// Zeit-Helfer
|
||||
TimeOfDay? parseDbTime(String? hhmmss) {
|
||||
if (hhmmss == null || hhmmss.isEmpty) return null;
|
||||
final parts = hhmmss.split(':');
|
||||
if (parts.length < 2) return null;
|
||||
final h = int.tryParse(parts[0]) ?? 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);
|
||||
}
|
||||
|
||||
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) {
|
||||
final t = s.trim();
|
||||
if (t.length != 5 || t[2] != ':') return null;
|
||||
final h = int.tryParse(t.substring(0, 2));
|
||||
final m = int.tryParse(t.substring(3, 5));
|
||||
if (s.length != 5 || s[2] != ':') return null;
|
||||
final h = int.tryParse(s.substring(0, 2));
|
||||
final m = int.tryParse(s.substring(3, 5));
|
||||
if (h == null || m == null) return null;
|
||||
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
|
||||
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) =>
|
||||
'${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
|
||||
List<WorkDay> fillMonth(DateTime monthStart, List<WorkDay> existing) {
|
||||
final map = <String, WorkDay>{for (final w in existing) ymd(w.date): w};
|
||||
final nextMonth = DateTime(monthStart.year, monthStart.month + 1, 1);
|
||||
final out = <WorkDay>[];
|
||||
for (DateTime d = monthStart; d.isBefore(nextMonth); d = d.add(const Duration(days: 1))) {
|
||||
final key = ymd(d);
|
||||
final wd = map[key];
|
||||
if (wd != null) {
|
||||
out.add(wd);
|
||||
const _weekdayShort = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
|
||||
String _shortWeekday(DateTime d) => _weekdayShort[(d.weekday - 1) % 7];
|
||||
String _shortDate(DateTime d) =>
|
||||
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.';
|
||||
|
||||
/// Links in der Tabelle (Tag vor Datum)
|
||||
String rightDayLabel(DateTime d) => '${_shortWeekday(d)} ${_shortDate(d)}';
|
||||
|
||||
/// 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 {
|
||||
final isWeekend = d.weekday == DateTime.saturday || d.weekday == DateTime.sunday;
|
||||
final target = isWeekend ? 0 : 8 * 60;
|
||||
out.add(WorkDay(date: d, intervals: const [], targetMinutes: target));
|
||||
days.add(WorkDay(date: d, intervals: const [], targetMinutes: 0, code: null));
|
||||
}
|
||||
}
|
||||
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 = ['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 '—';
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import '../utils/helpers.dart';
|
||||
|
||||
/// Sehr vereinfachte österreichische Feiertage (bundesweit).
|
||||
/// Key = 'YYYY-MM-DD', Value = Name.
|
||||
Map<String, String> buildHolidayMapAT(int year) {
|
||||
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;
|
||||
|
||||
add(DateTime(year, 1, 1), 'Neujahr');
|
||||
add(DateTime(year, 1, 6), 'Heilige Drei Könige');
|
||||
add(easterMon, 'Ostermontag');
|
||||
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, 10, 26), 'Nationalfeiertag');
|
||||
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, 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;
|
||||
}
|
||||
|
||||
/// Gaußsche Osterformel (Gregorianisch)
|
||||
DateTime _easterSunday(int year) {
|
||||
final a = year % 19;
|
||||
final b = year ~/ 100;
|
||||
@@ -36,7 +44,7 @@ DateTime _easterSunday(int year) {
|
||||
final k = c % 4;
|
||||
final l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
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;
|
||||
return DateTime(year, month, day);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,57 @@
|
||||
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 {
|
||||
const HHmmInputFormatter();
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
String digits = newValue.text.replaceAll(RegExp(r'[^0-9]'), '');
|
||||
if (digits.isEmpty) {
|
||||
return const TextEditingValue(text: '', selection: TextSelection.collapsed(offset: 0));
|
||||
}
|
||||
if (digits.length > 4) digits = digits.substring(0, 4);
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
var text = newValue.text;
|
||||
|
||||
String text;
|
||||
if (digits.length <= 2) {
|
||||
text = digits;
|
||||
} else {
|
||||
final hh = digits.substring(0, 2);
|
||||
final mm = digits.substring(2);
|
||||
text = '$hh:$mm';
|
||||
// nur Ziffern und ":" erlauben
|
||||
final filtered = StringBuffer();
|
||||
for (int i = 0; i < text.length; i++) {
|
||||
final c = text[i];
|
||||
if ((c.codeUnitAt(0) >= 48 && c.codeUnitAt(0) <= 57) || c == ':') {
|
||||
filtered.write(c);
|
||||
}
|
||||
}
|
||||
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;
|
||||
return TextEditingValue(text: text, selection: TextSelection.collapsed(offset: offset));
|
||||
// nur ein ":" erlauben, und nur an Stelle 2
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user