Skip to content

Commit 7c2a293

Browse files
sprobst76claude
andcommitted
feat: PDF Arbeitszeitnachweis + Stundensatz pro Projekt (beta.68)
PDF export: - New PdfExportService generates monthly timesheet PDF (A4) - Table: date, start, end, pause, net, project, notes + totals row - Summary: Soll / Ist / Saldo + signature fields - Report Month tab export button now shows Excel / PDF popup menu - Shares PDF via system share sheet (printing package) Stundensatz pro Projekt: - Project model gains HiveField(5) hourlyRate (double, default 0.0) - Add/Edit project dialogs show optional "Stundensatz €/h" field - Per-project card in Reports shows "Xh × Y€/h = Z€" when rate > 0 - Summary card shows total "Abrechenbar" amount when any rates are set Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3badf77 commit 7c2a293

File tree

8 files changed

+518
-17
lines changed

8 files changed

+518
-17
lines changed

lib/models/project.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,16 @@ class Project extends HiveObject {
2020
@HiveField(4)
2121
int sortOrder;
2222

23+
@HiveField(5)
24+
double hourlyRate; // Stundensatz in €, 0 = kein Stundensatz
25+
2326
Project({
2427
required this.id,
2528
required this.name,
2629
this.colorHex,
2730
this.isActive = true,
2831
this.sortOrder = 0,
32+
this.hourlyRate = 0.0,
2933
});
3034

3135
Color get color {

lib/models/project.g.dart

Lines changed: 5 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/providers.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,7 @@ class ProjectsNotifier extends StateNotifier<List<Project>> {
682682
Future<void> createProject({
683683
required String name,
684684
String? colorHex,
685+
double hourlyRate = 0.0,
685686
}) async {
686687
final id = DateTime.now().millisecondsSinceEpoch.toString();
687688
final sortOrder = state.isEmpty ? 0 : state.map((p) => p.sortOrder).reduce((a, b) => a > b ? a : b) + 1;
@@ -690,6 +691,7 @@ class ProjectsNotifier extends StateNotifier<List<Project>> {
690691
name: name,
691692
colorHex: colorHex,
692693
sortOrder: sortOrder,
694+
hourlyRate: hourlyRate,
693695
);
694696
await box.add(project);
695697
_refresh();
@@ -701,11 +703,13 @@ class ProjectsNotifier extends StateNotifier<List<Project>> {
701703
String? newColorHex,
702704
bool? newIsActive,
703705
int? newSortOrder,
706+
double? newHourlyRate,
704707
}) async {
705708
if (newName != null) project.name = newName;
706709
if (newColorHex != null) project.colorHex = newColorHex;
707710
if (newIsActive != null) project.isActive = newIsActive;
708711
if (newSortOrder != null) project.sortOrder = newSortOrder;
712+
if (newHourlyRate != null) project.hourlyRate = newHourlyRate;
709713
await project.save();
710714
_refresh();
711715
}

lib/screens/projects_screen.dart

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ class ProjectsScreen extends ConsumerWidget {
184184

185185
Future<void> _showAddProjectDialog(BuildContext context, WidgetRef ref) async {
186186
final nameController = TextEditingController();
187+
final rateController = TextEditingController();
187188
String selectedColor = '#2196F3'; // Default blue
188189

189190
final colors = [
@@ -245,6 +246,17 @@ class ProjectsScreen extends ConsumerWidget {
245246
);
246247
}).toList(),
247248
),
249+
const SizedBox(height: 16),
250+
TextField(
251+
controller: rateController,
252+
decoration: const InputDecoration(
253+
labelText: 'Stundensatz (optional)',
254+
hintText: '0,00',
255+
suffixText: '€/h',
256+
border: OutlineInputBorder(),
257+
),
258+
keyboardType: const TextInputType.numberWithOptions(decimal: true),
259+
),
248260
],
249261
),
250262
),
@@ -263,15 +275,20 @@ class ProjectsScreen extends ConsumerWidget {
263275
);
264276

265277
if (result == true && nameController.text.trim().isNotEmpty) {
278+
final rate = double.tryParse(rateController.text.replaceAll(',', '.')) ?? 0.0;
266279
await ref.read(projectsProvider.notifier).createProject(
267280
name: nameController.text.trim(),
268281
colorHex: selectedColor,
282+
hourlyRate: rate,
269283
);
270284
}
271285
}
272286

