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 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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<Map<String, dynamic>> recipes = [];
|
||||
Map<String, Map<String, dynamic>> recipeIngredients = {};
|
||||
Map<String, bool> ingredientSelections = {};
|
||||
|
||||
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, double> currentStock = {};
|
||||
final Map<String, int> productIds = {};
|
||||
final Map<String, String> productUnits = {};
|
||||
final Map<String, String> productCategories = {};
|
||||
final Map<String, int> productPrices = {};
|
||||
|
||||
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 {
|
||||
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<String?> 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<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() {
|
||||
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<String, double> get stockUsage {
|
||||
final required = requiredIngredients;
|
||||
final usage = <String, double>{};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -446,7 +446,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
|
|||
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<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),
|
||||
|
|
@ -501,18 +509,51 @@ class _PredictionScreenState extends State<PredictionScreen> {
|
|||
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<PredictionScreen> {
|
|||
),
|
||||
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<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 {
|
||||
const _EmptyCalcIcon();
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,19 @@ class ProductListController extends ChangeNotifier {
|
|||
|
||||
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 {
|
||||
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<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) {
|
||||
return StockStatusUtils.label(status, withIcon: true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -702,7 +702,7 @@ class _ProductListScreenState extends State<ProductListScreen> 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<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(
|
||||
child: ElevatedButton.icon(
|
||||
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}) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ class SettingsScreen extends StatefulWidget {
|
|||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
late final SettingsController _controller;
|
||||
String _userName = 'Ibu Sulastri';
|
||||
String _userEmail = 'sulastri.aritanto10@gmail.com';
|
||||
String _userName = '';
|
||||
String _userEmail = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
|||
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<TransactionScreen> {
|
|||
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<TransactionScreen> {
|
|||
),
|
||||
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<TransactionScreen> {
|
|||
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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -25,13 +25,12 @@ class AuthService {
|
|||
|
||||
static Future<String> getUserName() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
return preferences.getString(_userNameKey) ?? 'Ibu Sulastri';
|
||||
return preferences.getString(_userNameKey) ?? '';
|
||||
}
|
||||
|
||||
static Future<String> getUserEmail() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
return preferences.getString(_userEmailKey) ??
|
||||
'sulastri.aritanto10@gmail.com';
|
||||
return preferences.getString(_userEmailKey) ?? '';
|
||||
}
|
||||
|
||||
static Future<void> logout() async {
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
static Future<Map<String, dynamic>> addTransactionWithStockUpdate({
|
||||
required int productId,
|
||||
|
|
|
|||
Loading…
Reference in New Issue