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

View File

@ -11,6 +11,7 @@ class PredictionScreen extends StatefulWidget {
class _PredictionScreenState extends State<PredictionScreen> { class _PredictionScreenState extends State<PredictionScreen> {
late final PredictionController _controller; late final PredictionController _controller;
final TextEditingController _productionController = TextEditingController();
@override @override
void initState() { void initState() {
@ -32,9 +33,94 @@ class _PredictionScreenState extends State<PredictionScreen> {
if (!mounted) return; if (!mounted) return;
if (result['status'] == 'success') { if (result['status'] == 'success') {
ScaffoldMessenger.of(context).showSnackBar( await showDialog<void>(
const SnackBar(content: Text('✅ Stok berhasil diperbarui')), 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 { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result['message'] ?? 'Gagal submit')), SnackBar(content: Text(result['message'] ?? 'Gagal submit')),
@ -341,6 +427,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( TextField(
controller: _productionController,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
onChanged: onChanged:
_controller.setProductionQuantity, _controller.setProductionQuantity,
@ -419,7 +506,10 @@ class _PredictionScreenState extends State<PredictionScreen> {
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
OutlinedButton( OutlinedButton(
onPressed: _controller.reset, onPressed: () {
_controller.reset();
_productionController.clear();
},
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
side: const BorderSide( side: const BorderSide(
color: Color(0xFFE5E7EB), color: Color(0xFFE5E7EB),
@ -1151,6 +1241,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
@override @override
void dispose() { void dispose() {
_productionController.dispose();
_controller.dispose(); _controller.dispose();
super.dispose(); super.dispose();
} }

View File

@ -1,6 +1,7 @@
import 'package:finalproject/models/product_model.dart'; import 'package:finalproject/models/product_model.dart';
import 'package:finalproject/theme/colors.dart'; import 'package:finalproject/theme/colors.dart';
import 'package:finalproject/theme/text_styles.dart'; import 'package:finalproject/theme/text_styles.dart';
import 'package:finalproject/utils/route_observer.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'product_list_controller.dart'; import 'product_list_controller.dart';
@ -12,7 +13,7 @@ class ProductListScreen extends StatefulWidget {
State<ProductListScreen> createState() => _ProductListScreenState(); State<ProductListScreen> createState() => _ProductListScreenState();
} }
class _ProductListScreenState extends State<ProductListScreen> { class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
late final ProductListController _controller; late final ProductListController _controller;
@override @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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( return AnimatedBuilder(
@ -546,6 +561,7 @@ class _ProductListScreenState extends State<ProductListScreen> {
@override @override
void dispose() { void dispose() {
routeObserver.unsubscribe(this);
_controller.dispose(); _controller.dispose();
super.dispose(); super.dispose();
} }

View File

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

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.12:5000'; static const String baseUrl = 'http://10.103.65.129:5000';
static const int timeoutSeconds = 30; static const int timeoutSeconds = 30;
@ -229,14 +229,23 @@ class MLService {
print('[consumeStock] Response status: ${response.statusCode}'); print('[consumeStock] Response status: ${response.statusCode}');
print('[consumeStock] Response body: ${response.body}'); print('[consumeStock] Response body: ${response.body}');
final parsedBody =
response.body.isNotEmpty ? jsonDecode(response.body) : null;
if (response.statusCode == 200) { if (response.statusCode == 200) {
return jsonDecode(response.body); return parsedBody is Map<String, dynamic>
} else { ? parsedBody
: {'status': 'success'};
}
if (parsedBody is Map<String, dynamic>) {
return parsedBody;
}
return { return {
'status': 'error', 'status': 'error',
'message': 'Server error: ${response.statusCode}', 'message': 'Server error: ${response.statusCode}',
}; };
}
} catch (e) { } catch (e) {
print('Consume stock error: $e'); print('Consume stock error: $e');
return {'status': 'error', 'message': 'Connection 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__) app = Flask(__name__)
CORS(app) CORS(app)
# Prevent overlapping stock consumption transactions
transaction_in_progress = False
# ============================================================================ # ============================================================================
# DATABASE CONFIGURATION # DATABASE CONFIGURATION
# ============================================================================ # ============================================================================
@ -63,6 +60,13 @@ def get_product_name_column(connection) -> str:
cursor.close() cursor.close()
return 'name' if has_name else 'product_name' 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: def grams_to_kg_rounded(grams: float) -> float:
""" """
Convert grams to kilograms with rounding rules: Convert grams to kilograms with rounding rules:
@ -468,17 +472,14 @@ def consume_stock():
"allow_partial": false "allow_partial": false
} }
""" """
global transaction_in_progress print("FLASK TERBARU AKTIF")
if transaction_in_progress: print("REQUEST RECEIVED")
return jsonify({ connection = None
'status': 'error', cursor = None
'message': 'Transaction already in progress' started_transaction = False
}), 429
transaction_in_progress = True
try: try:
data = request.json or {} data = request.json or {}
logger.info(f"[stock/consume] Incoming payload: {data}") print("REQUEST DATA:", data)
items = data.get('items', []) items = data.get('items', [])
allow_partial = bool(data.get('allow_partial', False)) allow_partial = bool(data.get('allow_partial', False))
@ -493,10 +494,11 @@ def consume_stock():
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500 return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
name_column = get_product_name_column(connection) name_column = get_product_name_column(connection)
unit_column_exists = has_product_unit_column(connection)
history_enabled = table_exists(connection, 'stock_usage_history') history_enabled = table_exists(connection, 'stock_usage_history')
cursor = connection.cursor(dictionary=True) cursor = connection.cursor(dictionary=True)
connection.start_transaction() started_transaction = True
errors = [] errors = []
valid_items = [] valid_items = []
@ -514,14 +516,19 @@ def consume_stock():
}) })
continue 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: if product_id:
cursor.execute( 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,) (product_id,)
) )
else: else:
cursor.execute( 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,) (product_name,)
) )
@ -573,14 +580,13 @@ def consume_stock():
if errors and not allow_partial: if errors and not allow_partial:
connection.rollback() connection.rollback()
cursor.close()
connection.close()
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'Ada item gagal diproses', 'message': 'Ada item gagal diproses',
'errors': errors 'errors': errors
}), 400 }), 400
print("UPDATING STOCK")
results = [] results = []
for entry in valid_items: for entry in valid_items:
product = entry['product'] product = entry['product']
@ -618,8 +624,6 @@ def consume_stock():
}) })
connection.commit() connection.commit()
cursor.close()
connection.close()
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
@ -629,10 +633,25 @@ def consume_stock():
}), 200 }), 200
except Exception as e: 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)}") logger.error(f"Consume stock error: {str(e)}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
finally: 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']) @app.route('/transactions', methods=['POST'])
@ -998,6 +1017,11 @@ def internal_error(error):
return jsonify({'status': 'error', 'message': 'Internal server error'}), 500 return jsonify({'status': 'error', 'message': 'Internal server error'}), 500
@app.route('/test')
def test():
return {"message": "FLASK BARU AKTIF"}
# ============================================================================ # ============================================================================
# MAIN # MAIN
# ============================================================================ # ============================================================================