API laporan

This commit is contained in:
rhanarmt 2026-05-12 22:34:02 +07:00
parent 97ceebb831
commit fdbe51d7f3
5 changed files with 953 additions and 264 deletions

View File

@ -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.
-- =====================================================

View File

@ -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<String> 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<Map<String, dynamic>> stockItems = [];
final List<Map<String, dynamic>> stockHistory = [];
final List<Map<String, dynamic>> predictionItems = [];
final List<Map<String, dynamic>> criticalItems = [];
final List<Map<String, dynamic>> usageSummary = [];
final List<double> 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<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) =>
'${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<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();
}
}

View File

@ -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<ReportScreen> {
final DateFormat _dateFormat = DateFormat('dd/MM/yyyy');
final List<Map<String, dynamic>> _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<Map<String, dynamic>> _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<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},
];
@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<ReportScreen> {
final width = MediaQuery.of(context).size.width;
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(
backgroundColor: AppColors.bgLight,
appBar: AppBar(
@ -102,70 +59,106 @@ class _ReportScreenState extends State<ReportScreen> {
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<ReportScreen> {
);
}
Widget _buildStockTable() {
Widget _buildStockTable(List<Map<String, dynamic>> items) {
return Container(
decoration: BoxDecoration(
color: AppColors.bgWhite,
@ -287,47 +280,125 @@ class _ReportScreenState extends State<ReportScreen> {
),
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<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,
),
),
),
),
],
);
}).toList(),
),
)
.toList(),
),
);
}
Widget _buildStockHistory() {
Widget _buildPredictionList(List<Map<String, dynamic>> items) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
@ -337,97 +408,65 @@ class _ReportScreenState extends State<ReportScreen> {
),
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<Map<String, dynamic>> usageBars,
List<Map<String, dynamic>> usagePie,
List<double> demandTrend,
) {
return Column(
children: [
_buildChartCard(
@ -451,13 +490,13 @@ class _ReportScreenState extends State<ReportScreen> {
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<ReportScreen> {
),
),
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<ReportScreen> {
lineBarsData: [
LineChartBarData(
spots:
_demandTrend
demandTrend
.asMap()
.entries
.map(
@ -556,7 +595,7 @@ class _ReportScreenState extends State<ReportScreen> {
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<ReportScreen> {
);
}
Widget _buildCriticalItems() {
final criticalItems =
_stockItems.where((item) => item['status'] == 'Kritis').toList();
Widget _buildCriticalItems(List<Map<String, dynamic>> criticalItems) {
if (criticalItems.isEmpty) {
return Container(
padding: const EdgeInsets.all(16),
@ -652,7 +688,7 @@ class _ReportScreenState extends State<ReportScreen> {
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<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();
}
}

View File

@ -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<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
// ========================================================================

View File

@ -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
# ============================================================================