844 lines
32 KiB
Dart
844 lines
32 KiB
Dart
// lib/services/report_pdf_service.dart
|
|
|
|
import 'dart:io';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:pdf/pdf.dart';
|
|
import 'package:pdf/widgets.dart' as pw;
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import '../models/report_transaction.dart';
|
|
import '../models/report_signature_config.dart';
|
|
import '../utils/currency_format.dart';
|
|
import 'pdf_snapshot_service.dart';
|
|
|
|
class ReportPdfService {
|
|
/// Membagi daftar transaksi menjadi halaman-halaman sehingga lembar terakhir
|
|
/// dijamin memiliki minimal 5 baris transaksi bersama kotak tanda tangan.
|
|
static List<List<ReportTransaction>> paginateTransactions(
|
|
List<ReportTransaction> transactions,
|
|
) {
|
|
if (transactions.isEmpty) return [[]];
|
|
|
|
// Jika transaksi muat dalam 1 halaman bersama tanda tangan (<= 28 baris)
|
|
if (transactions.length <= 28) {
|
|
return [transactions];
|
|
}
|
|
|
|
// Kapasitas maksimum baris per halaman penuh (tanpa tanda tangan): ~34 baris
|
|
// Minimal baris di lembar terakhir bersama tanda tangan: 5 baris
|
|
const int maxRowsFullPage = 34;
|
|
const int minRowsLastPage = 5;
|
|
|
|
final pages = <List<ReportTransaction>>[];
|
|
int currentIndex = 0;
|
|
|
|
while (currentIndex < transactions.length) {
|
|
final remaining = transactions.length - currentIndex;
|
|
|
|
// Jika sisa transaksi bisa menjadi halaman terakhir (<= 22 baris dan >= 5 baris)
|
|
if (pages.isNotEmpty && remaining <= 22 && remaining >= minRowsLastPage) {
|
|
pages.add(transactions.sublist(currentIndex));
|
|
break;
|
|
}
|
|
|
|
// Jika mendekati halaman terakhir
|
|
if (remaining <= maxRowsFullPage + minRowsLastPage) {
|
|
if (remaining <= maxRowsFullPage) {
|
|
// remaining antara 29 s/d 34: alokasikan minimal 5 baris untuk halaman terakhir!
|
|
final rowsForCurrent = remaining - minRowsLastPage;
|
|
pages.add(transactions.sublist(currentIndex, currentIndex + rowsForCurrent));
|
|
currentIndex += rowsForCurrent;
|
|
pages.add(transactions.sublist(currentIndex));
|
|
break;
|
|
} else {
|
|
// remaining antara 35 s/d 39: pastikan halaman terakhir dapat minimal 5 baris
|
|
int rowsForCurrent = maxRowsFullPage;
|
|
if (remaining - rowsForCurrent < minRowsLastPage) {
|
|
rowsForCurrent = remaining - minRowsLastPage;
|
|
}
|
|
pages.add(transactions.sublist(currentIndex, currentIndex + rowsForCurrent));
|
|
currentIndex += rowsForCurrent;
|
|
pages.add(transactions.sublist(currentIndex));
|
|
break;
|
|
}
|
|
} else {
|
|
// Masih banyak transaksi (> 39 baris), ambil 1 halaman penuh
|
|
pages.add(transactions.sublist(currentIndex, currentIndex + maxRowsFullPage));
|
|
currentIndex += maxRowsFullPage;
|
|
}
|
|
}
|
|
|
|
return pages;
|
|
}
|
|
|
|
/// Menghasilkan file PDF dokumen Laporan Keuangan format A4
|
|
static Future<File> generateReportPdf({
|
|
required List<ReportTransaction> transactions,
|
|
required DateTime startDate,
|
|
required DateTime endDate,
|
|
required ReportSignatureConfig config,
|
|
String? accountName,
|
|
File? targetFile,
|
|
String? fireflyBaseUrl,
|
|
}) async {
|
|
final pdf = pw.Document();
|
|
|
|
String? baseUrl = fireflyBaseUrl;
|
|
if (baseUrl == null || baseUrl.isEmpty) {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
baseUrl = prefs.getString('firefly_url');
|
|
} catch (_) {}
|
|
}
|
|
|
|
final dateShortFmt = DateFormat('dd/MM/yyyy');
|
|
String formatIndonesianDate(DateTime d) {
|
|
const months = [
|
|
'',
|
|
'Januari',
|
|
'Februari',
|
|
'Maret',
|
|
'April',
|
|
'Mei',
|
|
'Juni',
|
|
'Juli',
|
|
'Agustus',
|
|
'September',
|
|
'Oktober',
|
|
'November',
|
|
'Desember'
|
|
];
|
|
return '${d.day} ${months[d.month]} ${d.year}';
|
|
}
|
|
|
|
final primaryGreen = PdfColor.fromHex('#14532D');
|
|
final lightGreen = PdfColor.fromHex('#F0FDF4');
|
|
final headerGrey = PdfColor.fromHex('#E5E7EB');
|
|
final borderGrey = PdfColor.fromHex('#9CA3AF');
|
|
|
|
// Perhitungan total
|
|
double totalPemasukan = 0;
|
|
double totalPengeluaran = 0;
|
|
for (final t in transactions) {
|
|
if (t.isPemasukan) {
|
|
totalPemasukan += t.amount;
|
|
} else {
|
|
totalPengeluaran += t.amount;
|
|
}
|
|
}
|
|
final finalSaldo = totalPemasukan - totalPengeluaran;
|
|
|
|
// ==================== HALAMAN TABEL REKAPITULASI KAS ====================
|
|
final pagedTransactions = paginateTransactions(transactions);
|
|
double currentRunningSaldo = 0;
|
|
int globalRowOffset = 0;
|
|
|
|
for (int pageIndex = 0; pageIndex < pagedTransactions.length; pageIndex++) {
|
|
final pageTx = pagedTransactions[pageIndex];
|
|
final isLastSummaryPage = (pageIndex == pagedTransactions.length - 1);
|
|
final startRunningSaldo = currentRunningSaldo;
|
|
final startIndex = globalRowOffset;
|
|
|
|
// Akumulasikan running saldo untuk halaman berikutnya
|
|
for (final t in pageTx) {
|
|
final pem = t.isPemasukan ? t.amount : 0.0;
|
|
final peng = t.isPengeluaran ? t.amount : 0.0;
|
|
currentRunningSaldo += (pem - peng);
|
|
}
|
|
globalRowOffset += pageTx.length;
|
|
|
|
pdf.addPage(
|
|
pw.Page(
|
|
pageFormat: PdfPageFormat.a4,
|
|
margin: const pw.EdgeInsets.symmetric(horizontal: 28, vertical: 22),
|
|
build: (context) {
|
|
return pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
|
children: [
|
|
// 1. Header Kop Laporan
|
|
pw.Center(
|
|
child: pw.Text(
|
|
config.reportTitle.toUpperCase(),
|
|
style: pw.TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: pw.FontWeight.bold,
|
|
color: PdfColors.black,
|
|
),
|
|
textAlign: pw.TextAlign.center,
|
|
),
|
|
),
|
|
pw.SizedBox(height: 2),
|
|
pw.Center(
|
|
child: pw.Text(
|
|
'PERIODE ${dateShortFmt.format(startDate)} - ${dateShortFmt.format(endDate)}'
|
|
'${accountName != null && accountName.isNotEmpty ? ' ($accountName)' : ''}',
|
|
style: pw.TextStyle(
|
|
fontSize: 9.5,
|
|
fontWeight: pw.FontWeight.normal,
|
|
color: PdfColors.grey800,
|
|
),
|
|
textAlign: pw.TextAlign.center,
|
|
),
|
|
),
|
|
pw.SizedBox(height: 8),
|
|
|
|
// 2. Tabel Transaksi
|
|
_buildTransactionTable(
|
|
transactions: pageTx,
|
|
startIndex: startIndex,
|
|
dateShortFmt: dateShortFmt,
|
|
primaryGreen: primaryGreen,
|
|
headerGrey: headerGrey,
|
|
borderGrey: borderGrey,
|
|
isLastPage: isLastSummaryPage,
|
|
totalPemasukan: totalPemasukan,
|
|
totalPengeluaran: totalPengeluaran,
|
|
finalSaldo: finalSaldo,
|
|
initialRunningSaldo: startRunningSaldo,
|
|
fireflyBaseUrl: baseUrl,
|
|
),
|
|
|
|
// 3. Kotak Tanda Tangan (Hanya di lembar terakhir rekapitulasi)
|
|
if (isLastSummaryPage) ...[
|
|
pw.SizedBox(height: 16),
|
|
_buildSignatureBlock(config, endDate, formatIndonesianDate),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
// ==================== HALAMAN LAMPIRAN BUKTI PENGELUARAN ====================
|
|
final pengeluaranWithAttachment = transactions
|
|
.where((t) => t.isPengeluaran && (t.hasFotoBarang || t.hasInvoice))
|
|
.toList();
|
|
|
|
if (pengeluaranWithAttachment.isNotEmpty) {
|
|
// Halaman Cover Lampiran
|
|
pdf.addPage(
|
|
pw.Page(
|
|
pageFormat: PdfPageFormat.a4,
|
|
margin: const pw.EdgeInsets.all(36),
|
|
build: (context) {
|
|
return pw.Center(
|
|
child: pw.Column(
|
|
mainAxisAlignment: pw.MainAxisAlignment.center,
|
|
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
|
children: [
|
|
pw.Text(
|
|
'LAMPIRAN',
|
|
style: pw.TextStyle(
|
|
fontSize: 26,
|
|
fontWeight: pw.FontWeight.bold,
|
|
color: primaryGreen,
|
|
),
|
|
),
|
|
pw.SizedBox(height: 10),
|
|
pw.Text(
|
|
'BUKTI PENGELUARAN',
|
|
style: pw.TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: pw.FontWeight.bold,
|
|
color: PdfColors.black,
|
|
),
|
|
),
|
|
pw.SizedBox(height: 16),
|
|
pw.Text(
|
|
'${config.reportTitle} - Periode ${dateShortFmt.format(startDate)} - ${dateShortFmt.format(endDate)}',
|
|
style: const pw.TextStyle(fontSize: 11, color: PdfColors.grey700),
|
|
),
|
|
pw.SizedBox(height: 8),
|
|
pw.Text(
|
|
'Jumlah Lampiran: ${pengeluaranWithAttachment.length} transaksi',
|
|
style: pw.TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: pw.FontWeight.bold,
|
|
color: PdfColors.grey900,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
|
|
// Detail Lampiran Tiap Transaksi Pengeluaran
|
|
for (int i = 0; i < pengeluaranWithAttachment.length; i++) {
|
|
final t = pengeluaranWithAttachment[i];
|
|
|
|
pw.Widget? fotoBarangWidget;
|
|
if (t.hasFotoBarang && File(t.fotoBarangPath!).existsSync()) {
|
|
try {
|
|
final bytes = File(t.fotoBarangPath!).readAsBytesSync();
|
|
final image = pw.MemoryImage(bytes);
|
|
fotoBarangWidget = pw.Container(
|
|
margin: const pw.EdgeInsets.only(top: 8, bottom: 14),
|
|
constraints: const pw.BoxConstraints(maxHeight: 250),
|
|
alignment: pw.Alignment.center,
|
|
child: pw.Image(image, fit: pw.BoxFit.contain),
|
|
);
|
|
} catch (e) {
|
|
fotoBarangWidget = pw.Text('(Gagal memuat foto barang: $e)');
|
|
}
|
|
}
|
|
|
|
pw.Widget? invoiceWidget;
|
|
if (t.hasInvoice && File(t.invoicePath!).existsSync()) {
|
|
if (t.invoiceType == 'image') {
|
|
try {
|
|
final bytes = File(t.invoicePath!).readAsBytesSync();
|
|
final image = pw.MemoryImage(bytes);
|
|
invoiceWidget = pw.Container(
|
|
margin: const pw.EdgeInsets.only(top: 8, bottom: 14),
|
|
constraints: const pw.BoxConstraints(maxHeight: 250),
|
|
alignment: pw.Alignment.center,
|
|
child: pw.Image(image, fit: pw.BoxFit.contain),
|
|
);
|
|
} catch (e) {
|
|
invoiceWidget = pw.Text('(Gagal memuat gambar invoice: $e)');
|
|
}
|
|
} else {
|
|
// PDF file
|
|
String? snapshotPath = t.invoiceSnapshotPath;
|
|
// Jika belum ada snapshot, coba render on-the-fly
|
|
if (snapshotPath == null || !File(snapshotPath).existsSync()) {
|
|
try {
|
|
final pdfFile = File(t.invoicePath!);
|
|
final targetPng =
|
|
'${pdfFile.parent.path}/snapshot_${t.id}_${pdfFile.uri.pathSegments.last}.png';
|
|
snapshotPath = await PdfSnapshotService.renderFirstPage(
|
|
pdfPath: t.invoicePath!,
|
|
outputPngPath: targetPng,
|
|
);
|
|
if (snapshotPath != null) {
|
|
t.invoiceSnapshotPath = snapshotPath;
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (snapshotPath != null && File(snapshotPath).existsSync()) {
|
|
try {
|
|
final bytes = File(snapshotPath).readAsBytesSync();
|
|
final image = pw.MemoryImage(bytes);
|
|
invoiceWidget = pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
pw.Container(
|
|
margin: const pw.EdgeInsets.only(top: 8, bottom: 8),
|
|
constraints: const pw.BoxConstraints(maxHeight: 250),
|
|
alignment: pw.Alignment.center,
|
|
child: pw.Image(image, fit: pw.BoxFit.contain),
|
|
),
|
|
pw.Container(
|
|
padding: const pw.EdgeInsets.symmetric(
|
|
horizontal: 8, vertical: 4),
|
|
decoration: pw.BoxDecoration(
|
|
color: headerGrey,
|
|
borderRadius:
|
|
const pw.BorderRadius.all(pw.Radius.circular(4)),
|
|
border: pw.Border.all(color: borderGrey, width: 0.5),
|
|
),
|
|
child: pw.Text(
|
|
'📄 Cuplikan Berkas PDF: ${t.invoiceName ?? "invoice.pdf"}',
|
|
style: pw.TextStyle(
|
|
fontSize: 8.5,
|
|
color: primaryGreen,
|
|
fontWeight: pw.FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
} catch (e) {
|
|
invoiceWidget = pw.Text('(Gagal memuat snapshot PDF: $e)');
|
|
}
|
|
} else {
|
|
// Fallback jika render snapshot tidak tersedia
|
|
invoiceWidget = pw.Container(
|
|
margin: const pw.EdgeInsets.only(top: 6, bottom: 10),
|
|
padding: const pw.EdgeInsets.all(12),
|
|
decoration: pw.BoxDecoration(
|
|
color: headerGrey,
|
|
borderRadius:
|
|
const pw.BorderRadius.all(pw.Radius.circular(6)),
|
|
border: pw.Border.all(color: borderGrey, width: 0.5),
|
|
),
|
|
child: pw.Row(
|
|
children: [
|
|
pw.Text(
|
|
'📄 Dokumen Lampiran Digital: ${t.invoiceName ?? "invoice.pdf"}',
|
|
style: pw.TextStyle(
|
|
fontWeight: pw.FontWeight.bold,
|
|
fontSize: 9.5,
|
|
color: primaryGreen,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
pdf.addPage(
|
|
pw.Page(
|
|
pageFormat: PdfPageFormat.a4,
|
|
margin: const pw.EdgeInsets.all(32),
|
|
build: (context) {
|
|
return pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
// Card info transaksi
|
|
pw.Container(
|
|
width: double.infinity,
|
|
padding: const pw.EdgeInsets.all(10),
|
|
decoration: pw.BoxDecoration(
|
|
color: lightGreen,
|
|
border: pw.Border(
|
|
left: pw.BorderSide(color: primaryGreen, width: 4),
|
|
top: pw.BorderSide(color: borderGrey, width: 0.5),
|
|
right: pw.BorderSide(color: borderGrey, width: 0.5),
|
|
bottom: pw.BorderSide(color: borderGrey, width: 0.5),
|
|
),
|
|
),
|
|
child: pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
pw.Text(
|
|
'Lampiran ${i + 1} dari ${pengeluaranWithAttachment.length}',
|
|
style: pw.TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: pw.FontWeight.bold,
|
|
color: primaryGreen,
|
|
),
|
|
),
|
|
pw.SizedBox(height: 4),
|
|
pw.Text(
|
|
'Uraian: ${t.description}',
|
|
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold),
|
|
),
|
|
pw.SizedBox(height: 2),
|
|
pw.Row(
|
|
children: [
|
|
pw.Text(
|
|
'Tanggal: ${dateShortFmt.format(t.date)}',
|
|
style: const pw.TextStyle(fontSize: 8.5),
|
|
),
|
|
pw.SizedBox(width: 16),
|
|
pw.Text(
|
|
'Pengeluaran: ${CurrencyFormat.formatRupiah(t.amount)}',
|
|
style: pw.TextStyle(
|
|
fontSize: 8.5,
|
|
fontWeight: pw.FontWeight.bold,
|
|
color: PdfColors.red800,
|
|
),
|
|
),
|
|
pw.SizedBox(width: 16),
|
|
pw.Text(
|
|
'Tujuan: ${t.counterpartyName}',
|
|
style: const pw.TextStyle(fontSize: 8.5),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
pw.SizedBox(height: 14),
|
|
|
|
// A. Foto Barang
|
|
if (fotoBarangWidget != null) ...[
|
|
pw.Text(
|
|
'A. Foto Fisik Barang',
|
|
style: pw.TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: pw.FontWeight.bold,
|
|
color: primaryGreen,
|
|
),
|
|
),
|
|
fotoBarangWidget,
|
|
],
|
|
|
|
// B. Invoice
|
|
if (invoiceWidget != null) ...[
|
|
pw.Text(
|
|
'B. Invoice / Bukti Pembayaran ${t.invoiceName != null ? "(${t.invoiceName})" : ""}',
|
|
style: pw.TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: pw.FontWeight.bold,
|
|
color: primaryGreen,
|
|
),
|
|
),
|
|
invoiceWidget,
|
|
],
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
final bytes = await pdf.save();
|
|
|
|
if (targetFile != null) {
|
|
await targetFile.writeAsBytes(bytes);
|
|
return targetFile;
|
|
}
|
|
|
|
// Simpan ke direktori dokumen lokal
|
|
final outputDir = await getApplicationDocumentsDirectory();
|
|
final fileName =
|
|
'Laporan_Keuangan_${dateShortFmt.format(startDate).replaceAll('/', '-')}_sd_${dateShortFmt.format(endDate).replaceAll('/', '-')}.pdf';
|
|
final file = File('${outputDir.path}/$fileName');
|
|
await file.writeAsBytes(bytes);
|
|
|
|
return file;
|
|
}
|
|
|
|
/// Membangun tabel transaksi dengan kolom yang presisi, link ke Firefly III,
|
|
/// dan baris TOTAL yang 100% sejajar dengan kolom tabel.
|
|
static pw.Table _buildTransactionTable({
|
|
required List<ReportTransaction> transactions,
|
|
required int startIndex,
|
|
required DateFormat dateShortFmt,
|
|
required PdfColor primaryGreen,
|
|
required PdfColor headerGrey,
|
|
required PdfColor borderGrey,
|
|
required bool isLastPage,
|
|
required double totalPemasukan,
|
|
required double totalPengeluaran,
|
|
required double finalSaldo,
|
|
required double initialRunningSaldo,
|
|
required String? fireflyBaseUrl,
|
|
}) {
|
|
final columnWidths = <int, pw.TableColumnWidth>{
|
|
0: const pw.FixedColumnWidth(22), // No
|
|
1: const pw.FixedColumnWidth(55), // Tanggal
|
|
2: const pw.FlexColumnWidth(2.2), // Uraian
|
|
3: const pw.FixedColumnWidth(70), // Pemasukan
|
|
4: const pw.FixedColumnWidth(70), // Pengeluaran
|
|
5: const pw.FixedColumnWidth(72), // Saldo
|
|
6: const pw.FlexColumnWidth(1.4), // Keterangan
|
|
};
|
|
|
|
final rows = <pw.TableRow>[];
|
|
|
|
// 1. Table Header Row
|
|
rows.add(
|
|
pw.TableRow(
|
|
decoration: pw.BoxDecoration(color: primaryGreen),
|
|
children: [
|
|
_headerCell('No', alignment: pw.Alignment.center),
|
|
_headerCell('Tanggal', alignment: pw.Alignment.center),
|
|
_headerCell('Uraian', alignment: pw.Alignment.centerLeft),
|
|
_headerCell('Pemasukan', alignment: pw.Alignment.centerRight),
|
|
_headerCell('Pengeluaran', alignment: pw.Alignment.centerRight),
|
|
_headerCell('Saldo', alignment: pw.Alignment.centerRight),
|
|
_headerCell('Keterangan', alignment: pw.Alignment.centerLeft),
|
|
],
|
|
),
|
|
);
|
|
|
|
// 2. Data Rows
|
|
double runningSaldo = initialRunningSaldo;
|
|
for (int i = 0; i < transactions.length; i++) {
|
|
final t = transactions[i];
|
|
final pemasukan = t.isPemasukan ? t.amount : 0.0;
|
|
final pengeluaran = t.isPengeluaran ? t.amount : 0.0;
|
|
runningSaldo += (pemasukan - pengeluaran);
|
|
|
|
final rowNum = startIndex + i + 1;
|
|
|
|
// Link ke Firefly III untuk kolom Keterangan
|
|
final txId = (t.transactionGroupId != null && t.transactionGroupId!.isNotEmpty)
|
|
? t.transactionGroupId!
|
|
: t.id;
|
|
final txUrl = (fireflyBaseUrl != null && fireflyBaseUrl.isNotEmpty && txId.isNotEmpty)
|
|
? '${fireflyBaseUrl.replaceAll(RegExp(r'/+$'), '')}/transactions/show/$txId'
|
|
: null;
|
|
|
|
pw.Widget counterpartyWidget = pw.Text(
|
|
t.counterpartyName,
|
|
style: pw.TextStyle(
|
|
fontSize: 8,
|
|
color: txUrl != null ? PdfColors.blue800 : PdfColors.black,
|
|
decoration: txUrl != null ? pw.TextDecoration.underline : pw.TextDecoration.none,
|
|
),
|
|
);
|
|
|
|
if (txUrl != null) {
|
|
counterpartyWidget = pw.UrlLink(
|
|
destination: txUrl,
|
|
child: counterpartyWidget,
|
|
);
|
|
}
|
|
|
|
rows.add(
|
|
pw.TableRow(
|
|
children: [
|
|
_dataCell('$rowNum', alignment: pw.Alignment.center),
|
|
_dataCell(dateShortFmt.format(t.date), alignment: pw.Alignment.center),
|
|
_dataCell(t.description, alignment: pw.Alignment.centerLeft),
|
|
_dataCell(pemasukan > 0 ? CurrencyFormat.formatRupiah(pemasukan) : '-',
|
|
alignment: pw.Alignment.centerRight),
|
|
_dataCell(pengeluaran > 0 ? CurrencyFormat.formatRupiah(pengeluaran) : '-',
|
|
alignment: pw.Alignment.centerRight),
|
|
_dataCell(CurrencyFormat.formatRupiah(runningSaldo),
|
|
alignment: pw.Alignment.centerRight),
|
|
_dataCellWidget(counterpartyWidget, alignment: pw.Alignment.centerLeft),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// 3. Baris TOTAL (Hanya pada lembar terakhir rekapitulasi transaksi)
|
|
// Ditempatkan langsung sebagai TableRow agar 100% presisi dan lurus dengan kolom di atasnya!
|
|
if (isLastPage) {
|
|
rows.add(
|
|
pw.TableRow(
|
|
decoration: pw.BoxDecoration(color: headerGrey),
|
|
children: [
|
|
_totalCell(''), // No
|
|
_totalCell(''), // Tanggal
|
|
_totalCell('TOTAL', alignment: pw.Alignment.centerRight, isBold: true, fontSize: 8.5),
|
|
_totalCell(CurrencyFormat.formatRupiah(totalPemasukan),
|
|
alignment: pw.Alignment.centerRight, isBold: true),
|
|
_totalCell(CurrencyFormat.formatRupiah(totalPengeluaran),
|
|
alignment: pw.Alignment.centerRight, isBold: true),
|
|
_totalCell(CurrencyFormat.formatRupiah(finalSaldo),
|
|
alignment: pw.Alignment.centerRight, isBold: true),
|
|
_totalCell(''), // Keterangan
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return pw.Table(
|
|
border: pw.TableBorder.all(color: borderGrey, width: 0.5),
|
|
columnWidths: columnWidths,
|
|
children: rows,
|
|
);
|
|
}
|
|
|
|
static pw.Widget _headerCell(String text, {required pw.Alignment alignment}) {
|
|
return pw.Container(
|
|
alignment: alignment,
|
|
padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
|
child: pw.Text(
|
|
text,
|
|
style: pw.TextStyle(
|
|
color: PdfColors.white,
|
|
fontWeight: pw.FontWeight.bold,
|
|
fontSize: 8.5,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
static pw.Widget _dataCell(String text, {required pw.Alignment alignment}) {
|
|
return pw.Container(
|
|
alignment: alignment,
|
|
padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 3.5),
|
|
child: pw.Text(
|
|
text,
|
|
style: const pw.TextStyle(fontSize: 8),
|
|
),
|
|
);
|
|
}
|
|
|
|
static pw.Widget _dataCellWidget(pw.Widget child, {required pw.Alignment alignment}) {
|
|
return pw.Container(
|
|
alignment: alignment,
|
|
padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 3.5),
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
static pw.Widget _totalCell(
|
|
String text, {
|
|
pw.Alignment alignment = pw.Alignment.center,
|
|
bool isBold = false,
|
|
double fontSize = 8,
|
|
}) {
|
|
return pw.Container(
|
|
alignment: alignment,
|
|
padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
|
child: pw.Text(
|
|
text,
|
|
style: pw.TextStyle(
|
|
fontSize: fontSize,
|
|
fontWeight: isBold ? pw.FontWeight.bold : pw.FontWeight.normal,
|
|
color: PdfColors.black,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Membangun blok tanda tangan 4 pihak dalam formasi grid 2x2
|
|
static pw.Widget _buildSignatureBlock(
|
|
ReportSignatureConfig config,
|
|
DateTime endDate,
|
|
String Function(DateTime) formatIndonesianDate,
|
|
) {
|
|
return pw.Container(
|
|
margin: const pw.EdgeInsets.only(top: 6),
|
|
child: pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
// Baris 1: Pelaksana UPJ (Kiri) & Pembuat Laporan (Kanan)
|
|
pw.Row(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
// Kiri Atas: Pelaksana UPJ Mekatronika (Mengetahui)
|
|
pw.Expanded(
|
|
child: pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
pw.Text(
|
|
config.signatory1Role,
|
|
style: const pw.TextStyle(fontSize: 8.5),
|
|
),
|
|
pw.SizedBox(height: 38),
|
|
pw.Text(
|
|
config.signatory1Name,
|
|
style: pw.TextStyle(
|
|
fontSize: 9,
|
|
fontWeight: pw.FontWeight.bold,
|
|
decoration: pw.TextDecoration.underline,
|
|
),
|
|
),
|
|
if (config.signatory1Nip.isNotEmpty)
|
|
pw.Text(
|
|
config.signatory1Nip.startsWith('N')
|
|
? config.signatory1Nip
|
|
: 'NIP. ${config.signatory1Nip}',
|
|
style: const pw.TextStyle(fontSize: 8),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Kanan Atas: Dibuat oleh (dengan Kota & Tanggal)
|
|
pw.Expanded(
|
|
child: pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
|
children: [
|
|
pw.Container(
|
|
width: 200,
|
|
child: pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
pw.Text(
|
|
'${config.city}, ${formatIndonesianDate(endDate)}',
|
|
style: const pw.TextStyle(fontSize: 8.5),
|
|
),
|
|
pw.SizedBox(height: 2),
|
|
pw.Text(
|
|
config.signatory2Role,
|
|
style: const pw.TextStyle(fontSize: 8.5),
|
|
),
|
|
pw.SizedBox(height: 38),
|
|
pw.Text(
|
|
config.signatory2Name,
|
|
style: pw.TextStyle(
|
|
fontSize: 9,
|
|
fontWeight: pw.FontWeight.bold,
|
|
decoration: pw.TextDecoration.underline,
|
|
),
|
|
),
|
|
if (config.signatory2Nip.isNotEmpty)
|
|
pw.Text(
|
|
config.signatory2Nip.startsWith('N')
|
|
? config.signatory2Nip
|
|
: 'NIP. ${config.signatory2Nip}',
|
|
style: const pw.TextStyle(fontSize: 8),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
pw.SizedBox(height: 18),
|
|
// Baris 2: Koordinator UPJ (Kiri) & KPA BLUD SMKN 4 Malang (Kanan)
|
|
pw.Row(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
// Kiri Bawah: Koordinator UPJ
|
|
pw.Expanded(
|
|
child: pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
pw.Text(
|
|
config.signatory3Role,
|
|
style: const pw.TextStyle(fontSize: 8.5),
|
|
),
|
|
pw.SizedBox(height: 38),
|
|
pw.Text(
|
|
config.signatory3Name,
|
|
style: pw.TextStyle(
|
|
fontSize: 9,
|
|
fontWeight: pw.FontWeight.bold,
|
|
decoration: pw.TextDecoration.underline,
|
|
),
|
|
),
|
|
if (config.signatory3Nip.isNotEmpty)
|
|
pw.Text(
|
|
config.signatory3Nip.startsWith('N')
|
|
? config.signatory3Nip
|
|
: 'NIP. ${config.signatory3Nip}',
|
|
style: const pw.TextStyle(fontSize: 8),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Kanan Bawah: KPA BLUD SMKN 4 Malang
|
|
pw.Expanded(
|
|
child: pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
|
children: [
|
|
pw.Container(
|
|
width: 200,
|
|
child: pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
pw.Text(
|
|
config.signatory4Role,
|
|
style: const pw.TextStyle(fontSize: 8.5),
|
|
),
|
|
pw.SizedBox(height: 38),
|
|
pw.Text(
|
|
config.signatory4Name,
|
|
style: pw.TextStyle(
|
|
fontSize: 9,
|
|
fontWeight: pw.FontWeight.bold,
|
|
decoration: pw.TextDecoration.underline,
|
|
),
|
|
),
|
|
if (config.signatory4Nip.isNotEmpty)
|
|
pw.Text(
|
|
config.signatory4Nip.startsWith('N')
|
|
? config.signatory4Nip
|
|
: 'NIP. ${config.signatory4Nip}',
|
|
style: const pw.TextStyle(fontSize: 8),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|