Update Flutter product and prediction flows
This commit is contained in:
parent
1a34474c37
commit
9b55f02ade
|
|
@ -5,6 +5,7 @@ class Transaction {
|
||||||
final int quantity;
|
final int quantity;
|
||||||
final int unitPrice;
|
final int unitPrice;
|
||||||
final int totalPrice;
|
final int totalPrice;
|
||||||
|
final String unit;
|
||||||
final DateTime date;
|
final DateTime date;
|
||||||
|
|
||||||
Transaction({
|
Transaction({
|
||||||
|
|
@ -14,6 +15,7 @@ class Transaction {
|
||||||
required this.quantity,
|
required this.quantity,
|
||||||
required this.unitPrice,
|
required this.unitPrice,
|
||||||
required this.totalPrice,
|
required this.totalPrice,
|
||||||
|
required this.unit,
|
||||||
required this.date,
|
required this.date,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -25,6 +27,7 @@ class Transaction {
|
||||||
quantity: json['quantity'] as int,
|
quantity: json['quantity'] as int,
|
||||||
unitPrice: json['unit_price'] as int,
|
unitPrice: json['unit_price'] as int,
|
||||||
totalPrice: json['total_price'] as int,
|
totalPrice: json['total_price'] as int,
|
||||||
|
unit: (json['unit'] as String?) ?? 'pcs',
|
||||||
date: DateTime.parse(json['date'] as String),
|
date: DateTime.parse(json['date'] as String),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -37,6 +40,7 @@ class Transaction {
|
||||||
'quantity': quantity,
|
'quantity': quantity,
|
||||||
'unit_price': unitPrice,
|
'unit_price': unitPrice,
|
||||||
'total_price': totalPrice,
|
'total_price': totalPrice,
|
||||||
|
'unit': unit,
|
||||||
'date': date.toIso8601String(),
|
'date': date.toIso8601String(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -48,6 +52,7 @@ class Transaction {
|
||||||
int? quantity,
|
int? quantity,
|
||||||
int? unitPrice,
|
int? unitPrice,
|
||||||
int? totalPrice,
|
int? totalPrice,
|
||||||
|
String? unit,
|
||||||
DateTime? date,
|
DateTime? date,
|
||||||
}) {
|
}) {
|
||||||
return Transaction(
|
return Transaction(
|
||||||
|
|
@ -57,6 +62,7 @@ class Transaction {
|
||||||
quantity: quantity ?? this.quantity,
|
quantity: quantity ?? this.quantity,
|
||||||
unitPrice: unitPrice ?? this.unitPrice,
|
unitPrice: unitPrice ?? this.unitPrice,
|
||||||
totalPrice: totalPrice ?? this.totalPrice,
|
totalPrice: totalPrice ?? this.totalPrice,
|
||||||
|
unit: unit ?? this.unit,
|
||||||
date: date ?? this.date,
|
date: date ?? this.date,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ class DashboardController extends ChangeNotifier {
|
||||||
_applyPenggunaanBahan(summary['penggunaan_bahan']);
|
_applyPenggunaanBahan(summary['penggunaan_bahan']);
|
||||||
} else {
|
} else {
|
||||||
errorMessage = summary['message']?.toString();
|
errorMessage = summary['message']?.toString();
|
||||||
_applyDummyPenggunaan();
|
penggunaanBahan.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
totalProduk = products.length;
|
totalProduk = products.length;
|
||||||
|
|
@ -59,7 +59,7 @@ class DashboardController extends ChangeNotifier {
|
||||||
errorMessage = 'Gagal memuat dashboard: $e';
|
errorMessage = 'Gagal memuat dashboard: $e';
|
||||||
bahanDigunakanError = 'Gagal memuat bahan digunakan hari ini';
|
bahanDigunakanError = 'Gagal memuat bahan digunakan hari ini';
|
||||||
_resetBahanDigunakanHariIni();
|
_resetBahanDigunakanHariIni();
|
||||||
_applyDummyPenggunaan();
|
penggunaanBahan.clear();
|
||||||
lowStockItems.clear();
|
lowStockItems.clear();
|
||||||
} finally {
|
} finally {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
|
|
@ -90,7 +90,6 @@ class DashboardController extends ChangeNotifier {
|
||||||
void _applyPenggunaanBahan(dynamic data) {
|
void _applyPenggunaanBahan(dynamic data) {
|
||||||
penggunaanBahan.clear();
|
penggunaanBahan.clear();
|
||||||
if (data is! List || data.isEmpty) {
|
if (data is! List || data.isEmpty) {
|
||||||
_applyDummyPenggunaan();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (final item in data) {
|
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) {
|
String _formatStock(double value) {
|
||||||
if (value % 1 == 0) {
|
if (value % 1 == 0) {
|
||||||
return value.toInt().toString();
|
return value.toInt().toString();
|
||||||
|
|
|
||||||
|
|
@ -7,25 +7,56 @@ class PredictionController extends ChangeNotifier {
|
||||||
bool isCalculated = false;
|
bool isCalculated = false;
|
||||||
bool isLoading = true;
|
bool isLoading = true;
|
||||||
bool isSubmitting = false;
|
bool isSubmitting = false;
|
||||||
|
bool isPredicting = false;
|
||||||
|
int? predictedDemand;
|
||||||
|
double? predictionRawValue;
|
||||||
|
double? predictionR2;
|
||||||
|
double? predictionMae;
|
||||||
|
double? predictionRmse;
|
||||||
|
String? predictionModelProduct;
|
||||||
|
|
||||||
List<Map<String, dynamic>> recipes = [];
|
List<Map<String, dynamic>> recipes = [];
|
||||||
Map<String, Map<String, dynamic>> recipeIngredients = {};
|
Map<String, Map<String, dynamic>> recipeIngredients = {};
|
||||||
Map<String, bool> ingredientSelections = {};
|
Map<String, bool> ingredientSelections = {};
|
||||||
|
|
||||||
final Map<String, double> currentStock = {
|
final Map<String, double> 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<String, int> productIds = {};
|
final Map<String, int> productIds = {};
|
||||||
final Map<String, String> productUnits = {};
|
final Map<String, String> productUnits = {};
|
||||||
|
final Map<String, String> productCategories = {};
|
||||||
|
final Map<String, int> productPrices = {};
|
||||||
|
|
||||||
static const double eggGramPerButir = 50;
|
static const double eggGramPerButir = 50;
|
||||||
|
static const Map<String, double> 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<String, String> 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<String, int> 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<String?> loadRecipes() async {
|
Future<String?> loadRecipes() async {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|
@ -82,6 +113,8 @@ class PredictionController extends ChangeNotifier {
|
||||||
currentStock.clear();
|
currentStock.clear();
|
||||||
productIds.clear();
|
productIds.clear();
|
||||||
productUnits.clear();
|
productUnits.clear();
|
||||||
|
productCategories.clear();
|
||||||
|
productPrices.clear();
|
||||||
|
|
||||||
for (final product in products) {
|
for (final product in products) {
|
||||||
final name =
|
final name =
|
||||||
|
|
@ -91,6 +124,8 @@ class PredictionController extends ChangeNotifier {
|
||||||
final id = _toInt(product['id']);
|
final id = _toInt(product['id']);
|
||||||
final unit = (product['unit'] ?? '').toString();
|
final unit = (product['unit'] ?? '').toString();
|
||||||
final stock = _toDouble(product['current_stock'] ?? product['stock']);
|
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;
|
currentStock[name] = stock;
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
|
|
@ -99,12 +134,17 @@ class PredictionController extends ChangeNotifier {
|
||||||
if (unit.isNotEmpty) {
|
if (unit.isNotEmpty) {
|
||||||
productUnits[name] = unit;
|
productUnits[name] = unit;
|
||||||
}
|
}
|
||||||
|
if (category.isNotEmpty) {
|
||||||
|
productCategories[name] = category;
|
||||||
|
}
|
||||||
|
productPrices[name] = price;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void setSelectedRecipe(String? value) {
|
void setSelectedRecipe(String? value) {
|
||||||
selectedRecipe = value;
|
selectedRecipe = value;
|
||||||
isCalculated = false;
|
isCalculated = false;
|
||||||
|
_clearPredictionResult();
|
||||||
_initializeIngredientSelections();
|
_initializeIngredientSelections();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
@ -112,20 +152,84 @@ class PredictionController extends ChangeNotifier {
|
||||||
void setProductionQuantity(String value) {
|
void setProductionQuantity(String value) {
|
||||||
productionQuantity = int.tryParse(value) ?? 0;
|
productionQuantity = int.tryParse(value) ?? 0;
|
||||||
isCalculated = false;
|
isCalculated = false;
|
||||||
|
_clearPredictionResult();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get canCalculate => selectedRecipe != null && productionQuantity > 0;
|
bool get canCalculate => selectedRecipe != null && !isPredicting;
|
||||||
|
|
||||||
void calculate() {
|
Future<String?> calculate() async {
|
||||||
isCalculated = true;
|
if (selectedRecipe == null) {
|
||||||
|
return 'Pilih produk terlebih dahulu';
|
||||||
|
}
|
||||||
|
|
||||||
|
isPredicting = true;
|
||||||
|
isCalculated = false;
|
||||||
|
_clearPredictionResult();
|
||||||
notifyListeners();
|
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<String, dynamic>? ?? {};
|
||||||
|
final accuracy = result['model_accuracy'] as Map<String, dynamic>? ?? {};
|
||||||
|
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() {
|
void reset() {
|
||||||
selectedRecipe = null;
|
selectedRecipe = null;
|
||||||
productionQuantity = 0;
|
productionQuantity = 0;
|
||||||
isCalculated = false;
|
isCalculated = false;
|
||||||
|
_clearPredictionResult();
|
||||||
ingredientSelections = {};
|
ingredientSelections = {};
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
@ -215,6 +319,20 @@ class PredictionController extends ChangeNotifier {
|
||||||
return key == null ? productIds[ingredient] : productIds[key];
|
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}) {
|
double toGram({required double amount, required String unit}) {
|
||||||
final normalized = _normalizeUnit(unit);
|
final normalized = _normalizeUnit(unit);
|
||||||
if (normalized == 'gr') return amount;
|
if (normalized == 'gr') return amount;
|
||||||
|
|
@ -308,8 +426,11 @@ class PredictionController extends ChangeNotifier {
|
||||||
required String ingredient,
|
required String ingredient,
|
||||||
required double amount,
|
required double amount,
|
||||||
required String fromUnit,
|
required String fromUnit,
|
||||||
|
String? overrideStockUnit,
|
||||||
}) {
|
}) {
|
||||||
final stockUnit = _normalizeUnit(getStockUnit(ingredient));
|
final stockUnit = _normalizeUnit(
|
||||||
|
overrideStockUnit ?? getStockUnit(ingredient),
|
||||||
|
);
|
||||||
final unit = _normalizeUnit(fromUnit);
|
final unit = _normalizeUnit(fromUnit);
|
||||||
|
|
||||||
if (unit == stockUnit) return amount;
|
if (unit == stockUnit) return amount;
|
||||||
|
|
@ -358,6 +479,129 @@ class PredictionController extends ChangeNotifier {
|
||||||
return null;
|
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<String, double> get stockUsage {
|
Map<String, double> get stockUsage {
|
||||||
final required = requiredIngredients;
|
final required = requiredIngredients;
|
||||||
final usage = <String, double>{};
|
final usage = <String, double>{};
|
||||||
|
|
@ -447,7 +691,10 @@ class PredictionController extends ChangeNotifier {
|
||||||
unitPrice: 0,
|
unitPrice: 0,
|
||||||
predictionDate: DateTime.now().toIso8601String().split('T').first,
|
predictionDate: DateTime.now().toIso8601String().split('T').first,
|
||||||
predictedQuantity: productionQuantity,
|
predictedQuantity: productionQuantity,
|
||||||
|
rawValue: predictionRawValue,
|
||||||
estimatedNeeds: buildEstimatedNeedsText(),
|
estimatedNeeds: buildEstimatedNeedsText(),
|
||||||
|
accuracyR2: predictionR2,
|
||||||
|
errorMae: predictionMae,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (final entry in usage.entries) {
|
for (final entry in usage.entries) {
|
||||||
|
|
|
||||||
|
|
@ -446,7 +446,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
|
||||||
CrossAxisAlignment.start,
|
CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
'Jumlah Produksi',
|
'Jumlah Produksi Manual (opsional)',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|
@ -491,6 +491,14 @@ class _PredictionScreenState extends State<PredictionScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
const Text(
|
||||||
|
'Kosongkan untuk memakai hasil prediksi Random Forest.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Color(0xFF9CA3AF),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
@ -501,18 +509,51 @@ class _PredictionScreenState extends State<PredictionScreen> {
|
||||||
onPressed:
|
onPressed:
|
||||||
_controller.canCalculate
|
_controller.canCalculate
|
||||||
? () async {
|
? () async {
|
||||||
await _controller
|
final error =
|
||||||
.refreshStock();
|
await _controller
|
||||||
_controller.calculate();
|
.calculate();
|
||||||
|
_productionController
|
||||||
|
.text = _controller
|
||||||
|
.productionQuantity
|
||||||
|
.toString();
|
||||||
|
if (!context.mounted ||
|
||||||
|
error == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
icon: const Icon(
|
icon:
|
||||||
Icons.calculate,
|
_controller.isPredicting
|
||||||
size: 18,
|
? const SizedBox(
|
||||||
),
|
width: 18,
|
||||||
label: const Text(
|
height: 18,
|
||||||
'Hitung Kebutuhan',
|
child: CircularProgressIndicator(
|
||||||
style: TextStyle(
|
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,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
|
|
@ -569,6 +610,124 @@ class _PredictionScreenState extends State<PredictionScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
if (_controller.isCalculated) ...[
|
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(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -1293,6 +1452,45 @@ class _PredictionScreenState extends State<PredictionScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
class _EmptyCalcIcon extends StatelessWidget {
|
||||||
const _EmptyCalcIcon();
|
const _EmptyCalcIcon();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,19 @@ class ProductListController extends ChangeNotifier {
|
||||||
|
|
||||||
List<Product> products = [];
|
List<Product> products = [];
|
||||||
|
|
||||||
|
static const Map<String, String> 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<void> loadProducts() async {
|
Future<void> loadProducts() async {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
@ -22,12 +35,14 @@ class ProductListController extends ChangeNotifier {
|
||||||
final stock = StockStatusUtils.parseStock(p['current_stock']);
|
final stock = StockStatusUtils.parseStock(p['current_stock']);
|
||||||
final minStock = StockStatusUtils.parseStock(p['min_stock']);
|
final minStock = StockStatusUtils.parseStock(p['min_stock']);
|
||||||
final category = p['category'] ?? '';
|
final category = p['category'] ?? '';
|
||||||
|
final name = p['name'] ?? '';
|
||||||
final unit =
|
final unit =
|
||||||
p['unit'] ??
|
p['unit'] ??
|
||||||
|
datasetUnits[name] ??
|
||||||
(category.toString().toLowerCase() == 'barang' ? 'pcs' : 'kg');
|
(category.toString().toLowerCase() == 'barang' ? 'pcs' : 'kg');
|
||||||
return Product(
|
return Product(
|
||||||
id: p['id'] ?? 0,
|
id: p['id'] ?? 0,
|
||||||
name: p['name'] ?? '',
|
name: name,
|
||||||
category: category,
|
category: category,
|
||||||
price: p['price'] ?? 0,
|
price: p['price'] ?? 0,
|
||||||
stock: stock,
|
stock: stock,
|
||||||
|
|
@ -128,6 +143,44 @@ class ProductListController extends ChangeNotifier {
|
||||||
.replaceAll(RegExp(r'\.$'), '');
|
.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<String?> 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) {
|
String getStatusLabel(String status) {
|
||||||
return StockStatusUtils.label(status, withIcon: true);
|
return StockStatusUtils.label(status, withIcon: true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -702,7 +702,7 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
||||||
icon: Icons.low_priority_outlined,
|
icon: Icons.low_priority_outlined,
|
||||||
label: 'Minimum',
|
label: 'Minimum',
|
||||||
value:
|
value:
|
||||||
'${_controller.formatStock(product.minStock)} ${product.unit}',
|
'${_controller.formatStock(product.minStock)} ${_controller.minimumStockUnit(product)}',
|
||||||
),
|
),
|
||||||
_buildDetailTile(
|
_buildDetailTile(
|
||||||
icon: Icons.straighten_outlined,
|
icon: Icons.straighten_outlined,
|
||||||
|
|
@ -791,7 +791,26 @@ class _ProductListScreenState extends State<ProductListScreen> 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(
|
Expanded(
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
|
@ -820,6 +839,193 @@ class _ProductListScreenState extends State<ProductListScreen> 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<String>(
|
||||||
|
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<Widget> children}) {
|
Widget _buildDetailGrid({required List<Widget> children}) {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ class SettingsScreen extends StatefulWidget {
|
||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
late final SettingsController _controller;
|
late final SettingsController _controller;
|
||||||
String _userName = 'Ibu Sulastri';
|
String _userName = '';
|
||||||
String _userEmail = 'sulastri.aritanto10@gmail.com';
|
String _userEmail = '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ class CartItem {
|
||||||
final String productName;
|
final String productName;
|
||||||
final String category;
|
final String category;
|
||||||
final int unitPrice;
|
final int unitPrice;
|
||||||
|
final String unit;
|
||||||
int quantity;
|
int quantity;
|
||||||
|
|
||||||
CartItem({
|
CartItem({
|
||||||
|
|
@ -14,6 +15,7 @@ class CartItem {
|
||||||
required this.productName,
|
required this.productName,
|
||||||
required this.category,
|
required this.category,
|
||||||
required this.unitPrice,
|
required this.unitPrice,
|
||||||
|
required this.unit,
|
||||||
required this.quantity,
|
required this.quantity,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -109,6 +111,9 @@ class TransactionController extends ChangeNotifier {
|
||||||
productName: selectedProduct!,
|
productName: selectedProduct!,
|
||||||
category: productCategories[selectedProduct!]!,
|
category: productCategories[selectedProduct!]!,
|
||||||
unitPrice: productPrices[selectedProduct!]!,
|
unitPrice: productPrices[selectedProduct!]!,
|
||||||
|
unit:
|
||||||
|
productUnits[selectedProduct!] ??
|
||||||
|
_defaultUnitFromCategory(productCategories[selectedProduct!]!),
|
||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -179,6 +184,7 @@ class TransactionController extends ChangeNotifier {
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
unitPrice: item.unitPrice,
|
unitPrice: item.unitPrice,
|
||||||
totalPrice: item.totalPrice,
|
totalPrice: item.totalPrice,
|
||||||
|
unit: item.unit,
|
||||||
date: date,
|
date: date,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
||||||
final newStockController = TextEditingController();
|
final newStockController = TextEditingController();
|
||||||
final newMinStockController = TextEditingController();
|
final newMinStockController = TextEditingController();
|
||||||
const unitOptionsByType = {
|
const unitOptionsByType = {
|
||||||
'Bahan': ['kg'],
|
'Bahan': ['kg', 'gr', 'L', 'ml'],
|
||||||
'Barang': ['pcs'],
|
'Barang': ['pcs'],
|
||||||
};
|
};
|
||||||
String selectedProductType = 'Bahan';
|
String selectedProductType = 'Bahan';
|
||||||
|
|
@ -593,7 +593,10 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
||||||
controller: _controller.quantityController,
|
controller: _controller.quantityController,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Masukkan jumlah unit',
|
hintText:
|
||||||
|
_controller.selectedProduct == null
|
||||||
|
? 'Masukkan jumlah'
|
||||||
|
: 'Masukkan jumlah ${_controller.productUnits[_controller.selectedProduct] ?? ''}',
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
|
|
@ -715,7 +718,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Rp ${item.unitPrice} / unit',
|
'Rp ${item.unitPrice} / ${item.unit}',
|
||||||
style: AppTextStyles
|
style: AppTextStyles
|
||||||
.labelSmall
|
.labelSmall
|
||||||
.copyWith(
|
.copyWith(
|
||||||
|
|
@ -1023,7 +1026,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
||||||
MainAxisAlignment.spaceBetween,
|
MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${tx.quantity} unit × Rp ${tx.unitPrice}',
|
'${tx.quantity} ${tx.unit} x Rp ${tx.unitPrice}',
|
||||||
style: AppTextStyles.labelMedium.copyWith(
|
style: AppTextStyles.labelMedium.copyWith(
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,12 @@ class AuthService {
|
||||||
|
|
||||||
static Future<String> getUserName() async {
|
static Future<String> getUserName() async {
|
||||||
final preferences = await SharedPreferences.getInstance();
|
final preferences = await SharedPreferences.getInstance();
|
||||||
return preferences.getString(_userNameKey) ?? 'Ibu Sulastri';
|
return preferences.getString(_userNameKey) ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<String> getUserEmail() async {
|
static Future<String> getUserEmail() async {
|
||||||
final preferences = await SharedPreferences.getInstance();
|
final preferences = await SharedPreferences.getInstance();
|
||||||
return preferences.getString(_userEmailKey) ??
|
return preferences.getString(_userEmailKey) ?? '';
|
||||||
'sulastri.aritanto10@gmail.com';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> logout() async {
|
static Future<void> logout() async {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
class MLService {
|
class MLService {
|
||||||
// API URL Railway production backend.
|
// API URL local Flask backend.
|
||||||
static const String baseUrl = 'https://web-production-c3c06.up.railway.app';
|
static const String baseUrl = 'http://192.168.1.67:5000';
|
||||||
|
//https://web-production-c3c06.up.railway.app/api
|
||||||
|
|
||||||
static const int timeoutSeconds = 30;
|
static const int timeoutSeconds = 30;
|
||||||
|
|
||||||
|
|
@ -174,49 +175,34 @@ class MLService {
|
||||||
required String productName,
|
required String productName,
|
||||||
required String category,
|
required String category,
|
||||||
required int unitPrice,
|
required int unitPrice,
|
||||||
|
int plannedQuantity = 1,
|
||||||
DateTime? predictionDate,
|
DateTime? predictionDate,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final date = predictionDate ?? DateTime.now();
|
final date = predictionDate ?? DateTime.now();
|
||||||
|
|
||||||
// Map product names to encoded values (adjust based on your encoding)
|
final response = await http
|
||||||
final productMap = {
|
.post(
|
||||||
'Tepung Terigu 1kg': 1,
|
Uri.parse('$baseUrl/prediksi'),
|
||||||
'Telur 1kg': 2,
|
headers: {'Content-Type': 'application/json'},
|
||||||
'Gula Pasir 1kg': 3,
|
body: jsonEncode({
|
||||||
'Susu Bubuk': 4,
|
'product_name': productName,
|
||||||
'Cokelat Bubuk 250gr': 5,
|
'category': category,
|
||||||
'Mentega 500gr': 6,
|
'unit_price': unitPrice,
|
||||||
'Keju Parut 250gr': 7,
|
'planned_quantity': plannedQuantity,
|
||||||
'Baking Powder': 8,
|
'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) {
|
} catch (e) {
|
||||||
return {'status': 'error', 'message': 'Connection error: $e'};
|
return {'status': 'error', 'message': 'Connection error: $e'};
|
||||||
}
|
}
|
||||||
|
|
@ -567,6 +553,53 @@ class MLService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update product in database
|
||||||
|
static Future<Map<String, dynamic>> 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<String, dynamic>
|
||||||
|
: <String, dynamic>{};
|
||||||
|
|
||||||
|
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
|
/// Save transaction and update stock automatically
|
||||||
static Future<Map<String, dynamic>> addTransactionWithStockUpdate({
|
static Future<Map<String, dynamic>> addTransactionWithStockUpdate({
|
||||||
required int productId,
|
required int productId,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue