Weekly, Monthly and Custom Range Worklog
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
|
||||
// ─── DTOs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class WorkLogEntryDto {
|
||||
const WorkLogEntryDto({
|
||||
required this.type,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.start,
|
||||
this.end,
|
||||
required this.isCreatorEvent,
|
||||
required this.duration,
|
||||
});
|
||||
|
||||
// 'attendance' | 'task' | 'ticket' | 'isr' | 'passSlip' | 'vacant'
|
||||
final String type;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final DateTime start;
|
||||
final DateTime? end;
|
||||
final bool isCreatorEvent;
|
||||
final Duration duration;
|
||||
}
|
||||
|
||||
class WorkLogSummaryDto {
|
||||
const WorkLogSummaryDto({
|
||||
required this.totalAttendance,
|
||||
required this.totalTasks,
|
||||
required this.totalTickets,
|
||||
required this.totalIsrs,
|
||||
required this.totalPassSlip,
|
||||
required this.totalVacant,
|
||||
});
|
||||
|
||||
final Duration totalAttendance;
|
||||
final Duration totalTasks;
|
||||
final Duration totalTickets;
|
||||
final Duration totalIsrs;
|
||||
final Duration totalPassSlip;
|
||||
final Duration totalVacant;
|
||||
|
||||
Duration get totalActive =>
|
||||
totalAttendance + totalTasks + totalTickets + totalIsrs + totalPassSlip;
|
||||
}
|
||||
|
||||
class DailyWorkLogDto {
|
||||
const DailyWorkLogDto({
|
||||
required this.date,
|
||||
required this.entries,
|
||||
required this.summary,
|
||||
});
|
||||
|
||||
final DateTime date;
|
||||
final List<WorkLogEntryDto> entries;
|
||||
final WorkLogSummaryDto summary;
|
||||
|
||||
bool get hasData => entries.any((e) => !e.isCreatorEvent);
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
String _fmt(Duration d) {
|
||||
if (d.inMinutes < 1) return '< 1m';
|
||||
final h = d.inHours;
|
||||
final m = d.inMinutes % 60;
|
||||
return h > 0 ? '${h}h ${m}m' : '${m}m';
|
||||
}
|
||||
|
||||
String _fmtTime(DateTime dt) => DateFormat('h:mm a').format(dt);
|
||||
|
||||
Future<pw.Font> _font(String path) async {
|
||||
final data = await rootBundle.load(path);
|
||||
return pw.Font.ttf(data);
|
||||
}
|
||||
|
||||
String _typeLabel(String type) => switch (type) {
|
||||
'attendance' => 'Attendance',
|
||||
'task' => 'Task',
|
||||
'ticket' => 'Ticket',
|
||||
'isr' => 'ISR',
|
||||
'passSlip' => 'Pass Slip',
|
||||
'vacant' => 'Vacant',
|
||||
_ => type,
|
||||
};
|
||||
|
||||
pw.TableRow _headerRow(pw.Font bold, List<String> labels) => pw.TableRow(
|
||||
decoration: const pw.BoxDecoration(color: PdfColors.grey200),
|
||||
children: labels
|
||||
.map((l) => pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(4),
|
||||
child: pw.Text(l, style: pw.TextStyle(font: bold, fontSize: 8)),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
pw.Widget _cell(String text, {bool bold = false, pw.Font? boldFont}) =>
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(4),
|
||||
child: pw.Text(
|
||||
text,
|
||||
style: bold && boldFont != null
|
||||
? pw.TextStyle(font: boldFont, fontSize: 8)
|
||||
: const pw.TextStyle(fontSize: 8),
|
||||
),
|
||||
);
|
||||
|
||||
pw.TableRow _summaryRow(
|
||||
String label,
|
||||
Duration dur, {
|
||||
bool isBold = false,
|
||||
pw.Font? boldFont,
|
||||
}) =>
|
||||
pw.TableRow(children: [
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(4),
|
||||
child: pw.Text(
|
||||
label,
|
||||
style: isBold && boldFont != null
|
||||
? pw.TextStyle(font: boldFont, fontSize: 9)
|
||||
: const pw.TextStyle(fontSize: 9),
|
||||
),
|
||||
),
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(4),
|
||||
child: pw.Text(
|
||||
_fmt(dur),
|
||||
style: isBold && boldFont != null
|
||||
? pw.TextStyle(font: boldFont, fontSize: 9)
|
||||
: const pw.TextStyle(fontSize: 9),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
// ─── Daily PDF ────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<Uint8List> buildDailyWorkLogPdfBytes({
|
||||
required String personName,
|
||||
required DateTime date,
|
||||
required List<WorkLogEntryDto> entries,
|
||||
required WorkLogSummaryDto summary,
|
||||
}) async {
|
||||
final regular = await _font('assets/fonts/Roboto-Regular.ttf');
|
||||
final bold = await _font('assets/fonts/Roboto-Bold.ttf');
|
||||
final dateFmt = DateFormat('EEEE, MMMM d, yyyy');
|
||||
|
||||
final doc = pw.Document();
|
||||
doc.addPage(pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(32),
|
||||
theme: pw.ThemeData.withFont(base: regular, bold: bold),
|
||||
header: (ctx) => pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text('Work Log',
|
||||
style: pw.TextStyle(font: bold, fontSize: 18)),
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(personName, style: const pw.TextStyle(fontSize: 12)),
|
||||
pw.Text(dateFmt.format(date),
|
||||
style:
|
||||
const pw.TextStyle(fontSize: 11, color: PdfColors.grey700)),
|
||||
pw.Divider(thickness: 0.5),
|
||||
],
|
||||
),
|
||||
footer: (ctx) => pw.Align(
|
||||
alignment: pw.Alignment.centerRight,
|
||||
child: pw.Text(
|
||||
'Page ${ctx.pageNumber} of ${ctx.pagesCount} • Generated by Tasq',
|
||||
style:
|
||||
const pw.TextStyle(fontSize: 8, color: PdfColors.grey600),
|
||||
),
|
||||
),
|
||||
build: (ctx) => [
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(width: 0.4, color: PdfColors.grey400),
|
||||
columnWidths: const {
|
||||
0: pw.FixedColumnWidth(68),
|
||||
1: pw.FixedColumnWidth(68),
|
||||
2: pw.FixedColumnWidth(58),
|
||||
3: pw.FlexColumnWidth(),
|
||||
4: pw.FixedColumnWidth(44),
|
||||
},
|
||||
children: [
|
||||
_headerRow(bold, ['Start', 'End', 'Type', 'Description', 'Duration']),
|
||||
for (final e in entries.where((e) => !e.isCreatorEvent))
|
||||
pw.TableRow(children: [
|
||||
_cell(_fmtTime(e.start)),
|
||||
_cell(e.end != null ? _fmtTime(e.end!) : '—'),
|
||||
_cell(_typeLabel(e.type)),
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(4),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(e.title,
|
||||
style: const pw.TextStyle(fontSize: 8)),
|
||||
if (e.subtitle != null)
|
||||
pw.Text(e.subtitle!,
|
||||
style: const pw.TextStyle(
|
||||
fontSize: 7, color: PdfColors.grey700)),
|
||||
],
|
||||
),
|
||||
),
|
||||
_cell(_fmt(e.duration)),
|
||||
]),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 12),
|
||||
pw.Text('Summary',
|
||||
style: pw.TextStyle(font: bold, fontSize: 11)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(width: 0.4, color: PdfColors.grey400),
|
||||
columnWidths: const {
|
||||
0: pw.FlexColumnWidth(),
|
||||
1: pw.FixedColumnWidth(60),
|
||||
},
|
||||
children: [
|
||||
if (summary.totalAttendance > Duration.zero)
|
||||
_summaryRow('Attendance', summary.totalAttendance, boldFont: bold),
|
||||
if (summary.totalTasks > Duration.zero)
|
||||
_summaryRow('Tasks', summary.totalTasks, boldFont: bold),
|
||||
if (summary.totalTickets > Duration.zero)
|
||||
_summaryRow('Tickets', summary.totalTickets, boldFont: bold),
|
||||
if (summary.totalIsrs > Duration.zero)
|
||||
_summaryRow('IT Service Requests', summary.totalIsrs, boldFont: bold),
|
||||
if (summary.totalPassSlip > Duration.zero)
|
||||
_summaryRow('Pass Slips', summary.totalPassSlip, boldFont: bold),
|
||||
_summaryRow('Total Active', summary.totalActive,
|
||||
isBold: true, boldFont: bold),
|
||||
],
|
||||
),
|
||||
],
|
||||
));
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
// ─── Multi-day Summary PDF ────────────────────────────────────────────────────
|
||||
|
||||
Future<Uint8List> buildMultiDayWorkLogPdfBytes({
|
||||
required String personName,
|
||||
required DateTime rangeStart,
|
||||
required DateTime rangeEnd,
|
||||
required String modeLabel,
|
||||
required List<DailyWorkLogDto> days,
|
||||
}) async {
|
||||
final regular = await _font('assets/fonts/Roboto-Regular.ttf');
|
||||
final bold = await _font('assets/fonts/Roboto-Bold.ttf');
|
||||
final dateFmt = DateFormat('MMM d, yyyy');
|
||||
final dayFmt = DateFormat('EEE, MMM d');
|
||||
|
||||
final doc = pw.Document();
|
||||
doc.addPage(pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(32),
|
||||
theme: pw.ThemeData.withFont(base: regular, bold: bold),
|
||||
header: (ctx) => pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text('Work Log — $modeLabel',
|
||||
style: pw.TextStyle(font: bold, fontSize: 18)),
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(personName, style: const pw.TextStyle(fontSize: 12)),
|
||||
pw.Text(
|
||||
'${dateFmt.format(rangeStart)} – '
|
||||
'${dateFmt.format(rangeEnd.subtract(const Duration(days: 1)))}',
|
||||
style:
|
||||
const pw.TextStyle(fontSize: 11, color: PdfColors.grey700),
|
||||
),
|
||||
pw.Divider(thickness: 0.5),
|
||||
],
|
||||
),
|
||||
footer: (ctx) => pw.Align(
|
||||
alignment: pw.Alignment.centerRight,
|
||||
child: pw.Text(
|
||||
'Page ${ctx.pageNumber} of ${ctx.pagesCount} • Generated by Tasq',
|
||||
style:
|
||||
const pw.TextStyle(fontSize: 8, color: PdfColors.grey600),
|
||||
),
|
||||
),
|
||||
build: (ctx) => [
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(width: 0.4, color: PdfColors.grey400),
|
||||
columnWidths: const {
|
||||
0: pw.FixedColumnWidth(80),
|
||||
1: pw.FixedColumnWidth(54),
|
||||
2: pw.FixedColumnWidth(40),
|
||||
3: pw.FixedColumnWidth(40),
|
||||
4: pw.FixedColumnWidth(36),
|
||||
5: pw.FixedColumnWidth(46),
|
||||
6: pw.FixedColumnWidth(38),
|
||||
7: pw.FixedColumnWidth(48),
|
||||
},
|
||||
children: [
|
||||
_headerRow(bold,
|
||||
['Date', 'Attend.', 'Tasks', 'Tickets', 'ISR', 'Pass Slip', 'Idle', 'Total']),
|
||||
for (final day in days)
|
||||
pw.TableRow(
|
||||
decoration: day.hasData
|
||||
? null
|
||||
: const pw.BoxDecoration(color: PdfColors.grey100),
|
||||
children: [
|
||||
_cell(dayFmt.format(day.date)),
|
||||
_cell(day.hasData ? _fmt(day.summary.totalAttendance) : '—'),
|
||||
_cell(day.hasData ? _fmt(day.summary.totalTasks) : '—'),
|
||||
_cell(day.hasData ? _fmt(day.summary.totalTickets) : '—'),
|
||||
_cell(day.hasData ? _fmt(day.summary.totalIsrs) : '—'),
|
||||
_cell(day.hasData ? _fmt(day.summary.totalPassSlip) : '—'),
|
||||
_cell(day.hasData ? _fmt(day.summary.totalVacant) : '—'),
|
||||
_cell(day.hasData ? _fmt(day.summary.totalActive) : '—'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
));
|
||||
return doc.save();
|
||||
}
|
||||
Reference in New Issue
Block a user