273287
Future<void> _showEditProjectDialog(BuildContext context, WidgetRef ref, Project project) async {
274288
final nameController = TextEditingController(text: project.name);
289+
final rateController = TextEditingController(
290+
text: project.hourlyRate > 0 ? project.hourlyRate.toStringAsFixed(2) : '',
291+
);
275292
String selectedColor = project.colorHex ?? '#2196F3';
276293

277294
final colors = [
@@ -331,6 +348,17 @@ class ProjectsScreen extends ConsumerWidget {
331348
);
332349
}).toList(),
333350
),
351+
const SizedBox(height: 16),
352+
TextField(
353+
controller: rateController,
354+
decoration: const InputDecoration(
355+
labelText: 'Stundensatz (optional)',
356+
hintText: '0,00',
357+
suffixText: '€/h',
358+
border: OutlineInputBorder(),
359+
),
360+
keyboardType: const TextInputType.numberWithOptions(decimal: true),
361+
),
334362
],
335363
),
336364
),
@@ -349,10 +377,12 @@ class ProjectsScreen extends ConsumerWidget {
349377
);
350378

351379
if (result == true && nameController.text.trim().isNotEmpty) {
380+
final rate = double.tryParse(rateController.text.replaceAll(',', '.')) ?? 0.0;
352381
await ref.read(projectsProvider.notifier).updateProject(
353382
project,
354383
newName: nameController.text.trim(),
355384
newColorHex: selectedColor,
385+
newHourlyRate: rate,
356386
);
357387
}
358388
}

lib/screens/report_screen.dart

Lines changed: 123 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import '../models/settings.dart';
77
import '../models/project.dart';
88
import '../services/holiday_service.dart';
99
import '../services/export_service.dart';
10+
import '../services/pdf_export_service.dart';
11+
import 'package:printing/printing.dart';
1012

1113
// Re-export AbsenceType for convenience
1214
export '../models/vacation.dart' show AbsenceType;
@@ -25,6 +27,7 @@ class _ReportScreenState extends ConsumerState<ReportScreen> with SingleTickerPr
2527
int _selectedYear = DateTime.now().year;
2628
final HolidayService _holidayService = HolidayService();
2729
final ExportService _exportService = ExportService();
30+
final PdfExportService _pdfExportService = PdfExportService();
2831
Map<DateTime, Holiday> _holidays = {};
2932
String? _loadedBundesland;
3033
bool _isExporting = false;
@@ -263,6 +266,49 @@ class _ReportScreenState extends ConsumerState<ReportScreen> with SingleTickerPr
263266
}
264267
}
265268

