API Fitur transaksi dan Produk
This commit is contained in:
parent
f1fcbddfeb
commit
95a703dd4e
|
|
@ -5,12 +5,14 @@ import 'package:finalproject/models/transaction_model.dart';
|
|||
import 'package:finalproject/services/ml_service.dart';
|
||||
|
||||
class CartItem {
|
||||
final int productId;
|
||||
final String productName;
|
||||
final String category;
|
||||
final int unitPrice;
|
||||
int quantity;
|
||||
|
||||
CartItem({
|
||||
required this.productId,
|
||||
required this.productName,
|
||||
required this.category,
|
||||
required this.unitPrice,
|
||||
|
|
@ -35,6 +37,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
|||
|
||||
// Products list (mutable)
|
||||
late List<String> products;
|
||||
late Map<String, int> productIds;
|
||||
late Map<String, String> productCategories;
|
||||
late Map<String, int> productPrices;
|
||||
late List<String> categories;
|
||||
|
|
@ -57,18 +60,21 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
|||
|
||||
setState(() {
|
||||
products = [];
|
||||
productIds = {};
|
||||
productCategories = {};
|
||||
productPrices = {};
|
||||
categories = [];
|
||||
|
||||
// Convert API response to local maps
|
||||
for (var product in fetchedProducts) {
|
||||
int id = product['id'] ?? 0;
|
||||
String name = product['name'] ?? '';
|
||||
String category = product['category'] ?? '';
|
||||
int price = product['price'] ?? 0;
|
||||
|
||||
if (name.isNotEmpty) {
|
||||
if (name.isNotEmpty && id > 0) {
|
||||
products.add(name);
|
||||
productIds[name] = id;
|
||||
productCategories[name] = category;
|
||||
productPrices[name] = price;
|
||||
|
||||
|
|
@ -117,8 +123,9 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
|||
return;
|
||||
}
|
||||
|
||||
final existingIndex =
|
||||
cartItems.indexWhere((item) => item.productName == _selectedProduct);
|
||||
final existingIndex = cartItems.indexWhere(
|
||||
(item) => item.productName == _selectedProduct,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
if (existingIndex >= 0) {
|
||||
|
|
@ -128,6 +135,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
|||
// Add new item to cart
|
||||
cartItems.add(
|
||||
CartItem(
|
||||
productId: productIds[_selectedProduct!] ?? 0,
|
||||
productName: _selectedProduct!,
|
||||
category: productCategories[_selectedProduct!]!,
|
||||
unitPrice: productPrices[_selectedProduct!]!,
|
||||
|
|
@ -183,28 +191,36 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
|||
final dateStr =
|
||||
'${_selectedDate!.year}-${_selectedDate!.month.toString().padLeft(2, '0')}-${_selectedDate!.day.toString().padLeft(2, '0')}';
|
||||
|
||||
int successCount = 0;
|
||||
String? firstError;
|
||||
|
||||
for (final item in cartItems) {
|
||||
await MLService.saveTransaction(
|
||||
productName: item.productName,
|
||||
category: item.category,
|
||||
// Use new endpoint that updates stock automatically
|
||||
final result = await MLService.addTransactionWithStockUpdate(
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
unitPrice: item.unitPrice,
|
||||
totalPrice: item.totalPrice,
|
||||
transactionDate: dateStr,
|
||||
);
|
||||
|
||||
// Add to transaction history
|
||||
final transaction = Transaction(
|
||||
id: transactions.length + 1,
|
||||
productName: item.productName,
|
||||
category: item.category,
|
||||
quantity: item.quantity,
|
||||
unitPrice: item.unitPrice,
|
||||
totalPrice: item.totalPrice,
|
||||
date: _selectedDate ?? DateTime.now(),
|
||||
);
|
||||
|
||||
transactions.insert(0, transaction);
|
||||
if (result['status'] == 'success') {
|
||||
successCount++;
|
||||
// Add to transaction history
|
||||
final transaction = Transaction(
|
||||
id: transactions.length + 1,
|
||||
productName: item.productName,
|
||||
category: item.category,
|
||||
quantity: item.quantity,
|
||||
unitPrice: item.unitPrice,
|
||||
totalPrice: item.totalPrice,
|
||||
date: _selectedDate ?? DateTime.now(),
|
||||
);
|
||||
transactions.insert(0, transaction);
|
||||
} else {
|
||||
firstError ??= result['message'] ?? 'Transaksi gagal';
|
||||
print('Transaction failed for ${item.productName}: ${result['message']}');
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
|
|
@ -213,13 +229,23 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
|||
_selectedDate = DateTime.now();
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text('✅ ${transactions.length} transaksi berhasil disimpan!'),
|
||||
backgroundColor: AppColors.statusSuccess,
|
||||
),
|
||||
);
|
||||
if (firstError != null && successCount < cartItems.length) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'⚠️ $successCount/${cartItems.length} transaksi berhasil. Error: $firstError',
|
||||
),
|
||||
backgroundColor: AppColors.statusError,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('✅ $successCount transaksi berhasil disimpan dan stok terupdate!'),
|
||||
backgroundColor: AppColors.statusSuccess,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _isLoading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
|
@ -241,330 +267,373 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
|||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setStateDialog) => Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bgWhite,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
width: double.infinity,
|
||||
builder:
|
||||
(context) => StatefulBuilder(
|
||||
builder:
|
||||
(context, setStateDialog) => Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryBrown,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
color: AppColors.bgWhite,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Tambah Produk Baru',
|
||||
style: AppTextStyles.headlineSmall.copyWith(
|
||||
color: Colors.white,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryBrown,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Tambah Produk Baru',
|
||||
style: AppTextStyles.headlineSmall.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Product Name
|
||||
Text(
|
||||
'Nama Produk',
|
||||
style: AppTextStyles.labelLarge.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: newProductNameController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Masukkan nama produk',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.bgLight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Category Selection
|
||||
Text(
|
||||
'Kategori',
|
||||
style: AppTextStyles.labelLarge.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (!_createNewCategory)
|
||||
Column(
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
value:
|
||||
_selectedCategory.isNotEmpty
|
||||
? _selectedCategory
|
||||
: null,
|
||||
items:
|
||||
categories
|
||||
.map(
|
||||
(cat) => DropdownMenuItem(
|
||||
value: cat,
|
||||
child: Text(cat),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
setStateDialog(
|
||||
() =>
|
||||
_selectedCategory = value ?? '',
|
||||
);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Pilih kategori',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
10,
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.bgLight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setStateDialog(
|
||||
() => _createNewCategory = true,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
'+ Tambah kategori baru',
|
||||
style: AppTextStyles.labelMedium
|
||||
.copyWith(
|
||||
color: AppColors.secondaryBlue,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: newCategoryController,
|
||||
decoration: InputDecoration(
|
||||
hintText:
|
||||
'Masukkan nama kategori baru',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
10,
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.bgLight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setStateDialog(
|
||||
() => _createNewCategory = false,
|
||||
);
|
||||
newCategoryController.clear();
|
||||
},
|
||||
child: Text(
|
||||
'← Kembali ke kategori existing',
|
||||
style: AppTextStyles.labelMedium
|
||||
.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Price
|
||||
Text(
|
||||
'Harga (Rp)',
|
||||
style: AppTextStyles.labelLarge.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: newPriceController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Masukkan harga',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.bgLight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Buttons
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
),
|
||||
side: BorderSide(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
10,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Batal',
|
||||
style: AppTextStyles.labelLarge
|
||||
.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
final productName =
|
||||
newProductNameController.text
|
||||
.trim();
|
||||
final category =
|
||||
_createNewCategory
|
||||
? newCategoryController.text
|
||||
.trim()
|
||||
: _selectedCategory;
|
||||
final price =
|
||||
newPriceController.text.trim();
|
||||
|
||||
if (productName.isEmpty ||
|
||||
category.isEmpty ||
|
||||
price.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text(
|
||||
'Semua field harus diisi',
|
||||
),
|
||||
backgroundColor:
|
||||
AppColors.statusError,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final priceInt =
|
||||
int.tryParse(price) ?? 0;
|
||||
if (priceInt <= 0) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text(
|
||||
'Harga harus lebih dari 0',
|
||||
),
|
||||
backgroundColor:
|
||||
AppColors.statusError,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Frontend validation: check duplicate
|
||||
if (products.contains(productName)) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Produk "$productName" sudah ada',
|
||||
),
|
||||
backgroundColor:
|
||||
AppColors.statusError,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Call API to create product
|
||||
setStateDialog(() {
|
||||
// Show loading in dialog via disabling button
|
||||
});
|
||||
|
||||
final result =
|
||||
await MLService.createProduct(
|
||||
name: productName,
|
||||
category: category,
|
||||
price: priceInt,
|
||||
currentStock:
|
||||
0, // Default stock 0
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (result['status'] == 'success') {
|
||||
// Add to local state for immediate UI update
|
||||
setState(() {
|
||||
products.add(productName);
|
||||
productCategories[productName] =
|
||||
category;
|
||||
productPrices[productName] =
|
||||
priceInt;
|
||||
|
||||
if (_createNewCategory &&
|
||||
!categories.contains(
|
||||
category,
|
||||
)) {
|
||||
categories.add(category);
|
||||
}
|
||||
});
|
||||
|
||||
Navigator.pop(context);
|
||||
|
||||
// Show success message
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Produk "$productName" berhasil ditambahkan',
|
||||
),
|
||||
backgroundColor:
|
||||
AppColors.statusSuccess,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Handle error from API
|
||||
String errorMsg =
|
||||
result['message'] ??
|
||||
'Gagal menambahkan produk';
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMsg),
|
||||
backgroundColor:
|
||||
AppColors.statusError,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor:
|
||||
AppColors.primaryBrown,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
10,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Simpan',
|
||||
style: AppTextStyles.labelLarge
|
||||
.copyWith(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Product Name
|
||||
Text(
|
||||
'Nama Produk',
|
||||
style: AppTextStyles.labelLarge.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: newProductNameController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Masukkan nama produk',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.bgLight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Category Selection
|
||||
Text(
|
||||
'Kategori',
|
||||
style: AppTextStyles.labelLarge.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (!_createNewCategory)
|
||||
Column(
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedCategory.isNotEmpty
|
||||
? _selectedCategory
|
||||
: null,
|
||||
items: categories
|
||||
.map((cat) => DropdownMenuItem(
|
||||
value: cat,
|
||||
child: Text(cat),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
setStateDialog(
|
||||
() => _selectedCategory = value ?? '',
|
||||
);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Pilih kategori',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.bgLight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setStateDialog(
|
||||
() => _createNewCategory = true);
|
||||
},
|
||||
child: Text(
|
||||
'+ Tambah kategori baru',
|
||||
style: AppTextStyles.labelMedium.copyWith(
|
||||
color: AppColors.secondaryBlue,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: newCategoryController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Masukkan nama kategori baru',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.bgLight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setStateDialog(
|
||||
() => _createNewCategory = false);
|
||||
newCategoryController.clear();
|
||||
},
|
||||
child: Text(
|
||||
'← Kembali ke kategori existing',
|
||||
style: AppTextStyles.labelMedium.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Price
|
||||
Text(
|
||||
'Harga (Rp)',
|
||||
style: AppTextStyles.labelLarge.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: newPriceController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Masukkan harga',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.bgLight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Buttons
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 12),
|
||||
side: BorderSide(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Batal',
|
||||
style: AppTextStyles.labelLarge.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
final productName =
|
||||
newProductNameController.text.trim();
|
||||
final category = _createNewCategory
|
||||
? newCategoryController.text.trim()
|
||||
: _selectedCategory;
|
||||
final price = newPriceController.text.trim();
|
||||
|
||||
if (productName.isEmpty ||
|
||||
category.isEmpty ||
|
||||
price.isEmpty) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text(
|
||||
'Semua field harus diisi'),
|
||||
backgroundColor:
|
||||
AppColors.statusError,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final priceInt =
|
||||
int.tryParse(price) ?? 0;
|
||||
if (priceInt <= 0) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text(
|
||||
'Harga harus lebih dari 0'),
|
||||
backgroundColor:
|
||||
AppColors.statusError,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Frontend validation: check duplicate
|
||||
if (products.contains(productName)) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Produk "$productName" sudah ada'),
|
||||
backgroundColor:
|
||||
AppColors.statusError,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Call API to create product
|
||||
setStateDialog(() {
|
||||
// Show loading in dialog via disabling button
|
||||
});
|
||||
|
||||
final result =
|
||||
await MLService.createProduct(
|
||||
name: productName,
|
||||
category: category,
|
||||
price: priceInt,
|
||||
currentStock: 0, // Default stock 0
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (result['status'] == 'success') {
|
||||
// Add to local state for immediate UI update
|
||||
setState(() {
|
||||
products.add(productName);
|
||||
productCategories[productName] =
|
||||
category;
|
||||
productPrices[productName] = priceInt;
|
||||
|
||||
if (_createNewCategory &&
|
||||
!categories.contains(category)) {
|
||||
categories.add(category);
|
||||
}
|
||||
});
|
||||
|
||||
Navigator.pop(context);
|
||||
|
||||
// Show success message
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Produk "$productName" berhasil ditambahkan'),
|
||||
backgroundColor:
|
||||
AppColors.statusSuccess,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Handle error from API
|
||||
String errorMsg = result['message'] ??
|
||||
'Gagal menambahkan produk';
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMsg),
|
||||
backgroundColor:
|
||||
AppColors.statusError,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryBrown,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Simpan',
|
||||
style: AppTextStyles.labelLarge.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -577,9 +646,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
|||
elevation: 0,
|
||||
title: Text(
|
||||
'Transaksi Penjualan',
|
||||
style: AppTextStyles.headlineLarge.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
style: AppTextStyles.headlineLarge.copyWith(color: Colors.white),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
|
|
@ -629,12 +696,15 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
|||
filled: true,
|
||||
fillColor: AppColors.bgLight,
|
||||
),
|
||||
items: products
|
||||
.map((product) => DropdownMenuItem(
|
||||
value: product,
|
||||
child: Text(product),
|
||||
))
|
||||
.toList(),
|
||||
items:
|
||||
products
|
||||
.map(
|
||||
(product) => DropdownMenuItem(
|
||||
value: product,
|
||||
child: Text(product),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
setState(() => _selectedProduct = value);
|
||||
},
|
||||
|
|
@ -690,9 +760,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
|||
label: const Text('Tambah Produk Baru'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
side: BorderSide(
|
||||
color: AppColors.secondaryBlue,
|
||||
),
|
||||
side: BorderSide(color: AppColors.secondaryBlue),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
|
|
@ -771,16 +839,17 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
|||
item.productName,
|
||||
style: AppTextStyles.labelLarge
|
||||
.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Rp ${item.unitPrice} / unit',
|
||||
style: AppTextStyles.labelSmall
|
||||
.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
color:
|
||||
AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -803,8 +872,11 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
|||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
_updateQuantity(index, item.quantity - 1),
|
||||
onTap:
|
||||
() => _updateQuantity(
|
||||
index,
|
||||
item.quantity - 1,
|
||||
),
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
|
|
@ -825,14 +897,17 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
|||
item.quantity.toString(),
|
||||
style: AppTextStyles.labelLarge
|
||||
.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
_updateQuantity(index, item.quantity + 1),
|
||||
onTap:
|
||||
() => _updateQuantity(
|
||||
index,
|
||||
item.quantity + 1,
|
||||
),
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
|
|
@ -859,8 +934,9 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
|||
decoration: BoxDecoration(
|
||||
color: AppColors.statusError
|
||||
.withOpacity(0.1),
|
||||
borderRadius:
|
||||
BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(
|
||||
6,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.delete,
|
||||
|
|
@ -963,18 +1039,19 @@ class _TransactionScreenState extends State<TransactionScreen> {
|
|||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
icon: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.white,
|
||||
icon:
|
||||
_isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.check_circle),
|
||||
)
|
||||
: const Icon(Icons.check_circle),
|
||||
label: Text(
|
||||
_isLoading ? 'Menyimpan...' : 'Simpan Transaksi',
|
||||
style: AppTextStyles.labelLarge,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ class MLService {
|
|||
// API URL - Change based on environment
|
||||
// Untuk emulator Android: 10.0.2.2
|
||||
// Untuk device fisik: 192.168.x.x atau 127.0.0.1 kalau local
|
||||
static const String baseUrl = 'http://192.168.1.2:5000';
|
||||
static const String baseUrl = 'http://192.168.18.30:5000';
|
||||
|
||||
static const int timeoutSeconds = 30;
|
||||
|
||||
|
|
@ -241,22 +241,19 @@ class MLService {
|
|||
final result = jsonDecode(response.body);
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': result['message'] ?? 'Bad request'
|
||||
'message': result['message'] ?? 'Bad request',
|
||||
};
|
||||
} else {
|
||||
print('Create product error - Status: ${response.statusCode}');
|
||||
print('Response body: ${response.body}');
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': 'Server error: ${response.statusCode}'
|
||||
'message': 'Server error: ${response.statusCode}',
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
print('Create product exception: $e');
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': 'Connection error: $e'
|
||||
};
|
||||
return {'status': 'error', 'message': 'Connection error: $e'};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -280,7 +277,47 @@ class MLService {
|
|||
}
|
||||
}
|
||||
|
||||
/// Save transaction to database
|
||||
/// Save transaction and update stock automatically
|
||||
static Future<Map<String, dynamic>> addTransactionWithStockUpdate({
|
||||
required int productId,
|
||||
required int quantity,
|
||||
required int unitPrice,
|
||||
required int totalPrice,
|
||||
required String transactionDate, // Format: 'YYYY-MM-DD'
|
||||
}) async {
|
||||
try {
|
||||
final data = {
|
||||
'produk_id': productId,
|
||||
'jumlah': quantity,
|
||||
'unit_price': unitPrice,
|
||||
'total_price': totalPrice,
|
||||
'transaction_date': transactionDate,
|
||||
};
|
||||
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$baseUrl/transaksi'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(data),
|
||||
)
|
||||
.timeout(Duration(seconds: timeoutSeconds));
|
||||
|
||||
if (response.statusCode == 201) {
|
||||
final result = jsonDecode(response.body);
|
||||
return result;
|
||||
} else {
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': 'Server error: ${response.statusCode}',
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
print('Add transaction with stock update error: $e');
|
||||
return {'status': 'error', 'message': 'Connection error: $e'};
|
||||
}
|
||||
}
|
||||
|
||||
/// Save transaction to database (legacy - without stock update)
|
||||
static Future<bool> saveTransaction({
|
||||
required String productName,
|
||||
required String category,
|
||||
|
|
|
|||
|
|
@ -236,8 +236,9 @@ def info():
|
|||
'POST /prediksi': 'Single prediction',
|
||||
'POST /batch-prediksi': 'Batch prediction',
|
||||
'GET /products': 'Get all products',
|
||||
'POST /transactions': 'Save transaction',
|
||||
'GET /transactions': 'Get transaction history'
|
||||
'POST /transactions': 'Save transaction (legacy)',
|
||||
'GET /transactions': 'Get transaction history',
|
||||
'POST /transaksi': 'Save transaction and update stock automatically'
|
||||
},
|
||||
'required_features': feature_columns
|
||||
}), 200
|
||||
|
|
@ -431,6 +432,94 @@ def save_transaction():
|
|||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/transaksi', methods=['POST'])
|
||||
def add_transaction_with_stock_update():
|
||||
"""
|
||||
Add transaction AND update stock automatically
|
||||
Body: {
|
||||
"produk_id": 1,
|
||||
"jumlah": 5,
|
||||
"unit_price": 15000,
|
||||
"total_price": 75000,
|
||||
"transaction_date": "2024-04-05"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
|
||||
# Validate required fields
|
||||
required_fields = ['produk_id', 'jumlah', 'unit_price', 'total_price', 'transaction_date']
|
||||
missing_fields = [f for f in required_fields if f not in data]
|
||||
|
||||
if missing_fields:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Missing fields: {", ".join(missing_fields)}',
|
||||
'required_fields': required_fields
|
||||
}), 400
|
||||
|
||||
connection = get_db_connection()
|
||||
if not connection:
|
||||
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
|
||||
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 1. Get product info
|
||||
cursor.execute("SELECT id, name, category FROM products WHERE id = %s", (data['produk_id'],))
|
||||
product = cursor.fetchone()
|
||||
|
||||
if not product:
|
||||
cursor.close()
|
||||
connection.close()
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Product ID {data["produk_id"]} not found'
|
||||
}), 404
|
||||
|
||||
# 2. Save transaction
|
||||
cursor.execute("""
|
||||
INSERT INTO transactions
|
||||
(product_name, category, quantity, unit_price, total_price, transaction_date)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
product['name'],
|
||||
product['category'],
|
||||
data['jumlah'],
|
||||
data['unit_price'],
|
||||
data['total_price'],
|
||||
data['transaction_date']
|
||||
))
|
||||
connection.commit()
|
||||
transaction_id = cursor.lastrowid
|
||||
|
||||
# 3. Update product stock (add quantity)
|
||||
cursor.execute("""
|
||||
UPDATE products
|
||||
SET current_stock = current_stock + %s
|
||||
WHERE id = %s
|
||||
""", (data['jumlah'], data['produk_id']))
|
||||
connection.commit()
|
||||
|
||||
cursor.close()
|
||||
connection.close()
|
||||
|
||||
logger.info(f"Transaction saved: ID={transaction_id}, Stock updated for product ID={data['produk_id']} (+{data['jumlah']})")
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Stok berhasil ditambahkan',
|
||||
'transaction_id': transaction_id,
|
||||
'product_id': data['produk_id'],
|
||||
'product_name': product['name'],
|
||||
'quantity_added': data['jumlah']
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Add transaction with stock update error: {str(e)}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
|
||||
@app.route('/transactions', methods=['GET'])
|
||||
def get_transactions():
|
||||
"""Get transaction history"""
|
||||
|
|
|
|||
Loading…
Reference in New Issue