From b0ed5950c2d75e7da6ed757e023bb2f9c729c431 Mon Sep 17 00:00:00 2001 From: rhanarmt Date: Thu, 14 May 2026 21:59:17 +0700 Subject: [PATCH] memperbarui beberapa fitur --- .../dashboard/dashboard_controller.dart | 74 +- lib/screens/dashboard/dashboard_page.dart | 160 +-- lib/screens/products/product_list_page.dart | 597 +++++++--- lib/screens/reports/report_controller.dart | 96 +- lib/screens/reports/report_page.dart | 1060 +++++++++++++---- lib/services/ml_service.dart | 2 +- pubspec.lock | 80 ++ pubspec.yaml | 1 + 8 files changed, 1550 insertions(+), 520 deletions(-) diff --git a/lib/screens/dashboard/dashboard_controller.dart b/lib/screens/dashboard/dashboard_controller.dart index 8cc689e..4edb137 100644 --- a/lib/screens/dashboard/dashboard_controller.dart +++ b/lib/screens/dashboard/dashboard_controller.dart @@ -38,13 +38,11 @@ class DashboardController extends ChangeNotifier { MLService.getBahanDigunakanHariIni(), MLService.getDashboardSummary(), MLService.getProducts(), - MLService.getReportCritical(), ]); final bahanDigunakan = results[0] as Map; final summary = results[1] as Map; final products = results[2] as List; - final critical = results[3] as Map; _applyBahanDigunakanHariIni(bahanDigunakan); @@ -56,18 +54,13 @@ class DashboardController extends ChangeNotifier { } totalProduk = products.length; - - if (critical['status'] == true) { - _applyLowStockItems(critical['data']); - } else { - _applyDummyLowStock(); - } + _applyLowStockProducts(products); } catch (e) { errorMessage = 'Gagal memuat dashboard: $e'; bahanDigunakanError = 'Gagal memuat bahan digunakan hari ini'; _resetBahanDigunakanHariIni(); _applyDummyPenggunaan(); - _applyDummyLowStock(); + lowStockItems.clear(); } finally { isLoading = false; isBahanDigunakanLoading = false; @@ -109,25 +102,31 @@ class DashboardController extends ChangeNotifier { } } - void _applyLowStockItems(dynamic data) { + void _applyLowStockProducts(List products) { lowStockItems.clear(); - if (data is! List || data.isEmpty) { - _applyDummyLowStock(); - return; - } - for (final item in data) { - final stockValue = StockStatusUtils.parseStock(item['stok']); + for (final item in products) { + if (item is! Map) continue; + + final stockValue = StockStatusUtils.parseStock(item['current_stock']); final statusKey = StockStatusUtils.statusFromStock(stockValue); - final unit = item['unit'] ?? 'kg'; - final stockLabel = item['stok']?.toString() ?? '0'; + if (statusKey != StockStatusUtils.statusKritis) continue; + + final category = item['category']?.toString().toLowerCase() ?? ''; + final unit = + item['unit']?.toString() ?? (category == 'barang' ? 'pcs' : 'kg'); lowStockItems.add({ - 'name': item['nama_bahan']?.toString() ?? '-', - 'stock': '$stockLabel $unit', + 'name': item['name']?.toString() ?? '-', + 'stock': '${_formatStock(stockValue)} $unit', + 'statusKey': statusKey, 'status': StockStatusUtils.label(statusKey), 'statusColor': StockStatusUtils.color(statusKey), }); } + + lowStockItems.sort( + (a, b) => a['name'].toString().compareTo(b['name'].toString()), + ); } void _applyDummyPenggunaan() { @@ -142,34 +141,11 @@ class DashboardController extends ChangeNotifier { ]); } - void _applyDummyLowStock() { - lowStockItems - ..clear() - ..addAll([ - { - 'name': 'Tepung Terigu', - 'stock': '5 kg', - 'status': StockStatusUtils.label(StockStatusUtils.statusFromStock(5)), - 'statusColor': StockStatusUtils.color( - StockStatusUtils.statusFromStock(5), - ), - }, - { - 'name': 'Gula Pasir', - 'stock': '8 kg', - 'status': StockStatusUtils.label(StockStatusUtils.statusFromStock(8)), - 'statusColor': StockStatusUtils.color( - StockStatusUtils.statusFromStock(8), - ), - }, - { - 'name': 'Mentega', - 'stock': '3 kg', - 'status': StockStatusUtils.label(StockStatusUtils.statusFromStock(3)), - 'statusColor': StockStatusUtils.color( - StockStatusUtils.statusFromStock(3), - ), - }, - ]); + String _formatStock(double value) { + if (value % 1 == 0) { + return value.toInt().toString(); + } + + return value.toStringAsFixed(1); } } diff --git a/lib/screens/dashboard/dashboard_page.dart b/lib/screens/dashboard/dashboard_page.dart index 355210f..944e0ee 100644 --- a/lib/screens/dashboard/dashboard_page.dart +++ b/lib/screens/dashboard/dashboard_page.dart @@ -90,32 +90,36 @@ class _DashboardScreenState extends State with RouteAware { padding: EdgeInsets.zero, ), ), - Positioned( - right: 4, - top: 4, - child: Container( - width: 22, - height: 22, - decoration: BoxDecoration( - color: AppColors.statusError, - borderRadius: BorderRadius.circular(11), - border: Border.all( - color: AppColors.primaryBrown, - width: 2, + if (_controller.lowStockItems.isNotEmpty) + Positioned( + right: 4, + top: 4, + child: Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: AppColors.statusError, + borderRadius: BorderRadius.circular( + 11, + ), + border: Border.all( + color: AppColors.primaryBrown, + width: 2, + ), ), - ), - child: const Center( - child: Text( - '3', - style: TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.bold, + child: Center( + child: Text( + _controller.lowStockItems.length + .toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), ), ), ), ), - ), ], ), ], @@ -278,58 +282,72 @@ class _DashboardScreenState extends State with RouteAware { ], ), const SizedBox(height: 16), - ..._controller.lowStockItems.map((item) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - item['name'], - style: AppTextStyles.labelLarge - .copyWith( - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 4), - Text( - 'Stok: ${item['stock']}', - style: AppTextStyles.bodySmall - .copyWith( - color: AppColors.textTertiary, - ), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: item['statusColor'], - borderRadius: BorderRadius.circular(6), - ), - child: Text( - item['status'], - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.white, + if (_controller.isLoading) + const Center(child: CircularProgressIndicator()) + else if (_controller.lowStockItems.isEmpty) + Text( + 'Tidak ada stok kritis', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textTertiary, + ), + ) + else + ..._controller.lowStockItems.map((item) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + item['name'], + style: AppTextStyles.labelLarge + .copyWith( + color: + AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Stok: ${item['stock']}', + style: AppTextStyles.bodySmall + .copyWith( + color: + AppColors.textTertiary, + ), + ), + ], ), ), - ), - ], - ), - ); - }), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: item['statusColor'], + borderRadius: BorderRadius.circular( + 6, + ), + ), + child: Text( + item['status'], + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ], + ), + ); + }), ], ), ), diff --git a/lib/screens/products/product_list_page.dart b/lib/screens/products/product_list_page.dart index f6b5026..0a32563 100644 --- a/lib/screens/products/product_list_page.dart +++ b/lib/screens/products/product_list_page.dart @@ -78,7 +78,7 @@ class _ProductListScreenState extends State with RouteAware { width: 40, height: 40, decoration: BoxDecoration( - color: Colors.white.withOpacity(0.3), + color: Colors.white.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(10), ), child: const Icon( @@ -363,202 +363,481 @@ class _ProductListScreenState extends State with RouteAware { final maxStock = _controller.maxStock; final stockPercentage = (product.stock / maxStock * 100).toInt(); - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(16), - boxShadow: [AppColors.shadowLight], - ), + return InkWell( + onTap: () => _showProductDetail(product), + borderRadius: BorderRadius.circular(16), child: Column( children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: _controller - .getStatusColor(product.status) - .withOpacity(0.12), - borderRadius: BorderRadius.circular(14), - ), - child: Icon( - _controller.getCategoryIcon(product.category), - color: _controller.getStatusColor(product.status), - size: 28, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.bgWhite, + borderRadius: BorderRadius.circular(16), + boxShadow: [AppColors.shadowLight], + ), + child: Column( + children: [ + Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - product.name, - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - fontWeight: FontWeight.w700, + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: _controller + .getStatusColor(product.status) + .withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + _controller.getCategoryIcon(product.category), + color: _controller.getStatusColor(product.status), + size: 28, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 6), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: AppColors.primaryBrown.withOpacity(0.1), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - product.category, - style: AppTextStyles.labelSmall.copyWith( - color: AppColors.primaryBrown, - fontWeight: FontWeight.w600, + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.name, + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - ), - const SizedBox(width: 8), - Text( - _controller.formatPrice(product.price), - style: AppTextStyles.labelSmall.copyWith( - color: AppColors.textSecondary, - fontWeight: FontWeight.w600, + const SizedBox(height: 6), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: AppColors.primaryBrown.withValues( + alpha: 0.1, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + product.category, + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.primaryBrown, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + Text( + _controller.formatPrice(product.price), + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ], ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: _controller + .getStatusColor(product.status) + .withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _controller + .getStatusColor(product.status) + .withValues(alpha: 0.3), + width: 1, ), - ], + ), + child: Text( + _controller.getStatusLabel(product.status), + style: AppTextStyles.labelSmall.copyWith( + color: _controller.getStatusColor(product.status), + fontWeight: FontWeight.w700, + ), + ), ), ], ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: _controller - .getStatusColor(product.status) - .withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: _controller - .getStatusColor(product.status) - .withOpacity(0.3), - width: 1, - ), - ), - child: Text( - _controller.getStatusLabel(product.status), - style: AppTextStyles.labelSmall.copyWith( - color: _controller.getStatusColor(product.status), - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ), - const SizedBox(height: 12), - Container(height: 1, color: AppColors.grey200), - const SizedBox(height: 12), - Row( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Stok Tersedia', - style: AppTextStyles.labelSmall.copyWith( - color: AppColors.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 4), - Text( - '${product.stock} ${product.unit}', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: 12), + Container(height: 1, color: AppColors.grey200), + const SizedBox(height: 12), + Row( children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Kapasitas', + 'Stok Tersedia', style: AppTextStyles.labelSmall.copyWith( color: AppColors.textSecondary, fontWeight: FontWeight.w500, ), ), + const SizedBox(height: 4), Text( - '$stockPercentage%', - style: AppTextStyles.labelSmall.copyWith( + '${product.stock} ${product.unit}', + style: AppTextStyles.labelLarge.copyWith( color: AppColors.textPrimary, fontWeight: FontWeight.w700, ), ), ], ), - const SizedBox(height: 6), - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: LinearProgressIndicator( - value: product.stock / maxStock, - minHeight: 6, - backgroundColor: AppColors.grey200, - valueColor: AlwaysStoppedAnimation( - _controller.getStatusColor(product.status), - ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Kapasitas', + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + Text( + '$stockPercentage%', + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: product.stock / maxStock, + minHeight: 6, + backgroundColor: AppColors.grey200, + valueColor: AlwaysStoppedAnimation( + _controller.getStatusColor(product.status), + ), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.primaryBrown.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.info_outline_rounded, + color: AppColors.primaryBrown, + size: 20, ), ), ], ), - ), - const SizedBox(width: 12), - GestureDetector( - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('${product.name} - Detail'), - duration: const Duration(seconds: 1), - ), - ); - }, - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: AppColors.primaryBrown.withOpacity(0.1), - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - Icons.arrow_forward_ios, - color: AppColors.primaryBrown, - size: 16, - ), - ), - ), - ], + ], + ), ), ], ), ); } + void _showProductDetail(Product product) { + final maxStock = _controller.maxStock; + final stockPercentage = + maxStock == 0 ? 0 : (product.stock / maxStock * 100).round(); + final statusColor = _controller.getStatusColor(product.status); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return Container( + margin: const EdgeInsets.all(12), + padding: EdgeInsets.only( + left: 18, + right: 18, + top: 12, + bottom: 18 + MediaQuery.of(context).padding.bottom, + ), + decoration: BoxDecoration( + color: AppColors.bgWhite, + borderRadius: BorderRadius.circular(18), + boxShadow: [AppColors.shadowMedium], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.grey200, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + const SizedBox(height: 18), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + _controller.getCategoryIcon(product.category), + color: statusColor, + size: 28, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.name, + style: AppTextStyles.headlineSmall.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + 'ID Produk: ${product.id}', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textTertiary, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _controller.getStatusLabel(product.status), + style: AppTextStyles.labelSmall.copyWith( + color: statusColor, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 18), + Container(height: 1, color: AppColors.grey200), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _buildDetailTile( + icon: Icons.category_outlined, + label: 'Kategori', + value: product.category, + ), + _buildDetailTile( + icon: Icons.sell_outlined, + label: 'Harga', + value: _formatRupiah(product.price), + ), + _buildDetailTile( + icon: Icons.inventory_2_outlined, + label: 'Stok', + value: '${product.stock} ${product.unit}', + ), + _buildDetailTile( + icon: Icons.straighten_outlined, + label: 'Satuan', + value: product.unit, + ), + _buildDetailTile( + icon: Icons.speed_outlined, + label: 'Kapasitas', + value: '$stockPercentage%', + ), + _buildDetailTile( + icon: Icons.verified_outlined, + label: 'Status', + value: _controller.getStatusLabel(product.status), + ), + ], + ), + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LinearProgressIndicator( + value: maxStock == 0 ? 0 : product.stock / maxStock, + minHeight: 8, + backgroundColor: AppColors.grey200, + valueColor: AlwaysStoppedAnimation(statusColor), + ), + ), + const SizedBox(height: 14), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: statusColor.withValues(alpha: 0.18), + ), + ), + child: Text( + _stockRecommendation(product), + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close_rounded), + label: const Text('Tutup'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: () { + Navigator.of(context).pop(); + Navigator.of(context).pushNamed('/transaction'); + }, + icon: const Icon(Icons.add_shopping_cart_rounded), + label: const Text('Tambah Stok'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.statusSuccess, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ], + ), + ); + }, + ); + } + + Widget _buildDetailTile({ + required IconData icon, + required String label, + required String value, + }) { + return SizedBox( + width: (MediaQuery.of(context).size.width - 60) / 2, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.bgLight, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.grey200), + ), + child: Row( + children: [ + Icon(icon, size: 18, color: AppColors.primaryBrown), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.textTertiary, + ), + ), + const SizedBox(height: 2), + Text( + value, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + String _formatRupiah(int price) { + final text = price.toString(); + final buffer = StringBuffer(); + for (var i = 0; i < text.length; i++) { + final reverseIndex = text.length - i; + buffer.write(text[i]); + if (reverseIndex > 1 && reverseIndex % 3 == 1) { + buffer.write('.'); + } + } + return 'Rp $buffer'; + } + + String _stockRecommendation(Product product) { + switch (product.status) { + case 'kritis': + return 'Stok kritis. Disarankan segera tambah stok agar produksi tidak terganggu.'; + case 'sedang': + return 'Stok mulai menipis. Pantau pemakaian dan siapkan pembelian berikutnya.'; + default: + return 'Stok aman. Produk masih cukup untuk kebutuhan operasional.'; + } + } + @override void dispose() { routeObserver.unsubscribe(this); diff --git a/lib/screens/reports/report_controller.dart b/lib/screens/reports/report_controller.dart index 4579238..c691538 100644 --- a/lib/screens/reports/report_controller.dart +++ b/lib/screens/reports/report_controller.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:finalproject/services/ml_service.dart'; +import 'package:finalproject/utils/stock_status.dart'; import 'package:flutter/material.dart'; class ReportController extends ChangeNotifier { @@ -37,15 +38,15 @@ class ReportController extends ChangeNotifier { MLService.getReportStock(), MLService.getReportStockIn(), MLService.getReportPredictions(), - MLService.getReportCritical(), MLService.getProducts(), + MLService.getDashboardSummary(), ]); final stockResponse = results[0] as Map; final stockInResponse = results[1] as Map; final predictionResponse = results[2] as Map; - final criticalResponse = results[3] as Map; - final productsResponse = results[4]; + final productsResponse = results[3]; + final dashboardSummary = results[4] as Map; if (stockResponse['status'] != true) { errorMessage = stockResponse['message']?.toString(); @@ -65,18 +66,22 @@ class ReportController extends ChangeNotifier { _applyPredictions(predictionResponse['data']); } - if (criticalResponse['status'] != true) { - errorMessage ??= criticalResponse['message']?.toString(); - } else { - _applyCriticalItems(criticalResponse['data']); - } - if (productsResponse is List) { _productCount = productsResponse.length; + _applyCriticalProducts(productsResponse); + } else { + _productCount = 0; + criticalItems.clear(); + } + + if (dashboardSummary['status'] != true) { + errorMessage ??= dashboardSummary['message']?.toString(); + usageSummary.clear(); + } else { + _applyUsageSummary(dashboardSummary['penggunaan_bahan']); } _rebuildSummary(); - _rebuildUsageSummary(); _rebuildDemandTrend(); } catch (e) { errorMessage = 'Gagal memuat laporan: $e'; @@ -150,17 +155,50 @@ class ReportController extends ChangeNotifier { } } - void _applyCriticalItems(dynamic data) { + void _applyCriticalProducts(List products) { criticalItems.clear(); - if (data is! List) return; - for (final item in data) { + + for (final item in products) { + if (item is! Map) continue; + + final stock = StockStatusUtils.parseStock(item['current_stock']); + final status = StockStatusUtils.statusFromStock(stock); + if (status != StockStatusUtils.statusKritis) continue; + + final category = item['category']?.toString().toLowerCase() ?? ''; + final unit = + item['unit']?.toString() ?? (category == 'barang' ? 'pcs' : 'kg'); criticalItems.add({ - 'name': item['nama_bahan']?.toString() ?? '-', - 'stock': _toDouble(item['stok']), - 'status': item['status']?.toString() ?? 'Kritis', - 'unit': item['unit']?.toString() ?? 'kg', + 'name': item['name']?.toString() ?? '-', + 'stock': stock, + 'status': StockStatusUtils.label(status), + 'unit': unit, }); } + + criticalItems.sort( + (a, b) => a['name'].toString().compareTo(b['name'].toString()), + ); + } + + void _applyUsageSummary(dynamic data) { + usageSummary.clear(); + if (data is! List) return; + + for (final item in data) { + if (item is! Map) continue; + + final label = item['nama_bahan']?.toString() ?? '-'; + final total = _toDouble(item['total_digunakan']); + final unit = item['satuan']?.toString() ?? 'kg'; + if (total <= 0) continue; + + usageSummary.add({'label': label, 'value': total, 'unit': unit}); + } + + usageSummary.sort( + (a, b) => (b['value'] as double).compareTo(a['value'] as double), + ); } void _rebuildSummary() { @@ -170,35 +208,17 @@ class ReportController extends ChangeNotifier { totalKritis = criticalItems.length; } - void _rebuildUsageSummary() { - final Map totals = {}; - for (final item in stockHistory) { - final name = item['name'] as String; - final amount = item['amount'] as double; - totals[name] = (totals[name] ?? 0) + amount; - } - - final sorted = - totals.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); - - usageSummary - ..clear() - ..addAll( - sorted - .take(4) - .map((entry) => {'label': entry.key, 'value': entry.value}), - ); - } - void _rebuildDemandTrend() { final sorted = predictionItems.toList()..sort( (a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime), ); + final latestItems = + sorted.length > 7 ? sorted.skip(sorted.length - 7) : sorted; demandTrend ..clear() - ..addAll(sorted.take(7).map((entry) => (entry['prediction'] as double))); + ..addAll(latestItems.map((entry) => (entry['prediction'] as double))); } double _toDouble(dynamic value) { diff --git a/lib/screens/reports/report_page.dart b/lib/screens/reports/report_page.dart index ca109fc..c9892f9 100644 --- a/lib/screens/reports/report_page.dart +++ b/lib/screens/reports/report_page.dart @@ -8,10 +8,34 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:open_filex/open_filex.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; import 'package:share_plus/share_plus.dart'; import 'report_controller.dart'; +enum ExportPeriod { + daily('Harian', 'harian'), + biweekly('2 Mingguan', 'dua_mingguan'), + monthly('Bulanan', 'bulanan'); + + const ExportPeriod(this.label, this.fileKey); + + final String label; + final String fileKey; +} + +enum ExportFormat { + csv('CSV', 'csv', 'csv'), + pdf('PDF', 'pdf', 'pdf'); + + const ExportFormat(this.label, this.fileKey, this.extension); + + final String label; + final String fileKey; + final String extension; +} + class ReportScreen extends StatefulWidget { const ReportScreen({super.key}); @@ -50,10 +74,7 @@ class _ReportScreenState extends State { final usageBars = _buildUsageBars(_controller.usageSummary); final usagePie = _buildUsagePie(usageBars); - final List demandTrend = - _controller.demandTrend.isNotEmpty - ? _controller.demandTrend - : const [28, 32, 40, 36, 44, 50, 48]; + final List demandTrend = _controller.demandTrend; return Scaffold( backgroundColor: AppColors.bgLight, @@ -438,6 +459,10 @@ class _ReportScreenState extends State { } Widget _buildPredictionList(List> items) { + final listHeight = items.length * 76.0; + final maxHeight = _sectionMaxHeight(); + final height = listHeight < maxHeight ? listHeight : maxHeight; + return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( @@ -445,23 +470,29 @@ class _ReportScreenState extends State { borderRadius: BorderRadius.circular(16), boxShadow: [AppColors.shadowLight], ), - child: Column( - children: - items.isEmpty - ? [ - Padding( - padding: const EdgeInsets.all(12), - child: Text( - 'Belum ada data prediksi.', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textSecondary, - ), - ), + child: + items.isEmpty + ? Padding( + padding: const EdgeInsets.all(12), + child: Text( + 'Belum ada data prediksi.', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, ), - ] - : items - .map( - (item) => ListTile( + ), + ) + : SizedBox( + height: height, + child: Scrollbar( + child: ListView.separated( + padding: EdgeInsets.zero, + itemCount: items.length, + separatorBuilder: + (context, index) => + const Divider(height: 1, indent: 56), + itemBuilder: (context, index) { + final item = items[index]; + return ListTile( contentPadding: const EdgeInsets.symmetric( horizontal: 8, ), @@ -481,25 +512,30 @@ class _ReportScreenState extends State { ), title: Text( item['product'] as String, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: AppTextStyles.labelLarge, ), subtitle: Text( '${item['needs']} - ${_dateFormat.format(item['date'] as DateTime)}', + maxLines: 2, + overflow: TextOverflow.ellipsis, style: AppTextStyles.bodySmall.copyWith( color: AppColors.textTertiary, ), ), trailing: Text( - '${item['prediction']} unit', + '${_formatQuantity(item['prediction'])} unit', style: AppTextStyles.labelLarge.copyWith( color: AppColors.primaryBrown, fontWeight: FontWeight.w700, ), ), - ), - ) - .toList(), - ), + ); + }, + ), + ), + ), ); } @@ -512,161 +548,111 @@ class _ReportScreenState extends State { children: [ _buildChartCard( title: 'Bar Chart Penggunaan Bahan', - child: SizedBox( - height: 180, - child: BarChart( - BarChartData( - borderData: FlBorderData(show: false), - titlesData: FlTitlesData( - leftTitles: AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - rightTitles: AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - topTitles: AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - interval: 1, - getTitlesWidget: (value, meta) { - if (value % 1 != 0) { - return const SizedBox.shrink(); - } - if (value < 0 || value >= usageBars.length) { - return const SizedBox.shrink(); - } + child: + usageBars.isEmpty + ? _buildEmptyChartState( + icon: Icons.inventory_2_outlined, + message: + 'Belum ada data penggunaan barang atau bahan dari API untuk membuat grafik.', + ) + : SizedBox( + height: 180, + child: BarChart( + BarChartData( + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + interval: 1, + getTitlesWidget: (value, meta) { + if (value % 1 != 0) { + return const SizedBox.shrink(); + } + if (value < 0 || value >= usageBars.length) { + return const SizedBox.shrink(); + } - return Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - usageBars[value.toInt()]['label'] as String, - style: AppTextStyles.labelSmall.copyWith( - color: AppColors.textSecondary, + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + usageBars[value.toInt()]['label'] as String, + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.textSecondary, + ), + ), + ); + }, ), ), - ); - }, + ), + barGroups: + usageBars.asMap().entries.map((entry) { + return BarChartGroupData( + x: entry.key, + barRods: [ + BarChartRodData( + toY: entry.value['value'] as double, + color: entry.value['color'] as Color, + width: 18, + borderRadius: BorderRadius.circular(6), + ), + ], + ); + }).toList(), + ), ), ), - ), - barGroups: - usageBars.asMap().entries.map((entry) { - return BarChartGroupData( - x: entry.key, - barRods: [ - BarChartRodData( - toY: entry.value['value'] as double, - color: entry.value['color'] as Color, - width: 18, - borderRadius: BorderRadius.circular(6), - ), - ], - ); - }).toList(), - ), - ), - ), ), const SizedBox(height: 16), _buildChartCard( - title: 'Line Chart Permintaan Produk', - child: SizedBox( - height: 180, - child: LineChart( - LineChartData( - borderData: FlBorderData(show: false), - gridData: FlGridData(show: false), - titlesData: FlTitlesData( - leftTitles: AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - rightTitles: AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - topTitles: AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - interval: 1, - getTitlesWidget: (value, meta) { - if (value % 1 != 0) { - return const SizedBox.shrink(); - } - if (value < 0 || value >= demandTrend.length) { - return const SizedBox.shrink(); - } - - return Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - 'M${value.toInt() + 1}', - style: AppTextStyles.labelSmall.copyWith( - color: AppColors.textSecondary, - ), - ), - ); - }, - ), - ), - ), - lineBarsData: [ - LineChartBarData( - spots: - demandTrend - .asMap() - .entries - .map( - (entry) => - FlSpot(entry.key.toDouble(), entry.value), - ) - .toList(), - isCurved: true, - color: AppColors.secondaryBlue, - barWidth: 3, - dotData: FlDotData(show: false), - belowBarData: BarAreaData( - show: true, - color: AppColors.secondaryBlue.withValues(alpha: 0.15), - ), - ), - ], - ), - ), - ), + title: 'Tren Permintaan Produk', + child: _buildDemandTrendChart(demandTrend), ), const SizedBox(height: 16), _buildChartCard( title: 'Pie Chart Bahan Paling Sering Digunakan', - child: SizedBox( - height: 200, - child: PieChart( - PieChartData( - sectionsSpace: 2, - centerSpaceRadius: 40, - sections: - usagePie.map((entry) { - final value = entry['value'] as double; + child: + usagePie.isEmpty + ? _buildEmptyChartState( + icon: Icons.pie_chart_outline_rounded, + message: + 'Belum ada data penggunaan barang atau bahan dari API untuk menghitung item paling sering digunakan.', + ) + : SizedBox( + height: 200, + child: PieChart( + PieChartData( + sectionsSpace: 2, + centerSpaceRadius: 40, + sections: + usagePie.map((entry) { + final value = entry['value'] as double; - return PieChartSectionData( - value: value, - color: entry['color'] as Color, - radius: 50, - showTitle: value >= 5, - title: '${value.toStringAsFixed(1)}%', - titleStyle: AppTextStyles.labelSmall.copyWith( - color: Colors.white, - fontWeight: FontWeight.w700, - ), - ); - }).toList(), - ), - ), - ), + return PieChartSectionData( + value: value, + color: entry['color'] as Color, + radius: 50, + showTitle: value >= 5, + title: '${value.toStringAsFixed(1)}%', + titleStyle: AppTextStyles.labelSmall.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ); + }).toList(), + ), + ), + ), ), ], ); @@ -696,6 +682,274 @@ class _ReportScreenState extends State { ); } + Widget _buildDemandTrendChart(List demandTrend) { + if (demandTrend.isEmpty) { + return _buildEmptyChartState( + icon: Icons.show_chart_rounded, + message: + 'Belum ada data prediksi dari API untuk menampilkan tren permintaan produk.', + ); + } + + final minDemand = demandTrend.reduce((a, b) => a < b ? a : b); + final maxDemand = demandTrend.reduce((a, b) => a > b ? a : b); + final averageDemand = + demandTrend.fold(0, (sum, value) => sum + value) / + demandTrend.length; + final yInterval = + (maxDemand / 4).ceilToDouble().clamp(1.0, double.infinity).toDouble(); + final maxY = (maxDemand + yInterval).ceilToDouble(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Grafik ini menunjukkan jumlah unit produk yang diprediksi akan diminta pada 7 data prediksi terakhir.', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildTrendInfoChip( + icon: Icons.trending_down_rounded, + label: 'Terendah', + value: '${_formatQuantity(minDemand)} unit', + color: AppColors.statusWarning, + ), + _buildTrendInfoChip( + icon: Icons.show_chart_rounded, + label: 'Rata-rata', + value: '${_formatQuantity(averageDemand)} unit', + color: AppColors.secondaryBlue, + ), + _buildTrendInfoChip( + icon: Icons.trending_up_rounded, + label: 'Tertinggi', + value: '${_formatQuantity(maxDemand)} unit', + color: AppColors.statusSuccess, + ), + ], + ), + const SizedBox(height: 14), + Row( + children: [ + RotatedBox( + quarterTurns: 3, + child: Text( + 'Jumlah permintaan (unit)', + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.textTertiary, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 220, + child: LineChart( + LineChartData( + minX: 0, + maxX: (demandTrend.length - 1).toDouble(), + minY: 0, + maxY: maxY, + borderData: FlBorderData( + show: true, + border: Border( + left: BorderSide(color: AppColors.grey200), + bottom: BorderSide(color: AppColors.grey200), + ), + ), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: yInterval, + getDrawingHorizontalLine: + (_) => FlLine( + color: AppColors.grey200.withValues(alpha: 0.7), + strokeWidth: 1, + ), + ), + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + tooltipBgColor: AppColors.textPrimary, + getTooltipItems: + (spots) => + spots.map((spot) { + return LineTooltipItem( + 'Prediksi ${spot.x.toInt() + 1}\n${_formatQuantity(spot.y)} unit', + AppTextStyles.labelSmall.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ); + }).toList(), + ), + ), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 34, + interval: yInterval, + getTitlesWidget: (value, meta) { + if (value < 0 || value > maxY) { + return const SizedBox.shrink(); + } + return Text( + _formatQuantity(value), + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.textTertiary, + ), + ); + }, + ), + ), + rightTitles: AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + interval: 1, + reservedSize: 30, + getTitlesWidget: (value, meta) { + if (value % 1 != 0) { + return const SizedBox.shrink(); + } + if (value < 0 || value >= demandTrend.length) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'P${value.toInt() + 1}', + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.textSecondary, + fontWeight: FontWeight.w700, + ), + ), + ); + }, + ), + ), + ), + lineBarsData: [ + LineChartBarData( + spots: + demandTrend + .asMap() + .entries + .map( + (entry) => + FlSpot(entry.key.toDouble(), entry.value), + ) + .toList(), + isCurved: true, + color: AppColors.secondaryBlue, + barWidth: 3, + dotData: FlDotData(show: true), + belowBarData: BarAreaData( + show: true, + color: AppColors.secondaryBlue.withValues( + alpha: 0.12, + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Center( + child: Text( + 'P1-P${demandTrend.length} = urutan data prediksi dari paling lama ke paling baru', + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.textTertiary, + ), + textAlign: TextAlign.center, + ), + ), + ], + ); + } + + Widget _buildTrendInfoChip({ + required IconData icon, + required String label, + required String value, + required Color color, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withValues(alpha: 0.25)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + Text( + '$label: ', + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.textSecondary, + ), + ), + Text( + value, + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } + + Widget _buildEmptyChartState({ + required IconData icon, + required String message, + }) { + return Container( + width: double.infinity, + height: 160, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.bgLight, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.grey200), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: AppColors.textTertiary, size: 32), + const SizedBox(height: 10), + Text( + message, + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + Widget _buildCriticalItems(List> criticalItems) { if (criticalItems.isEmpty) { return Container( @@ -830,6 +1084,11 @@ class _ReportScreenState extends State { } Future _exportReport() async { + final period = await _selectExportPeriod(); + if (period == null) return; + final format = await _selectExportFormat(); + if (format == null) return; + if (_controller.stockItems.isEmpty && _controller.stockHistory.isEmpty && _controller.predictionItems.isEmpty) { @@ -845,16 +1104,29 @@ class _ReportScreenState extends State { final timestamp = _fileDateFormat.format(DateTime.now()); final filePath = '${directory.path}${Platform.pathSeparator}' - 'laporan_$timestamp.csv'; + 'laporan_${period.fileKey}_${format.fileKey}_$timestamp' + '.${format.extension}'; final file = File(filePath); - await file.writeAsString(_buildCsvContent()); + if (format == ExportFormat.pdf) { + await file.writeAsBytes(await _buildPdfContent(period)); + } else { + await file.writeAsString('\ufeff${_buildCsvContent(period)}'); + } if (!mounted) return; setState(() => _lastExportPath = filePath); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Laporan tersimpan: $filePath'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Laporan ${format.label} telah diekspor'), + action: SnackBarAction( + label: 'Buka', + onPressed: () { + OpenFilex.open(filePath); + }, + ), + ), + ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of( @@ -863,6 +1135,42 @@ class _ReportScreenState extends State { } } + Future _selectExportPeriod() { + return showDialog( + context: context, + builder: (context) { + return SimpleDialog( + title: const Text('Pilih Periode Export'), + children: + ExportPeriod.values.map((period) { + return SimpleDialogOption( + onPressed: () => Navigator.of(context).pop(period), + child: Text(period.label), + ); + }).toList(), + ); + }, + ); + } + + Future _selectExportFormat() { + return showDialog( + context: context, + builder: (context) { + return SimpleDialog( + title: const Text('Pilih Format Export'), + children: + ExportFormat.values.map((format) { + return SimpleDialogOption( + onPressed: () => Navigator.of(context).pop(format), + child: Text(format.label), + ); + }).toList(), + ); + }, + ); + } + Future _openLastExport() async { final path = _lastExportPath; if (path == null) return; @@ -886,49 +1194,292 @@ class _ReportScreenState extends State { ); } - String _buildCsvContent() { - final buffer = StringBuffer(); - buffer.writeln('Laporan & Analitik'); - buffer.writeln('Tanggal,${_dateFormat.format(DateTime.now())}'); - buffer.writeln(''); + Future> _buildPdfContent(ExportPeriod period) async { + final now = DateTime.now(); + final startDate = _periodStartDate(period, now); + final endDate = DateTime(now.year, now.month, now.day, 23, 59, 59); + final stockHistory = + _controller.stockHistory.where((entry) { + return _isDateInRange(entry['date'] as DateTime, startDate, endDate); + }).toList(); + final predictionItems = + _controller.predictionItems.where((entry) { + return _isDateInRange(entry['date'] as DateTime, startDate, endDate); + }).toList(); + final stockItems = _sortedStockItems(_controller.stockItems); + final stockSummary = _summarizeStockHistory(stockHistory); + final predictionSummary = _summarizePredictions(predictionItems); - buffer.writeln('Ringkasan'); - buffer.writeln('Total Produk,${_controller.totalProduk}'); - buffer.writeln('Total Bahan,${_controller.totalBahan}'); - buffer.writeln('Stok Kritis,${_controller.totalKritis}'); - buffer.writeln('Total Prediksi,${_controller.totalPrediksi}'); - buffer.writeln(''); + final document = pw.Document(); + document.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(28), + footer: (context) { + return pw.Align( + alignment: pw.Alignment.centerRight, + child: pw.Text( + 'Halaman ${context.pageNumber} dari ${context.pagesCount}', + style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey600), + ), + ); + }, + build: (context) { + return [ + pw.Text( + 'LAPORAN & ANALITIK', + style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold), + ), + pw.SizedBox(height: 12), + _pdfSectionTitle('Informasi Laporan'), + _pdfTable([ + ['Keterangan', 'Nilai'], + ['Jenis Rekap', period.label], + [ + 'Periode', + '${_dateFormat.format(startDate)} - ${_dateFormat.format(endDate)}', + ], + ['Tanggal Export', _dateFormat.format(now)], + ]), + _pdfSectionTitle('Ringkasan Laporan'), + _pdfTable([ + ['Metrik', 'Nilai'], + ['Total Produk', _controller.totalProduk.toString()], + ['Total Bahan', _controller.totalBahan.toString()], + ['Stok Kritis', _controller.totalKritis.toString()], + ['Total Prediksi Periode', predictionItems.length.toString()], + [ + 'Total Transaksi Stok Masuk Periode', + stockHistory.length.toString(), + ], + ]), + _pdfSectionTitle('Stok Bahan Saat Ini'), + _pdfTable([ + ['No', 'Nama Bahan', 'Stok', 'Unit', 'Status'], + ...stockItems.asMap().entries.map((entry) { + final item = entry.value; + final stockValue = StockStatusUtils.parseStock(item['stock']); + final statusKey = StockStatusUtils.statusFromStock(stockValue); + return [ + '${entry.key + 1}', + item['name']?.toString() ?? '-', + _formatQuantity(stockValue), + item['unit']?.toString() ?? 'kg', + StockStatusUtils.label(statusKey), + ]; + }), + ]), + _pdfSectionTitle('Rekap Stok Masuk Periode'), + _pdfTable([ + ['No', 'Nama Bahan', 'Total Jumlah', 'Unit'], + if (stockSummary.isEmpty) + ['-', 'Tidak ada data stok masuk pada periode ini', '-', '-'] + else + ...stockSummary.asMap().entries.map((entry) { + final item = entry.value; + return [ + '${entry.key + 1}', + item['name']?.toString() ?? '-', + _formatQuantity(item['amount']), + item['unit']?.toString() ?? 'kg', + ]; + }), + ]), + _pdfSectionTitle('Detail Riwayat Stok Masuk Periode'), + _pdfTable([ + ['No', 'Tanggal', 'Nama Bahan', 'Jumlah', 'Unit'], + if (stockHistory.isEmpty) + ['-', 'Tidak ada data', '-', '-', '-'] + else + ...stockHistory.asMap().entries.map((entry) { + final item = entry.value; + return [ + '${entry.key + 1}', + _dateFormat.format(item['date'] as DateTime), + item['name']?.toString() ?? '-', + _formatQuantity(item['amount']), + item['unit']?.toString() ?? 'kg', + ]; + }), + ]), + _pdfSectionTitle('Rekap Prediksi Periode'), + _pdfTable([ + ['No', 'Produk', 'Total Prediksi', 'Estimasi Kebutuhan Terakhir'], + if (predictionSummary.isEmpty) + ['-', 'Tidak ada data prediksi pada periode ini', '-', '-'] + else + ...predictionSummary.asMap().entries.map((entry) { + final item = entry.value; + return [ + '${entry.key + 1}', + item['product']?.toString() ?? '-', + _formatQuantity(item['prediction']), + item['needs']?.toString() ?? '-', + ]; + }), + ]), + _pdfSectionTitle('Detail Prediksi Permintaan Periode'), + _pdfTable([ + ['No', 'Tanggal', 'Produk', 'Prediksi', 'Estimasi Kebutuhan'], + if (predictionItems.isEmpty) + ['-', 'Tidak ada data', '-', '-', '-'] + else + ...predictionItems.asMap().entries.map((entry) { + final item = entry.value; + return [ + '${entry.key + 1}', + _dateFormat.format(item['date'] as DateTime), + item['product']?.toString() ?? '-', + _formatQuantity(item['prediction']), + item['needs']?.toString() ?? '-', + ]; + }), + ]), + ]; + }, + ), + ); - buffer.writeln('Laporan Stok Bahan'); - buffer.writeln('Nama Bahan,Stok,Unit,Status'); - for (final item in _controller.stockItems) { + return document.save(); + } + + pw.Widget _pdfSectionTitle(String title) { + return pw.Padding( + padding: const pw.EdgeInsets.only(top: 14, bottom: 6), + child: pw.Text( + title, + style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold), + ), + ); + } + + pw.Widget _pdfTable(List> rows) { + return pw.TableHelper.fromTextArray( + data: rows, + headerCount: 1, + border: pw.TableBorder.all(color: PdfColors.grey300, width: 0.5), + headerDecoration: const pw.BoxDecoration(color: PdfColors.grey200), + headerStyle: pw.TextStyle(fontSize: 8, fontWeight: pw.FontWeight.bold), + cellStyle: const pw.TextStyle(fontSize: 8), + cellAlignment: pw.Alignment.centerLeft, + headerAlignment: pw.Alignment.centerLeft, + cellPadding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 3), + ); + } + + String _buildCsvContent(ExportPeriod period) { + final now = DateTime.now(); + final startDate = _periodStartDate(period, now); + final endDate = DateTime(now.year, now.month, now.day, 23, 59, 59); + final stockHistory = + _controller.stockHistory.where((entry) { + return _isDateInRange(entry['date'] as DateTime, startDate, endDate); + }).toList(); + final predictionItems = + _controller.predictionItems.where((entry) { + return _isDateInRange(entry['date'] as DateTime, startDate, endDate); + }).toList(); + + final buffer = + StringBuffer() + ..writeln('LAPORAN & ANALITIK') + ..writeln(''); + + _writeSection(buffer, 'Informasi Laporan'); + buffer + ..writeln('Keterangan,Nilai') + ..writeln('Jenis Rekap,${period.label}') + ..writeln( + 'Periode,${_dateFormat.format(startDate)} - ${_dateFormat.format(endDate)}', + ) + ..writeln('Tanggal Export,${_dateFormat.format(now)}') + ..writeln(''); + + _writeSection(buffer, 'Ringkasan Laporan'); + buffer + ..writeln('Metrik,Nilai') + ..writeln('Total Produk,${_controller.totalProduk}') + ..writeln('Total Bahan,${_controller.totalBahan}') + ..writeln('Stok Kritis,${_controller.totalKritis}') + ..writeln('Total Prediksi Periode,${predictionItems.length}') + ..writeln('Total Transaksi Stok Masuk Periode,${stockHistory.length}') + ..writeln(''); + + _writeSection(buffer, 'Stok Bahan Saat Ini'); + buffer.writeln('No,Nama Bahan,Stok,Unit,Status'); + final stockItems = _sortedStockItems(_controller.stockItems); + for (var index = 0; index < stockItems.length; index++) { + final item = stockItems[index]; final stockValue = StockStatusUtils.parseStock(item['stock']); final statusKey = StockStatusUtils.statusFromStock(stockValue); final statusLabel = StockStatusUtils.label(statusKey); buffer.writeln( - '${_escapeCsv(item['name'])},${item['stock']},' + '${index + 1},${_escapeCsv(item['name'])},' + '${_formatQuantity(stockValue)},' '${_escapeCsv(item['unit'])},$statusLabel', ); } buffer.writeln(''); - buffer.writeln('Riwayat Stok Masuk'); - buffer.writeln('Tanggal,Nama Bahan,Jumlah,Unit'); - for (final entry in _controller.stockHistory) { - final date = _dateFormat.format(entry['date'] as DateTime); + _writeSection(buffer, 'Rekap Stok Masuk Periode'); + buffer.writeln('No,Nama Bahan,Total Jumlah,Unit'); + final stockSummary = _summarizeStockHistory(stockHistory); + if (stockSummary.isEmpty) { + buffer.writeln('-,Tidak ada data stok masuk pada periode ini,-,-'); + } + for (var index = 0; index < stockSummary.length; index++) { + final entry = stockSummary[index]; buffer.writeln( - '$date,${_escapeCsv(entry['name'])},${entry['amount']},' + '${index + 1},${_escapeCsv(entry['name'])},' + '${_formatQuantity(entry['amount'])},' '${_escapeCsv(entry['unit'])}', ); } buffer.writeln(''); - buffer.writeln('Laporan Prediksi Permintaan'); - buffer.writeln('Tanggal,Produk,Prediksi,Estimasi Kebutuhan'); - for (final item in _controller.predictionItems) { + _writeSection(buffer, 'Detail Riwayat Stok Masuk Periode'); + buffer.writeln('No,Tanggal,Nama Bahan,Jumlah,Unit'); + if (stockHistory.isEmpty) { + buffer.writeln('-,Tidak ada data,-,-,-'); + } + for (var index = 0; index < stockHistory.length; index++) { + final entry = stockHistory[index]; + final date = _dateFormat.format(entry['date'] as DateTime); + buffer.writeln( + '${index + 1},$date,${_escapeCsv(entry['name'])},' + '${_formatQuantity(entry['amount'])},' + '${_escapeCsv(entry['unit'])}', + ); + } + buffer.writeln(''); + + _writeSection(buffer, 'Rekap Prediksi Periode'); + buffer.writeln('No,Produk,Total Prediksi,Estimasi Kebutuhan Terakhir'); + final predictionSummary = _summarizePredictions(predictionItems); + if (predictionSummary.isEmpty) { + buffer.writeln('-,Tidak ada data prediksi pada periode ini,-,-'); + } + for (var index = 0; index < predictionSummary.length; index++) { + final item = predictionSummary[index]; + buffer.writeln( + '${index + 1},${_escapeCsv(item['product'])},' + '${_formatQuantity(item['prediction'])},' + '${_escapeCsv(item['needs'])}', + ); + } + buffer.writeln(''); + + _writeSection(buffer, 'Detail Prediksi Permintaan Periode'); + buffer.writeln('No,Tanggal,Produk,Prediksi,Estimasi Kebutuhan'); + if (predictionItems.isEmpty) { + buffer.writeln('-,Tidak ada data,-,-,-'); + } + for (var index = 0; index < predictionItems.length; index++) { + final item = predictionItems[index]; final date = _dateFormat.format(item['date'] as DateTime); buffer.writeln( - '$date,${_escapeCsv(item['product'])},${item['prediction']},' + '${index + 1},$date,${_escapeCsv(item['product'])},' + '${_formatQuantity(item['prediction'])},' '${_escapeCsv(item['needs'])}', ); } @@ -937,6 +1488,109 @@ class _ReportScreenState extends State { return buffer.toString(); } + void _writeSection(StringBuffer buffer, String title) { + buffer + ..writeln(title) + ..writeln('---'); + } + + List> _sortedStockItems( + List> items, + ) { + return items.toList()..sort((a, b) { + final aStock = StockStatusUtils.parseStock(a['stock']); + final bStock = StockStatusUtils.parseStock(b['stock']); + final aStatus = StockStatusUtils.statusFromStock(aStock); + final bStatus = StockStatusUtils.statusFromStock(bStock); + final statusComparison = _statusOrder( + aStatus, + ).compareTo(_statusOrder(bStatus)); + if (statusComparison != 0) return statusComparison; + return a['name'].toString().compareTo(b['name'].toString()); + }); + } + + int _statusOrder(String status) { + switch (StockStatusUtils.normalizeStatus(status)) { + case StockStatusUtils.statusKritis: + return 0; + case StockStatusUtils.statusSedang: + return 1; + case StockStatusUtils.statusTersedia: + default: + return 2; + } + } + + DateTime _periodStartDate(ExportPeriod period, DateTime now) { + final today = DateTime(now.year, now.month, now.day); + switch (period) { + case ExportPeriod.daily: + return today; + case ExportPeriod.biweekly: + return today.subtract(const Duration(days: 13)); + case ExportPeriod.monthly: + return DateTime(now.year, now.month); + } + } + + bool _isDateInRange(DateTime date, DateTime start, DateTime end) { + return !date.isBefore(start) && !date.isAfter(end); + } + + List> _summarizeStockHistory( + List> items, + ) { + final Map> summary = {}; + for (final item in items) { + final name = item['name']?.toString() ?? '-'; + final unit = item['unit']?.toString() ?? 'kg'; + final key = '$name|$unit'; + final current = summary[key]; + if (current == null) { + summary[key] = { + 'name': name, + 'unit': unit, + 'amount': item['amount'] as double, + }; + } else { + current['amount'] = (current['amount'] as double) + item['amount']; + } + } + + return summary.values.toList() + ..sort((a, b) => a['name'].toString().compareTo(b['name'].toString())); + } + + List> _summarizePredictions( + List> items, + ) { + final Map> summary = {}; + for (final item in items) { + final product = item['product']?.toString() ?? '-'; + final current = summary[product]; + if (current == null) { + summary[product] = { + 'product': product, + 'prediction': item['prediction'] as double, + 'needs': item['needs'], + 'date': item['date'], + }; + } else { + current['prediction'] = + (current['prediction'] as double) + item['prediction']; + if ((item['date'] as DateTime).isAfter(current['date'] as DateTime)) { + current['needs'] = item['needs']; + current['date'] = item['date']; + } + } + } + + return summary.values.toList()..sort( + (a, b) => a['product'].toString().compareTo(b['product'].toString()), + ); + } + String _escapeCsv(dynamic value) { final text = value?.toString() ?? ''; if (text.contains(',') || text.contains('"') || text.contains('\n')) { @@ -946,16 +1600,17 @@ class _ReportScreenState extends State { return text; } + String _formatQuantity(dynamic value) { + final number = StockStatusUtils.parseStock(value); + if (number % 1 == 0) return number.toInt().toString(); + return number.toStringAsFixed(2).replaceFirst(RegExp(r'0$'), ''); + } + List> _buildUsageBars( List> usageSummary, ) { if (usageSummary.isEmpty) { - return [ - {'label': 'Tepung', 'value': 40.0, 'color': AppColors.primaryBrown}, - {'label': 'Gula', 'value': 28.0, 'color': AppColors.secondaryOrange}, - {'label': 'Telur', 'value': 18.0, 'color': AppColors.secondaryBlue}, - {'label': 'Mentega', 'value': 12.0, 'color': AppColors.secondaryGreen}, - ]; + return []; } final colors = [ @@ -971,6 +1626,7 @@ class _ReportScreenState extends State { return { 'label': item['label'] as String, 'value': item['value'] as double, + 'unit': item['unit']?.toString() ?? 'kg', 'color': colors[index % colors.length], }; }).toList(); diff --git a/lib/services/ml_service.dart b/lib/services/ml_service.dart index f90efe4..61b80d4 100644 --- a/lib/services/ml_service.dart +++ b/lib/services/ml_service.dart @@ -5,7 +5,7 @@ class MLService { // API URL - Change based on environment // Untuk emulator Android: 10.0.2.2 // Untuk device fisik: 192.168.x.x atau 127.0.0.1 kalau local - static const String baseUrl = 'http://192.168.18.30:5000'; + static const String baseUrl = 'http://192.168.1.44:5000'; static const int timeoutSeconds = 30; diff --git a/pubspec.lock b/pubspec.lock index 6e4cd9c..36b9b30 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" async: dependency: transitive description: @@ -9,6 +17,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.12.0" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" boolean_selector: dependency: transitive description: @@ -152,6 +176,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" intl: dependency: "direct main" description: @@ -240,6 +272,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -288,6 +328,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pdf: + dependency: "direct main" + description: + name: pdf + sha256: "28eacad99bffcce2e05bba24e50153890ad0255294f4dd78a17075a2ba5c8416" + url: "https://pub.dev" + source: hosted + version: "3.11.3" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + url: "https://pub.dev" + source: hosted + version: "6.1.0" platform: dependency: transitive description: @@ -304,6 +360,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" share_plus: dependency: "direct main" description: @@ -461,6 +533,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" sdks: dart: ">=3.7.0 <4.0.0" flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml index 95006c1..e579e5c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: # File export path_provider: ^2.1.4 + pdf: ^3.11.1 # Open file + share open_filex: ^4.5.0