API Fitur transaksi dan Produk

This commit is contained in:
rhanarmt 2026-04-21 16:11:21 +07:00
parent f1fcbddfeb
commit 95a703dd4e
3 changed files with 592 additions and 389 deletions

View File

@ -5,12 +5,14 @@ import 'package:finalproject/models/transaction_model.dart';
import 'package:finalproject/services/ml_service.dart'; import 'package:finalproject/services/ml_service.dart';
class CartItem { class CartItem {
final int productId;
final String productName; final String productName;
final String category; final String category;
final int unitPrice; final int unitPrice;
int quantity; int quantity;
CartItem({ CartItem({
required this.productId,
required this.productName, required this.productName,
required this.category, required this.category,
required this.unitPrice, required this.unitPrice,
@ -35,6 +37,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
// Products list (mutable) // Products list (mutable)
late List<String> products; late List<String> products;
late Map<String, int> productIds;
late Map<String, String> productCategories; late Map<String, String> productCategories;
late Map<String, int> productPrices; late Map<String, int> productPrices;
late List<String> categories; late List<String> categories;
@ -57,18 +60,21 @@ class _TransactionScreenState extends State<TransactionScreen> {
setState(() { setState(() {
products = []; products = [];
productIds = {};
productCategories = {}; productCategories = {};
productPrices = {}; productPrices = {};
categories = []; categories = [];
// Convert API response to local maps // Convert API response to local maps
for (var product in fetchedProducts) { for (var product in fetchedProducts) {
int id = product['id'] ?? 0;
String name = product['name'] ?? ''; String name = product['name'] ?? '';
String category = product['category'] ?? ''; String category = product['category'] ?? '';
int price = product['price'] ?? 0; int price = product['price'] ?? 0;
if (name.isNotEmpty) { if (name.isNotEmpty && id > 0) {
products.add(name); products.add(name);
productIds[name] = id;
productCategories[name] = category; productCategories[name] = category;
productPrices[name] = price; productPrices[name] = price;
@ -117,8 +123,9 @@ class _TransactionScreenState extends State<TransactionScreen> {
return; return;
} }
final existingIndex = final existingIndex = cartItems.indexWhere(
cartItems.indexWhere((item) => item.productName == _selectedProduct); (item) => item.productName == _selectedProduct,
);
setState(() { setState(() {
if (existingIndex >= 0) { if (existingIndex >= 0) {
@ -128,6 +135,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
// Add new item to cart // Add new item to cart
cartItems.add( cartItems.add(
CartItem( CartItem(
productId: productIds[_selectedProduct!] ?? 0,
productName: _selectedProduct!, productName: _selectedProduct!,
category: productCategories[_selectedProduct!]!, category: productCategories[_selectedProduct!]!,
unitPrice: productPrices[_selectedProduct!]!, unitPrice: productPrices[_selectedProduct!]!,
@ -183,16 +191,21 @@ class _TransactionScreenState extends State<TransactionScreen> {
final dateStr = final dateStr =
'${_selectedDate!.year}-${_selectedDate!.month.toString().padLeft(2, '0')}-${_selectedDate!.day.toString().padLeft(2, '0')}'; '${_selectedDate!.year}-${_selectedDate!.month.toString().padLeft(2, '0')}-${_selectedDate!.day.toString().padLeft(2, '0')}';
int successCount = 0;
String? firstError;
for (final item in cartItems) { for (final item in cartItems) {
await MLService.saveTransaction( // Use new endpoint that updates stock automatically
productName: item.productName, final result = await MLService.addTransactionWithStockUpdate(
category: item.category, productId: item.productId,
quantity: item.quantity, quantity: item.quantity,
unitPrice: item.unitPrice, unitPrice: item.unitPrice,
totalPrice: item.totalPrice, totalPrice: item.totalPrice,
transactionDate: dateStr, transactionDate: dateStr,
); );
if (result['status'] == 'success') {
successCount++;
// Add to transaction history // Add to transaction history
final transaction = Transaction( final transaction = Transaction(
id: transactions.length + 1, id: transactions.length + 1,
@ -203,8 +216,11 @@ class _TransactionScreenState extends State<TransactionScreen> {
totalPrice: item.totalPrice, totalPrice: item.totalPrice,
date: _selectedDate ?? DateTime.now(), date: _selectedDate ?? DateTime.now(),
); );
transactions.insert(0, transaction); transactions.insert(0, transaction);
} else {
firstError ??= result['message'] ?? 'Transaksi gagal';
print('Transaction failed for ${item.productName}: ${result['message']}');
}
} }
setState(() { setState(() {
@ -213,13 +229,23 @@ class _TransactionScreenState extends State<TransactionScreen> {
_selectedDate = DateTime.now(); _selectedDate = DateTime.now();
}); });
if (firstError != null && successCount < cartItems.length) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: content: Text(
Text('${transactions.length} transaksi berhasil disimpan!'), '⚠️ $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, backgroundColor: AppColors.statusSuccess,
), ),
); );
}
} catch (e) { } catch (e) {
setState(() => _isLoading = false); setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -241,9 +267,13 @@ class _TransactionScreenState extends State<TransactionScreen> {
showDialog( showDialog(
context: context, context: context,
builder: (context) => StatefulBuilder( builder:
builder: (context, setStateDialog) => Dialog( (context) => StatefulBuilder(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), builder:
(context, setStateDialog) => Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.bgWhite, color: AppColors.bgWhite,
@ -310,24 +340,31 @@ class _TransactionScreenState extends State<TransactionScreen> {
Column( Column(
children: [ children: [
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: _selectedCategory.isNotEmpty value:
_selectedCategory.isNotEmpty
? _selectedCategory ? _selectedCategory
: null, : null,
items: categories items:
.map((cat) => DropdownMenuItem( categories
.map(
(cat) => DropdownMenuItem(
value: cat, value: cat,
child: Text(cat), child: Text(cat),
)) ),
)
.toList(), .toList(),
onChanged: (value) { onChanged: (value) {
setStateDialog( setStateDialog(
() => _selectedCategory = value ?? '', () =>
_selectedCategory = value ?? '',
); );
}, },
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Pilih kategori', hintText: 'Pilih kategori',
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(
10,
),
), ),
filled: true, filled: true,
fillColor: AppColors.bgLight, fillColor: AppColors.bgLight,
@ -337,11 +374,13 @@ class _TransactionScreenState extends State<TransactionScreen> {
GestureDetector( GestureDetector(
onTap: () { onTap: () {
setStateDialog( setStateDialog(
() => _createNewCategory = true); () => _createNewCategory = true,
);
}, },
child: Text( child: Text(
'+ Tambah kategori baru', '+ Tambah kategori baru',
style: AppTextStyles.labelMedium.copyWith( style: AppTextStyles.labelMedium
.copyWith(
color: AppColors.secondaryBlue, color: AppColors.secondaryBlue,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@ -355,9 +394,12 @@ class _TransactionScreenState extends State<TransactionScreen> {
TextFormField( TextFormField(
controller: newCategoryController, controller: newCategoryController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Masukkan nama kategori baru', hintText:
'Masukkan nama kategori baru',
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(
10,
),
), ),
filled: true, filled: true,
fillColor: AppColors.bgLight, fillColor: AppColors.bgLight,
@ -367,12 +409,14 @@ class _TransactionScreenState extends State<TransactionScreen> {
GestureDetector( GestureDetector(
onTap: () { onTap: () {
setStateDialog( setStateDialog(
() => _createNewCategory = false); () => _createNewCategory = false,
);
newCategoryController.clear(); newCategoryController.clear();
}, },
child: Text( child: Text(
'← Kembali ke kategori existing', '← Kembali ke kategori existing',
style: AppTextStyles.labelMedium.copyWith( style: AppTextStyles.labelMedium
.copyWith(
color: AppColors.textSecondary, color: AppColors.textSecondary,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@ -411,18 +455,22 @@ class _TransactionScreenState extends State<TransactionScreen> {
child: OutlinedButton( child: OutlinedButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
padding: padding: const EdgeInsets.symmetric(
const EdgeInsets.symmetric(vertical: 12), vertical: 12,
),
side: BorderSide( side: BorderSide(
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(
10,
),
), ),
), ),
child: Text( child: Text(
'Batal', 'Batal',
style: AppTextStyles.labelLarge.copyWith( style: AppTextStyles.labelLarge
.copyWith(
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
), ),
@ -433,20 +481,26 @@ class _TransactionScreenState extends State<TransactionScreen> {
child: ElevatedButton( child: ElevatedButton(
onPressed: () async { onPressed: () async {
final productName = final productName =
newProductNameController.text.trim(); newProductNameController.text
final category = _createNewCategory .trim();
? newCategoryController.text.trim() final category =
_createNewCategory
? newCategoryController.text
.trim()
: _selectedCategory; : _selectedCategory;
final price = newPriceController.text.trim(); final price =
newPriceController.text.trim();
if (productName.isEmpty || if (productName.isEmpty ||
category.isEmpty || category.isEmpty ||
price.isEmpty) { price.isEmpty) {
ScaffoldMessenger.of(context) ScaffoldMessenger.of(
.showSnackBar( context,
).showSnackBar(
SnackBar( SnackBar(
content: const Text( content: const Text(
'Semua field harus diisi'), 'Semua field harus diisi',
),
backgroundColor: backgroundColor:
AppColors.statusError, AppColors.statusError,
), ),
@ -457,11 +511,13 @@ class _TransactionScreenState extends State<TransactionScreen> {
final priceInt = final priceInt =
int.tryParse(price) ?? 0; int.tryParse(price) ?? 0;
if (priceInt <= 0) { if (priceInt <= 0) {
ScaffoldMessenger.of(context) ScaffoldMessenger.of(
.showSnackBar( context,
).showSnackBar(
SnackBar( SnackBar(
content: const Text( content: const Text(
'Harga harus lebih dari 0'), 'Harga harus lebih dari 0',
),
backgroundColor: backgroundColor:
AppColors.statusError, AppColors.statusError,
), ),
@ -471,11 +527,13 @@ class _TransactionScreenState extends State<TransactionScreen> {
// Frontend validation: check duplicate // Frontend validation: check duplicate
if (products.contains(productName)) { if (products.contains(productName)) {
ScaffoldMessenger.of(context) ScaffoldMessenger.of(
.showSnackBar( context,
).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
'Produk "$productName" sudah ada'), 'Produk "$productName" sudah ada',
),
backgroundColor: backgroundColor:
AppColors.statusError, AppColors.statusError,
), ),
@ -493,7 +551,8 @@ class _TransactionScreenState extends State<TransactionScreen> {
name: productName, name: productName,
category: category, category: category,
price: priceInt, price: priceInt,
currentStock: 0, // Default stock 0 currentStock:
0, // Default stock 0
); );
if (!mounted) return; if (!mounted) return;
@ -504,10 +563,13 @@ class _TransactionScreenState extends State<TransactionScreen> {
products.add(productName); products.add(productName);
productCategories[productName] = productCategories[productName] =
category; category;
productPrices[productName] = priceInt; productPrices[productName] =
priceInt;
if (_createNewCategory && if (_createNewCategory &&
!categories.contains(category)) { !categories.contains(
category,
)) {
categories.add(category); categories.add(category);
} }
}); });
@ -515,21 +577,25 @@ class _TransactionScreenState extends State<TransactionScreen> {
Navigator.pop(context); Navigator.pop(context);
// Show success message // Show success message
ScaffoldMessenger.of(context) ScaffoldMessenger.of(
.showSnackBar( context,
).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
'Produk "$productName" berhasil ditambahkan'), 'Produk "$productName" berhasil ditambahkan',
),
backgroundColor: backgroundColor:
AppColors.statusSuccess, AppColors.statusSuccess,
), ),
); );
} else { } else {
// Handle error from API // Handle error from API
String errorMsg = result['message'] ?? String errorMsg =
result['message'] ??
'Gagal menambahkan produk'; 'Gagal menambahkan produk';
ScaffoldMessenger.of(context) ScaffoldMessenger.of(
.showSnackBar( context,
).showSnackBar(
SnackBar( SnackBar(
content: Text(errorMsg), content: Text(errorMsg),
backgroundColor: backgroundColor:
@ -539,18 +605,21 @@ class _TransactionScreenState extends State<TransactionScreen> {
} }
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBrown, backgroundColor:
AppColors.primaryBrown,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
vertical: 12), vertical: 12,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(
10,
),
), ),
), ),
child: Text( child: Text(
'Simpan', 'Simpan',
style: AppTextStyles.labelLarge.copyWith( style: AppTextStyles.labelLarge
color: Colors.white, .copyWith(color: Colors.white),
),
), ),
), ),
), ),
@ -577,9 +646,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
elevation: 0, elevation: 0,
title: Text( title: Text(
'Transaksi Penjualan', 'Transaksi Penjualan',
style: AppTextStyles.headlineLarge.copyWith( style: AppTextStyles.headlineLarge.copyWith(color: Colors.white),
color: Colors.white,
),
), ),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white), icon: const Icon(Icons.arrow_back, color: Colors.white),
@ -629,11 +696,14 @@ class _TransactionScreenState extends State<TransactionScreen> {
filled: true, filled: true,
fillColor: AppColors.bgLight, fillColor: AppColors.bgLight,
), ),
items: products items:
.map((product) => DropdownMenuItem( products
.map(
(product) => DropdownMenuItem(
value: product, value: product,
child: Text(product), child: Text(product),
)) ),
)
.toList(), .toList(),
onChanged: (value) { onChanged: (value) {
setState(() => _selectedProduct = value); setState(() => _selectedProduct = value);
@ -690,9 +760,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
label: const Text('Tambah Produk Baru'), label: const Text('Tambah Produk Baru'),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
side: BorderSide( side: BorderSide(color: AppColors.secondaryBlue),
color: AppColors.secondaryBlue,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
@ -779,7 +847,8 @@ class _TransactionScreenState extends State<TransactionScreen> {
'Rp ${item.unitPrice} / unit', 'Rp ${item.unitPrice} / unit',
style: AppTextStyles.labelSmall style: AppTextStyles.labelSmall
.copyWith( .copyWith(
color: AppColors.textSecondary, color:
AppColors.textSecondary,
), ),
), ),
], ],
@ -803,8 +872,11 @@ class _TransactionScreenState extends State<TransactionScreen> {
Row( Row(
children: [ children: [
GestureDetector( GestureDetector(
onTap: () => onTap:
_updateQuantity(index, item.quantity - 1), () => _updateQuantity(
index,
item.quantity - 1,
),
child: Container( child: Container(
width: 32, width: 32,
height: 32, height: 32,
@ -831,8 +903,11 @@ class _TransactionScreenState extends State<TransactionScreen> {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
GestureDetector( GestureDetector(
onTap: () => onTap:
_updateQuantity(index, item.quantity + 1), () => _updateQuantity(
index,
item.quantity + 1,
),
child: Container( child: Container(
width: 32, width: 32,
height: 32, height: 32,
@ -859,8 +934,9 @@ class _TransactionScreenState extends State<TransactionScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.statusError color: AppColors.statusError
.withOpacity(0.1), .withOpacity(0.1),
borderRadius: borderRadius: BorderRadius.circular(
BorderRadius.circular(6), 6,
),
), ),
child: Icon( child: Icon(
Icons.delete, Icons.delete,
@ -963,7 +1039,8 @@ class _TransactionScreenState extends State<TransactionScreen> {
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
), ),
icon: _isLoading icon:
_isLoading
? const SizedBox( ? const SizedBox(
width: 20, width: 20,
height: 20, height: 20,

View File

@ -5,7 +5,7 @@ class MLService {
// API URL - Change based on environment // API URL - Change based on environment
// Untuk emulator Android: 10.0.2.2 // Untuk emulator Android: 10.0.2.2
// Untuk device fisik: 192.168.x.x atau 127.0.0.1 kalau local // Untuk device fisik: 192.168.x.x atau 127.0.0.1 kalau local
static const String baseUrl = 'http://192.168.1.2:5000'; static const String baseUrl = 'http://192.168.18.30:5000';
static const int timeoutSeconds = 30; static const int timeoutSeconds = 30;
@ -241,22 +241,19 @@ class MLService {
final result = jsonDecode(response.body); final result = jsonDecode(response.body);
return { return {
'status': 'error', 'status': 'error',
'message': result['message'] ?? 'Bad request' 'message': result['message'] ?? 'Bad request',
}; };
} else { } else {
print('Create product error - Status: ${response.statusCode}'); print('Create product error - Status: ${response.statusCode}');
print('Response body: ${response.body}'); print('Response body: ${response.body}');
return { return {
'status': 'error', 'status': 'error',
'message': 'Server error: ${response.statusCode}' 'message': 'Server error: ${response.statusCode}',
}; };
} }
} catch (e) { } catch (e) {
print('Create product exception: $e'); print('Create product exception: $e');
return { return {'status': 'error', 'message': 'Connection error: $e'};
'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({ static Future<bool> saveTransaction({
required String productName, required String productName,
required String category, required String category,

View File

@ -236,8 +236,9 @@ def info():
'POST /prediksi': 'Single prediction', 'POST /prediksi': 'Single prediction',
'POST /batch-prediksi': 'Batch prediction', 'POST /batch-prediksi': 'Batch prediction',
'GET /products': 'Get all products', 'GET /products': 'Get all products',
'POST /transactions': 'Save transaction', 'POST /transactions': 'Save transaction (legacy)',
'GET /transactions': 'Get transaction history' 'GET /transactions': 'Get transaction history',
'POST /transaksi': 'Save transaction and update stock automatically'
}, },
'required_features': feature_columns 'required_features': feature_columns
}), 200 }), 200
@ -431,6 +432,94 @@ def save_transaction():
return jsonify({'status': 'error', 'message': str(e)}), 500 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']) @app.route('/transactions', methods=['GET'])
def get_transactions(): def get_transactions():
"""Get transaction history""" """Get transaction history"""