memperbarui fitur fitur 1

This commit is contained in:
rhanarmt 2026-05-13 20:07:51 +07:00
parent fdbe51d7f3
commit 16594b95e6
23 changed files with 1507 additions and 304 deletions

View File

@ -8,7 +8,7 @@ plugins {
android { android {
namespace = "com.example.finalproject" namespace = "com.example.finalproject"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = "27.0.12077973"
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11

View File

@ -5,7 +5,7 @@ class Product {
final int price; final int price;
final int stock; final int stock;
final String unit; final String unit;
final String status; // 'tersedia', 'rendah', 'kritis' final String status; // 'tersedia', 'sedang', 'kritis'
Product({ Product({
required this.id, required this.id,

View File

@ -1,32 +1,175 @@
import 'package:finalproject/theme/colors.dart'; import 'package:finalproject/services/ml_service.dart';
import 'package:finalproject/utils/stock_status.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class DashboardController extends ChangeNotifier { class DashboardController extends ChangeNotifier {
int selectedIndex = 0; int selectedIndex = 0;
final List<Map<String, dynamic>> lowStockItems = [ bool isLoading = true;
{ bool isBahanDigunakanLoading = true;
'name': 'Tepung Terigu', String? errorMessage;
'stock': '5 kg', String? bahanDigunakanError;
'status': 'Kritis',
'statusColor': AppColors.statusError, double totalBahanDigunakanHariIni = 0;
}, String bahanDigunakanSatuan = 'kg';
{ String bahanDigunakanKeterangan = 'total penggunaan hari ini';
'name': 'Gula Pasir', int totalProduk = 0;
'stock': '8 kg',
'status': 'Rendah', final List<Map<String, dynamic>> penggunaanBahan = [];
'statusColor': AppColors.statusWarning, final List<Map<String, dynamic>> lowStockItems = [];
},
{
'name': 'Mentega',
'stock': '3 kg',
'status': 'Kritis',
'statusColor': AppColors.statusError,
},
];
void setSelectedIndex(int index) { void setSelectedIndex(int index) {
selectedIndex = index; selectedIndex = index;
notifyListeners(); notifyListeners();
} }
Future<void> loadDashboard({bool showLoading = true}) async {
if (showLoading) {
isLoading = true;
isBahanDigunakanLoading = true;
notifyListeners();
}
try {
errorMessage = null;
bahanDigunakanError = null;
final results = await Future.wait([
MLService.getBahanDigunakanHariIni(),
MLService.getDashboardSummary(),
MLService.getProducts(),
MLService.getReportCritical(),
]);
final bahanDigunakan = results[0] as Map<String, dynamic>;
final summary = results[1] as Map<String, dynamic>;
final products = results[2] as List;
final critical = results[3] as Map<String, dynamic>;
_applyBahanDigunakanHariIni(bahanDigunakan);
if (summary['status'] == true) {
_applyPenggunaanBahan(summary['penggunaan_bahan']);
} else {
errorMessage = summary['message']?.toString();
_applyDummyPenggunaan();
}
totalProduk = products.length;
if (critical['status'] == true) {
_applyLowStockItems(critical['data']);
} else {
_applyDummyLowStock();
}
} catch (e) {
errorMessage = 'Gagal memuat dashboard: $e';
bahanDigunakanError = 'Gagal memuat bahan digunakan hari ini';
_resetBahanDigunakanHariIni();
_applyDummyPenggunaan();
_applyDummyLowStock();
} finally {
isLoading = false;
isBahanDigunakanLoading = false;
notifyListeners();
}
}
void _applyBahanDigunakanHariIni(Map<String, dynamic> data) {
if (data['status'] == false) {
bahanDigunakanError = data['message']?.toString();
_resetBahanDigunakanHariIni();
return;
}
totalBahanDigunakanHariIni = (data['total'] as num?)?.toDouble() ?? 0;
bahanDigunakanSatuan = data['satuan']?.toString() ?? 'kg';
bahanDigunakanKeterangan =
data['keterangan']?.toString() ?? 'total penggunaan hari ini';
}
void _resetBahanDigunakanHariIni() {
totalBahanDigunakanHariIni = 0;
bahanDigunakanSatuan = 'kg';
bahanDigunakanKeterangan = 'total penggunaan hari ini';
}
void _applyPenggunaanBahan(dynamic data) {
penggunaanBahan.clear();
if (data is! List || data.isEmpty) {
_applyDummyPenggunaan();
return;
}
for (final item in data) {
penggunaanBahan.add({
'name': item['nama_bahan']?.toString() ?? '-',
'total': (item['total_digunakan'] as num?)?.toDouble() ?? 0,
'unit': item['satuan']?.toString() ?? 'kg',
});
}
}
void _applyLowStockItems(dynamic data) {
lowStockItems.clear();
if (data is! List || data.isEmpty) {
_applyDummyLowStock();
return;
}
for (final item in data) {
final stockValue = StockStatusUtils.parseStock(item['stok']);
final statusKey = StockStatusUtils.statusFromStock(stockValue);
final unit = item['unit'] ?? 'kg';
final stockLabel = item['stok']?.toString() ?? '0';
lowStockItems.add({
'name': item['nama_bahan']?.toString() ?? '-',
'stock': '$stockLabel $unit',
'status': StockStatusUtils.label(statusKey),
'statusColor': StockStatusUtils.color(statusKey),
});
}
}
void _applyDummyPenggunaan() {
penggunaanBahan
..clear()
..addAll([
{'name': 'Tepung Terigu', 'total': 120.0, 'unit': 'kg'},
{'name': 'Gula', 'total': 80.0, 'unit': 'kg'},
{'name': 'Mentega', 'total': 45.0, 'unit': 'kg'},
{'name': 'Telur', 'total': 30.0, 'unit': 'kg'},
{'name': 'Coklat Bubuk', 'total': 20.0, 'unit': 'kg'},
]);
}
void _applyDummyLowStock() {
lowStockItems
..clear()
..addAll([
{
'name': 'Tepung Terigu',
'stock': '5 kg',
'status': StockStatusUtils.label(StockStatusUtils.statusFromStock(5)),
'statusColor': StockStatusUtils.color(
StockStatusUtils.statusFromStock(5),
),
},
{
'name': 'Gula Pasir',
'stock': '8 kg',
'status': StockStatusUtils.label(StockStatusUtils.statusFromStock(8)),
'statusColor': StockStatusUtils.color(
StockStatusUtils.statusFromStock(8),
),
},
{
'name': 'Mentega',
'stock': '3 kg',
'status': StockStatusUtils.label(StockStatusUtils.statusFromStock(3)),
'statusColor': StockStatusUtils.color(
StockStatusUtils.statusFromStock(3),
),
},
]);
}
} }

View File

@ -1,5 +1,6 @@
import 'package:finalproject/theme/colors.dart'; import 'package:finalproject/theme/colors.dart';
import 'package:finalproject/theme/text_styles.dart'; import 'package:finalproject/theme/text_styles.dart';
import 'package:finalproject/utils/route_observer.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dashboard_controller.dart'; import 'dashboard_controller.dart';
@ -11,24 +12,41 @@ class DashboardScreen extends StatefulWidget {
State<DashboardScreen> createState() => _DashboardScreenState(); State<DashboardScreen> createState() => _DashboardScreenState();
} }
class _DashboardScreenState extends State<DashboardScreen> { class _DashboardScreenState extends State<DashboardScreen> with RouteAware {
late final DashboardController _controller; late final DashboardController _controller;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = DashboardController(); _controller = DashboardController();
_controller.loadDashboard();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
if (route != null) {
routeObserver.subscribe(this, route);
}
}
@override
void didPopNext() {
_controller.loadDashboard(showLoading: false);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final topPadding = MediaQuery.of(context).padding.top;
final headerHeight = 240 + topPadding;
return AnimatedBuilder( return AnimatedBuilder(
animation: _controller, animation: _controller,
builder: (context, _) { builder: (context, _) {
return Scaffold( return Scaffold(
backgroundColor: AppColors.bgLight, backgroundColor: AppColors.bgLight,
appBar: PreferredSize( appBar: PreferredSize(
preferredSize: const Size.fromHeight(240), preferredSize: Size.fromHeight(headerHeight),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.primaryBrown, color: AppColors.primaryBrown,
@ -57,7 +75,9 @@ class _DashboardScreenState extends State<DashboardScreen> {
width: 40, width: 40,
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.3), color: Colors.white.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: IconButton( child: IconButton(
@ -136,18 +156,21 @@ class _DashboardScreenState extends State<DashboardScreen> {
children: [ children: [
Expanded( Expanded(
child: _buildStatCard( child: _buildStatCard(
title: 'Total Penjualan', title: 'Bahan Digunakan Hari Ini',
value: 'Rp 67 Jt', value:
change: '+12.5%', '${_formatNumber(_controller.totalBahanDigunakanHariIni)} ${_controller.bahanDigunakanSatuan}',
icon: Icons.trending_up, change: _controller.bahanDigunakanKeterangan,
icon: Icons.inventory_2_outlined,
iconBgColor: AppColors.statusSuccess, iconBgColor: AppColors.statusSuccess,
isLoading: _controller.isBahanDigunakanLoading,
hasError: _controller.bahanDigunakanError != null,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: _buildStatCard( child: _buildStatCard(
title: 'Produk', title: 'Produk',
value: '24', value: _controller.totalProduk.toString(),
change: 'Aktif', change: 'Aktif',
icon: Icons.shopping_bag, icon: Icons.shopping_bag,
iconBgColor: AppColors.secondaryBlue, iconBgColor: AppColors.secondaryBlue,
@ -187,7 +210,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Grafik Penjualan', 'Penggunaan Bahan',
style: AppTextStyles.headlineSmall style: AppTextStyles.headlineSmall
.copyWith( .copyWith(
color: AppColors.textPrimary, color: AppColors.textPrimary,
@ -195,7 +218,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
'6 Bulan Terakhir', 'Top 5 bahan paling sering digunakan',
style: AppTextStyles.bodySmall.copyWith( style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textTertiary, color: AppColors.textTertiary,
), ),
@ -203,57 +226,27 @@ class _DashboardScreenState extends State<DashboardScreen> {
], ],
), ),
Icon( Icon(
Icons.trending_up, Icons.bar_chart_rounded,
color: AppColors.statusSuccess, color: AppColors.secondaryBlue,
size: 20, size: 20,
), ),
], ],
), ),
const SizedBox(height: 20), const SizedBox(height: 16),
SizedBox( if (_controller.isLoading)
height: 200, const Center(child: CircularProgressIndicator())
child: Column( else
mainAxisAlignment: Column(
MainAxisAlignment.spaceBetween, children:
crossAxisAlignment: CrossAxisAlignment.start, _controller.penggunaanBahan.map((item) {
children: [ return Padding(
...[ padding: const EdgeInsets.only(
'80000000', bottom: 12,
'60000000', ),
'40000000', child: _buildUsageRow(item),
'20000000', );
'0', }).toList(),
].map((label) {
return Text(
label,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.grey300,
),
);
}).toList(),
],
), ),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children:
[
'Jan',
'Feb',
'Mar',
'Apr',
'Mei',
'Jun',
].map((month) {
return Text(
month,
style: AppTextStyles.labelSmall.copyWith(
color: AppColors.textTertiary,
),
);
}).toList(),
),
], ],
), ),
), ),
@ -336,7 +329,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
], ],
), ),
); );
}).toList(), }),
], ],
), ),
), ),
@ -469,9 +462,12 @@ class _DashboardScreenState extends State<DashboardScreen> {
required String change, required String change,
required IconData icon, required IconData icon,
required Color iconBgColor, required Color iconBgColor,
bool isLoading = false,
bool hasError = false,
}) { }) {
return Container( return Container(
padding: const EdgeInsets.all(14), constraints: const BoxConstraints(minHeight: 120),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.bgWhite, color: AppColors.bgWhite,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
@ -490,38 +486,63 @@ class _DashboardScreenState extends State<DashboardScreen> {
children: [ children: [
Text( Text(
title, title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTextStyles.bodySmall.copyWith( style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textTertiary, color: AppColors.textTertiary,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
fontSize: 11,
height: 1.2,
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 4),
Text( if (isLoading)
value, const SizedBox(
style: AppTextStyles.titleLarge.copyWith( width: 22,
color: AppColors.textPrimary, height: 22,
fontWeight: FontWeight.w700, child: CircularProgressIndicator(strokeWidth: 2.4),
)
else
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
value,
maxLines: 1,
style: AppTextStyles.titleLarge.copyWith(
color:
hasError
? AppColors.textSecondary
: AppColors.textPrimary,
fontWeight: FontWeight.w700,
fontSize: 18,
height: 1.1,
),
),
), ),
), const SizedBox(height: 4),
const SizedBox(height: 6),
Text( Text(
change, change,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTextStyles.bodySmall.copyWith( style: AppTextStyles.bodySmall.copyWith(
color: color:
change.contains('+') hasError
? AppColors.statusSuccess ? AppColors.statusError
: AppColors.textTertiary, : AppColors.textTertiary,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 11,
height: 1.2,
), ),
), ),
], ],
), ),
), ),
Container( Container(
width: 44, width: 40,
height: 44, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: iconBgColor.withOpacity(0.15), color: iconBgColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: Center(child: Icon(icon, color: iconBgColor, size: 22)), child: Center(child: Icon(icon, color: iconBgColor, size: 22)),
@ -533,8 +554,66 @@ class _DashboardScreenState extends State<DashboardScreen> {
); );
} }
String _formatNumber(num value) {
if (value % 1 == 0) {
return value.toInt().toString();
}
return value.toStringAsFixed(1);
}
Widget _buildUsageRow(Map<String, dynamic> item) {
final total = (item['total'] as num?)?.toDouble() ?? 0.0;
final unit = item['unit']?.toString() ?? 'kg';
final max = _controller.penggunaanBahan.fold<double>(
0,
(value, element) =>
(((element['total'] as num?)?.toDouble() ?? 0.0) > value)
? (element['total'] as num?)!.toDouble()
: value,
);
final progress = max == 0 ? 0.0 : total / max;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
item['name']?.toString() ?? '-',
style: AppTextStyles.labelLarge.copyWith(
color: AppColors.textPrimary,
fontWeight: FontWeight.w600,
),
),
),
Text(
'${total.toStringAsFixed(0)} $unit',
style: AppTextStyles.labelSmall.copyWith(
color: AppColors.textSecondary,
),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: progress,
minHeight: 8,
backgroundColor: AppColors.grey200,
valueColor: AlwaysStoppedAnimation<Color>(AppColors.secondaryBlue),
),
),
],
);
}
@override @override
void dispose() { void dispose() {
routeObserver.unsubscribe(this);
_controller.dispose(); _controller.dispose();
super.dispose(); super.dispose();
} }