269+
Future<void> _exportMonthPdf(
270+
List<WorkEntry> entries,
271+
WeeklyHoursPeriodsNotifier periodsNotifier,
272+
) async {
273+
setState(() => _isExporting = true);
274+
try {
275+
final projects = ref.read(projectsProvider);
276+
final vacations = ref.read(vacationProvider);
277+
final settings = ref.read(settingsProvider);
278+
final monthData = _calculateMonthData(entries, vacations, periodsNotifier, settings);
279+
280+
final monthEntries = entries.where((e) {
281+
return e.start.year == _selectedMonth.year &&
282+
e.start.month == _selectedMonth.month;
283+
}).toList();
284+
285+
final bytes = await _pdfExportService.generateMonthlyTimesheet(
286+
entries: monthEntries,
287+
month: _selectedMonth,
288+
settings: settings,
289+
projects: projects,
290+
targetHours: monthData.targetHours,
291+
);
292+
293+
const names = [
294+
'', 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
295+
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
296+
];
297+
final filename =
298+
'Arbeitszeitnachweis_${names[_selectedMonth.month]}_${_selectedMonth.year}.pdf';
299+
300+
await Printing.sharePdf(bytes: bytes, filename: filename);
301+
} catch (e) {
302+
if (mounted) {
303+
ScaffoldMessenger.of(context).showSnackBar(
304+
SnackBar(content: Text('PDF-Export fehlgeschlagen: $e')),
305+
);
306+
}
307+
} finally {
308+
if (mounted) setState(() => _isExporting = false);
309+
}
310+
}
311+
266312
@override
267313
Widget build(BuildContext context) {
268314
final workEntries = ref.watch(workListProvider);
@@ -291,20 +337,52 @@ class _ReportScreenState extends ConsumerState<ReportScreen> with SingleTickerPr
291337
builder: (context, _) {
292338
final tab = _tabController.index;
293339
if (tab != 0 && tab != 1) return const SizedBox.shrink();
294-
return IconButton(
295-
icon: _isExporting
296-
? const SizedBox(
297-
width: 24,
298-
height: 24,
299-
child: CircularProgressIndicator(strokeWidth: 2),
300-
)
301-
: const Icon(Icons.file_download),
302-
tooltip: 'Als Excel exportieren',
303-
onPressed: _isExporting
304-
? null
305-
: tab == 0
306-
? () => _exportWeek(workEntries)
307-
: () => _exportMonth(workEntries, settings.weeklyHours, periodsNotifier),
340+
if (_isExporting) {
341+
return const Padding(
342+
padding: EdgeInsets.all(12),
343+
child: SizedBox(
344+
width: 24, height: 24,
345+
child: CircularProgressIndicator(strokeWidth: 2),
346+
),
347+
);
348+
}
349+
// Woche: nur Excel
350+
if (tab == 0) {
351+
return IconButton(
352+
icon: const Icon(Icons.file_download),
353+
tooltip: 'Als Excel exportieren',
354+
onPressed: () => _exportWeek(workEntries),
355+
);
356+
}
357+
// Monat: Excel + PDF
358+
return PopupMenuButton<String>(
359+
icon: const Icon(Icons.file_download),
360+
tooltip: 'Exportieren',
361+
onSelected: (choice) {
362+
if (choice == 'excel') {
363+
_exportMonth(workEntries, settings.weeklyHours, periodsNotifier);
364+
} else {
365+
_exportMonthPdf(workEntries, periodsNotifier);
366+
}
367+
},
368+
itemBuilder: (_) => const [
369+
PopupMenuItem(
370+
value: 'excel',
371+
child: Row(children: [
372+
Icon(Icons.table_chart_outlined, size: 18),
373+
SizedBox(width: 10),
374+
Text('Als Excel exportieren'),
375+
]),
376+
),
377+
PopupMenuItem(
378+
value: 'pdf',
379+
child: Row(children: [
380+
Icon(Icons.picture_as_pdf_outlined, size: 18),
381+
SizedBox(width: 10),
382+
Text('Als PDF exportieren'),
383+
]),
384+
),
385+
],
308386
);
309387
},
310388
),
@@ -1261,6 +1339,16 @@ class _ReportScreenState extends ConsumerState<ReportScreen> with SingleTickerPr
12611339

12621340
final totalHours = sorted.fold(0.0, (s, e) => s + e.value);
12631341

1342+
// Gesamter Rechnungsbetrag für Projekte mit Stundensatz
1343+
double totalBilling = 0.0;
1344+
for (final e in sorted) {
1345+
if (e.key == null) continue;
1346+
final proj = projects.where((p) => p.id == e.key).firstOrNull;
1347+
if (proj != null && proj.hourlyRate > 0) {
1348+
totalBilling += e.value * proj.hourlyRate;
1349+
}
1350+
}
1351+
12641352
if (entries.isEmpty) {
12651353
return const Center(
12661354
child: Text('Noch keine abgeschlossenen Einträge.',
@@ -1281,6 +1369,8 @@ class _ReportScreenState extends ConsumerState<ReportScreen> with SingleTickerPr
12811369
_buildMiniStat('Gesamt', _formatHours(totalHours), Colors.blue.shade700),
12821370
_buildMiniStat('Einträge', '${entries.length}', Colors.grey.shade700),
12831371
_buildMiniStat('Projekte', '${hoursById.keys.where((k) => k != null).length}', Colors.grey.shade700),
1372+
if (totalBilling > 0)
1373+
_buildMiniStat('Abrechenbar', '${totalBilling.toStringAsFixed(2)} €', Colors.green.shade700),
12841374
],
12851375
),
12861376
),
@@ -1311,6 +1401,8 @@ class _ReportScreenState extends ConsumerState<ReportScreen> with SingleTickerPr
13111401

13121402
final name = project?.name ?? (id == null ? 'Kein Projekt' : 'Gelöscht ($id)');
13131403
final color = project?.color ?? Colors.grey;
1404+
final rate = project?.hourlyRate ?? 0.0;
1405+
final billing = rate > 0 ? hours * rate : 0.0;
13141406

13151407
return Card(
13161408
margin: const EdgeInsets.only(bottom: 8),
@@ -1350,6 +1442,23 @@ class _ReportScreenState extends ConsumerState<ReportScreen> with SingleTickerPr
13501442
minHeight: 6,
13511443
),
13521444
),
1445+
if (billing > 0) ...[
1446+
const SizedBox(height: 6),
1447+
Row(
1448+
children: [
1449+
Icon(Icons.euro, size: 13, color: Colors.green.shade700),
1450+
const SizedBox(width: 4),
1451+
Text(
1452+
'${hours.toStringAsFixed(2)} h × ${rate.toStringAsFixed(2)} €/h = ${billing.toStringAsFixed(2)} €',
1453+
style: TextStyle(
1454+
fontSize: 12,
1455+
color: Colors.green.shade700,
1456+
fontWeight: FontWeight.w500,
1457+
),
1458+
),
1459+
],
1460+
),
1461+
],
13531462
],
13541463
),
13551464
),

0 commit comments

Comments
 (0)