API laporan
This commit is contained in:
parent
97ceebb831
commit
fdbe51d7f3
|
|
@ -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.
|
||||||
|
-- =====================================================
|
||||||
|
|
@ -1,22 +1,223 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:finalproject/services/ml_service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class ReportController extends ChangeNotifier {
|
class ReportController extends ChangeNotifier {
|
||||||
String selectedPeriod = 'bulanan';
|
bool isLoading = true;
|
||||||
|
String? errorMessage;
|
||||||
|
|
||||||
final List<String> periods = ['harian', 'mingguan', 'bulanan', 'tahunan'];
|
final List<Map<String, dynamic>> stockItems = [];
|
||||||
final List<(String, int)> topProducts = const [
|
final List<Map<String, dynamic>> stockHistory = [];
|
||||||
('Tepung Terigu', 25),
|
final List<Map<String, dynamic>> predictionItems = [];
|
||||||
('Gula Pasir', 18),
|
final List<Map<String, dynamic>> criticalItems = [];
|
||||||
('Telur', 15),
|
final List<Map<String, dynamic>> usageSummary = [];
|
||||||
('Mentega', 12),
|
final List<double> demandTrend = [];
|
||||||
('Cokelat Bubuk', 10),
|
|
||||||
];
|
|
||||||
|
|
||||||
void setSelectedPeriod(String value) {
|
int totalProduk = 0;
|
||||||
selectedPeriod = value;
|
int totalBahan = 0;
|
||||||
notifyListeners();
|
int totalPrediksi = 0;
|
||||||
|
int totalKritis = 0;
|
||||||
|
|
||||||
|
int _productCount = 0;
|
||||||
|
|
||||||
|
Timer? _refreshTimer;
|
||||||
|
|
||||||
|
/// Load semua data laporan dari API.
|
||||||
|
Future<void> 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<String, dynamic>;
|
||||||
|
final stockInResponse = results[1] as Map<String, dynamic>;
|
||||||
|
final predictionResponse = results[2] as Map<String, dynamic>;
|
||||||
|
final criticalResponse = results[3] as Map<String, dynamic>;
|
||||||
|
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) =>
|
/// Refresh otomatis agar laporan selalu realtime.
|
||||||
'${value[0].toUpperCase()}${value.substring(1)}';
|
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<String, double> 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:fl_chart/fl_chart.dart';
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'report_controller.dart';
|
||||||
|
|
||||||
class ReportScreen extends StatefulWidget {
|
class ReportScreen extends StatefulWidget {
|
||||||
const ReportScreen({super.key});
|
const ReportScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -14,67 +16,15 @@ class ReportScreen extends StatefulWidget {
|
||||||
class _ReportScreenState extends State<ReportScreen> {
|
class _ReportScreenState extends State<ReportScreen> {
|
||||||
final DateFormat _dateFormat = DateFormat('dd/MM/yyyy');
|
final DateFormat _dateFormat = DateFormat('dd/MM/yyyy');
|
||||||
|
|
||||||
final List<Map<String, dynamic>> _stockItems = [
|
late final ReportController _controller;
|
||||||
{'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'},
|
|
||||||
];
|
|
||||||
|
|
||||||
final List<Map<String, dynamic>> _stockHistory = [
|
@override
|
||||||
{
|
void initState() {
|
||||||
'date': DateTime.now().subtract(const Duration(days: 1)),
|
super.initState();
|
||||||
'name': 'Tepung Terigu 1kg',
|
_controller = ReportController();
|
||||||
'amount': 10,
|
_controller.loadReports();
|
||||||
},
|
_controller.startAutoRefresh();
|
||||||
{
|
}
|
||||||
'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<Map<String, dynamic>> _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<Map<String, dynamic>> _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<double> _demandTrend = [28, 32, 40, 36, 44, 50, 48];
|
|
||||||
|
|
||||||
final List<Map<String, dynamic>> _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},
|
|
||||||
];
|
|
||||||
|
|
||||||
Color _statusColor(String status) {
|
Color _statusColor(String status) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|
@ -92,6 +42,13 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
final width = MediaQuery.of(context).size.width;
|
final width = MediaQuery.of(context).size.width;
|
||||||
final cardWidth = (width - 48) / 2;
|
final cardWidth = (width - 48) / 2;
|
||||||
|
|
||||||
|
final usageBars = _buildUsageBars(_controller.usageSummary);
|
||||||
|
final usagePie = _buildUsagePie(usageBars);
|
||||||
|
final List<double> demandTrend =
|
||||||
|
_controller.demandTrend.isNotEmpty
|
||||||
|
? _controller.demandTrend
|
||||||
|
: const <double>[28, 32, 40, 36, 44, 50, 48];
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.bgLight,
|
backgroundColor: AppColors.bgLight,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
@ -102,70 +59,106 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
style: AppTextStyles.headlineLarge.copyWith(color: Colors.white),
|
style: AppTextStyles.headlineLarge.copyWith(color: Colors.white),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: AnimatedBuilder(
|
||||||
padding: const EdgeInsets.all(16),
|
animation: _controller,
|
||||||
child: Column(
|
builder: (context, _) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
if (_controller.isLoading &&
|
||||||
children: [
|
_controller.stockItems.isEmpty &&
|
||||||
Wrap(
|
_controller.stockHistory.isEmpty &&
|
||||||
spacing: 16,
|
_controller.predictionItems.isEmpty) {
|
||||||
runSpacing: 16,
|
return const Center(child: CircularProgressIndicator());
|
||||||
children: [
|
}
|
||||||
_buildSummaryCard(
|
|
||||||
width: cardWidth,
|
return RefreshIndicator(
|
||||||
title: 'Total Produk',
|
onRefresh: () => _controller.loadReports(),
|
||||||
value: '24',
|
child: SingleChildScrollView(
|
||||||
icon: Icons.shopping_bag,
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
color: AppColors.secondaryBlue,
|
padding: const EdgeInsets.all(16),
|
||||||
),
|
child: Column(
|
||||||
_buildSummaryCard(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
width: cardWidth,
|
children: [
|
||||||
title: 'Total Bahan',
|
if (_controller.errorMessage != null) ...[
|
||||||
value: '18',
|
Container(
|
||||||
icon: Icons.inventory_2,
|
width: double.infinity,
|
||||||
color: AppColors.primaryBrown,
|
padding: const EdgeInsets.all(12),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
_buildSummaryCard(
|
color: AppColors.statusError.withOpacity(0.1),
|
||||||
width: cardWidth,
|
borderRadius: BorderRadius.circular(12),
|
||||||
title: 'Stok Kritis',
|
border: Border.all(
|
||||||
value: '3',
|
color: AppColors.statusError.withOpacity(0.3),
|
||||||
icon: Icons.warning_amber_rounded,
|
),
|
||||||
color: AppColors.statusError,
|
),
|
||||||
),
|
child: Text(
|
||||||
_buildSummaryCard(
|
_controller.errorMessage!,
|
||||||
width: cardWidth,
|
style: AppTextStyles.bodySmall.copyWith(
|
||||||
title: 'Total Prediksi',
|
color: AppColors.statusError,
|
||||||
value: '210',
|
),
|
||||||
icon: Icons.trending_up,
|
),
|
||||||
color: AppColors.statusSuccess,
|
),
|
||||||
),
|
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(
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
currentIndex: 4,
|
currentIndex: 4,
|
||||||
|
|
@ -278,7 +271,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStockTable() {
|
Widget _buildStockTable(List<Map<String, dynamic>> items) {
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.bgWhite,
|
color: AppColors.bgWhite,
|
||||||
|
|
@ -287,47 +280,125 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
),
|
),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: DataTable(
|
child:
|
||||||
columns: const [
|
items.isEmpty
|
||||||
DataColumn(label: Text('Nama Bahan')),
|
? Padding(
|
||||||
DataColumn(label: Text('Stok Tersedia')),
|
padding: const EdgeInsets.all(16),
|
||||||
DataColumn(label: Text('Status')),
|
child: Text(
|
||||||
],
|
'Data stok bahan belum tersedia.',
|
||||||
rows:
|
style: AppTextStyles.bodySmall.copyWith(
|
||||||
_stockItems.map((item) {
|
color: AppColors.textSecondary,
|
||||||
final statusColor = _statusColor(item['status'] as String);
|
),
|
||||||
return DataRow(
|
),
|
||||||
cells: [
|
)
|
||||||
DataCell(Text(item['name'] as String)),
|
: DataTable(
|
||||||
DataCell(Text('${item['stock']} kg')),
|
columns: const [
|
||||||
DataCell(
|
DataColumn(label: Text('Nama Bahan')),
|
||||||
Container(
|
DataColumn(label: Text('Stok Tersedia')),
|
||||||
padding: const EdgeInsets.symmetric(
|
DataColumn(label: Text('Status')),
|
||||||
horizontal: 10,
|
],
|
||||||
vertical: 4,
|
rows:
|
||||||
),
|
items.map((item) {
|
||||||
decoration: BoxDecoration(
|
final statusColor = _statusColor(
|
||||||
color: statusColor.withOpacity(0.15),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
item['status'] as String,
|
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<Map<String, dynamic>> 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,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
],
|
.toList(),
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStockHistory() {
|
Widget _buildPredictionList(List<Map<String, dynamic>> items) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -337,97 +408,65 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children:
|
children:
|
||||||
_stockHistory
|
items.isEmpty
|
||||||
.map(
|
? [
|
||||||
(entry) => ListTile(
|
Padding(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.all(12),
|
||||||
leading: Container(
|
child: Text(
|
||||||
width: 40,
|
'Belum ada data prediksi.',
|
||||||
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(
|
style: AppTextStyles.bodySmall.copyWith(
|
||||||
color: AppColors.textTertiary,
|
color: AppColors.textSecondary,
|
||||||
),
|
|
||||||
),
|
|
||||||
trailing: Text(
|
|
||||||
'+${entry['amount']} kg',
|
|
||||||
style: AppTextStyles.labelLarge.copyWith(
|
|
||||||
color: AppColors.statusSuccess,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
]
|
||||||
.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() {
|
Widget _buildCharts(
|
||||||
return Container(
|
List<Map<String, dynamic>> usageBars,
|
||||||
padding: const EdgeInsets.all(12),
|
List<Map<String, dynamic>> usagePie,
|
||||||
decoration: BoxDecoration(
|
List<double> demandTrend,
|
||||||
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() {
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
_buildChartCard(
|
_buildChartCard(
|
||||||
|
|
@ -451,13 +490,13 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
sideTitles: SideTitles(
|
sideTitles: SideTitles(
|
||||||
showTitles: true,
|
showTitles: true,
|
||||||
getTitlesWidget: (value, meta) {
|
getTitlesWidget: (value, meta) {
|
||||||
if (value < 0 || value >= _usageBars.length) {
|
if (value < 0 || value >= usageBars.length) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 6),
|
padding: const EdgeInsets.only(top: 6),
|
||||||
child: Text(
|
child: Text(
|
||||||
_usageBars[value.toInt()]['label'] as String,
|
usageBars[value.toInt()]['label'] as String,
|
||||||
style: AppTextStyles.labelSmall.copyWith(
|
style: AppTextStyles.labelSmall.copyWith(
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
|
|
@ -468,7 +507,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
barGroups:
|
barGroups:
|
||||||
_usageBars.asMap().entries.map((entry) {
|
usageBars.asMap().entries.map((entry) {
|
||||||
return BarChartGroupData(
|
return BarChartGroupData(
|
||||||
x: entry.key,
|
x: entry.key,
|
||||||
barRods: [
|
barRods: [
|
||||||
|
|
@ -524,7 +563,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
lineBarsData: [
|
lineBarsData: [
|
||||||
LineChartBarData(
|
LineChartBarData(
|
||||||
spots:
|
spots:
|
||||||
_demandTrend
|
demandTrend
|
||||||
.asMap()
|
.asMap()
|
||||||
.entries
|
.entries
|
||||||
.map(
|
.map(
|
||||||
|
|
@ -556,7 +595,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
sectionsSpace: 2,
|
sectionsSpace: 2,
|
||||||
centerSpaceRadius: 40,
|
centerSpaceRadius: 40,
|
||||||
sections:
|
sections:
|
||||||
_usagePie.map((entry) {
|
usagePie.map((entry) {
|
||||||
return PieChartSectionData(
|
return PieChartSectionData(
|
||||||
value: entry['value'] as double,
|
value: entry['value'] as double,
|
||||||
color: entry['color'] as Color,
|
color: entry['color'] as Color,
|
||||||
|
|
@ -601,10 +640,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCriticalItems() {
|
Widget _buildCriticalItems(List<Map<String, dynamic>> criticalItems) {
|
||||||
final criticalItems =
|
|
||||||
_stockItems.where((item) => item['status'] == 'Kritis').toList();
|
|
||||||
|
|
||||||
if (criticalItems.isEmpty) {
|
if (criticalItems.isEmpty) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|
@ -652,7 +688,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
style: AppTextStyles.labelLarge,
|
style: AppTextStyles.labelLarge,
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'Sisa stok: ${item['stock']} kg',
|
'Sisa stok: ${item['stock']} ${item['unit'] ?? 'kg'}',
|
||||||
style: AppTextStyles.bodySmall.copyWith(
|
style: AppTextStyles.bodySmall.copyWith(
|
||||||
color: AppColors.textTertiary,
|
color: AppColors.textTertiary,
|
||||||
),
|
),
|
||||||
|
|
@ -708,4 +744,62 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _buildUsageBars(
|
||||||
|
List<Map<String, dynamic>> 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<Map<String, dynamic>> _buildUsagePie(
|
||||||
|
List<Map<String, dynamic>> usageBars,
|
||||||
|
) {
|
||||||
|
final total = usageBars.fold<double>(
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ class MLService {
|
||||||
// API URL - Change based on environment
|
// API URL - Change based on environment
|
||||||
// Untuk emulator Android: 10.0.2.2
|
// Untuk emulator Android: 10.0.2.2
|
||||||
// Untuk device fisik: 192.168.x.x atau 127.0.0.1 kalau local
|
// 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;
|
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<Map<String, dynamic>> _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<Map<String, dynamic>> getReportStock() async {
|
||||||
|
return _getReport('/laporan/stok');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Riwayat stok masuk
|
||||||
|
static Future<Map<String, dynamic>> getReportStockIn() async {
|
||||||
|
return _getReport('/laporan/stok-masuk');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Laporan prediksi permintaan
|
||||||
|
static Future<Map<String, dynamic>> getReportPredictions() async {
|
||||||
|
return _getReport('/laporan/prediksi');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Laporan bahan kritis
|
||||||
|
static Future<Map<String, dynamic>> getReportCritical() async {
|
||||||
|
return _getReport('/laporan/bahan-kritis');
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// DATABASE ENDPOINTS - PRODUCTS & TRANSACTIONS
|
// DATABASE ENDPOINTS - PRODUCTS & TRANSACTIONS
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
|
||||||
278
ml_model/app.py
278
ml_model/app.py
|
|
@ -70,6 +70,85 @@ def has_product_unit_column(connection) -> bool:
|
||||||
cursor.close()
|
cursor.close()
|
||||||
return has_unit
|
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:
|
def grams_to_kg_rounded(grams: float) -> float:
|
||||||
"""
|
"""
|
||||||
Convert grams to kilograms with rounding rules:
|
Convert grams to kilograms with rounding rules:
|
||||||
|
|
@ -927,6 +1006,205 @@ def save_prediction():
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
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
|
# RECIPES ENDPOINTS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue