diff --git a/lib/models/transaction_model.dart b/lib/models/transaction_model.dart index 49a7fe3..adab5a1 100644 --- a/lib/models/transaction_model.dart +++ b/lib/models/transaction_model.dart @@ -5,6 +5,7 @@ class Transaction { final int quantity; final int unitPrice; final int totalPrice; + final String unit; final DateTime date; Transaction({ @@ -14,6 +15,7 @@ class Transaction { required this.quantity, required this.unitPrice, required this.totalPrice, + required this.unit, required this.date, }); @@ -25,6 +27,7 @@ class Transaction { quantity: json['quantity'] as int, unitPrice: json['unit_price'] as int, totalPrice: json['total_price'] as int, + unit: (json['unit'] as String?) ?? 'pcs', date: DateTime.parse(json['date'] as String), ); } @@ -37,6 +40,7 @@ class Transaction { 'quantity': quantity, 'unit_price': unitPrice, 'total_price': totalPrice, + 'unit': unit, 'date': date.toIso8601String(), }; } @@ -48,6 +52,7 @@ class Transaction { int? quantity, int? unitPrice, int? totalPrice, + String? unit, DateTime? date, }) { return Transaction( @@ -57,6 +62,7 @@ class Transaction { quantity: quantity ?? this.quantity, unitPrice: unitPrice ?? this.unitPrice, totalPrice: totalPrice ?? this.totalPrice, + unit: unit ?? this.unit, date: date ?? this.date, ); } diff --git a/lib/screens/dashboard/dashboard_controller.dart b/lib/screens/dashboard/dashboard_controller.dart index b1db091..2150c42 100644 --- a/lib/screens/dashboard/dashboard_controller.dart +++ b/lib/screens/dashboard/dashboard_controller.dart @@ -50,7 +50,7 @@ class DashboardController extends ChangeNotifier { _applyPenggunaanBahan(summary['penggunaan_bahan']); } else { errorMessage = summary['message']?.toString(); - _applyDummyPenggunaan(); + penggunaanBahan.clear(); } totalProduk = products.length; @@ -59,7 +59,7 @@ class DashboardController extends ChangeNotifier { errorMessage = 'Gagal memuat dashboard: $e'; bahanDigunakanError = 'Gagal memuat bahan digunakan hari ini'; _resetBahanDigunakanHariIni(); - _applyDummyPenggunaan(); + penggunaanBahan.clear(); lowStockItems.clear(); } finally { isLoading = false; @@ -90,7 +90,6 @@ class DashboardController extends ChangeNotifier { void _applyPenggunaanBahan(dynamic data) { penggunaanBahan.clear(); if (data is! List || data.isEmpty) { - _applyDummyPenggunaan(); return; } for (final item in data) { @@ -133,18 +132,6 @@ class DashboardController extends ChangeNotifier { ); } - 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'}, - ]); - } - String _formatStock(double value) { if (value % 1 == 0) { return value.toInt().toString(); diff --git a/lib/screens/prediction/prediction_controller.dart b/lib/screens/prediction/prediction_controller.dart index 6d3d2ff..92040f1 100644 --- a/lib/screens/prediction/prediction_controller.dart +++ b/lib/screens/prediction/prediction_controller.dart @@ -7,25 +7,56 @@ class PredictionController extends ChangeNotifier { bool isCalculated = false; bool isLoading = true; bool isSubmitting = false; + bool isPredicting = false; + int? predictedDemand; + double? predictionRawValue; + double? predictionR2; + double? predictionMae; + double? predictionRmse; + String? predictionModelProduct; List> recipes = []; Map> recipeIngredients = {}; Map ingredientSelections = {}; - final Map currentStock = { - 'Tepung Terigu 1kg': 45000, - 'Telur 1kg': 12, - 'Gula Pasir 1kg': 28000, - 'Susu Bubuk': 8000, - 'Cokelat Bubuk 250gr': 22000, - 'Mentega 500gr': 15000, - 'Keju Parut 250gr': 3000, - 'Baking Powder': 60000, - }; + final Map currentStock = {}; final Map productIds = {}; final Map productUnits = {}; + final Map productCategories = {}; + final Map productPrices = {}; static const double eggGramPerButir = 50; + static const Map datasetPackageSizes = { + 'Baking Powder 45gr': 45, + 'Cokelat Bubuk 250gr': 250, + 'Gula Pasir 1kg': 1000, + 'Keju Parut 250gr': 250, + 'Mentega 500gr': 500, + 'Susu Bubuk 27gr': 27, + 'Susu Bubuk': 27, + 'Telur 1kg': 1000, + 'Tepung Terigu 1kg': 1000, + }; + static const Map datasetCategories = { + 'Baking Powder 45gr': 'Bahan Tambahan', + 'Cokelat Bubuk 250gr': 'Cokelat', + 'Gula Pasir 1kg': 'Gula', + 'Keju Parut 250gr': 'Keju', + 'Mentega 500gr': 'Mentega', + 'Susu Bubuk 27gr': 'Susu', + 'Telur 1kg': 'Telur', + 'Tepung Terigu 1kg': 'Tepung', + }; + static const Map datasetMedianPrices = { + 'Baking Powder 45gr': 8180, + 'Cokelat Bubuk 250gr': 21036, + 'Gula Pasir 1kg': 14608, + 'Keju Parut 250gr': 23373, + 'Mentega 500gr': 17529, + 'Susu Bubuk 27gr': 17529, + 'Telur 1kg': 26878, + 'Tepung Terigu 1kg': 11687, + }; Future loadRecipes() async { isLoading = true; @@ -82,6 +113,8 @@ class PredictionController extends ChangeNotifier { currentStock.clear(); productIds.clear(); productUnits.clear(); + productCategories.clear(); + productPrices.clear(); for (final product in products) { final name = @@ -91,6 +124,8 @@ class PredictionController extends ChangeNotifier { final id = _toInt(product['id']); final unit = (product['unit'] ?? '').toString(); final stock = _toDouble(product['current_stock'] ?? product['stock']); + final category = (product['category'] ?? '').toString(); + final price = _toInt(product['price'] ?? product['unit_price']) ?? 0; currentStock[name] = stock; if (id != null) { @@ -99,12 +134,17 @@ class PredictionController extends ChangeNotifier { if (unit.isNotEmpty) { productUnits[name] = unit; } + if (category.isNotEmpty) { + productCategories[name] = category; + } + productPrices[name] = price; } } void setSelectedRecipe(String? value) { selectedRecipe = value; isCalculated = false; + _clearPredictionResult(); _initializeIngredientSelections(); notifyListeners(); } @@ -112,20 +152,84 @@ class PredictionController extends ChangeNotifier { void setProductionQuantity(String value) { productionQuantity = int.tryParse(value) ?? 0; isCalculated = false; + _clearPredictionResult(); notifyListeners(); } - bool get canCalculate => selectedRecipe != null && productionQuantity > 0; + bool get canCalculate => selectedRecipe != null && !isPredicting; - void calculate() { - isCalculated = true; + Future calculate() async { + if (selectedRecipe == null) { + return 'Pilih produk terlebih dahulu'; + } + + isPredicting = true; + isCalculated = false; + _clearPredictionResult(); notifyListeners(); + + try { + await refreshStock(); + + final recipeIngredient = _primaryIngredientForPrediction(); + if (recipeIngredient == null) { + return 'Resep ini belum memiliki bahan yang cocok dengan dataset model'; + } + final modelProduct = _datasetProductName(recipeIngredient)!; + + final manualQuantity = productionQuantity; + final plannedQuantity = manualQuantity > 0 ? manualQuantity : 1; + final result = await MLService.simplePrediksi( + productName: modelProduct, + category: _datasetCategory(modelProduct), + unitPrice: _datasetPrice(modelProduct), + plannedQuantity: plannedQuantity, + ); + + if (result['status'] != 'success') { + return result['message']?.toString() ?? 'Prediksi gagal diproses'; + } + + final prediction = result['prediksi'] as Map? ?? {}; + final accuracy = result['model_accuracy'] as Map? ?? {}; + final rawValue = _toDouble(prediction['nilai_raw']); + final demand = + _toInt(prediction['jumlah_unit']) ?? + rawValue.round().clamp(1, 999999).toInt(); + final demandInStockUnit = _datasetDemandToStockUnit( + ingredient: recipeIngredient, + demand: demand.toDouble(), + ); + final perUnitNeed = _quantityPerUnitInStockUnit(recipeIngredient); + final modelProduction = + perUnitNeed > 0 ? (demandInStockUnit / perUnitNeed).round() : demand; + + productionQuantity = + manualQuantity > 0 + ? manualQuantity + : modelProduction.clamp(1, 999999); + predictedDemand = manualQuantity > 0 ? manualQuantity : demand; + predictionRawValue = rawValue; + predictionR2 = _toDouble(accuracy['r2_score']); + predictionMae = _toDouble(accuracy['mae']); + predictionRmse = _toDouble(accuracy['rmse']); + predictionModelProduct = modelProduct; + isCalculated = true; + + return null; + } catch (e) { + return 'Gagal menjalankan prediksi Random Forest: $e'; + } finally { + isPredicting = false; + notifyListeners(); + } } void reset() { selectedRecipe = null; productionQuantity = 0; isCalculated = false; + _clearPredictionResult(); ingredientSelections = {}; notifyListeners(); } @@ -215,6 +319,20 @@ class PredictionController extends ChangeNotifier { return key == null ? productIds[ingredient] : productIds[key]; } + String getProductCategory(String ingredient) { + final key = _matchingProductName(ingredient); + return key == null + ? productCategories[ingredient] ?? 'Bahan Tambahan' + : productCategories[key] ?? 'Bahan Tambahan'; + } + + int getProductPrice(String ingredient) { + final key = _matchingProductName(ingredient); + return key == null + ? productPrices[ingredient] ?? 0 + : productPrices[key] ?? 0; + } + double toGram({required double amount, required String unit}) { final normalized = _normalizeUnit(unit); if (normalized == 'gr') return amount; @@ -308,8 +426,11 @@ class PredictionController extends ChangeNotifier { required String ingredient, required double amount, required String fromUnit, + String? overrideStockUnit, }) { - final stockUnit = _normalizeUnit(getStockUnit(ingredient)); + final stockUnit = _normalizeUnit( + overrideStockUnit ?? getStockUnit(ingredient), + ); final unit = _normalizeUnit(fromUnit); if (unit == stockUnit) return amount; @@ -358,6 +479,129 @@ class PredictionController extends ChangeNotifier { return null; } + String? _datasetProductName(String ingredient) { + final value = ingredient.toLowerCase(); + + if (value.contains('tepung terigu') || value == 'tepung') { + return 'Tepung Terigu 1kg'; + } + if (value.contains('telur')) return 'Telur 1kg'; + if (value.contains('gula pasir') || value == 'gula') { + return 'Gula Pasir 1kg'; + } + if (value.contains('susu bubuk') || value == 'susu') { + return 'Susu Bubuk 27gr'; + } + if (value.contains('cokelat') || value.contains('coklat')) { + return 'Cokelat Bubuk 250gr'; + } + if (value.contains('mentega')) return 'Mentega 500gr'; + if (value.contains('keju')) return 'Keju Parut 250gr'; + if (value.contains('baking powder')) return 'Baking Powder 45gr'; + + return null; + } + + String _datasetCategory(String datasetProductName) { + return datasetCategories[datasetProductName] ?? 'Bahan Tambahan'; + } + + int _datasetPrice(String datasetProductName) { + return datasetMedianPrices[datasetProductName] ?? 1; + } + + void _clearPredictionResult() { + predictedDemand = null; + predictionRawValue = null; + predictionR2 = null; + predictionMae = null; + predictionRmse = null; + predictionModelProduct = null; + } + + String? _primaryIngredientForPrediction() { + if (selectedRecipe == null) return null; + + final ingredients = recipeIngredients[selectedRecipe] ?? {}; + if (ingredients.isEmpty) return null; + + String? selectedIngredient; + double highestNeed = -1; + + ingredients.forEach((ingredient, details) { + if (!isIngredientSelected(ingredient)) return; + if (_datasetProductName(ingredient) == null) return; + + final quantity = _toDouble(details['quantity']); + final unit = details['unit']?.toString() ?? getIngredientUnit(ingredient); + final stockNeed = _convertToStockUnit( + ingredient: ingredient, + amount: quantity, + fromUnit: unit, + ); + + if (stockNeed > highestNeed) { + highestNeed = stockNeed; + selectedIngredient = ingredient; + } + }); + + return selectedIngredient; + } + + double _quantityPerUnitInStockUnit(String ingredient) { + if (selectedRecipe == null) return 0; + final details = recipeIngredients[selectedRecipe]?[ingredient]; + if (details == null) return 0; + + return _convertToStockUnit( + ingredient: ingredient, + amount: _toDouble(details['quantity']), + fromUnit: details['unit']?.toString() ?? getIngredientUnit(ingredient), + ); + } + + double _datasetDemandToStockUnit({ + required String ingredient, + required double demand, + }) { + final packageGram = _datasetPackageGram(ingredient); + return _convertToStockUnit( + ingredient: ingredient, + amount: demand * packageGram, + fromUnit: 'gr', + overrideStockUnit: getStockUnit(ingredient), + ); + } + + double _datasetPackageGram(String ingredient) { + final matchedName = _matchingProductName(ingredient) ?? ingredient; + + for (final entry in datasetPackageSizes.entries) { + if (_ingredientKey(entry.key) == _ingredientKey(matchedName) || + _ingredientKey(entry.key) == _ingredientKey(ingredient)) { + return entry.value; + } + } + + final lower = matchedName.toLowerCase(); + final gramMatch = RegExp( + r'(\d+(?:[,.]\d+)?)\s*(gr|g|gram)\b', + ).firstMatch(lower); + if (gramMatch != null) { + return double.parse(gramMatch.group(1)!.replaceAll(',', '.')); + } + + final kgMatch = RegExp( + r'(\d+(?:[,.]\d+)?)\s*(kg|kilogram)\b', + ).firstMatch(lower); + if (kgMatch != null) { + return double.parse(kgMatch.group(1)!.replaceAll(',', '.')) * 1000; + } + + return 1000; + } + Map get stockUsage { final required = requiredIngredients; final usage = {}; @@ -447,7 +691,10 @@ class PredictionController extends ChangeNotifier { unitPrice: 0, predictionDate: DateTime.now().toIso8601String().split('T').first, predictedQuantity: productionQuantity, + rawValue: predictionRawValue, estimatedNeeds: buildEstimatedNeedsText(), + accuracyR2: predictionR2, + errorMae: predictionMae, ); for (final entry in usage.entries) { diff --git a/lib/screens/prediction/prediction_page.dart b/lib/screens/prediction/prediction_page.dart index b5a43b7..ee13bb1 100644 --- a/lib/screens/prediction/prediction_page.dart +++ b/lib/screens/prediction/prediction_page.dart @@ -446,7 +446,7 @@ class _PredictionScreenState extends State { CrossAxisAlignment.start, children: [ const Text( - 'Jumlah Produksi', + 'Jumlah Produksi Manual (opsional)', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, @@ -491,6 +491,14 @@ class _PredictionScreenState extends State { ), ), ), + const SizedBox(height: 6), + const Text( + 'Kosongkan untuk memakai hasil prediksi Random Forest.', + style: TextStyle( + fontSize: 11, + color: Color(0xFF9CA3AF), + ), + ), ], ), const SizedBox(height: 20), @@ -501,18 +509,51 @@ class _PredictionScreenState extends State { onPressed: _controller.canCalculate ? () async { - await _controller - .refreshStock(); - _controller.calculate(); + final error = + await _controller + .calculate(); + _productionController + .text = _controller + .productionQuantity + .toString(); + if (!context.mounted || + error == null) { + return; + } + + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: Text( + error, + ), + ), + ); } : null, - icon: const Icon( - Icons.calculate, - size: 18, - ), - label: const Text( - 'Hitung Kebutuhan', - style: TextStyle( + icon: + _controller.isPredicting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation< + Color + >(Colors.white), + ), + ) + : const Icon( + Icons.auto_graph, + size: 18, + ), + label: Text( + _controller.isPredicting + ? 'Memprediksi...' + : 'Prediksi Random Forest', + style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, ), @@ -569,6 +610,124 @@ class _PredictionScreenState extends State { ), const SizedBox(height: 24), if (_controller.isCalculated) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color( + 0xFFA89080, + ).withOpacity(0.08), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color( + 0xFFA89080, + ).withOpacity(0.25), + ), + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color( + 0xFFA89080, + ).withOpacity(0.18), + borderRadius: + BorderRadius.circular(10), + ), + child: const Icon( + Icons.auto_graph, + color: Color(0xFFA89080), + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + const Text( + 'Hasil Prediksi Random Forest', + style: TextStyle( + fontSize: 14, + fontWeight: + FontWeight.w700, + color: Color(0xFF1F2937), + ), + ), + const SizedBox(height: 2), + Text( + 'Bahan acuan: ${_controller.cleanIngredientName(_controller.predictionModelProduct ?? '-')}', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF6B7280), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: _PredictionMetric( + label: 'Permintaan', + value: + '${_controller.predictedDemand ?? 0}', + ), + ), + const SizedBox(width: 10), + Expanded( + child: _PredictionMetric( + label: 'Produksi', + value: + '${_controller.productionQuantity} pcs', + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _PredictionMetric( + label: 'R2', + value: + _controller.predictionR2 == + null + ? '-' + : _controller + .predictionR2! + .toStringAsFixed(4), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _PredictionMetric( + label: 'MAE', + value: + _controller.predictionMae == + null + ? '-' + : _controller + .predictionMae! + .toStringAsFixed(2), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 16), Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -1293,6 +1452,45 @@ class _PredictionScreenState extends State { } } +class _PredictionMetric extends StatelessWidget { + const _PredictionMetric({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFE5E7EB)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle(fontSize: 11, color: Color(0xFF6B7280)), + ), + const SizedBox(height: 4), + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1F2937), + ), + ), + ], + ), + ); + } +} + class _EmptyCalcIcon extends StatelessWidget { const _EmptyCalcIcon(); diff --git a/lib/screens/products/product_list_controller.dart b/lib/screens/products/product_list_controller.dart index 582b7d1..cdb5041 100644 --- a/lib/screens/products/product_list_controller.dart +++ b/lib/screens/products/product_list_controller.dart @@ -10,6 +10,19 @@ class ProductListController extends ChangeNotifier { List products = []; + static const Map datasetUnits = { + 'Baking Powder 45gr': 'gr', + 'Baking Powder': 'gr', + 'Cokelat Bubuk 250gr': 'gr', + 'Gula Pasir 1kg': 'kg', + 'Keju Parut 250gr': 'gr', + 'Mentega 500gr': 'gr', + 'Susu Bubuk 27gr': 'gr', + 'Susu Bubuk': 'gr', + 'Telur 1kg': 'kg', + 'Tepung Terigu 1kg': 'kg', + }; + Future loadProducts() async { isLoading = true; notifyListeners(); @@ -22,12 +35,14 @@ class ProductListController extends ChangeNotifier { final stock = StockStatusUtils.parseStock(p['current_stock']); final minStock = StockStatusUtils.parseStock(p['min_stock']); final category = p['category'] ?? ''; + final name = p['name'] ?? ''; final unit = p['unit'] ?? + datasetUnits[name] ?? (category.toString().toLowerCase() == 'barang' ? 'pcs' : 'kg'); return Product( id: p['id'] ?? 0, - name: p['name'] ?? '', + name: name, category: category, price: p['price'] ?? 0, stock: stock, @@ -128,6 +143,44 @@ class ProductListController extends ChangeNotifier { .replaceAll(RegExp(r'\.$'), ''); } + String minimumStockUnit(Product product) { + final unit = product.unit.toLowerCase(); + if (product.category.toLowerCase() == 'barang' || unit == 'pcs') { + return 'pcs'; + } + if (unit == 'ml' || unit == 'l') { + return 'L'; + } + return 'kg'; + } + + Future updateProduct({ + required Product product, + required String name, + required String category, + required int price, + required double stock, + required double minStock, + required String unit, + }) async { + final result = await MLService.updateProduct( + productId: product.id, + name: name, + category: category, + price: price, + currentStock: stock, + minStock: minStock, + unit: unit, + ); + + if (result['status'] != 'success') { + return result['message']?.toString() ?? 'Gagal mengubah produk'; + } + + await loadProducts(); + return null; + } + String getStatusLabel(String status) { return StockStatusUtils.label(status, withIcon: true); } diff --git a/lib/screens/products/product_list_page.dart b/lib/screens/products/product_list_page.dart index a3ff9af..1708f02 100644 --- a/lib/screens/products/product_list_page.dart +++ b/lib/screens/products/product_list_page.dart @@ -702,7 +702,7 @@ class _ProductListScreenState extends State with RouteAware { icon: Icons.low_priority_outlined, label: 'Minimum', value: - '${_controller.formatStock(product.minStock)} ${product.unit}', + '${_controller.formatStock(product.minStock)} ${_controller.minimumStockUnit(product)}', ), _buildDetailTile( icon: Icons.straighten_outlined, @@ -791,7 +791,26 @@ class _ProductListScreenState extends State with RouteAware { ), ), ), - const SizedBox(width: 12), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: () { + Navigator.of(context).pop(); + _showEditProductDialog(product); + }, + icon: const Icon(Icons.edit_outlined), + label: const Text('Edit'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primaryBrown, + side: BorderSide(color: AppColors.primaryBrown), + padding: const EdgeInsets.symmetric(vertical: 13), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + ), + ), + const SizedBox(width: 8), Expanded( child: ElevatedButton.icon( onPressed: () { @@ -820,6 +839,193 @@ class _ProductListScreenState extends State with RouteAware { ); } + void _showEditProductDialog(Product product) { + final nameController = TextEditingController(text: product.name); + final categoryController = TextEditingController(text: product.category); + final priceController = TextEditingController( + text: product.price.toString(), + ); + final stockController = TextEditingController( + text: _controller.formatStock(product.stock), + ); + final minStockController = TextEditingController( + text: _controller.formatStock(product.minStock), + ); + final unitOptions = ['kg', 'gr', 'L', 'ml', 'butir', 'pcs']; + var selectedUnit = product.unit == 'l' ? 'L' : product.unit; + if (!unitOptions.contains(selectedUnit)) { + selectedUnit = product.unit.toLowerCase(); + } + if (!unitOptions.contains(selectedUnit)) { + selectedUnit = unitOptions.first; + } + var isSaving = false; + + showDialog( + context: context, + builder: + (context) => StatefulBuilder( + builder: + (context, setStateDialog) => AlertDialog( + title: const Text('Edit Produk'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: 'Nama Produk', + ), + ), + const SizedBox(height: 12), + TextField( + controller: categoryController, + decoration: const InputDecoration( + labelText: 'Kategori', + ), + ), + const SizedBox(height: 12), + TextField( + controller: priceController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Harga'), + ), + const SizedBox(height: 12), + TextField( + controller: stockController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration(labelText: 'Stok'), + ), + const SizedBox(height: 12), + TextField( + controller: minStockController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + labelText: 'Minimum', + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: selectedUnit, + items: + unitOptions + .map( + (unit) => DropdownMenuItem( + value: unit, + child: Text(unit), + ), + ) + .toList(), + onChanged: + isSaving + ? null + : (value) { + if (value == null) return; + setStateDialog(() => selectedUnit = value); + }, + decoration: const InputDecoration( + labelText: 'Satuan', + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: + isSaving ? null : () => Navigator.of(context).pop(), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: + isSaving + ? null + : () async { + final name = nameController.text.trim(); + final category = categoryController.text.trim(); + final price = + int.tryParse(priceController.text.trim()) ?? + 0; + final stock = + double.tryParse( + stockController.text.trim().replaceAll( + ',', + '.', + ), + ) ?? + -1; + final minStock = + double.tryParse( + minStockController.text.trim().replaceAll( + ',', + '.', + ), + ) ?? + -1; + + if (name.isEmpty || + category.isEmpty || + price <= 0 || + stock < 0 || + minStock < 0) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + 'Isi data produk dengan benar', + ), + backgroundColor: AppColors.statusError, + ), + ); + return; + } + + setStateDialog(() => isSaving = true); + final error = await _controller.updateProduct( + product: product, + name: name, + category: category, + price: price, + stock: stock, + minStock: minStock, + unit: selectedUnit, + ); + + if (!context.mounted) return; + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + error ?? 'Produk berhasil diperbarui', + ), + backgroundColor: + error == null + ? AppColors.statusSuccess + : AppColors.statusError, + ), + ); + }, + child: + isSaving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Text('Simpan'), + ), + ], + ), + ), + ); + } + Widget _buildDetailGrid({required List children}) { return LayoutBuilder( builder: (context, constraints) { diff --git a/lib/screens/settings/settings_page.dart b/lib/screens/settings/settings_page.dart index 857892b..066e594 100644 --- a/lib/screens/settings/settings_page.dart +++ b/lib/screens/settings/settings_page.dart @@ -15,8 +15,8 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { late final SettingsController _controller; - String _userName = 'Ibu Sulastri'; - String _userEmail = 'sulastri.aritanto10@gmail.com'; + String _userName = ''; + String _userEmail = ''; @override void initState() { diff --git a/lib/screens/transaction/transaction_controller.dart b/lib/screens/transaction/transaction_controller.dart index ecc9ac5..7d40317 100644 --- a/lib/screens/transaction/transaction_controller.dart +++ b/lib/screens/transaction/transaction_controller.dart @@ -7,6 +7,7 @@ class CartItem { final String productName; final String category; final int unitPrice; + final String unit; int quantity; CartItem({ @@ -14,6 +15,7 @@ class CartItem { required this.productName, required this.category, required this.unitPrice, + required this.unit, required this.quantity, }); @@ -109,6 +111,9 @@ class TransactionController extends ChangeNotifier { productName: selectedProduct!, category: productCategories[selectedProduct!]!, unitPrice: productPrices[selectedProduct!]!, + unit: + productUnits[selectedProduct!] ?? + _defaultUnitFromCategory(productCategories[selectedProduct!]!), quantity: quantity, ), ); @@ -179,6 +184,7 @@ class TransactionController extends ChangeNotifier { quantity: item.quantity, unitPrice: item.unitPrice, totalPrice: item.totalPrice, + unit: item.unit, date: date, ), ); diff --git a/lib/screens/transaction/transaction_page.dart b/lib/screens/transaction/transaction_page.dart index 7620877..bd7992d 100644 --- a/lib/screens/transaction/transaction_page.dart +++ b/lib/screens/transaction/transaction_page.dart @@ -108,7 +108,7 @@ class _TransactionScreenState extends State { final newStockController = TextEditingController(); final newMinStockController = TextEditingController(); const unitOptionsByType = { - 'Bahan': ['kg'], + 'Bahan': ['kg', 'gr', 'L', 'ml'], 'Barang': ['pcs'], }; String selectedProductType = 'Bahan'; @@ -593,7 +593,10 @@ class _TransactionScreenState extends State { controller: _controller.quantityController, keyboardType: TextInputType.number, decoration: InputDecoration( - hintText: 'Masukkan jumlah unit', + hintText: + _controller.selectedProduct == null + ? 'Masukkan jumlah' + : 'Masukkan jumlah ${_controller.productUnits[_controller.selectedProduct] ?? ''}', border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), ), @@ -715,7 +718,7 @@ class _TransactionScreenState extends State { ), const SizedBox(height: 4), Text( - 'Rp ${item.unitPrice} / unit', + 'Rp ${item.unitPrice} / ${item.unit}', style: AppTextStyles .labelSmall .copyWith( @@ -1023,7 +1026,7 @@ class _TransactionScreenState extends State { MainAxisAlignment.spaceBetween, children: [ Text( - '${tx.quantity} unit × Rp ${tx.unitPrice}', + '${tx.quantity} ${tx.unit} x Rp ${tx.unitPrice}', style: AppTextStyles.labelMedium.copyWith( color: AppColors.textSecondary, ), diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 7b576a4..6779d9a 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -25,13 +25,12 @@ class AuthService { static Future getUserName() async { final preferences = await SharedPreferences.getInstance(); - return preferences.getString(_userNameKey) ?? 'Ibu Sulastri'; + return preferences.getString(_userNameKey) ?? ''; } static Future getUserEmail() async { final preferences = await SharedPreferences.getInstance(); - return preferences.getString(_userEmailKey) ?? - 'sulastri.aritanto10@gmail.com'; + return preferences.getString(_userEmailKey) ?? ''; } static Future logout() async { diff --git a/lib/services/ml_service.dart b/lib/services/ml_service.dart index 913af19..911a902 100644 --- a/lib/services/ml_service.dart +++ b/lib/services/ml_service.dart @@ -3,8 +3,9 @@ import 'dart:async'; import 'dart:convert'; class MLService { - // API URL Railway production backend. - static const String baseUrl = 'https://web-production-c3c06.up.railway.app'; + // API URL local Flask backend. + static const String baseUrl = 'http://192.168.1.67:5000'; + //https://web-production-c3c06.up.railway.app/api static const int timeoutSeconds = 30; @@ -174,49 +175,34 @@ class MLService { required String productName, required String category, required int unitPrice, + int plannedQuantity = 1, DateTime? predictionDate, }) async { try { final date = predictionDate ?? DateTime.now(); - // Map product names to encoded values (adjust based on your encoding) - final productMap = { - 'Tepung Terigu 1kg': 1, - 'Telur 1kg': 2, - 'Gula Pasir 1kg': 3, - 'Susu Bubuk': 4, - 'Cokelat Bubuk 250gr': 5, - 'Mentega 500gr': 6, - 'Keju Parut 250gr': 7, - 'Baking Powder': 8, + final response = await http + .post( + Uri.parse('$baseUrl/prediksi'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'product_name': productName, + 'category': category, + 'unit_price': unitPrice, + 'planned_quantity': plannedQuantity, + 'prediction_date': date.toIso8601String().split('T').first, + }), + ) + .timeout(Duration(seconds: timeoutSeconds)); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } + + return { + 'status': 'error', + 'message': 'Server error: ${response.statusCode}', }; - - final categoryMap = { - 'Tepung': 1, - 'Telur': 2, - 'Gula': 3, - 'Susu': 4, - 'Cokelat': 5, - 'Mentega': 6, - 'Keju': 7, - 'Bahan Tambahan': 8, - }; - - final produkEncoded = productMap[productName] ?? 1; - final kategoriEncoded = categoryMap[category] ?? 1; - - return await prediksiStok( - tahun: date.year, - bulan: date.month, - hari: date.day, - hariDalamMinggu: date.weekday, - hariMinggu: date.weekday, - hargaSatuanUpdate: unitPrice, - totalHargaUpdate: unitPrice, // Simplified - produkEncoded: produkEncoded, - namaProdukEncoded: produkEncoded, - kategoriProdukEncoded: kategoriEncoded, - ); } catch (e) { return {'status': 'error', 'message': 'Connection error: $e'}; } @@ -567,6 +553,53 @@ class MLService { } } + /// Update product in database + static Future> updateProduct({ + required int productId, + String? name, + String? category, + int? price, + double? currentStock, + double? minStock, + String? unit, + }) async { + try { + final data = { + if (name != null) 'name': name, + if (category != null) 'category': category, + if (price != null) 'price': price, + if (currentStock != null) 'current_stock': currentStock, + if (minStock != null) 'min_stock': minStock, + if (unit != null) 'unit': unit, + }; + + final response = await http + .put( + Uri.parse('$baseUrl/products/$productId'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(data), + ) + .timeout(Duration(seconds: timeoutSeconds)); + + final body = + response.body.isNotEmpty + ? jsonDecode(response.body) as Map + : {}; + + if (response.statusCode >= 200 && response.statusCode < 300) { + return body; + } + + return { + 'status': 'error', + 'message': body['message'] ?? 'Server error: ${response.statusCode}', + }; + } catch (e) { + print('Update product error: $e'); + return {'status': 'error', 'message': 'Connection error: $e'}; + } + } + /// Save transaction and update stock automatically static Future> addTransactionWithStockUpdate({ required int productId,