View File

@ -179,7 +179,7 @@ class PredictionController extends ChangeNotifier {
amount: neededAmount, amount: neededAmount,
fromUnit: getIngredientUnit(ingredient), fromUnit: getIngredientUnit(ingredient),
); );
final available = currentStock[ingredient] ?? 0; final available = getCurrentStock(ingredient);
if (available < requiredInStockUnit) { if (available < requiredInStockUnit) {
insufficient[ingredient] = requiredInStockUnit - available; insufficient[ingredient] = requiredInStockUnit - available;
} }
@ -200,15 +200,28 @@ class PredictionController extends ChangeNotifier {
} }
String getStockUnit(String ingredient) { String getStockUnit(String ingredient) {
final unit = productUnits[ingredient]; final key = _matchingProductName(ingredient);
if (unit != null && unit.isNotEmpty) return unit; final unit = key == null ? productUnits[ingredient] : productUnits[key];
if (unit != null && unit.isNotEmpty) return _normalizeUnit(unit);
return 'kg'; return 'kg';
} }
double getCurrentStock(String ingredient) {
final key = _matchingProductName(ingredient);
return key == null ? currentStock[ingredient] ?? 0 : currentStock[key] ?? 0;
}
int? getProductId(String ingredient) {
final key = _matchingProductName(ingredient);
return key == null ? productIds[ingredient] : productIds[key];
}
double toGram({required double amount, required String unit}) { double toGram({required double amount, required String unit}) {
final normalized = unit.toLowerCase(); final normalized = _normalizeUnit(unit);
if (normalized == 'gr') return amount; if (normalized == 'gr') return amount;
if (normalized == 'kg') return amount * 1000; if (normalized == 'kg') return amount * 1000;
if (normalized == 'ml') return amount;
if (normalized == 'l') return amount * 1000;
if (normalized == 'butir') return amount * eggGramPerButir; if (normalized == 'butir') return amount * eggGramPerButir;
return amount; return amount;
} }
@ -228,14 +241,22 @@ class PredictionController extends ChangeNotifier {
amount: requiredIngredients[ingredient] ?? 0, amount: requiredIngredients[ingredient] ?? 0,
fromUnit: getIngredientUnit(ingredient), fromUnit: getIngredientUnit(ingredient),
); );
final available = currentStock[ingredient] ?? 0; final available = getCurrentStock(ingredient);
return available >= required return available >= required
? const Color(0xFF10B981) ? const Color(0xFF10B981)
: const Color(0xFFDC2626); : const Color(0xFFDC2626);
} }
String cleanIngredientName(String ingredient) { String cleanIngredientName(String ingredient) {
return ingredient.replaceAll(RegExp(r' \d+(kg|gr)'), '').trim(); return ingredient
.replaceAll(
RegExp(
r'\s+\d+([,.]\d+)?\s*(kg|kilogram|g|gr|gram|ml|l|liter|ltr|butir|pcs)\b',
caseSensitive: false,
),
'',
)
.trim();
} }
String formatQuantity(double value) { String formatQuantity(double value) {
@ -248,13 +269,38 @@ class PredictionController extends ChangeNotifier {
.replaceAll(RegExp(r'\.$'), ''); .replaceAll(RegExp(r'\.$'), '');
} }
String formatStockQuantity(String ingredient, double value) {
final unit = getStockUnit(ingredient);
if (unit == 'kg' && value > 0 && value < 1) {
return '${formatQuantity(value * 1000)} gr';
}
if (unit == 'l' && value > 0 && value < 1) {
return '${formatQuantity(value * 1000)} ml';
}
return '${formatQuantity(value)} $unit';
}
String _normalizeUnit(String unit) {
final normalized = unit.trim().toLowerCase();
if (['g', 'gr', 'gram', 'grams'].contains(normalized)) return 'gr';
if (['kg', 'kilogram', 'kilograms'].contains(normalized)) return 'kg';
if (['ml', 'mili', 'mililiter', 'milliliter'].contains(normalized)) {
return 'ml';
}
if (['l', 'lt', 'ltr', 'liter', 'litre'].contains(normalized)) return 'l';
if (['butir', 'pcs', 'piece', 'pieces'].contains(normalized)) {
return 'butir';
}
return normalized;
}
double _convertToStockUnit({ double _convertToStockUnit({
required String ingredient, required String ingredient,
required double amount, required double amount,
required String fromUnit, required String fromUnit,
}) { }) {
final stockUnit = getStockUnit(ingredient).toLowerCase(); final stockUnit = _normalizeUnit(getStockUnit(ingredient));
final unit = fromUnit.toLowerCase(); final unit = _normalizeUnit(fromUnit);
if (unit == stockUnit) return amount; if (unit == stockUnit) return amount;
@ -267,6 +313,14 @@ class PredictionController extends ChangeNotifier {
if (unit == 'butir' && stockUnit == 'gr') { if (unit == 'butir' && stockUnit == 'gr') {
return amount * eggGramPerButir; return amount * eggGramPerButir;
} }
if (unit == 'ml' && stockUnit == 'kg') return amount / 1000;
if (unit == 'l' && stockUnit == 'kg') return amount;
if (unit == 'kg' && stockUnit == 'ml') return amount * 1000;
if (unit == 'kg' && stockUnit == 'l') return amount;
if (unit == 'ml' && stockUnit == 'gr') return amount;
if (unit == 'gr' && stockUnit == 'ml') return amount;
if (unit == 'l' && stockUnit == 'ml') return amount * 1000;
if (unit == 'ml' && stockUnit == 'l') return amount / 1000;
if (unit == 'kg' && stockUnit == 'butir') { if (unit == 'kg' && stockUnit == 'butir') {
return (amount * 1000) / eggGramPerButir; return (amount * 1000) / eggGramPerButir;
} }
@ -277,23 +331,52 @@ class PredictionController extends ChangeNotifier {
return amount; return amount;
} }
double _roundRequiredGramToKg(double grams) { String _ingredientKey(String value) {
if (grams <= 0) return 0; return cleanIngredientName(
return MLService.gramsToKgRounded(grams); value,
).toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
} }
Map<String, double> get roundedUsage { String? _matchingProductName(String ingredient) {
if (currentStock.containsKey(ingredient)) return ingredient;
final target = _ingredientKey(ingredient);
for (final name in currentStock.keys) {
if (_ingredientKey(name) == target) return name;
}
return null;
}
Map<String, double> get stockUsage {
final required = requiredIngredients; final required = requiredIngredients;
final rounded = <String, double>{}; final usage = <String, double>{};
required.forEach((ingredient, neededAmount) { required.forEach((ingredient, neededAmount) {
if (!isIngredientSelected(ingredient)) return; if (!isIngredientSelected(ingredient)) return;
final unit = getIngredientUnit(ingredient); final unit = getIngredientUnit(ingredient);
final requiredGram = toGram(amount: neededAmount, unit: unit); usage[ingredient] = _convertToStockUnit(
rounded[ingredient] = _roundRequiredGramToKg(requiredGram); ingredient: ingredient,
amount: neededAmount,
fromUnit: unit,
);
}); });
return rounded; return usage;
}
String buildEstimatedNeedsText() {
final usage = stockUsage;
if (usage.isEmpty) return 'Belum tersedia';
return usage.entries
.where((entry) => entry.value > 0)
.map((entry) {
final name = cleanIngredientName(entry.key);
final amount = formatStockQuantity(entry.key, entry.value);
return '$name: $amount';
})
.join(', ');
} }
Future<Map<String, dynamic>> submitProduction() async { Future<Map<String, dynamic>> submitProduction() async {
@ -323,20 +406,20 @@ class PredictionController extends ChangeNotifier {
}; };
} }
final rounded = roundedUsage; final usage = stockUsage;
if (rounded.isEmpty) { if (usage.isEmpty) {
return {'status': 'error', 'message': 'Tidak ada bahan yang dipilih'}; return {'status': 'error', 'message': 'Tidak ada bahan yang dipilih'};
} }
final items = final items =
rounded.entries.where((entry) => entry.value > 0).map((entry) { usage.entries.where((entry) => entry.value > 0).map((entry) {
final ingredient = entry.key; final ingredient = entry.key;
final quantityKg = entry.value; final quantity = entry.value;
return { return {
'product_id': productIds[ingredient], 'product_id': getProductId(ingredient),
'product_name': ingredient, 'product_name': ingredient,
'quantity': quantityKg, 'quantity': quantity,
'unit': 'kg', 'unit': getStockUnit(ingredient),
}; };
}).toList(); }).toList();
@ -347,14 +430,22 @@ class PredictionController extends ChangeNotifier {
); );
if (result['status'] == 'success') { if (result['status'] == 'success') {
for (final entry in rounded.entries) { // Simpan hasil prediksi ke database agar laporan total prediksi ter-update.
await MLService.savePrediction(
productName: selectedRecipe ?? 'Produk',
category: 'Produk',
unitPrice: 0,
predictionDate: DateTime.now().toIso8601String().split('T').first,
predictedQuantity: productionQuantity,
estimatedNeeds: buildEstimatedNeedsText(),
);
for (final entry in usage.entries) {
final ingredient = entry.key; final ingredient = entry.key;
final quantity = entry.value; final quantity = entry.value;
final available = currentStock[ingredient] ?? 0; final key = _matchingProductName(ingredient) ?? ingredient;
currentStock[ingredient] = (available - quantity).clamp( final available = currentStock[key] ?? 0;
0, currentStock[key] = (available - quantity).clamp(0, double.infinity);
double.infinity,
);
} }
await refreshStock(); await refreshStock();
} }

View File

@ -706,10 +706,8 @@ class _PredictionScreenState extends State<PredictionScreen> {
_controller _controller
.productionQuantity .productionQuantity
: 0.0; : 0.0;
final stockKg = final stockKg = _controller
_controller .getCurrentStock(ingredient);
.currentStock[ingredient] ??
0.0;
final requiredGram = final requiredGram =
isSelected isSelected
? _controller.toGram( ? _controller.toGram(
@ -845,7 +843,10 @@ class _PredictionScreenState extends State<PredictionScreen> {
), ),
), ),
Text( Text(
'${_controller.formatQuantity(stockKg)} kg', _controller.formatStockQuantity(
ingredient,
stockKg,
),
style: const TextStyle( style: const TextStyle(
fontSize: fontSize:
12, 12,
@ -921,7 +922,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (_controller.roundedUsage.isNotEmpty) ...[ if (_controller.stockUsage.isNotEmpty) ...[
const Text( const Text(
'Ringkasan Pengurangan Stok', 'Ringkasan Pengurangan Stok',
style: TextStyle( style: TextStyle(
@ -946,13 +947,11 @@ class _PredictionScreenState extends State<PredictionScreen> {
), ),
child: Column( child: Column(
children: children:
_controller.roundedUsage.entries.map(( _controller.stockUsage.entries.map((
entry, entry,
) { ) {
final ingredient = entry.key; final ingredient = entry.key;
final amount = entry.value; final amount = entry.value;
final stockUnit = _controller
.getStockUnit(ingredient);
return Padding( return Padding(
padding: padding:
const EdgeInsets.symmetric( const EdgeInsets.symmetric(
@ -980,7 +979,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
), ),
), ),
Text( Text(
'-${_controller.formatQuantity(amount)} $stockUnit', '-${_controller.formatStockQuantity(ingredient, amount)}',
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: fontWeight:

View File

@ -1,5 +1,6 @@
import 'package:finalproject/models/product_model.dart'; import 'package:finalproject/models/product_model.dart';
import 'package:finalproject/services/ml_service.dart'; import 'package:finalproject/services/ml_service.dart';
import 'package:finalproject/utils/stock_status.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ProductListController extends ChangeNotifier { class ProductListController extends ChangeNotifier {
@ -16,12 +17,6 @@ class ProductListController extends ChangeNotifier {
try { try {
final fetchedProducts = await MLService.getProducts(); final fetchedProducts = await MLService.getProducts();
String getStatus(int stock) {
if (stock == 0) return 'kritis';
if (stock <= 5) return 'rendah';
return 'tersedia';
}
products = products =
fetchedProducts.map((p) { fetchedProducts.map((p) {
final stock = p['current_stock'] ?? 0; final stock = p['current_stock'] ?? 0;
@ -36,7 +31,7 @@ class ProductListController extends ChangeNotifier {
price: p['price'] ?? 0, price: p['price'] ?? 0,
stock: stock, stock: stock,
unit: unit, unit: unit,
status: getStatus(stock), status: StockStatusUtils.statusFromStock(stock),
); );
}).toList(); }).toList();
} finally { } finally {
@ -84,16 +79,7 @@ class ProductListController extends ChangeNotifier {
} }
Color getStatusColor(String status) { Color getStatusColor(String status) {
switch (status) { return StockStatusUtils.color(status);
case 'tersedia':
return const Color(0xFF10B981);
case 'rendah':
return const Color(0xFFFB923C);
case 'kritis':
return const Color(0xFFDC2626);
default:
return const Color(0xFF9CA3AF);
}
} }
IconData getCategoryIcon(String category) { IconData getCategoryIcon(String category) {
@ -122,16 +108,7 @@ class ProductListController extends ChangeNotifier {
String formatPrice(int price) => 'Rp ${(price ~/ 1000)}K'; String formatPrice(int price) => 'Rp ${(price ~/ 1000)}K';
String getStatusLabel(String status) { String getStatusLabel(String status) {
switch (status) { return StockStatusUtils.label(status, withIcon: true);
case 'tersedia':
return '✅ Tersedia';
case 'rendah':
return '⚠️ Rendah';
case 'kritis':
return '🔴 Kritis';
default:
return 'Unknown';
}
} }
String capitalize(String text) => String capitalize(String text) =>

View File

@ -186,7 +186,7 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
children: children:
['semua', 'tersedia', 'rendah', 'kritis'] ['semua', 'tersedia', 'sedang', 'kritis']
.map( .map(
(filter) => Padding( (filter) => Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(

View File

@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:async';
import 'package:finalproject/services/ml_service.dart'; import 'package:finalproject/services/ml_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View File

@ -1,8 +1,14 @@
import 'dart:io';
import 'package:finalproject/theme/colors.dart'; import 'package:finalproject/theme/colors.dart';
import 'package:finalproject/theme/text_styles.dart'; import 'package:finalproject/theme/text_styles.dart';
import 'package:flutter/material.dart'; import 'package:finalproject/utils/stock_status.dart';
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:open_filex/open_filex.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'report_controller.dart'; import 'report_controller.dart';
@ -15,8 +21,12 @@ 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 DateFormat _fileDateFormat = DateFormat('yyyyMMdd_HHmmss');
final ScrollController _stockTableScrollController = ScrollController();
final ScrollController _stockHistoryScrollController = ScrollController();
late final ReportController _controller; late final ReportController _controller;
String? _lastExportPath;
@override @override
void initState() { void initState() {
@ -26,15 +36,11 @@ class _ReportScreenState extends State<ReportScreen> {
_controller.startAutoRefresh(); _controller.startAutoRefresh();
} }
Color _statusColor(String status) { double _sectionMaxHeight() {
switch (status) { final height = MediaQuery.of(context).size.height * 0.35;
case 'Kritis': if (height < 220) return 220;
return AppColors.statusError; if (height > 360) return 360;
case 'Rendah': return height;
return AppColors.statusWarning;
default:
return AppColors.statusSuccess;
}
} }
@override @override
@ -82,10 +88,10 @@ class _ReportScreenState extends State<ReportScreen> {
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.statusError.withOpacity(0.1), color: AppColors.statusError.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: AppColors.statusError.withOpacity(0.3), color: AppColors.statusError.withValues(alpha: 0.3),
), ),
), ),
child: Text( child: Text(
@ -152,7 +158,7 @@ class _ReportScreenState extends State<ReportScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
_buildCriticalItems(_controller.criticalItems), _buildCriticalItems(_controller.criticalItems),
const SizedBox(height: 24), const SizedBox(height: 24),
_buildExportButton(), _buildExportActions(),
const SizedBox(height: 24), const SizedBox(height: 24),
], ],
), ),
@ -233,7 +239,7 @@ class _ReportScreenState extends State<ReportScreen> {
width: 42, width: 42,
height: 42, height: 42,
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withOpacity(0.15), color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Icon(icon, color: color, size: 22), child: Icon(icon, color: color, size: 22),
@ -278,59 +284,81 @@ class _ReportScreenState extends State<ReportScreen> {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: [AppColors.shadowLight], boxShadow: [AppColors.shadowLight],
), ),
child: SingleChildScrollView( child:
scrollDirection: Axis.horizontal, items.isEmpty
child: ? Padding(
items.isEmpty padding: const EdgeInsets.all(16),
? Padding( child: Text(
padding: const EdgeInsets.all(16), 'Data stok bahan belum tersedia.',
child: Text( style: AppTextStyles.bodySmall.copyWith(
'Data stok bahan belum tersedia.', color: AppColors.textSecondary,
style: AppTextStyles.bodySmall.copyWith( ),
color: AppColors.textSecondary, ),
)
: SizedBox(
height: _sectionMaxHeight(),
child: Scrollbar(
thumbVisibility: true,
controller: _stockTableScrollController,
child: SingleChildScrollView(
controller: _stockTableScrollController,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columns: const [
DataColumn(label: Text('Nama Bahan')),
DataColumn(label: Text('Stok Tersedia')),
DataColumn(label: Text('Status')),
],
rows:
items.map((item) {
final stockValue = StockStatusUtils.parseStock(
item['stock'],
);
final statusKey =
StockStatusUtils.statusFromStock(stockValue);
final statusColor = StockStatusUtils.color(
statusKey,
);
final statusLabel = StockStatusUtils.label(
statusKey,
);
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.withValues(
alpha: 0.15,
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
statusLabel,
style: AppTextStyles.labelSmall
.copyWith(
color: statusColor,
fontWeight: FontWeight.w700,
),
),
),
),
],
);
}).toList(),
),
), ),
), ),
)
: 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,
);
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(),
), ),
), ),
); );
} }
@ -342,23 +370,31 @@ class _ReportScreenState extends State<ReportScreen> {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: [AppColors.shadowLight], boxShadow: [AppColors.shadowLight],
), ),
child: Column( child:
children: items.isEmpty
items.isEmpty ? Padding(
? [ padding: const EdgeInsets.all(12),
Padding( child: Text(
padding: const EdgeInsets.all(12), 'Belum ada riwayat stok masuk.',
child: Text( style: AppTextStyles.bodySmall.copyWith(
'Belum ada riwayat stok masuk.', color: AppColors.textSecondary,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
), ),
] ),
: items )
.map( : SizedBox(
(entry) => ListTile( height: _sectionMaxHeight(),
child: Scrollbar(
thumbVisibility: true,
controller: _stockHistoryScrollController,
child: ListView.separated(
controller: _stockHistoryScrollController,
padding: EdgeInsets.zero,
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 8),
itemBuilder: (context, index) {
final entry = items[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 8, horizontal: 8,
), ),
@ -366,7 +402,9 @@ class _ReportScreenState extends State<ReportScreen> {
width: 40, width: 40,
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.primaryBrown.withOpacity(0.12), color: AppColors.primaryBrown.withValues(
alpha: 0.12,
),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: const Icon( child: const Icon(
@ -391,10 +429,11 @@ class _ReportScreenState extends State<ReportScreen> {
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
), );
) },
.toList(), ),
), ),
),
); );
} }
@ -430,7 +469,9 @@ class _ReportScreenState extends State<ReportScreen> {
width: 40, width: 40,
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.secondaryBlue.withOpacity(0.12), color: AppColors.secondaryBlue.withValues(
alpha: 0.12,
),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: const Icon( child: const Icon(
@ -443,7 +484,7 @@ class _ReportScreenState extends State<ReportScreen> {
style: AppTextStyles.labelLarge, style: AppTextStyles.labelLarge,
), ),
subtitle: Text( subtitle: Text(
'${item['needs']} ${_dateFormat.format(item['date'] as DateTime)}', '${item['needs']} - ${_dateFormat.format(item['date'] as DateTime)}',
style: AppTextStyles.bodySmall.copyWith( style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textTertiary, color: AppColors.textTertiary,
), ),
@ -489,10 +530,15 @@ class _ReportScreenState extends State<ReportScreen> {
bottomTitles: AxisTitles( bottomTitles: AxisTitles(
sideTitles: SideTitles( sideTitles: SideTitles(
showTitles: true, showTitles: true,
interval: 1,
getTitlesWidget: (value, meta) { getTitlesWidget: (value, meta) {
if (value % 1 != 0) {
return const SizedBox.shrink();
}
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(
@ -546,7 +592,15 @@ class _ReportScreenState extends State<ReportScreen> {
bottomTitles: AxisTitles( bottomTitles: AxisTitles(
sideTitles: SideTitles( sideTitles: SideTitles(
showTitles: true, showTitles: true,
interval: 1,
getTitlesWidget: (value, meta) { getTitlesWidget: (value, meta) {
if (value % 1 != 0) {
return const SizedBox.shrink();
}
if (value < 0 || value >= demandTrend.length) {
return const SizedBox.shrink();
}
return Padding( return Padding(
padding: const EdgeInsets.only(top: 6), padding: const EdgeInsets.only(top: 6),
child: Text( child: Text(
@ -577,7 +631,7 @@ class _ReportScreenState extends State<ReportScreen> {
dotData: FlDotData(show: false), dotData: FlDotData(show: false),
belowBarData: BarAreaData( belowBarData: BarAreaData(
show: true, show: true,
color: AppColors.secondaryBlue.withOpacity(0.15), color: AppColors.secondaryBlue.withValues(alpha: 0.15),
), ),
), ),
], ],
@ -596,12 +650,14 @@ class _ReportScreenState extends State<ReportScreen> {
centerSpaceRadius: 40, centerSpaceRadius: 40,
sections: sections:
usagePie.map((entry) { usagePie.map((entry) {
final value = entry['value'] as double;
return PieChartSectionData( return PieChartSectionData(
value: entry['value'] as double, value: value,
color: entry['color'] as Color, color: entry['color'] as Color,
radius: 50, radius: 50,
showTitle: true, showTitle: value >= 5,
title: '${entry['value']}%', title: '${value.toStringAsFixed(1)}%',
titleStyle: AppTextStyles.labelSmall.copyWith( titleStyle: AppTextStyles.labelSmall.copyWith(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@ -675,7 +731,7 @@ class _ReportScreenState extends State<ReportScreen> {
width: 40, width: 40,
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.statusError.withOpacity(0.12), color: AppColors.statusError.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: const Icon( child: const Icon(
@ -720,31 +776,176 @@ class _ReportScreenState extends State<ReportScreen> {
); );
} }
Widget _buildExportButton() { Widget _buildExportActions() {
return SizedBox( return Column(
width: double.infinity, crossAxisAlignment: CrossAxisAlignment.stretch,
child: ElevatedButton.icon( children: [
onPressed: () { ElevatedButton.icon(
ScaffoldMessenger.of(context).showSnackBar( onPressed: _exportReport,
const SnackBar( icon: const Icon(Icons.download_rounded),
content: Text('Export laporan masih menggunakan data dummy.'), label: const Text('Export Laporan'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBrown,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
), ),
);
},
icon: const Icon(Icons.download_rounded),
label: const Text('Export Laporan'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBrown,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
), ),
), ),
), const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _lastExportPath == null ? null : _openLastExport,
icon: const Icon(Icons.open_in_new),
label: const Text('Buka File'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _lastExportPath == null ? null : _shareLastExport,
icon: const Icon(Icons.share_outlined),
label: const Text('Bagikan'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
),
],
); );
} }
Future<void> _exportReport() async {
if (_controller.stockItems.isEmpty &&
_controller.stockHistory.isEmpty &&
_controller.predictionItems.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Data laporan masih kosong.')),
);
return;
}
try {
final directory = await getApplicationDocumentsDirectory();
final timestamp = _fileDateFormat.format(DateTime.now());
final filePath =
'${directory.path}${Platform.pathSeparator}'
'laporan_$timestamp.csv';
final file = File(filePath);
await file.writeAsString(_buildCsvContent());
if (!mounted) return;
setState(() => _lastExportPath = filePath);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Laporan tersimpan: $filePath')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Gagal export laporan: $e')));
}
}
Future<void> _openLastExport() async {
final path = _lastExportPath;
if (path == null) return;
final result = await OpenFilex.open(path);
if (!mounted || result.type == ResultType.done) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal membuka file: ${result.message}')),
);
}
Future<void> _shareLastExport() async {
final path = _lastExportPath;
if (path == null) return;
await Share.shareXFiles(
[XFile(path)],
text: 'Laporan & Analitik',
subject: 'Export Laporan',
);
}
String _buildCsvContent() {
final buffer = StringBuffer();
buffer.writeln('Laporan & Analitik');
buffer.writeln('Tanggal,${_dateFormat.format(DateTime.now())}');
buffer.writeln('');
buffer.writeln('Ringkasan');
buffer.writeln('Total Produk,${_controller.totalProduk}');
buffer.writeln('Total Bahan,${_controller.totalBahan}');
buffer.writeln('Stok Kritis,${_controller.totalKritis}');
buffer.writeln('Total Prediksi,${_controller.totalPrediksi}');
buffer.writeln('');
buffer.writeln('Laporan Stok Bahan');
buffer.writeln('Nama Bahan,Stok,Unit,Status');
for (final item in _controller.stockItems) {
final stockValue = StockStatusUtils.parseStock(item['stock']);
final statusKey = StockStatusUtils.statusFromStock(stockValue);
final statusLabel = StockStatusUtils.label(statusKey);
buffer.writeln(
'${_escapeCsv(item['name'])},${item['stock']},'
'${_escapeCsv(item['unit'])},$statusLabel',
);
}
buffer.writeln('');
buffer.writeln('Riwayat Stok Masuk');
buffer.writeln('Tanggal,Nama Bahan,Jumlah,Unit');
for (final entry in _controller.stockHistory) {
final date = _dateFormat.format(entry['date'] as DateTime);
buffer.writeln(
'$date,${_escapeCsv(entry['name'])},${entry['amount']},'
'${_escapeCsv(entry['unit'])}',
);
}
buffer.writeln('');
buffer.writeln('Laporan Prediksi Permintaan');
buffer.writeln('Tanggal,Produk,Prediksi,Estimasi Kebutuhan');
for (final item in _controller.predictionItems) {
final date = _dateFormat.format(item['date'] as DateTime);
buffer.writeln(
'$date,${_escapeCsv(item['product'])},${item['prediction']},'
'${_escapeCsv(item['needs'])}',
);
}
buffer.writeln('');
return buffer.toString();
}
String _escapeCsv(dynamic value) {
final text = value?.toString() ?? '';
if (text.contains(',') || text.contains('"') || text.contains('\n')) {
final escaped = text.replaceAll('"', '""');
return '"$escaped"';
}
return text;
}
List<Map<String, dynamic>> _buildUsageBars( List<Map<String, dynamic>> _buildUsageBars(
List<Map<String, dynamic>> usageSummary, List<Map<String, dynamic>> usageSummary,
) { ) {
@ -799,6 +1000,8 @@ class _ReportScreenState extends State<ReportScreen> {
@override @override
void dispose() { void dispose() {
_stockTableScrollController.dispose();
_stockHistoryScrollController.dispose();
_controller.dispose(); _controller.dispose();
super.dispose(); super.dispose();
} }

View File

@ -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.1.91:5000'; static const String baseUrl = 'http://192.168.18.30:5000';
static const int timeoutSeconds = 30; static const int timeoutSeconds = 30;
@ -198,6 +198,63 @@ class MLService {
} }
} }
// ========================================================================
// DASHBOARD ENDPOINTS
// ========================================================================
/// Ringkasan dashboard: penggunaan bahan
static Future<Map<String, dynamic>> getDashboardSummary() async {
try {
final response = await http
.get(Uri.parse('$baseUrl/api/dashboard/summary'))
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 200) {
return jsonDecode(response.body);
}
return {
'status': false,
'message': 'Server error: ${response.statusCode}',
'penggunaan_bahan': [],
};
} catch (e) {
return {
'status': false,
'message': 'Connection error: $e',
'penggunaan_bahan': [],
};
}
}
/// Total bahan keluar yang digunakan hari ini.
static Future<Map<String, dynamic>> getBahanDigunakanHariIni() async {
const fallback = {
'total': 0,
'satuan': 'kg',
'keterangan': 'total penggunaan hari ini',
};
try {
final response = await http
.get(Uri.parse('$baseUrl/api/dashboard/bahan-digunakan-hari-ini'))
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data is Map<String, dynamic> ? data : fallback;
}
return {
...fallback,
'status': false,
'message': 'Server error: ${response.statusCode}',
};
} catch (e) {
return {...fallback, 'status': false, 'message': 'Connection error: $e'};
}
}
// ======================================================================== // ========================================================================
// REPORT ENDPOINTS - LAPORAN // REPORT ENDPOINTS - LAPORAN
// ======================================================================== // ========================================================================
@ -505,6 +562,7 @@ class MLService {
required int predictedQuantity, required int predictedQuantity,
double? rawValue, double? rawValue,
int? estimatedTotalPrice, int? estimatedTotalPrice,
String? estimatedNeeds,
double? accuracyR2, double? accuracyR2,
double? errorMae, double? errorMae,
}) async { }) async {
@ -518,6 +576,7 @@ class MLService {
if (rawValue != null) 'raw_value': rawValue, if (rawValue != null) 'raw_value': rawValue,
if (estimatedTotalPrice != null) if (estimatedTotalPrice != null)
'estimated_total_price': estimatedTotalPrice, 'estimated_total_price': estimatedTotalPrice,
if (estimatedNeeds != null) 'estimated_needs': estimatedNeeds,
if (accuracyR2 != null) 'accuracy_r2': accuracyR2, if (accuracyR2 != null) 'accuracy_r2': accuracyR2,
if (errorMae != null) 'error_mae': errorMae, if (errorMae != null) 'error_mae': errorMae,
}; };

View File

@ -14,7 +14,7 @@ class AppColors {
// Status Colors // Status Colors
static const Color statusSuccess = Color(0xFF4CAF50); // Tersedia (green) static const Color statusSuccess = Color(0xFF4CAF50); // Tersedia (green)
static const Color statusWarning = Color(0xFFFF9800); // Rendah (orange) static const Color statusWarning = Color(0xFFFF9800); // Sedang (orange)
static const Color statusError = Color(0xFFF44336); // Kritis (red) static const Color statusError = Color(0xFFF44336); // Kritis (red)
// Neutral Colors // Neutral Colors

View File

@ -0,0 +1,61 @@
import 'package:finalproject/theme/colors.dart';
import 'package:flutter/material.dart';
class StockStatusUtils {
StockStatusUtils._();
static const double criticalStockLimitKg = 5;
static const double warningStockLimitKg = 20;
static const String statusTersedia = 'tersedia';
static const String statusSedang = 'sedang';
static const String statusKritis = 'kritis';
static double parseStock(dynamic value) {
if (value is num) return value.toDouble();
return double.tryParse(value?.toString() ?? '') ?? 0.0;
}
static String statusFromStock(num stockKg) {
final stock = stockKg.toDouble();
if (stock < criticalStockLimitKg) return statusKritis;
if (stock < warningStockLimitKg) return statusSedang;
return statusTersedia;
}
static String normalizeStatus(String status) {
final normalized = status.trim().toLowerCase();
if (normalized == 'rendah') return statusSedang;
if (normalized == 'habis') return statusKritis;
if (normalized == statusTersedia ||
normalized == statusSedang ||
normalized == statusKritis) {
return normalized;
}
return statusKritis;
}
static String label(String status, {bool withIcon = false}) {
switch (normalizeStatus(status)) {
case statusTersedia:
return withIcon ? '✅ Tersedia' : 'Tersedia';
case statusSedang:
return withIcon ? '⚠️ Sedang' : 'Sedang';
case statusKritis:
default:
return withIcon ? '🔴 Kritis' : 'Kritis';
}
}
static Color color(String status) {
switch (normalizeStatus(status)) {
case statusTersedia:
return AppColors.statusSuccess;
case statusSedang:
return AppColors.statusWarning;
case statusKritis:
default:
return AppColors.statusError;
}
}
}

View File

@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
} }

View File

@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@ -5,6 +5,10 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import path_provider_foundation
import share_plus
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
} }

View File

@ -97,6 +97,61 @@ def build_report_response(success: bool, message: str, data: list | None = None,
'data': data or [] 'data': data or []
}), status_code }), status_code
def ensure_prediction_needs_column(connection, table_name: str = 'predictions') -> str | None:
needs_col = get_existing_column(
connection,
table_name,
['estimated_needs', 'estimasi_kebutuhan_bahan']
)
if needs_col:
return needs_col
if not table_exists(connection, table_name):
return None
cursor = connection.cursor()
try:
cursor.execute(
f"ALTER TABLE {escape_table_name(table_name)} ADD COLUMN estimated_needs TEXT"
)
connection.commit()
return 'estimated_needs'
except Error as e:
logger.warning(f"Could not add estimated_needs column: {e}")
return None
finally:
cursor.close()
def ensure_stock_usage_tables(connection):
cursor = connection.cursor()
try:
cursor.execute("""
CREATE TABLE IF NOT EXISTS stock_usage_history (
id INT AUTO_INCREMENT PRIMARY KEY,
recipe_name VARCHAR(255),
production_quantity INT,
product_id INT,
product_name VARCHAR(255) NOT NULL,
quantity_used FLOAT NOT NULL,
unit VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS stok_keluar (
id INT AUTO_INCREMENT PRIMARY KEY,
bahan_id INT,
jumlah_keluar FLOAT NOT NULL,
satuan VARCHAR(50) DEFAULT 'kg',
tanggal_keluar DATE NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
connection.commit()
finally:
cursor.close()
def compute_stock_status(stok: float, stok_minimum: float) -> str: def compute_stock_status(stok: float, stok_minimum: float) -> str:
if stok > stok_minimum: if stok > stok_minimum:
return 'Aman' return 'Aman'
@ -105,7 +160,8 @@ def compute_stock_status(stok: float, stok_minimum: float) -> str:
return 'Kritis' return 'Kritis'
def fetch_stock_report(connection): def fetch_stock_report(connection):
table_name = get_existing_table(connection, ['bahan', 'products']) # Prioritaskan tabel products agar stok kritis mengikuti data produk aktif.
table_name = get_existing_table(connection, ['products', 'bahan'])
if not table_name: if not table_name:
return None, 'Tabel bahan atau products tidak ditemukan' return None, 'Tabel bahan atau products tidak ditemukan'
@ -149,6 +205,173 @@ def fetch_stock_report(connection):
return data, None return data, None
def fetch_penggunaan_bahan(connection):
table_name = get_existing_table(connection, ['stok_keluar', 'stock_usage_history'])
if not table_name:
return []
cursor = connection.cursor(dictionary=True)
if table_name == 'stock_usage_history':
cursor.execute(
f"""
SELECT product_name AS nama_bahan,
COALESCE(SUM(quantity_used), 0) AS total_digunakan,
COALESCE(unit, 'kg') AS satuan
FROM {escape_table_name(table_name)}
GROUP BY product_name, unit
ORDER BY total_digunakan DESC
LIMIT 5
"""
)
else:
name_col = get_existing_column(connection, table_name, ['nama_bahan'])
qty_col = get_existing_column(connection, table_name, ['jumlah_keluar', 'quantity_used', 'jumlah'])
unit_col = get_existing_column(connection, table_name, ['satuan', 'unit'])
bahan_id_col = get_existing_column(connection, table_name, ['bahan_id', 'product_id'])
if name_col and qty_col:
unit_select = unit_col if unit_col else "'kg'"
cursor.execute(
f"""
SELECT {name_col} AS nama_bahan,
COALESCE(SUM({qty_col}), 0) AS total_digunakan,
{unit_select} AS satuan
FROM {escape_table_name(table_name)}
GROUP BY {name_col}, {unit_select}
ORDER BY total_digunakan DESC
LIMIT 5
"""
)
elif bahan_id_col and qty_col:
bahan_table = get_existing_table(connection, ['bahan', 'products'])
if not bahan_table:
cursor.close()
return []
bahan_name_col = get_existing_column(connection, bahan_table, ['nama_bahan', 'product_name', 'name'])
bahan_unit_col = get_existing_column(connection, bahan_table, ['satuan', 'unit'])
if not bahan_name_col:
cursor.close()
return []
unit_select = f"b.{bahan_unit_col}" if bahan_unit_col else "'kg'"
cursor.execute(
f"""
SELECT b.{bahan_name_col} AS nama_bahan,
COALESCE(SUM(k.{qty_col}), 0) AS total_digunakan,
{unit_select} AS satuan
FROM {escape_table_name(table_name)} k
JOIN {escape_table_name(bahan_table)} b ON b.id = k.{bahan_id_col}
GROUP BY b.{bahan_name_col}, {unit_select}
ORDER BY total_digunakan DESC
LIMIT 5
"""
)
else:
cursor.close()
return []
rows = cursor.fetchall()
cursor.close()
data = []
for row in rows:
data.append({
'nama_bahan': row.get('nama_bahan'),
'total_digunakan': float(row.get('total_digunakan') or 0),
'satuan': row.get('satuan') or 'kg'
})
return data
def getBahanDigunakanHariIni(connection):
"""Total bahan keluar hari ini dari stok_keluar, fallback ke stock_usage_history."""
table_name = get_existing_table(connection, ['stok_keluar'])
totalBahanDigunakanHariIni = 0
if table_name:
qty_col = get_existing_column(connection, table_name, ['jumlah_keluar'])
date_col = get_existing_column(connection, table_name, ['tanggal_keluar'])
created_col = get_existing_column(connection, table_name, ['created_at'])
if qty_col:
table_sql = escape_table_name(table_name)
cursor = connection.cursor()
if date_col and created_col:
cursor.execute(
f"""
SELECT COALESCE(SUM({qty_col}), 0)
FROM {table_sql}
WHERE (
{date_col} >= CURDATE()
AND {date_col} < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
)
OR (
{date_col} IS NULL
AND {created_col} >= CURDATE()
AND {created_col} < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
)
"""
)
elif date_col:
cursor.execute(
f"""
SELECT COALESCE(SUM({qty_col}), 0)
FROM {table_sql}
WHERE {date_col} >= CURDATE()
AND {date_col} < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
"""
)
elif created_col:
cursor.execute(
f"""
SELECT COALESCE(SUM({qty_col}), 0)
FROM {table_sql}
WHERE {created_col} >= CURDATE()
AND {created_col} < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
"""
)
else:
cursor.close()
cursor = None
if cursor:
totalBahanDigunakanHariIni = cursor.fetchone()[0] or 0
cursor.close()
if float(totalBahanDigunakanHariIni or 0) == 0 and table_exists(connection, 'stock_usage_history'):
cursor = connection.cursor(dictionary=True)
cursor.execute(
"""
SELECT quantity_used, unit
FROM stock_usage_history
WHERE created_at >= CURDATE()
AND created_at < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
"""
)
rows = cursor.fetchall()
cursor.close()
totalBahanDigunakanHariIni = sum(
convert_stock_quantity(
float(row.get('quantity_used') or 0),
row.get('unit') or 'kg',
'kg'
)
for row in rows
)
total = float(totalBahanDigunakanHariIni)
return {
'total': int(total) if total.is_integer() else total,
'satuan': 'kg',
'keterangan': 'total penggunaan hari ini'
}
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:
@ -164,6 +387,49 @@ def grams_to_kg_rounded(grams: float) -> float:
return 1.0 return 1.0
return math.ceil(grams / 500.0) * 0.5 return math.ceil(grams / 500.0) * 0.5
def normalize_stock_unit(unit: str | None) -> str:
normalized = (unit or '').strip().lower()
if normalized in ['g', 'gr', 'gram', 'grams']:
return 'gr'
if normalized in ['kg', 'kilogram', 'kilograms']:
return 'kg'
if normalized in ['ml', 'mili', 'mililiter', 'milliliter']:
return 'ml'
if normalized in ['l', 'lt', 'ltr', 'liter', 'litre']:
return 'l'
if normalized in ['butir', 'pcs', 'piece', 'pieces']:
return 'butir'
return normalized
def convert_stock_quantity(quantity: float, from_unit: str, to_unit: str) -> float:
unit = normalize_stock_unit(from_unit)
stock_unit = normalize_stock_unit(to_unit)
if unit == stock_unit or not unit or not stock_unit:
return quantity
if unit == 'gr' and stock_unit == 'kg':
return quantity / 1000
if unit == 'kg' and stock_unit == 'gr':
return quantity * 1000
if unit == 'ml' and stock_unit == 'kg':
return quantity / 1000
if unit == 'l' and stock_unit == 'kg':
return quantity
if unit == 'kg' and stock_unit == 'ml':
return quantity * 1000
if unit == 'kg' and stock_unit == 'l':
return quantity
if unit == 'ml' and stock_unit == 'gr':
return quantity
if unit == 'gr' and stock_unit == 'ml':
return quantity
if unit == 'l' and stock_unit == 'ml':
return quantity * 1000
if unit == 'ml' and stock_unit == 'l':
return quantity / 1000
return quantity
# ============================================================================ # ============================================================================
# LOAD MODELS AT STARTUP # LOAD MODELS AT STARTUP
# ============================================================================ # ============================================================================
@ -575,9 +841,22 @@ def consume_stock():
if not connection: if not connection:
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500 return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
ensure_stock_usage_tables(connection)
name_column = get_product_name_column(connection) name_column = get_product_name_column(connection)
unit_column_exists = has_product_unit_column(connection) unit_column_exists = has_product_unit_column(connection)
history_enabled = table_exists(connection, 'stock_usage_history') history_enabled = table_exists(connection, 'stock_usage_history')
stock_out_enabled = table_exists(connection, 'stok_keluar')
stock_out_product_col = None
stock_out_qty_col = None
stock_out_unit_col = None
stock_out_date_col = None
if stock_out_enabled:
stock_out_product_col = get_existing_column(connection, 'stok_keluar', ['bahan_id', 'product_id'])
stock_out_qty_col = get_existing_column(connection, 'stok_keluar', ['jumlah_keluar'])
stock_out_unit_col = get_existing_column(connection, 'stok_keluar', ['satuan', 'unit'])
stock_out_date_col = get_existing_column(connection, 'stok_keluar', ['tanggal_keluar'])
stock_out_enabled = bool(stock_out_product_col and stock_out_qty_col)
cursor = connection.cursor(dictionary=True) cursor = connection.cursor(dictionary=True)
started_transaction = True started_transaction = True
@ -623,26 +902,16 @@ def consume_stock():
continue continue
product_unit = (product.get('unit') or '').strip().lower() product_unit = (product.get('unit') or '').strip().lower()
effective_quantity = quantity effective_unit = normalize_stock_unit(product_unit or unit)
effective_unit = unit or product_unit effective_quantity = convert_stock_quantity(
float(quantity),
unit or effective_unit,
effective_unit
)
if unit in ['g', 'gram', 'grams']: logger.info(
try: f"[stock/consume] Using quantity: {quantity} {unit or effective_unit} -> {effective_quantity} {effective_unit} (product_id={product.get('id')})"
effective_quantity = grams_to_kg_rounded(float(quantity)) )
effective_unit = 'kg'
logger.info(
f"[stock/consume] Rounded grams to kg: {quantity}g -> {effective_quantity}kg (product_id={product.get('id')})"
)
except ValueError:
errors.append({
'item': item,
'message': 'Quantity gram harus lebih dari 0'
})
continue
else:
logger.info(
f"[stock/consume] Using quantity without gram rounding: {quantity} {effective_unit} (product_id={product.get('id')})"
)
if product['current_stock'] < effective_quantity: if product['current_stock'] < effective_quantity:
errors.append({ errors.append({
@ -695,6 +964,24 @@ def consume_stock():
) )
) )
if stock_out_enabled:
columns = [stock_out_product_col, stock_out_qty_col]
values = [product['id'], quantity]
if stock_out_unit_col:
columns.append(stock_out_unit_col)
values.append(entry['unit'])
if stock_out_date_col:
columns.append(stock_out_date_col)
values.append(datetime.now().date())
column_sql = ', '.join(columns)
placeholders = ', '.join(['%s'] * len(columns))
cursor.execute(
f"INSERT INTO stok_keluar ({column_sql}) VALUES ({placeholders})",
tuple(values)
)
results.append({ results.append({
'product_id': product['id'], 'product_id': product['id'],
'product_name': product['name'], 'product_name': product['name'],
@ -970,13 +1257,21 @@ def save_prediction():
if not connection: if not connection:
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500 return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
cursor = connection.cursor() table_name = 'predictions'
cursor.execute(""" needs_col = ensure_prediction_needs_column(connection, table_name)
INSERT INTO predictions
(product_name, category, unit_price, prediction_date, predicted_quantity, columns = [
raw_value, estimated_total_price, accuracy_r2, error_mae) 'product_name',
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) 'category',
""", ( 'unit_price',
'prediction_date',
'predicted_quantity',
'raw_value',
'estimated_total_price',
'accuracy_r2',
'error_mae'
]
values = [
data['product_name'], data['product_name'],
data['category'], data['category'],
data['unit_price'], data['unit_price'],
@ -986,7 +1281,19 @@ def save_prediction():
data.get('estimated_total_price'), data.get('estimated_total_price'),
data.get('accuracy_r2'), data.get('accuracy_r2'),
data.get('error_mae') data.get('error_mae')
)) ]
if needs_col:
columns.append(needs_col)
values.append(data.get('estimated_needs') or data.get('estimasi_kebutuhan_bahan'))
cursor = connection.cursor()
placeholders = ', '.join(['%s'] * len(columns))
column_sql = ', '.join(columns)
cursor.execute(
f"INSERT INTO predictions ({column_sql}) VALUES ({placeholders})",
tuple(values)
)
connection.commit() connection.commit()
prediction_id = cursor.lastrowid prediction_id = cursor.lastrowid
@ -1205,6 +1512,68 @@ def laporan_prediksi():
return build_report_response(False, f'Gagal mengambil data: {str(e)}', [], 500) return build_report_response(False, f'Gagal mengambil data: {str(e)}', [], 500)
# ============================================================================
# DASHBOARD ENDPOINTS
# ============================================================================
@app.route('/api/dashboard/bahan-digunakan-hari-ini', methods=['GET'])
def bahanDigunakanHariIni():
"""Total bahan keluar hari ini dari stok_keluar."""
connection = None
try:
connection = get_db_connection()
if not connection:
return jsonify({
'status': False,
'message': 'Database connection failed',
'total': 0,
'satuan': 'kg',
'keterangan': 'total penggunaan hari ini'
}), 500
data = getBahanDigunakanHariIni(connection)
return jsonify(data), 200
except Exception as e:
logger.error(f"Bahan digunakan hari ini error: {str(e)}")
return jsonify({
'status': False,
'message': f'Gagal mengambil data: {str(e)}',
'total': 0,
'satuan': 'kg',
'keterangan': 'total penggunaan hari ini'
}), 500
finally:
if connection:
connection.close()
@app.route('/api/dashboard/summary', methods=['GET'])
def dashboard_summary():
"""Ringkasan dashboard untuk grafik penggunaan bahan."""
try:
connection = get_db_connection()
if not connection:
return jsonify({
'status': False,
'message': 'Database connection failed',
'penggunaan_bahan': []
}), 500
penggunaan = fetch_penggunaan_bahan(connection)
connection.close()
return jsonify({
'status': True,
'penggunaan_bahan': penggunaan
}), 200
except Exception as e:
logger.error(f"Dashboard summary error: {str(e)}")
return jsonify({
'status': False,
'message': f'Gagal mengambil data: {str(e)}',
'penggunaan_bahan': []
}), 500
# ============================================================================ # ============================================================================
# RECIPES ENDPOINTS # RECIPES ENDPOINTS
# ============================================================================ # ============================================================================

View File

@ -93,6 +93,7 @@ def create_tables():
predicted_quantity DECIMAL(10, 2), predicted_quantity DECIMAL(10, 2),
raw_value DECIMAL(10, 2), raw_value DECIMAL(10, 2),
estimated_total_price DECIMAL(10, 2), estimated_total_price DECIMAL(10, 2),
estimated_needs TEXT,
accuracy_r2 DECIMAL(5, 4), accuracy_r2 DECIMAL(5, 4),
error_mae DECIMAL(5, 4), error_mae DECIMAL(5, 4),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

View File

@ -53,6 +53,7 @@ CREATE TABLE IF NOT EXISTS predictions (
predicted_quantity INT NOT NULL, predicted_quantity INT NOT NULL,
raw_value FLOAT, raw_value FLOAT,
estimated_total_price INT, estimated_total_price INT,
estimated_needs TEXT,
accuracy_r2 FLOAT, accuracy_r2 FLOAT,
error_mae FLOAT, error_mae FLOAT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP

View File

@ -41,6 +41,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "942a4791cd385a68ccb3b32c71c427aba508a1bb949b86dff2adbe4049f16239"
url: "https://pub.dev"
source: hosted
version: "0.3.5"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@ -65,6 +81,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.2" version: "1.3.2"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
fl_chart: fl_chart:
dependency: "direct main" dependency: "direct main"
description: description:
@ -91,6 +131,11 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@ -171,6 +216,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
open_filex:
dependency: "direct main"
description:
name: open_filex
sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900"
url: "https://pub.dev"
source: hosted
version: "4.7.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@ -179,6 +240,86 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
url: "https://pub.dev"
source: hosted
version: "2.2.19"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
url: "https://pub.dev"
source: hosted
version: "10.1.4"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
url: "https://pub.dev"
source: hosted
version: "5.0.2"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -240,6 +381,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
url: "https://pub.dev"
source: hosted
version: "3.1.4"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@ -264,6 +445,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba"
url: "https://pub.dev"
source: hosted
version: "5.13.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sdks: sdks:
dart: ">=3.7.0 <4.0.0" dart: ">=3.7.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.29.0"

View File

@ -44,6 +44,13 @@ dependencies:
# Charts # Charts
fl_chart: ^0.66.2 fl_chart: ^0.66.2
# File export
path_provider: ^2.1.4
# Open file + share
open_filex: ^4.5.0
share_plus: ^10.0.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter

View File

@ -6,6 +6,12 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }

View File

@ -3,6 +3,8 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
share_plus
url_launcher_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST