footer
This commit is contained in:
+58
-53
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 '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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 '—';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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