memperbarui beberapa fitur
This commit is contained in:
parent
16594b95e6
commit
b0ed5950c2
|
|
@ -38,13 +38,11 @@ class DashboardController extends ChangeNotifier {
|
|||
MLService.getBahanDigunakanHariIni(),
|
||||
MLService.getDashboardSummary(),
|
||||
MLService.getProducts(),
|
||||
MLService.getReportCritical(),
|
||||
]);
|
||||
|
||||
final bahanDigunakan = results[0] as Map<String, dynamic>;
|
||||
final summary = results[1] as Map<String, dynamic>;
|
||||
final products = results[2] as List;
|
||||
final critical = results[3] as Map<String, dynamic>;
|
||||
|
||||
_applyBahanDigunakanHariIni(bahanDigunakan);
|
||||
|
||||
|
|
@ -56,18 +54,13 @@ class DashboardController extends ChangeNotifier {
|
|||
}
|
||||
|
||||
totalProduk = products.length;
|
||||
|
||||
if (critical['status'] == true) {
|
||||
_applyLowStockItems(critical['data']);
|
||||
} else {
|
||||
_applyDummyLowStock();
|
||||
}
|
||||
_applyLowStockProducts(products);
|
||||
} catch (e) {
|
||||
errorMessage = 'Gagal memuat dashboard: $e';
|
||||
bahanDigunakanError = 'Gagal memuat bahan digunakan hari ini';
|
||||
_resetBahanDigunakanHariIni();
|
||||
_applyDummyPenggunaan();
|
||||
_applyDummyLowStock();
|
||||
lowStockItems.clear();
|
||||
} finally {
|
||||
isLoading = false;
|
||||
isBahanDigunakanLoading = false;
|
||||
|
|
@ -109,25 +102,31 @@ class DashboardController extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
void _applyLowStockItems(dynamic data) {
|
||||
void _applyLowStockProducts(List<dynamic> products) {
|
||||
lowStockItems.clear();
|
||||
if (data is! List || data.isEmpty) {
|
||||
_applyDummyLowStock();
|
||||
return;
|
||||
}
|
||||
|
||||
for (final item in data) {
|
||||
final stockValue = StockStatusUtils.parseStock(item['stok']);
|
||||
for (final item in products) {
|
||||
if (item is! Map) continue;
|
||||
|
||||
final stockValue = StockStatusUtils.parseStock(item['current_stock']);
|
||||
final statusKey = StockStatusUtils.statusFromStock(stockValue);
|
||||
final unit = item['unit'] ?? 'kg';
|
||||
final stockLabel = item['stok']?.toString() ?? '0';
|
||||
if (statusKey != StockStatusUtils.statusKritis) continue;
|
||||
|
||||
final category = item['category']?.toString().toLowerCase() ?? '';
|
||||
final unit =
|
||||
item['unit']?.toString() ?? (category == 'barang' ? 'pcs' : 'kg');
|
||||
lowStockItems.add({
|
||||
'name': item['nama_bahan']?.toString() ?? '-',
|
||||
'stock': '$stockLabel $unit',
|
||||
'name': item['name']?.toString() ?? '-',
|
||||
'stock': '${_formatStock(stockValue)} $unit',
|
||||
'statusKey': statusKey,
|
||||
'status': StockStatusUtils.label(statusKey),
|
||||
'statusColor': StockStatusUtils.color(statusKey),
|
||||
});
|
||||
}
|
||||
|
||||
lowStockItems.sort(
|
||||
(a, b) => a['name'].toString().compareTo(b['name'].toString()),
|
||||
);
|
||||
}
|
||||
|
||||
void _applyDummyPenggunaan() {
|
||||
|
|
@ -142,34 +141,11 @@ class DashboardController extends ChangeNotifier {
|
|||
]);
|
||||
}
|
||||
|
||||
void _applyDummyLowStock() {
|
||||
lowStockItems
|
||||
..clear()
|
||||
..addAll([
|
||||
{
|
||||
'name': 'Tepung Terigu',
|
||||
'stock': '5 kg',
|
||||
'status': StockStatusUtils.label(StockStatusUtils.statusFromStock(5)),
|
||||
'statusColor': StockStatusUtils.color(
|
||||
StockStatusUtils.statusFromStock(5),
|
||||
),
|
||||
},
|
||||
{
|
||||
'name': 'Gula Pasir',
|
||||
'stock': '8 kg',
|
||||
'status': StockStatusUtils.label(StockStatusUtils.statusFromStock(8)),
|
||||
'statusColor': StockStatusUtils.color(
|
||||
StockStatusUtils.statusFromStock(8),
|
||||
),
|
||||
},
|
||||
{
|
||||
'name': 'Mentega',
|
||||
'stock': '3 kg',
|
||||
'status': StockStatusUtils.label(StockStatusUtils.statusFromStock(3)),
|
||||
'statusColor': StockStatusUtils.color(
|
||||
StockStatusUtils.statusFromStock(3),
|
||||
),
|
||||
},
|
||||
]);
|
||||
String _formatStock(double value) {
|
||||
if (value % 1 == 0) {
|
||||
return value.toInt().toString();
|
||||
}
|
||||
|
||||
return value.toStringAsFixed(1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,32 +90,36 @@ class _DashboardScreenState extends State<DashboardScreen> with RouteAware {
|
|||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 4,
|
||||
top: 4,
|
||||
child: Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.statusError,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryBrown,
|
||||
width: 2,
|
||||
if (_controller.lowStockItems.isNotEmpty)
|
||||
Positioned(
|
||||
right: 4,
|
||||
top: 4,
|
||||
child: Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.statusError,
|
||||
borderRadius: BorderRadius.circular(
|
||||
11,
|
||||
),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryBrown,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'3',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
child: Center(
|
||||
child: Text(
|
||||
_controller.lowStockItems.length
|
||||
.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
@ -278,58 +282,72 @@ class _DashboardScreenState extends State<DashboardScreen> with RouteAware {
|
|||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
..._controller.lowStockItems.map((item) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item['name'],
|
||||
style: AppTextStyles.labelLarge
|
||||
.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Stok: ${item['stock']}',
|
||||
style: AppTextStyles.bodySmall
|
||||
.copyWith(
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: item['statusColor'],
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
item['status'],
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
if (_controller.isLoading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (_controller.lowStockItems.isEmpty)
|
||||
Text(
|
||||
'Tidak ada stok kritis',
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
)
|
||||
else
|
||||
..._controller.lowStockItems.map((item) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item['name'],
|
||||
style: AppTextStyles.labelLarge
|
||||
.copyWith(
|
||||
color:
|
||||
AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Stok: ${item['stock']}',
|
||||
style: AppTextStyles.bodySmall
|
||||
.copyWith(
|
||||
color:
|
||||
AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: item['statusColor'],
|
||||
borderRadius: BorderRadius.circular(
|
||||
6,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
item['status'],
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
|||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
|
|
@ -363,202 +363,481 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
|||
final maxStock = _controller.maxStock;
|
||||
final stockPercentage = (product.stock / maxStock * 100).toInt();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bgWhite,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [AppColors.shadowLight],
|
||||
),
|
||||
return InkWell(
|
||||
onTap: () => _showProductDetail(product),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: _controller
|
||||
.getStatusColor(product.status)
|
||||
.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
_controller.getCategoryIcon(product.category),
|
||||
color: _controller.getStatusColor(product.status),
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bgWhite,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [AppColors.shadowLight],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
product.name,
|
||||
style: AppTextStyles.labelLarge.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: _controller
|
||||
.getStatusColor(product.status)
|
||||
.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
_controller.getCategoryIcon(product.category),
|
||||
color: _controller.getStatusColor(product.status),
|
||||
size: 28,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryBrown.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
product.category,
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: AppColors.primaryBrown,
|
||||
fontWeight: FontWeight.w600,
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
product.name,
|
||||
style: AppTextStyles.labelLarge.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_controller.formatPrice(product.price),
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryBrown.withValues(
|
||||
alpha: 0.1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
product.category,
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: AppColors.primaryBrown,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_controller.formatPrice(product.price),
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _controller
|
||||
.getStatusColor(product.status)
|
||||
.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: _controller
|
||||
.getStatusColor(product.status)
|
||||
.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
_controller.getStatusLabel(product.status),
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: _controller.getStatusColor(product.status),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _controller
|
||||
.getStatusColor(product.status)
|
||||
.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: _controller
|
||||
.getStatusColor(product.status)
|
||||
.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_controller.getStatusLabel(product.status),
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: _controller.getStatusColor(product.status),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(height: 1, color: AppColors.grey200),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Stok Tersedia',
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${product.stock} ${product.unit}',
|
||||
style: AppTextStyles.labelLarge.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
const SizedBox(height: 12),
|
||||
Container(height: 1, color: AppColors.grey200),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Kapasitas',
|
||||
'Stok Tersedia',
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$stockPercentage%',
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
'${product.stock} ${product.unit}',
|
||||
style: AppTextStyles.labelLarge.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: LinearProgressIndicator(
|
||||
value: product.stock / maxStock,
|
||||
minHeight: 6,
|
||||
backgroundColor: AppColors.grey200,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
_controller.getStatusColor(product.status),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Kapasitas',
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$stockPercentage%',
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: LinearProgressIndicator(
|
||||
value: product.stock / maxStock,
|
||||
minHeight: 6,
|
||||
backgroundColor: AppColors.grey200,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
_controller.getStatusColor(product.status),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryBrown.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: AppColors.primaryBrown,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${product.name} - Detail'),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryBrown.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
color: AppColors.primaryBrown,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showProductDetail(Product product) {
|
||||
final maxStock = _controller.maxStock;
|
||||
final stockPercentage =
|
||||
maxStock == 0 ? 0 : (product.stock / maxStock * 100).round();
|
||||
final statusColor = _controller.getStatusColor(product.status);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(12),
|
||||
padding: EdgeInsets.only(
|
||||
left: 18,
|
||||
right: 18,
|
||||
top: 12,
|
||||
bottom: 18 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bgWhite,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
boxShadow: [AppColors.shadowMedium],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.grey200,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
_controller.getCategoryIcon(product.category),
|
||||
color: statusColor,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
product.name,
|
||||
style: AppTextStyles.headlineSmall.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'ID Produk: ${product.id}',
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_controller.getStatusLabel(product.status),
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Container(height: 1, color: AppColors.grey200),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_buildDetailTile(
|
||||
icon: Icons.category_outlined,
|
||||
label: 'Kategori',
|
||||
value: product.category,
|
||||
),
|
||||
_buildDetailTile(
|
||||
icon: Icons.sell_outlined,
|
||||
label: 'Harga',
|
||||
value: _formatRupiah(product.price),
|
||||
),
|
||||
_buildDetailTile(
|
||||
icon: Icons.inventory_2_outlined,
|
||||
label: 'Stok',
|
||||
value: '${product.stock} ${product.unit}',
|
||||
),
|
||||
_buildDetailTile(
|
||||
icon: Icons.straighten_outlined,
|
||||
label: 'Satuan',
|
||||
value: product.unit,
|
||||
),
|
||||
_buildDetailTile(
|
||||
icon: Icons.speed_outlined,
|
||||
label: 'Kapasitas',
|
||||
value: '$stockPercentage%',
|
||||
),
|
||||
_buildDetailTile(
|
||||
icon: Icons.verified_outlined,
|
||||
label: 'Status',
|
||||
value: _controller.getStatusLabel(product.status),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: LinearProgressIndicator(
|
||||
value: maxStock == 0 ? 0 : product.stock / maxStock,
|
||||
minHeight: 8,
|
||||
backgroundColor: AppColors.grey200,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(statusColor),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: statusColor.withValues(alpha: 0.18),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_stockRecommendation(product),
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
label: const Text('Tutup'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pushNamed('/transaction');
|
||||
},
|
||||
icon: const Icon(Icons.add_shopping_cart_rounded),
|
||||
label: const Text('Tambah Stok'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.statusSuccess,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailTile({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String value,
|
||||
}) {
|
||||
return SizedBox(
|
||||
width: (MediaQuery.of(context).size.width - 60) / 2,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bgLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.grey200),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: AppColors.primaryBrown),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatRupiah(int price) {
|
||||
final text = price.toString();
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < text.length; i++) {
|
||||
final reverseIndex = text.length - i;
|
||||
buffer.write(text[i]);
|
||||
if (reverseIndex > 1 && reverseIndex % 3 == 1) {
|
||||
buffer.write('.');
|
||||
}
|
||||
}
|
||||
return 'Rp $buffer';
|
||||
}
|
||||
|
||||
String _stockRecommendation(Product product) {
|
||||
switch (product.status) {
|
||||
case 'kritis':
|
||||
return 'Stok kritis. Disarankan segera tambah stok agar produksi tidak terganggu.';
|
||||
case 'sedang':
|
||||
return 'Stok mulai menipis. Pantau pemakaian dan siapkan pembelian berikutnya.';
|
||||
default:
|
||||
return 'Stok aman. Produk masih cukup untuk kebutuhan operasional.';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
routeObserver.unsubscribe(this);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:finalproject/services/ml_service.dart';
|
||||
import 'package:finalproject/utils/stock_status.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ReportController extends ChangeNotifier {
|
||||
|
|
@ -37,15 +38,15 @@ class ReportController extends ChangeNotifier {
|
|||
MLService.getReportStock(),
|
||||
MLService.getReportStockIn(),
|
||||
MLService.getReportPredictions(),
|
||||
MLService.getReportCritical(),
|
||||
MLService.getProducts(),
|
||||
MLService.getDashboardSummary(),
|
||||
]);
|
||||
|
||||
final stockResponse = results[0] as Map<String, dynamic>;
|
||||
final stockInResponse = results[1] as Map<String, dynamic>;
|
||||
final predictionResponse = results[2] as Map<String, dynamic>;
|
||||
final criticalResponse = results[3] as Map<String, dynamic>;
|
||||
final productsResponse = results[4];
|
||||
final productsResponse = results[3];
|
||||
final dashboardSummary = results[4] as Map<String, dynamic>;
|
||||
|
||||
if (stockResponse['status'] != true) {
|
||||
errorMessage = stockResponse['message']?.toString();
|
||||
|
|
@ -65,18 +66,22 @@ class ReportController extends ChangeNotifier {
|
|||
_applyPredictions(predictionResponse['data']);
|
||||
}
|
||||
|
||||
if (criticalResponse['status'] != true) {
|
||||
errorMessage ??= criticalResponse['message']?.toString();
|
||||
} else {
|
||||
_applyCriticalItems(criticalResponse['data']);
|
||||
}
|
||||
|
||||
if (productsResponse is List) {
|
||||
_productCount = productsResponse.length;
|
||||
_applyCriticalProducts(productsResponse);
|
||||
} else {
|
||||
_productCount = 0;
|
||||
criticalItems.clear();
|
||||
}
|
||||
|
||||
if (dashboardSummary['status'] != true) {
|
||||
errorMessage ??= dashboardSummary['message']?.toString();
|
||||
usageSummary.clear();
|
||||
} else {
|
||||
_applyUsageSummary(dashboardSummary['penggunaan_bahan']);
|
||||
}
|
||||
|
||||
_rebuildSummary();
|
||||
_rebuildUsageSummary();
|
||||
_rebuildDemandTrend();
|
||||
} catch (e) {
|
||||
errorMessage = 'Gagal memuat laporan: $e';
|
||||
|
|
@ -150,17 +155,50 @@ class ReportController extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
void _applyCriticalItems(dynamic data) {
|
||||
void _applyCriticalProducts(List<dynamic> products) {
|
||||
criticalItems.clear();
|
||||
if (data is! List) return;
|
||||
for (final item in data) {
|
||||
|
||||
for (final item in products) {
|
||||
if (item is! Map) continue;
|
||||
|
||||
final stock = StockStatusUtils.parseStock(item['current_stock']);
|
||||
final status = StockStatusUtils.statusFromStock(stock);
|
||||
if (status != StockStatusUtils.statusKritis) continue;
|
||||
|
||||
final category = item['category']?.toString().toLowerCase() ?? '';
|
||||
final unit =
|
||||
item['unit']?.toString() ?? (category == 'barang' ? 'pcs' : 'kg');
|
||||
criticalItems.add({
|
||||
'name': item['nama_bahan']?.toString() ?? '-',
|
||||
'stock': _toDouble(item['stok']),
|
||||
'status': item['status']?.toString() ?? 'Kritis',
|
||||
'unit': item['unit']?.toString() ?? 'kg',
|
||||
'name': item['name']?.toString() ?? '-',
|
||||
'stock': stock,
|
||||
'status': StockStatusUtils.label(status),
|
||||
'unit': unit,
|
||||
});
|
||||
}
|
||||
|
||||
criticalItems.sort(
|
||||
(a, b) => a['name'].toString().compareTo(b['name'].toString()),
|
||||
);
|
||||
}
|
||||
|
||||
void _applyUsageSummary(dynamic data) {
|
||||
usageSummary.clear();
|
||||
if (data is! List) return;
|
||||
|
||||
for (final item in data) {
|
||||
if (item is! Map) continue;
|
||||
|
||||
final label = item['nama_bahan']?.toString() ?? '-';
|
||||
final total = _toDouble(item['total_digunakan']);
|
||||
final unit = item['satuan']?.toString() ?? 'kg';
|
||||
if (total <= 0) continue;
|
||||
|
||||
usageSummary.add({'label': label, 'value': total, 'unit': unit});
|
||||
}
|
||||
|
||||
usageSummary.sort(
|
||||
(a, b) => (b['value'] as double).compareTo(a['value'] as double),
|
||||
);
|
||||
}
|
||||
|
||||
void _rebuildSummary() {
|
||||
|
|
@ -170,35 +208,17 @@ class ReportController extends ChangeNotifier {
|
|||
totalKritis = criticalItems.length;
|
||||
}
|
||||
|
||||
void _rebuildUsageSummary() {
|
||||
final Map<String, double> totals = {};
|
||||
for (final item in stockHistory) {
|
||||
final name = item['name'] as String;
|
||||
final amount = item['amount'] as double;
|
||||
totals[name] = (totals[name] ?? 0) + amount;
|
||||
}
|
||||
|
||||
final sorted =
|
||||
totals.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
usageSummary
|
||||
..clear()
|
||||
..addAll(
|
||||
sorted
|
||||
.take(4)
|
||||
.map((entry) => {'label': entry.key, 'value': entry.value}),
|
||||
);
|
||||
}
|
||||
|
||||
void _rebuildDemandTrend() {
|
||||
final sorted =
|
||||
predictionItems.toList()..sort(
|
||||
(a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime),
|
||||
);
|
||||
final latestItems =
|
||||
sorted.length > 7 ? sorted.skip(sorted.length - 7) : sorted;
|
||||
|
||||
demandTrend
|
||||
..clear()
|
||||
..addAll(sorted.take(7).map((entry) => (entry['prediction'] as double)));
|
||||
..addAll(latestItems.map((entry) => (entry['prediction'] as double)));
|
||||
}
|
||||
|
||||
double _toDouble(dynamic value) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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.18.30:5000';
|
||||
static const String baseUrl = 'http://192.168.1.44:5000';
|
||||
|
||||
static const int timeoutSeconds = 30;
|
||||
|
||||
|
|
|
|||
80
pubspec.lock
80
pubspec.lock
|
|
@ -1,6 +1,14 @@
|
|||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.9"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -9,6 +17,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.12.0"
|
||||
barcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: barcode
|
||||
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.9"
|
||||
bidi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bidi
|
||||
sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.13"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -152,6 +176,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.4"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -240,6 +272,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_parsing
|
||||
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -288,6 +328,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
pdf:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: pdf
|
||||
sha256: "28eacad99bffcce2e05bba24e50153890ad0255294f4dd78a17075a2ba5c8416"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.11.3"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -304,6 +360,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
qr:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: qr
|
||||
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -461,6 +533,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
sdks:
|
||||
dart: ">=3.7.0 <4.0.0"
|
||||
flutter: ">=3.29.0"
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ dependencies:
|
|||
|
||||
# File export
|
||||
path_provider: ^2.1.4
|
||||
pdf: ^3.11.1
|
||||
|
||||
# Open file + share
|
||||
open_filex: ^4.5.0
|
||||
|
|
|
|||
Loading…
Reference in New Issue