From 2c75fcf582a77f830ac460bd98c2f9f89e2a673e Mon Sep 17 00:00:00 2001 From: efrilm Date: Fri, 15 Aug 2025 01:36:50 +0700 Subject: [PATCH] feat: inventory report pdf --- lib/core/utils/inventory_report.dart | 568 ++++++++++++++++++ lib/core/utils/transaction_report.dart | 2 +- .../widgets/inventory_report_widget.dart | 38 +- 3 files changed, 606 insertions(+), 2 deletions(-) create mode 100644 lib/core/utils/inventory_report.dart diff --git a/lib/core/utils/inventory_report.dart b/lib/core/utils/inventory_report.dart new file mode 100644 index 0000000..9bc65b7 --- /dev/null +++ b/lib/core/utils/inventory_report.dart @@ -0,0 +1,568 @@ +import 'dart:io'; + +import 'package:enaklo_pos/core/utils/helper_pdf_service.dart'; +import 'package:enaklo_pos/data/datasources/outlet_local_datasource.dart'; +import 'package:enaklo_pos/data/models/response/inventory_analytic_response_model.dart'; +import 'package:flutter/services.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; + +class InventoryReport { + static final primaryColor = PdfColor.fromHex("36175e"); + + static Future previewPdf({ + required String searchDateFormatted, + required InventoryAnalyticData? inventory, + }) async { + final pdf = pw.Document(); + final ByteData dataImage = await rootBundle.load('assets/logo/logo.png'); + final Uint8List bytes = dataImage.buffer.asUint8List(); + final outlet = await OutletLocalDatasource().get(); + + final image = pw.MemoryImage(bytes); + pdf.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: pw.EdgeInsets.zero, + build: (pw.Context context) { + return [ + pw.Container( + padding: pw.EdgeInsets.all(20), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + // Bagian kiri - Logo dan Info Perusahaan + pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + // Icon/Logo placeholder (bisa diganti dengan gambar logo) + pw.Container( + width: 40, + height: 40, + child: pw.Image(image), + ), + pw.SizedBox(width: 15), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'Apskel', + style: pw.TextStyle( + fontSize: 28, + fontWeight: pw.FontWeight.bold, + color: primaryColor, + ), + ), + pw.SizedBox(height: 4), + pw.Text( + outlet.name ?? "", + style: pw.TextStyle( + fontSize: 16, + color: PdfColors.grey700, + ), + ), + pw.SizedBox(height: 2), + pw.Text( + outlet.address ?? "", + style: pw.TextStyle( + fontSize: 12, + color: PdfColors.grey600, + ), + ), + ], + ), + ], + ), + // Bagian kanan - Info Laporan + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.Text( + 'Laporan Transaksi', + style: pw.TextStyle( + fontSize: 24, + fontWeight: pw.FontWeight.bold, + color: PdfColors.grey800, + ), + ), + pw.SizedBox(height: 8), + pw.Text( + searchDateFormatted, + style: pw.TextStyle( + fontSize: 14, + color: PdfColors.grey600, + ), + ), + pw.SizedBox(height: 4), + pw.Text( + 'Laporan', + style: pw.TextStyle( + fontSize: 12, + color: PdfColors.grey500, + ), + ), + ], + ), + ], + ), + ), + pw.Container( + width: double.infinity, + height: 3, + color: primaryColor, + ), + + // Summary + pw.Container( + padding: pw.EdgeInsets.all(20), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + _buildSectionWidget('1. Ringkasan'), + pw.SizedBox(height: 30), + pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Expanded( + flex: 1, + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + _buildSummaryItem( + 'Total Item', + (inventory?.summary.totalProducts ?? 0) + .toString(), + ), + _buildSummaryItem( + 'Total Item Masuk', + (inventory?.products.fold( + 0, + (sum, item) => + sum + (item.totalIn)) ?? + 0) + .toString(), + ), + _buildSummaryItem( + 'Total Item Keluar', + (inventory?.products.fold(0, + (sum, item) => sum + (item.totalOut))) + .toString(), + ), + ], + ), + ), + pw.SizedBox(width: 20), + pw.Expanded( + flex: 1, + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + _buildSummaryItem( + 'Total Ingredient', + (inventory?.summary.totalIngredients ?? 0) + .toString(), + ), + _buildSummaryItem( + 'Total Ingredient Masuk', + (inventory?.ingredients.fold( + 0, + (sum, item) => + sum + (item.totalIn)) ?? + 0) + .toString(), + ), + _buildSummaryItem( + 'Total Ingredient Keluar', + (inventory?.ingredients.fold( + 0, + (sum, item) => + sum + (item.totalOut)) ?? + 0) + .toString(), + ), + ], + ), + ), + ], + ), + ], + ), + ), + + // Summary Item + pw.Container( + padding: pw.EdgeInsets.all(20), + child: pw.Column( + children: [ + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + _buildSectionWidget('2. Item'), + pw.SizedBox(height: 30), + pw.Container( + decoration: pw.BoxDecoration( + color: primaryColor, // Purple color + borderRadius: pw.BorderRadius.only( + topLeft: pw.Radius.circular(8), + topRight: pw.Radius.circular(8), + ), + ), + child: pw.Table( + columnWidths: const { + 0: pw.FlexColumnWidth(2.5), // Produk + 1: pw.FlexColumnWidth(2), // Kategori + 2: pw.FlexColumnWidth(1), // Stock + 3: pw.FlexColumnWidth(2), // Masuk + 4: pw.FlexColumnWidth(2), // Keluar + }, + children: [ + pw.TableRow( + children: [ + _buildHeaderCell('Nama'), + _buildHeaderCell('Kategori'), + _buildHeaderCell('Stock'), + _buildHeaderCell('Masuk'), + _buildHeaderCell('Keluar'), + ], + ), + ], + ), + ), + pw.Container( + decoration: pw.BoxDecoration( + color: PdfColors.white, + ), + child: pw.Table( + columnWidths: { + 0: pw.FlexColumnWidth(2.5), // Produk + 1: pw.FlexColumnWidth(2), // Kategori + 2: pw.FlexColumnWidth(1), // Stock + 3: pw.FlexColumnWidth(2), // Masuk + 4: pw.FlexColumnWidth(2), // Keluar + }, + children: inventory?.products + .map((item) => _buildProductDataRow( + item, + inventory.products.indexOf(item) % + 2 == + 0, + )) + .toList() ?? + [], + ), + ), + pw.Container( + decoration: pw.BoxDecoration( + color: primaryColor, // Purple color + borderRadius: pw.BorderRadius.only( + bottomLeft: pw.Radius.circular(8), + bottomRight: pw.Radius.circular(8), + ), + ), + child: pw.Table( + columnWidths: const { + 0: pw.FlexColumnWidth(2.5), // Produk + 1: pw.FlexColumnWidth(2), // Kategori + 2: pw.FlexColumnWidth(1), // Stock + 3: pw.FlexColumnWidth(2), // Masuk + 4: pw.FlexColumnWidth(2), // Keluar + }, + children: [ + pw.TableRow( + children: [ + _buildTotalCell('TOTAL'), + _buildTotalCell(''), + _buildTotalCell( + (inventory?.products.fold( + 0, + (sum, item) => + sum + (item.quantity)) ?? + 0) + .toString(), + ), + _buildTotalCell( + (inventory?.products.fold( + 0, + (sum, item) => + sum + (item.totalIn)) ?? + 0) + .toString(), + ), + _buildTotalCell( + (inventory?.products.fold( + 0, + (sum, item) => + sum + (item.totalOut)) ?? + 0) + .toString(), + ), + ], + ), + ], + ), + ), + ], + ), + ], + ), + ), + + // Summary Ingredient + pw.Container( + padding: pw.EdgeInsets.all(20), + child: pw.Column( + children: [ + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + _buildSectionWidget('3. Ingredient'), + pw.SizedBox(height: 30), + pw.Container( + decoration: pw.BoxDecoration( + color: primaryColor, // Purple color + borderRadius: pw.BorderRadius.only( + topLeft: pw.Radius.circular(8), + topRight: pw.Radius.circular(8), + ), + ), + child: pw.Table( + columnWidths: const { + 0: pw.FlexColumnWidth(2.5), // Name + 1: pw.FlexColumnWidth(1), // Stock + 2: pw.FlexColumnWidth(2), // Masuk + 3: pw.FlexColumnWidth(2), // Keluar + }, + children: [ + pw.TableRow( + children: [ + _buildHeaderCell('Nama'), + _buildHeaderCell('Stock'), + _buildHeaderCell('Masuk'), + _buildHeaderCell('Keluar'), + ], + ), + ], + ), + ), + pw.Container( + decoration: pw.BoxDecoration( + color: PdfColors.white, + ), + child: pw.Table( + columnWidths: { + 0: pw.FlexColumnWidth(2.5), // Name + 1: pw.FlexColumnWidth(1), // Stock + 2: pw.FlexColumnWidth(2), // Masuk + 3: pw.FlexColumnWidth(2), // Keluar + }, + children: inventory?.ingredients + .map((item) => _buildIngredientsDataRow( + item, + inventory.ingredients.indexOf(item) % + 2 == + 0, + )) + .toList() ?? + [], + ), + ), + pw.Container( + decoration: pw.BoxDecoration( + color: primaryColor, // Purple color + borderRadius: pw.BorderRadius.only( + bottomLeft: pw.Radius.circular(8), + bottomRight: pw.Radius.circular(8), + ), + ), + child: pw.Table( + columnWidths: const { + 0: pw.FlexColumnWidth(2.5), // Name + 1: pw.FlexColumnWidth(1), // Stock + 2: pw.FlexColumnWidth(2), // Masuk + 3: pw.FlexColumnWidth(2), // Keluar + }, + children: [ + pw.TableRow( + children: [ + _buildTotalCell('TOTAL'), + _buildTotalCell( + (inventory?.ingredients.fold( + 0, + (sum, item) => + sum + (item.quantity)) ?? + 0) + .toString(), + ), + _buildTotalCell( + (inventory?.ingredients.fold( + 0, + (sum, item) => + sum + (item.totalIn)) ?? + 0) + .toString(), + ), + _buildTotalCell( + (inventory?.ingredients.fold( + 0, + (sum, item) => + sum + (item.totalOut)) ?? + 0) + .toString(), + ), + ], + ), + ], + ), + ), + ], + ), + ], + ), + ), + ]; + }), + ); + + return HelperPdfService.saveDocument( + name: + 'Apskel POS | Inventory Report | ${DateTime.now().millisecondsSinceEpoch}.pdf', + pdf: pdf); + } + + static pw.Widget _buildSectionWidget(String title) { + return pw.Text( + title, + style: pw.TextStyle( + fontSize: 20, + fontWeight: pw.FontWeight.bold, + color: primaryColor, + ), + ); + } + + static pw.Widget _buildSummaryItem( + String label, + String value, { + pw.TextStyle? valueStyle, + pw.TextStyle? labelStyle, + }) { + return pw.Container( + padding: pw.EdgeInsets.only(bottom: 8), + margin: pw.EdgeInsets.only(bottom: 16), + decoration: pw.BoxDecoration( + border: pw.Border( + bottom: pw.BorderSide( + color: PdfColors.grey300, + ), + ), + ), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text(label, style: labelStyle), + pw.Text( + value, + style: valueStyle ?? + pw.TextStyle( + fontWeight: pw.FontWeight.bold, + ), + ), + ], + ), + ); + } + + static pw.Widget _buildHeaderCell(String text) { + return pw.Container( + padding: pw.EdgeInsets.symmetric(horizontal: 12, vertical: 16), + child: pw.Text( + text, + style: pw.TextStyle( + color: PdfColors.white, + fontWeight: pw.FontWeight.bold, + fontSize: 12, + ), + textAlign: pw.TextAlign.center, + ), + ); + } + + static pw.Widget _buildDataCell(String text, + {pw.Alignment alignment = pw.Alignment.center, PdfColor? textColor}) { + return pw.Container( + padding: pw.EdgeInsets.symmetric(horizontal: 12, vertical: 16), + alignment: alignment, + child: pw.Text( + text, + style: pw.TextStyle( + fontSize: 12, + color: textColor ?? PdfColors.black, + fontWeight: pw.FontWeight.normal, + ), + textAlign: alignment == pw.Alignment.centerLeft + ? pw.TextAlign.left + : pw.TextAlign.center, + ), + ); + } + + static pw.Widget _buildTotalCell(String text) { + return pw.Container( + padding: pw.EdgeInsets.symmetric(horizontal: 12, vertical: 16), + child: pw.Text( + text, + style: pw.TextStyle( + color: PdfColors.white, + fontWeight: pw.FontWeight.bold, + fontSize: 12, + ), + textAlign: pw.TextAlign.center, + ), + ); + } + + static pw.TableRow _buildProductDataRow( + InventoryProductItem product, bool isEven) { + return pw.TableRow( + decoration: pw.BoxDecoration( + color: product.isZeroStock + ? PdfColors.red100 + : product.isLowStock + ? PdfColors.yellow100 + : isEven + ? PdfColors.grey50 + : PdfColors.white, + ), + children: [ + _buildDataCell(product.productName, alignment: pw.Alignment.centerLeft), + _buildDataCell(product.categoryName, + alignment: pw.Alignment.centerLeft), + _buildDataCell(product.quantity.toString()), + _buildDataCell(product.totalIn.toString()), + _buildDataCell(product.totalOut.toString()), + ], + ); + } + + static pw.TableRow _buildIngredientsDataRow( + InventoryIngredientItem item, bool isEven) { + return pw.TableRow( + decoration: pw.BoxDecoration( + color: item.isZeroStock + ? PdfColors.red100 + : item.isLowStock + ? PdfColors.yellow100 + : isEven + ? PdfColors.grey50 + : PdfColors.white, + ), + children: [ + _buildDataCell(item.ingredientName, alignment: pw.Alignment.centerLeft), + _buildDataCell(item.quantity.toString()), + _buildDataCell(item.totalIn.toString()), + _buildDataCell(item.totalOut.toString()), + ], + ); + } +} diff --git a/lib/core/utils/transaction_report.dart b/lib/core/utils/transaction_report.dart index 4567cc8..ec0c73e 100644 --- a/lib/core/utils/transaction_report.dart +++ b/lib/core/utils/transaction_report.dart @@ -187,7 +187,7 @@ class TransactionReport { .currencyFormatRpV2, ), _buildSummaryItem( - 'Total Total Terjual', + 'Total Terjual', (profitLossData?.summary.totalOrders ?? 0) .toString(), ), diff --git a/lib/presentation/report/widgets/inventory_report_widget.dart b/lib/presentation/report/widgets/inventory_report_widget.dart index 3be1d44..a96ada1 100644 --- a/lib/presentation/report/widgets/inventory_report_widget.dart +++ b/lib/presentation/report/widgets/inventory_report_widget.dart @@ -1,5 +1,10 @@ +import 'dart:developer'; + import 'package:enaklo_pos/core/constants/colors.dart'; import 'package:enaklo_pos/core/extensions/string_ext.dart'; +import 'package:enaklo_pos/core/utils/helper_pdf_service.dart'; +import 'package:enaklo_pos/core/utils/inventory_report.dart'; +import 'package:enaklo_pos/core/utils/permession_handler.dart'; import 'package:enaklo_pos/data/models/response/inventory_analytic_response_model.dart'; import 'package:flutter/material.dart'; @@ -73,7 +78,38 @@ class _InventoryReportWidgetState extends State { children: [ // Download Button GestureDetector( - onTap: () {}, + onTap: () async { + try { + final status = + await PermessionHelper().checkPermission(); + if (status) { + final pdfFile = + await InventoryReport.previewPdf( + searchDateFormatted: + widget.searchDateFormatted, + inventory: widget.inventory, + ); + log("pdfFile: $pdfFile"); + await HelperPdfService.openFile(pdfFile); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Storage permission is required to save PDF'), + backgroundColor: Colors.red, + ), + ); + } + } catch (e) { + log("Error generating PDF: $e"); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to generate PDF: $e'), + backgroundColor: Colors.red, + ), + ); + } + }, child: Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration(