diff --git a/database/report_schema.sql b/database/report_schema.sql new file mode 100644 index 0000000..7924d56 --- /dev/null +++ b/database/report_schema.sql @@ -0,0 +1,71 @@ +-- ===================================================== +-- Schema Laporan: bahan, stok_masuk, prediksi +-- Tujuan: mendukung fitur laporan realtime +-- ===================================================== + +-- 1) TABEL: bahan +-- Menyimpan stok bahan dengan stok minimum +CREATE TABLE IF NOT EXISTS bahan ( + id INT PRIMARY KEY AUTO_INCREMENT, + product_id INT NULL, + nama_bahan VARCHAR(100) NOT NULL, + stok DOUBLE NOT NULL DEFAULT 0, + stok_minimum DOUBLE NOT NULL DEFAULT 0, + unit VARCHAR(20) DEFAULT 'kg', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_bahan_product + FOREIGN KEY (product_id) REFERENCES products(id) + ON DELETE SET NULL + ON UPDATE CASCADE, + KEY idx_bahan_nama (nama_bahan), + KEY idx_bahan_product (product_id) +); + +-- 2) TABEL: stok_masuk +-- Riwayat stok masuk bahan +CREATE TABLE IF NOT EXISTS stok_masuk ( + id INT PRIMARY KEY AUTO_INCREMENT, + bahan_id INT NULL, + product_id INT NULL, + tanggal DATE NOT NULL, + jumlah DOUBLE NOT NULL DEFAULT 0, + unit VARCHAR(20) DEFAULT 'kg', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_stok_masuk_bahan + FOREIGN KEY (bahan_id) REFERENCES bahan(id) + ON DELETE SET NULL + ON UPDATE CASCADE, + CONSTRAINT fk_stok_masuk_product + FOREIGN KEY (product_id) REFERENCES products(id) + ON DELETE SET NULL + ON UPDATE CASCADE, + KEY idx_stok_masuk_tanggal (tanggal), + KEY idx_stok_masuk_bahan (bahan_id), + KEY idx_stok_masuk_product (product_id) +); + +-- 3) TABEL: prediksi +-- Menyimpan hasil prediksi permintaan +CREATE TABLE IF NOT EXISTS prediksi ( + id INT PRIMARY KEY AUTO_INCREMENT, + product_id INT NULL, + nama_produk VARCHAR(100) NOT NULL, + hasil_prediksi DOUBLE NOT NULL DEFAULT 0, + estimasi_kebutuhan_bahan TEXT, + tanggal_prediksi DATE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_prediksi_product + FOREIGN KEY (product_id) REFERENCES products(id) + ON DELETE SET NULL + ON UPDATE CASCADE, + KEY idx_prediksi_tanggal (tanggal_prediksi), + KEY idx_prediksi_product (product_id) +); + +-- ===================================================== +-- Catatan: +-- - Jika tabel products sudah menyimpan stok (current_stock, min_stock), +-- endpoint laporan akan otomatis fallback ke tabel products. +-- - Pastikan data bahan/produk sinkron agar laporan tampil konsisten. +-- ===================================================== diff --git a/lib/screens/reports/report_controller.dart b/lib/screens/reports/report_controller.dart index 13bf947..fefa2d1 100644 --- a/lib/screens/reports/report_controller.dart +++ b/lib/screens/reports/report_controller.dart @@ -1,22 +1,223 @@ +import 'dart:async'; +import 'dart:async'; + +import 'package:finalproject/services/ml_service.dart'; import 'package:flutter/material.dart'; class ReportController extends ChangeNotifier { - String selectedPeriod = 'bulanan'; + bool isLoading = true; + String? errorMessage; - final List periods = ['harian', 'mingguan', 'bulanan', 'tahunan']; - final List<(String, int)> topProducts = const [ - ('Tepung Terigu', 25), - ('Gula Pasir', 18), - ('Telur', 15), - ('Mentega', 12), - ('Cokelat Bubuk', 10), - ]; + final List> stockItems = []; + final List> stockHistory = []; + final List> predictionItems = []; + final List> criticalItems = []; + final List> usageSummary = []; + final List demandTrend = []; - void setSelectedPeriod(String value) { - selectedPeriod = value; - notifyListeners(); + int totalProduk = 0; + int totalBahan = 0; + int totalPrediksi = 0; + int totalKritis = 0; + + int _productCount = 0; + + Timer? _refreshTimer; + + /// Load semua data laporan dari API. + Future loadReports({bool showLoading = true}) async { + if (showLoading) { + isLoading = true; + notifyListeners(); + } + + try { + errorMessage = null; + + final results = await Future.wait([ + MLService.getReportStock(), + MLService.getReportStockIn(), + MLService.getReportPredictions(), + MLService.getReportCritical(), + MLService.getProducts(), + ]); + + 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]; + + if (stockResponse['status'] != true) { + errorMessage = stockResponse['message']?.toString(); + } else { + _applyStockItems(stockResponse['data']); + } + + if (stockInResponse['status'] != true) { + errorMessage ??= stockInResponse['message']?.toString(); + } else { + _applyStockHistory(stockInResponse['data']); + } + + if (predictionResponse['status'] != true) { + errorMessage ??= predictionResponse['message']?.toString(); + } else { + _applyPredictions(predictionResponse['data']); + } + + if (criticalResponse['status'] != true) { + errorMessage ??= criticalResponse['message']?.toString(); + } else { + _applyCriticalItems(criticalResponse['data']); + } + + if (productsResponse is List) { + _productCount = productsResponse.length; + } + + _rebuildSummary(); + _rebuildUsageSummary(); + _rebuildDemandTrend(); + } catch (e) { + errorMessage = 'Gagal memuat laporan: $e'; + } finally { + isLoading = false; + notifyListeners(); + } } - String capitalize(String value) => - '${value[0].toUpperCase()}${value.substring(1)}'; + /// Refresh otomatis agar laporan selalu realtime. + void startAutoRefresh() { + _refreshTimer?.cancel(); + _refreshTimer = Timer.periodic( + const Duration(minutes: 1), + (_) => loadReports(showLoading: false), + ); + } + + void stopAutoRefresh() { + _refreshTimer?.cancel(); + _refreshTimer = null; + } + + void _applyStockItems(dynamic data) { + stockItems.clear(); + if (data is! List) return; + for (final item in data) { + stockItems.add({ + 'name': item['nama_bahan']?.toString() ?? '-', + 'stock': _toDouble(item['stok']), + 'min_stock': _toDouble(item['stok_minimum']), + 'status': item['status']?.toString() ?? 'Aman', + 'unit': item['unit']?.toString() ?? 'kg', + }); + } + } + + void _applyStockHistory(dynamic data) { + stockHistory.clear(); + if (data is! List) return; + for (final item in data) { + stockHistory.add({ + 'date': _parseDate(item['tanggal']), + 'name': item['nama_bahan']?.toString() ?? '-', + 'amount': _toDouble(item['jumlah']), + 'unit': item['unit']?.toString() ?? 'kg', + }); + } + stockHistory.sort((a, b) { + final aDate = a['date'] as DateTime; + final bDate = b['date'] as DateTime; + return bDate.compareTo(aDate); + }); + } + + void _applyPredictions(dynamic data) { + predictionItems.clear(); + if (data is! List) return; + for (final item in data) { + final needsValue = item['estimasi_kebutuhan_bahan']; + final needsText = + needsValue == null || needsValue.toString().isEmpty + ? 'Belum tersedia' + : needsValue.toString(); + predictionItems.add({ + 'product': item['nama_produk']?.toString() ?? '-', + 'prediction': _toDouble(item['hasil_prediksi']), + 'needs': needsText, + 'date': _parseDate(item['tanggal_prediksi']), + }); + } + } + + void _applyCriticalItems(dynamic data) { + criticalItems.clear(); + if (data is! List) return; + for (final item in data) { + criticalItems.add({ + 'name': item['nama_bahan']?.toString() ?? '-', + 'stock': _toDouble(item['stok']), + 'status': item['status']?.toString() ?? 'Kritis', + 'unit': item['unit']?.toString() ?? 'kg', + }); + } + } + + void _rebuildSummary() { + totalBahan = stockItems.length; + totalProduk = _productCount; + totalPrediksi = predictionItems.length; + 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), + ); + + demandTrend + ..clear() + ..addAll(sorted.take(7).map((entry) => (entry['prediction'] as double))); + } + + double _toDouble(dynamic value) { + if (value is num) return value.toDouble(); + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + DateTime _parseDate(dynamic value) { + if (value is DateTime) return value; + if (value is String) { + return DateTime.tryParse(value) ?? DateTime.now(); + } + return DateTime.now(); + } + + @override + void dispose() { + stopAutoRefresh(); + super.dispose(); + } } diff --git a/lib/screens/reports/report_page.dart b/lib/screens/reports/report_page.dart index f8e452b..e3a1a79 100644 --- a/lib/screens/reports/report_page.dart +++ b/lib/screens/reports/report_page.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:intl/intl.dart'; +import 'report_controller.dart'; + class ReportScreen extends StatefulWidget { const ReportScreen({super.key}); @@ -14,67 +16,15 @@ class ReportScreen extends StatefulWidget { class _ReportScreenState extends State { final DateFormat _dateFormat = DateFormat('dd/MM/yyyy'); - final List> _stockItems = [ - {'name': 'Tepung Terigu 1kg', 'stock': 18.0, 'status': 'Aman'}, - {'name': 'Gula Pasir 1kg', 'stock': 6.5, 'status': 'Rendah'}, - {'name': 'Telur 1kg', 'stock': 2.0, 'status': 'Kritis'}, - {'name': 'Mentega 500gr', 'stock': 4.0, 'status': 'Rendah'}, - {'name': 'Baking Powder', 'stock': 12.0, 'status': 'Aman'}, - ]; + late final ReportController _controller; - final List> _stockHistory = [ - { - 'date': DateTime.now().subtract(const Duration(days: 1)), - 'name': 'Tepung Terigu 1kg', - 'amount': 10, - }, - { - 'date': DateTime.now().subtract(const Duration(days: 2)), - 'name': 'Gula Pasir 1kg', - 'amount': 6, - }, - { - 'date': DateTime.now().subtract(const Duration(days: 4)), - 'name': 'Mentega 500gr', - 'amount': 3, - }, - { - 'date': DateTime.now().subtract(const Duration(days: 6)), - 'name': 'Telur 1kg', - 'amount': 5, - }, - ]..sort((a, b) => (b['date'] as DateTime).compareTo(a['date'] as DateTime)); - - final List> _predictionItems = [ - { - 'product': 'Donat Cokelat', - 'prediction': 120, - 'needs': 'Tepung 15kg, Gula 6kg, Telur 4kg', - 'date': DateTime.now(), - }, - { - 'product': 'Roti Manis', - 'prediction': 90, - 'needs': 'Tepung 12kg, Gula 5kg, Mentega 3kg', - 'date': DateTime.now().subtract(const Duration(days: 2)), - }, - ]; - - final List> _usageBars = [ - {'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}, - ]; - - final List _demandTrend = [28, 32, 40, 36, 44, 50, 48]; - - final List> _usagePie = [ - {'label': 'Tepung', 'value': 40.0, 'color': AppColors.primaryBrown}, - {'label': 'Gula', 'value': 25.0, 'color': AppColors.secondaryOrange}, - {'label': 'Telur', 'value': 20.0, 'color': AppColors.secondaryBlue}, - {'label': 'Mentega', 'value': 15.0, 'color': AppColors.secondaryGreen}, - ]; + @override + void initState() { + super.initState(); + _controller = ReportController(); + _controller.loadReports(); + _controller.startAutoRefresh(); + } Color _statusColor(String status) { switch (status) { @@ -92,6 +42,13 @@ class _ReportScreenState extends State { final width = MediaQuery.of(context).size.width; final cardWidth = (width - 48) / 2; + 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]; + return Scaffold( backgroundColor: AppColors.bgLight, appBar: AppBar( @@ -102,70 +59,106 @@ class _ReportScreenState extends State { style: AppTextStyles.headlineLarge.copyWith(color: Colors.white), ), ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 16, - runSpacing: 16, - children: [ - _buildSummaryCard( - width: cardWidth, - title: 'Total Produk', - value: '24', - icon: Icons.shopping_bag, - color: AppColors.secondaryBlue, - ), - _buildSummaryCard( - width: cardWidth, - title: 'Total Bahan', - value: '18', - icon: Icons.inventory_2, - color: AppColors.primaryBrown, - ), - _buildSummaryCard( - width: cardWidth, - title: 'Stok Kritis', - value: '3', - icon: Icons.warning_amber_rounded, - color: AppColors.statusError, - ), - _buildSummaryCard( - width: cardWidth, - title: 'Total Prediksi', - value: '210', - icon: Icons.trending_up, - color: AppColors.statusSuccess, - ), - ], + body: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + if (_controller.isLoading && + _controller.stockItems.isEmpty && + _controller.stockHistory.isEmpty && + _controller.predictionItems.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + return RefreshIndicator( + onRefresh: () => _controller.loadReports(), + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_controller.errorMessage != null) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.statusError.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.statusError.withOpacity(0.3), + ), + ), + child: Text( + _controller.errorMessage!, + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.statusError, + ), + ), + ), + const SizedBox(height: 16), + ], + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _buildSummaryCard( + width: cardWidth, + title: 'Total Produk', + value: _controller.totalProduk.toString(), + icon: Icons.shopping_bag, + color: AppColors.secondaryBlue, + ), + _buildSummaryCard( + width: cardWidth, + title: 'Total Bahan', + value: _controller.totalBahan.toString(), + icon: Icons.inventory_2, + color: AppColors.primaryBrown, + ), + _buildSummaryCard( + width: cardWidth, + title: 'Stok Kritis', + value: _controller.totalKritis.toString(), + icon: Icons.warning_amber_rounded, + color: AppColors.statusError, + ), + _buildSummaryCard( + width: cardWidth, + title: 'Total Prediksi', + value: _controller.totalPrediksi.toString(), + icon: Icons.trending_up, + color: AppColors.statusSuccess, + ), + ], + ), + const SizedBox(height: 24), + _buildSectionTitle('Laporan Stok Bahan'), + const SizedBox(height: 12), + _buildStockTable(_controller.stockItems), + const SizedBox(height: 24), + _buildSectionTitle('Riwayat Stok Masuk'), + const SizedBox(height: 12), + _buildStockHistory(_controller.stockHistory), + const SizedBox(height: 24), + _buildSectionTitle('Laporan Prediksi Permintaan'), + const SizedBox(height: 12), + _buildPredictionList(_controller.predictionItems), + const SizedBox(height: 24), + _buildSectionTitle('Grafik Penggunaan Bahan'), + const SizedBox(height: 12), + _buildCharts(usageBars, usagePie, demandTrend), + const SizedBox(height: 24), + _buildSectionTitle('Bahan Kritis'), + const SizedBox(height: 12), + _buildCriticalItems(_controller.criticalItems), + const SizedBox(height: 24), + _buildExportButton(), + const SizedBox(height: 24), + ], + ), ), - const SizedBox(height: 24), - _buildSectionTitle('Laporan Stok Bahan'), - const SizedBox(height: 12), - _buildStockTable(), - const SizedBox(height: 24), - _buildSectionTitle('Riwayat Stok Masuk'), - const SizedBox(height: 12), - _buildStockHistory(), - const SizedBox(height: 24), - _buildSectionTitle('Laporan Prediksi Permintaan'), - const SizedBox(height: 12), - _buildPredictionList(), - const SizedBox(height: 24), - _buildSectionTitle('Grafik Penggunaan Bahan'), - const SizedBox(height: 12), - _buildCharts(), - const SizedBox(height: 24), - _buildSectionTitle('Bahan Kritis'), - const SizedBox(height: 12), - _buildCriticalItems(), - const SizedBox(height: 24), - _buildExportButton(), - const SizedBox(height: 24), - ], - ), + ); + }, ), bottomNavigationBar: BottomNavigationBar( currentIndex: 4, @@ -278,7 +271,7 @@ class _ReportScreenState extends State { ); } - Widget _buildStockTable() { + Widget _buildStockTable(List> items) { return Container( decoration: BoxDecoration( color: AppColors.bgWhite, @@ -287,47 +280,125 @@ class _ReportScreenState extends State { ), child: SingleChildScrollView( scrollDirection: Axis.horizontal, - child: DataTable( - columns: const [ - DataColumn(label: Text('Nama Bahan')), - DataColumn(label: Text('Stok Tersedia')), - DataColumn(label: Text('Status')), - ], - rows: - _stockItems.map((item) { - final statusColor = _statusColor(item['status'] as String); - return DataRow( - cells: [ - DataCell(Text(item['name'] as String)), - DataCell(Text('${item['stock']} kg')), - DataCell( - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: statusColor.withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - ), - child: Text( + child: + items.isEmpty + ? Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Data stok bahan belum tersedia.', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + ), + ) + : DataTable( + columns: const [ + DataColumn(label: Text('Nama Bahan')), + DataColumn(label: Text('Stok Tersedia')), + DataColumn(label: Text('Status')), + ], + rows: + items.map((item) { + final statusColor = _statusColor( item['status'] as String, - style: AppTextStyles.labelSmall.copyWith( - color: statusColor, + ); + final unit = item['unit']?.toString() ?? 'kg'; + return DataRow( + cells: [ + DataCell(Text(item['name'] as String)), + DataCell(Text('${item['stock']} $unit')), + DataCell( + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + item['status'] as String, + style: AppTextStyles.labelSmall.copyWith( + color: statusColor, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ); + }).toList(), + ), + ), + ); + } + + Widget _buildStockHistory(List> items) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.bgWhite, + borderRadius: BorderRadius.circular(16), + boxShadow: [AppColors.shadowLight], + ), + child: Column( + children: + items.isEmpty + ? [ + Padding( + padding: const EdgeInsets.all(12), + child: Text( + 'Belum ada riwayat stok masuk.', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + ), + ), + ] + : items + .map( + (entry) => ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, + ), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.primaryBrown.withOpacity(0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.add_circle_outline, + color: AppColors.primaryBrown, + ), + ), + title: Text( + entry['name'] as String, + style: AppTextStyles.labelLarge, + ), + subtitle: Text( + _dateFormat.format(entry['date'] as DateTime), + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textTertiary, + ), + ), + trailing: Text( + '+${entry['amount']} ${entry['unit'] ?? 'kg'}', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.statusSuccess, fontWeight: FontWeight.w700, ), ), ), - ), - ], - ); - }).toList(), - ), + ) + .toList(), ), ); } - Widget _buildStockHistory() { + Widget _buildPredictionList(List> items) { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( @@ -337,97 +408,65 @@ class _ReportScreenState extends State { ), child: Column( children: - _stockHistory - .map( - (entry) => ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 8), - leading: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: AppColors.primaryBrown.withOpacity(0.12), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.add_circle_outline, - color: AppColors.primaryBrown, - ), - ), - title: Text( - entry['name'] as String, - style: AppTextStyles.labelLarge, - ), - subtitle: Text( - _dateFormat.format(entry['date'] as DateTime), + items.isEmpty + ? [ + Padding( + padding: const EdgeInsets.all(12), + child: Text( + 'Belum ada data prediksi.', style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textTertiary, - ), - ), - trailing: Text( - '+${entry['amount']} kg', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.statusSuccess, - fontWeight: FontWeight.w700, + color: AppColors.textSecondary, ), ), ), - ) - .toList(), + ] + : items + .map( + (item) => ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, + ), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.secondaryBlue.withOpacity(0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.trending_up, + color: AppColors.secondaryBlue, + ), + ), + title: Text( + item['product'] as String, + style: AppTextStyles.labelLarge, + ), + subtitle: Text( + '${item['needs']} • ${_dateFormat.format(item['date'] as DateTime)}', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textTertiary, + ), + ), + trailing: Text( + '${item['prediction']} unit', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.primaryBrown, + fontWeight: FontWeight.w700, + ), + ), + ), + ) + .toList(), ), ); } - Widget _buildPredictionList() { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(16), - boxShadow: [AppColors.shadowLight], - ), - child: Column( - children: - _predictionItems - .map( - (item) => ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 8), - leading: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: AppColors.secondaryBlue.withOpacity(0.12), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.trending_up, - color: AppColors.secondaryBlue, - ), - ), - title: Text( - item['product'] as String, - style: AppTextStyles.labelLarge, - ), - subtitle: Text( - '${item['needs']} • ${_dateFormat.format(item['date'] as DateTime)}', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textTertiary, - ), - ), - trailing: Text( - '${item['prediction']} unit', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.primaryBrown, - fontWeight: FontWeight.w700, - ), - ), - ), - ) - .toList(), - ), - ); - } - - Widget _buildCharts() { + Widget _buildCharts( + List> usageBars, + List> usagePie, + List demandTrend, + ) { return Column( children: [ _buildChartCard( @@ -451,13 +490,13 @@ class _ReportScreenState extends State { sideTitles: SideTitles( showTitles: true, getTitlesWidget: (value, meta) { - if (value < 0 || value >= _usageBars.length) { + 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, + usageBars[value.toInt()]['label'] as String, style: AppTextStyles.labelSmall.copyWith( color: AppColors.textSecondary, ), @@ -468,7 +507,7 @@ class _ReportScreenState extends State { ), ), barGroups: - _usageBars.asMap().entries.map((entry) { + usageBars.asMap().entries.map((entry) { return BarChartGroupData( x: entry.key, barRods: [ @@ -524,7 +563,7 @@ class _ReportScreenState extends State { lineBarsData: [ LineChartBarData( spots: - _demandTrend + demandTrend .asMap() .entries .map( @@ -556,7 +595,7 @@ class _ReportScreenState extends State { sectionsSpace: 2, centerSpaceRadius: 40, sections: - _usagePie.map((entry) { + usagePie.map((entry) { return PieChartSectionData( value: entry['value'] as double, color: entry['color'] as Color, @@ -601,10 +640,7 @@ class _ReportScreenState extends State { ); } - Widget _buildCriticalItems() { - final criticalItems = - _stockItems.where((item) => item['status'] == 'Kritis').toList(); - + Widget _buildCriticalItems(List> criticalItems) { if (criticalItems.isEmpty) { return Container( padding: const EdgeInsets.all(16), @@ -652,7 +688,7 @@ class _ReportScreenState extends State { style: AppTextStyles.labelLarge, ), subtitle: Text( - 'Sisa stok: ${item['stock']} kg', + 'Sisa stok: ${item['stock']} ${item['unit'] ?? 'kg'}', style: AppTextStyles.bodySmall.copyWith( color: AppColors.textTertiary, ), @@ -708,4 +744,62 @@ class _ReportScreenState extends State { ), ); } + + 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}, + ]; + } + + final colors = [ + AppColors.primaryBrown, + AppColors.secondaryOrange, + AppColors.secondaryBlue, + AppColors.secondaryGreen, + ]; + + return usageSummary.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + return { + 'label': item['label'] as String, + 'value': item['value'] as double, + 'color': colors[index % colors.length], + }; + }).toList(); + } + + List> _buildUsagePie( + List> usageBars, + ) { + final total = usageBars.fold( + 0, + (sum, item) => sum + (item['value'] as double), + ); + if (total == 0) { + return usageBars; + } + + return usageBars + .map( + (entry) => { + 'label': entry['label'], + 'value': ((entry['value'] as double) / total) * 100, + 'color': entry['color'], + }, + ) + .toList(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } } diff --git a/lib/services/ml_service.dart b/lib/services/ml_service.dart index 2bf822c..7f1b62b 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.91:5000'; static const int timeoutSeconds = 30; @@ -198,6 +198,51 @@ class MLService { } } + // ======================================================================== + // REPORT ENDPOINTS - LAPORAN + // ======================================================================== + + /// Helper GET untuk endpoint laporan (response: {status, message, data}) + static Future> _getReport(String path) async { + try { + final response = await http + .get(Uri.parse('$baseUrl$path')) + .timeout(Duration(seconds: timeoutSeconds)); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } + + return { + 'status': false, + 'message': 'Server error: ${response.statusCode}', + 'data': [], + }; + } catch (e) { + return {'status': false, 'message': 'Connection error: $e', 'data': []}; + } + } + + /// Laporan stok bahan + static Future> getReportStock() async { + return _getReport('/laporan/stok'); + } + + /// Riwayat stok masuk + static Future> getReportStockIn() async { + return _getReport('/laporan/stok-masuk'); + } + + /// Laporan prediksi permintaan + static Future> getReportPredictions() async { + return _getReport('/laporan/prediksi'); + } + + /// Laporan bahan kritis + static Future> getReportCritical() async { + return _getReport('/laporan/bahan-kritis'); + } + // ======================================================================== // DATABASE ENDPOINTS - PRODUCTS & TRANSACTIONS // ======================================================================== diff --git a/ml_model/app.py b/ml_model/app.py index 9ee9770..36000c4 100644 --- a/ml_model/app.py +++ b/ml_model/app.py @@ -70,6 +70,85 @@ def has_product_unit_column(connection) -> bool: cursor.close() return has_unit +def escape_table_name(table_name: str) -> str: + return f"`{table_name}`" + +def get_existing_table(connection, candidates: list[str]) -> str | None: + for table_name in candidates: + if table_exists(connection, table_name): + return table_name + return None + +def get_existing_column(connection, table_name: str, candidates: list[str]) -> str | None: + cursor = connection.cursor() + try: + for column in candidates: + cursor.execute(f"SHOW COLUMNS FROM {escape_table_name(table_name)} LIKE %s", (column,)) + if cursor.fetchone() is not None: + return column + finally: + cursor.close() + return None + +def build_report_response(success: bool, message: str, data: list | None = None, status_code: int = 200): + return jsonify({ + 'status': success, + 'message': message, + 'data': data or [] + }), status_code + +def compute_stock_status(stok: float, stok_minimum: float) -> str: + if stok > stok_minimum: + return 'Aman' + if stok == stok_minimum: + return 'Rendah' + return 'Kritis' + +def fetch_stock_report(connection): + table_name = get_existing_table(connection, ['bahan', 'products']) + if not table_name: + return None, 'Tabel bahan atau products tidak ditemukan' + + name_col = get_existing_column(connection, table_name, ['nama_bahan', 'product_name', 'name']) + stock_col = get_existing_column(connection, table_name, ['stok', 'current_stock', 'stock']) + min_col = get_existing_column(connection, table_name, ['stok_minimum', 'min_stock', 'minimum_stock']) + unit_col = get_existing_column(connection, table_name, ['unit']) + + if not name_col or not stock_col: + return None, 'Kolom bahan/stok tidak ditemukan' + + select_min = min_col if min_col else '0' + select_unit = unit_col if unit_col else "''" + + cursor = connection.cursor(dictionary=True) + cursor.execute( + f""" + SELECT {name_col} AS nama_bahan, + {stock_col} AS stok, + {select_min} AS stok_minimum, + {select_unit} AS unit + FROM {escape_table_name(table_name)} + ORDER BY {name_col} + """ + ) + rows = cursor.fetchall() + cursor.close() + + data = [] + for row in rows: + stok = float(row.get('stok') or 0) + stok_minimum = float(row.get('stok_minimum') or 0) + status = compute_stock_status(stok, stok_minimum) + data.append({ + 'nama_bahan': row.get('nama_bahan'), + 'stok': stok, + 'stok_minimum': stok_minimum, + 'status': status, + 'unit': row.get('unit') or 'kg' + }) + + return data, None + def grams_to_kg_rounded(grams: float) -> float: """ Convert grams to kilograms with rounding rules: @@ -927,6 +1006,205 @@ def save_prediction(): return jsonify({'status': 'error', 'message': str(e)}), 500 +# ============================================================================ +# REPORT ENDPOINTS - LAPORAN +# ============================================================================ + +@app.route('/laporan/stok', methods=['GET']) +def laporan_stok(): + """Laporan stok bahan dari tabel bahan/products.""" + try: + connection = get_db_connection() + if not connection: + return build_report_response(False, 'Database connection failed', [], 500) + + data, error_message = fetch_stock_report(connection) + connection.close() + + if error_message: + return build_report_response(False, error_message, [], 404) + + return build_report_response(True, 'Data berhasil diambil', data, 200) + except Exception as e: + logger.error(f"Laporan stok error: {str(e)}") + return build_report_response(False, f'Gagal mengambil data: {str(e)}', [], 500) + + +@app.route('/laporan/bahan-kritis', methods=['GET']) +def laporan_bahan_kritis(): + """Laporan bahan kritis/ rendah berdasarkan stok minimum.""" + try: + connection = get_db_connection() + if not connection: + return build_report_response(False, 'Database connection failed', [], 500) + + data, error_message = fetch_stock_report(connection) + connection.close() + + if error_message: + return build_report_response(False, error_message, [], 404) + + critical_items = [ + item for item in data + if item['status'] in ['Rendah', 'Kritis'] + ] + + return build_report_response(True, 'Data berhasil diambil', critical_items, 200) + except Exception as e: + logger.error(f"Laporan bahan kritis error: {str(e)}") + return build_report_response(False, f'Gagal mengambil data: {str(e)}', [], 500) + + +@app.route('/laporan/stok-masuk', methods=['GET']) +def laporan_stok_masuk(): + """Laporan riwayat stok masuk dari tabel stok_masuk/Stock In/transactions.""" + try: + connection = get_db_connection() + if not connection: + return build_report_response(False, 'Database connection failed', [], 500) + + table_name = get_existing_table(connection, ['stok_masuk', 'Stock In', 'transactions']) + if not table_name: + connection.close() + return build_report_response(False, 'Tabel stok_masuk/transactions tidak ditemukan', [], 404) + + name_col = get_existing_column(connection, table_name, ['nama_bahan', 'product_name', 'name']) + qty_col = get_existing_column(connection, table_name, ['jumlah', 'quantity']) + date_col = get_existing_column(connection, table_name, ['tanggal', 'transaction_date', 'created_at']) + unit_col = get_existing_column(connection, table_name, ['unit']) + product_id_col = get_existing_column(connection, table_name, ['product_id', 'produk_id', 'bahan_id']) + + if not qty_col or not date_col: + connection.close() + return build_report_response(False, 'Kolom stok masuk tidak lengkap', [], 500) + + cursor = connection.cursor(dictionary=True) + table_sql = escape_table_name(table_name) + + if not name_col and product_id_col: + product_table = get_existing_table(connection, ['products', 'bahan']) + if product_table: + product_name_col = get_existing_column(connection, product_table, ['nama_bahan', 'product_name', 'name']) + product_unit_col = get_existing_column(connection, product_table, ['unit']) + if product_name_col: + cursor.execute( + f""" + SELECT p.{product_name_col} AS nama_bahan, + sm.{qty_col} AS jumlah, + sm.{date_col} AS tanggal, + p.{product_unit_col} AS unit + FROM {table_sql} sm + JOIN {escape_table_name(product_table)} p ON p.id = sm.{product_id_col} + ORDER BY sm.{date_col} DESC + """ + ) + else: + cursor.execute( + f""" + SELECT sm.{qty_col} AS jumlah, + sm.{date_col} AS tanggal + FROM {table_sql} sm + ORDER BY sm.{date_col} DESC + """ + ) + else: + cursor.execute( + f""" + SELECT sm.{qty_col} AS jumlah, + sm.{date_col} AS tanggal + FROM {table_sql} sm + ORDER BY sm.{date_col} DESC + """ + ) + else: + unit_select = unit_col if unit_col else "''" + cursor.execute( + f""" + SELECT {name_col} AS nama_bahan, + {qty_col} AS jumlah, + {date_col} AS tanggal, + {unit_select} AS unit + FROM {table_sql} + ORDER BY {date_col} DESC + """ + ) + + rows = cursor.fetchall() + cursor.close() + connection.close() + + data = [] + for row in rows: + data.append({ + 'nama_bahan': row.get('nama_bahan'), + 'jumlah': float(row.get('jumlah') or 0), + 'tanggal': row.get('tanggal'), + 'unit': row.get('unit') or 'kg' + }) + + return build_report_response(True, 'Data berhasil diambil', data, 200) + except Exception as e: + logger.error(f"Laporan stok masuk error: {str(e)}") + return build_report_response(False, f'Gagal mengambil data: {str(e)}', [], 500) + + +@app.route('/laporan/prediksi', methods=['GET']) +def laporan_prediksi(): + """Laporan prediksi permintaan dari tabel prediksi/predictions.""" + try: + connection = get_db_connection() + if not connection: + return build_report_response(False, 'Database connection failed', [], 500) + + table_name = get_existing_table(connection, ['prediksi', 'predictions']) + if not table_name: + connection.close() + return build_report_response(False, 'Tabel prediksi/predictions tidak ditemukan', [], 404) + + name_col = get_existing_column(connection, table_name, ['nama_produk', 'product_name', 'name']) + result_col = get_existing_column(connection, table_name, ['hasil_prediksi', 'predicted_quantity']) + estimate_col = get_existing_column(connection, table_name, ['estimasi_kebutuhan_bahan', 'estimated_needs', 'raw_value']) + date_col = get_existing_column(connection, table_name, ['tanggal_prediksi', 'prediction_date', 'created_at']) + + if not result_col or not date_col: + connection.close() + return build_report_response(False, 'Kolom prediksi tidak lengkap', [], 500) + + cursor = connection.cursor(dictionary=True) + table_sql = escape_table_name(table_name) + estimate_select = estimate_col if estimate_col else 'NULL' + name_select = name_col if name_col else "''" + + cursor.execute( + f""" + SELECT {name_select} AS nama_produk, + {result_col} AS hasil_prediksi, + {estimate_select} AS estimasi_kebutuhan_bahan, + {date_col} AS tanggal_prediksi + FROM {table_sql} + ORDER BY {date_col} DESC + """ + ) + + rows = cursor.fetchall() + cursor.close() + connection.close() + + data = [] + for row in rows: + data.append({ + 'nama_produk': row.get('nama_produk'), + 'hasil_prediksi': float(row.get('hasil_prediksi') or 0), + 'estimasi_kebutuhan_bahan': row.get('estimasi_kebutuhan_bahan'), + 'tanggal_prediksi': row.get('tanggal_prediksi') + }) + + return build_report_response(True, 'Data berhasil diambil', data, 200) + except Exception as e: + logger.error(f"Laporan prediksi error: {str(e)}") + return build_report_response(False, f'Gagal mengambil data: {str(e)}', [], 500) + + # ============================================================================ # RECIPES ENDPOINTS # ============================================================================