fix submit fitur prediksi dan pengurangan stock

This commit is contained in:
rhanarmt 2026-05-07 14:53:54 +07:00
parent ac2bb063bd
commit 6ad544fb75
7 changed files with 198 additions and 44 deletions

View File

@ -8,6 +8,7 @@ import 'package:finalproject/screens/transaction/transaction_page.dart';
import 'package:finalproject/screens/products/product_list_page.dart';
import 'package:finalproject/screens/reports/report_page.dart';
import 'package:finalproject/screens/settings/settings_page.dart';
import 'package:finalproject/utils/route_observer.dart';
void main() {
runApp(const MyApp());
@ -22,7 +23,8 @@ class MyApp extends StatelessWidget {
title: 'Prediksi Stok Bahan Kue',
theme: AppTheme.lightTheme(),
debugShowCheckedModeBanner: false,
initialRoute: '/login',
navigatorObservers: [routeObserver],
initialRoute: '/splash',
routes: {
'/splash': (context) => const SplashScreen(),
'/login': (context) => const LoginScreen(),

View File

@ -11,6 +11,7 @@ class PredictionScreen extends StatefulWidget {
class _PredictionScreenState extends State<PredictionScreen> {
late final PredictionController _controller;
final TextEditingController _productionController = TextEditingController();
@override
void initState() {
@ -32,9 +33,94 @@ class _PredictionScreenState extends State<PredictionScreen> {
if (!mounted) return;
if (result['status'] == 'success') {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('✅ Stok berhasil diperbarui')),
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) {
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 24),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.12),
blurRadius: 18,
offset: const Offset(0, 6),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: const Color(0xFF10B981).withOpacity(0.15),
shape: BoxShape.circle,
),
child: const Icon(
Icons.check_circle,
color: Color(0xFF10B981),
size: 36,
),
),
const SizedBox(height: 14),
const Text(
'Produksi Berhasil',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Color(0xFF1F2937),
),
),
const SizedBox(height: 6),
const Text(
'Stok bahan sudah diperbarui sesuai produksi.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
height: 1.4,
color: Color(0xFF6B7280),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFA89080),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'Sukses',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
);
},
);
await _controller.refreshStock();
_controller.reset();
_productionController.clear();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result['message'] ?? 'Gagal submit')),
@ -341,6 +427,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
),
const SizedBox(height: 8),
TextField(
controller: _productionController,
keyboardType: TextInputType.number,
onChanged:
_controller.setProductionQuantity,
@ -419,7 +506,10 @@ class _PredictionScreenState extends State<PredictionScreen> {
),
const SizedBox(width: 10),
OutlinedButton(
onPressed: _controller.reset,
onPressed: () {
_controller.reset();
_productionController.clear();
},
style: OutlinedButton.styleFrom(
side: const BorderSide(
color: Color(0xFFE5E7EB),
@ -1151,6 +1241,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
@override
void dispose() {
_productionController.dispose();
_controller.dispose();
super.dispose();
}

View File

@ -1,6 +1,7 @@
import 'package:finalproject/models/product_model.dart';
import 'package:finalproject/theme/colors.dart';
import 'package:finalproject/theme/text_styles.dart';
import 'package:finalproject/utils/route_observer.dart';
import 'package:flutter/material.dart';
import 'product_list_controller.dart';
@ -12,7 +13,7 @@ class ProductListScreen extends StatefulWidget {
State<ProductListScreen> createState() => _ProductListScreenState();
}
class _ProductListScreenState extends State<ProductListScreen> {
class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
late final ProductListController _controller;
@override
@ -30,6 +31,20 @@ class _ProductListScreenState extends State<ProductListScreen> {
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
if (route != null) {
routeObserver.subscribe(this, route);
}
}
@override
void didPopNext() {
_controller.loadProducts();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
@ -546,6 +561,7 @@ class _ProductListScreenState extends State<ProductListScreen> {
@override
void dispose() {
routeObserver.unsubscribe(this);
_controller.dispose();
super.dispose();
}

View File

@ -64,16 +64,16 @@ class _SplashScreenState extends State<SplashScreen>
await Future.delayed(const Duration(seconds: 1));
if (mounted) {
// Navigate ke Dashboard
Navigator.of(context).pushReplacementNamed('/dashboard');
// Navigate ke Login
Navigator.of(context).pushReplacementNamed('/login');
}
} catch (e) {
setState(() => _statusMessage = 'Error: ${e.toString()}');
await Future.delayed(const Duration(seconds: 2));
if (mounted) {
// Navigate ke Dashboard even on error
Navigator.of(context).pushReplacementNamed('/dashboard');
// Navigate ke Login even on error
Navigator.of(context).pushReplacementNamed('/login');
}
}
}
@ -166,8 +166,10 @@ class _SplashScreenState extends State<SplashScreen>
// App name with fade-in
FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0)
.animate(
opacity: Tween<double>(
begin: 0.0,
end: 1.0,
).animate(
CurvedAnimation(
parent: _fadeController,
curve: Curves.easeInOut,
@ -237,8 +239,10 @@ class _SplashScreenState extends State<SplashScreen>
mainAxisAlignment: MainAxisAlignment.center,
children: [
ScaleTransition(
scale: Tween<double>(begin: 0.4, end: 1.0)
.animate(
scale: Tween<double>(
begin: 0.4,
end: 1.0,
).animate(
CurvedAnimation(
parent: _rotateController,
curve: Curves.easeInOut,
@ -255,8 +259,10 @@ class _SplashScreenState extends State<SplashScreen>
),
const SizedBox(width: 6),
ScaleTransition(
scale: Tween<double>(begin: 0.4, end: 1.0)
.animate(
scale: Tween<double>(
begin: 0.4,
end: 1.0,
).animate(
CurvedAnimation(
parent: _rotateController,
curve: Curves.easeInOut,
@ -273,8 +279,10 @@ class _SplashScreenState extends State<SplashScreen>
),
const SizedBox(width: 6),
ScaleTransition(
scale: Tween<double>(begin: 0.4, end: 1.0)
.animate(
scale: Tween<double>(
begin: 0.4,
end: 1.0,
).animate(
CurvedAnimation(
parent: _rotateController,
curve: Curves.easeInOut,

View File

@ -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.12:5000';
static const String baseUrl = 'http://10.103.65.129:5000';
static const int timeoutSeconds = 30;
@ -229,14 +229,23 @@ class MLService {
print('[consumeStock] Response status: ${response.statusCode}');
print('[consumeStock] Response body: ${response.body}');
final parsedBody =
response.body.isNotEmpty ? jsonDecode(response.body) : null;
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
return {
'status': 'error',
'message': 'Server error: ${response.statusCode}',
};
return parsedBody is Map<String, dynamic>
? parsedBody
: {'status': 'success'};
}
if (parsedBody is Map<String, dynamic>) {
return parsedBody;
}
return {
'status': 'error',
'message': 'Server error: ${response.statusCode}',
};
} catch (e) {
print('Consume stock error: $e');
return {'status': 'error', 'message': 'Connection error: $e'};

View File

@ -0,0 +1,4 @@
import 'package:flutter/material.dart';
final RouteObserver<ModalRoute<void>> routeObserver =
RouteObserver<ModalRoute<void>>();

View File

@ -21,9 +21,6 @@ logger = logging.getLogger(__name__)
app = Flask(__name__)
CORS(app)
# Prevent overlapping stock consumption transactions
transaction_in_progress = False
# ============================================================================
# DATABASE CONFIGURATION
# ============================================================================
@ -63,6 +60,13 @@ def get_product_name_column(connection) -> str:
cursor.close()
return 'name' if has_name else 'product_name'
def has_product_unit_column(connection) -> bool:
cursor = connection.cursor()
cursor.execute("SHOW COLUMNS FROM products LIKE 'unit'")
has_unit = cursor.fetchone() is not None
cursor.close()
return has_unit
def grams_to_kg_rounded(grams: float) -> float:
"""
Convert grams to kilograms with rounding rules:
@ -468,17 +472,14 @@ def consume_stock():
"allow_partial": false
}
"""
global transaction_in_progress
if transaction_in_progress:
return jsonify({
'status': 'error',
'message': 'Transaction already in progress'
}), 429
transaction_in_progress = True
print("FLASK TERBARU AKTIF")
print("REQUEST RECEIVED")
connection = None
cursor = None
started_transaction = False
try:
data = request.json or {}
logger.info(f"[stock/consume] Incoming payload: {data}")
print("REQUEST DATA:", data)
items = data.get('items', [])
allow_partial = bool(data.get('allow_partial', False))
@ -493,10 +494,11 @@ def consume_stock():
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
name_column = get_product_name_column(connection)
unit_column_exists = has_product_unit_column(connection)
history_enabled = table_exists(connection, 'stock_usage_history')
cursor = connection.cursor(dictionary=True)
connection.start_transaction()
started_transaction = True
errors = []
valid_items = []
@ -514,14 +516,19 @@ def consume_stock():
})
continue
if unit_column_exists:
select_fields = f"id, {name_column} as name, current_stock, unit"
else:
select_fields = f"id, {name_column} as name, current_stock"
if product_id:
cursor.execute(
f"SELECT id, {name_column} as name, current_stock, unit FROM products WHERE id = %s",
f"SELECT {select_fields} FROM products WHERE id = %s",
(product_id,)
)
else:
cursor.execute(
f"SELECT id, {name_column} as name, current_stock, unit FROM products WHERE {name_column} = %s",
f"SELECT {select_fields} FROM products WHERE {name_column} = %s",
(product_name,)
)
@ -573,14 +580,13 @@ def consume_stock():
if errors and not allow_partial:
connection.rollback()
cursor.close()
connection.close()
return jsonify({
'status': 'error',
'message': 'Ada item gagal diproses',
'errors': errors
}), 400
print("UPDATING STOCK")
results = []
for entry in valid_items:
product = entry['product']
@ -618,8 +624,6 @@ def consume_stock():
})
connection.commit()
cursor.close()
connection.close()
return jsonify({
'status': 'success',
@ -629,10 +633,25 @@ def consume_stock():
}), 200
except Exception as e:
if connection and started_transaction:
try:
connection.rollback()
except Exception:
logger.exception("[stock/consume] Failed to rollback transaction")
logger.error(f"Consume stock error: {str(e)}")
return jsonify({'status': 'error', 'message': str(e)}), 500
finally:
transaction_in_progress = False
if cursor is not None:
try:
cursor.close()
except Exception:
logger.exception("[stock/consume] Failed to close cursor")
if connection is not None:
try:
connection.close()
except Exception:
logger.exception("[stock/consume] Failed to close connection")
print("REQUEST FINISHED")
@app.route('/transactions', methods=['POST'])
@ -998,6 +1017,11 @@ def internal_error(error):
return jsonify({'status': 'error', 'message': 'Internal server error'}), 500
@app.route('/test')
def test():
return {"message": "FLASK BARU AKTIF"}
# ============================================================================
# MAIN
# ============================================================================