memperbarui beberapa fitur
This commit is contained in:
parent
16594b95e6
commit
b0ed5950c2
|
|
@ -38,13 +38,11 @@ class DashboardController extends ChangeNotifier {
|
||||||
MLService.getBahanDigunakanHariIni(),
|
MLService.getBahanDigunakanHariIni(),
|
||||||
MLService.getDashboardSummary(),
|
MLService.getDashboardSummary(),
|
||||||
MLService.getProducts(),
|
MLService.getProducts(),
|
||||||
MLService.getReportCritical(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
final bahanDigunakan = results[0] as Map<String, dynamic>;
|
final bahanDigunakan = results[0] as Map<String, dynamic>;
|
||||||
final summary = results[1] as Map<String, dynamic>;
|
final summary = results[1] as Map<String, dynamic>;
|
||||||
final products = results[2] as List;
|
final products = results[2] as List;
|
||||||
final critical = results[3] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
_applyBahanDigunakanHariIni(bahanDigunakan);
|
_applyBahanDigunakanHariIni(bahanDigunakan);
|
||||||
|
|
||||||
|
|
@ -56,18 +54,13 @@ class DashboardController extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
totalProduk = products.length;
|
totalProduk = products.length;
|
||||||
|
_applyLowStockProducts(products);
|
||||||
if (critical['status'] == true) {
|
|
||||||
_applyLowStockItems(critical['data']);
|
|
||||||
} else {
|
|
||||||
_applyDummyLowStock();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMessage = 'Gagal memuat dashboard: $e';
|
errorMessage = 'Gagal memuat dashboard: $e';
|
||||||
bahanDigunakanError = 'Gagal memuat bahan digunakan hari ini';
|
bahanDigunakanError = 'Gagal memuat bahan digunakan hari ini';
|
||||||
_resetBahanDigunakanHariIni();
|
_resetBahanDigunakanHariIni();
|
||||||
_applyDummyPenggunaan();
|
_applyDummyPenggunaan();
|
||||||
_applyDummyLowStock();
|
lowStockItems.clear();
|
||||||
} finally {
|
} finally {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
isBahanDigunakanLoading = false;
|
isBahanDigunakanLoading = false;
|
||||||
|
|
@ -109,25 +102,31 @@ class DashboardController extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applyLowStockItems(dynamic data) {
|
void _applyLowStockProducts(List<dynamic> products) {
|
||||||
lowStockItems.clear();
|
lowStockItems.clear();
|
||||||
if (data is! List || data.isEmpty) {
|
|
||||||
_applyDummyLowStock();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final item in data) {
|
for (final item in products) {
|
||||||
final stockValue = StockStatusUtils.parseStock(item['stok']);
|
if (item is! Map) continue;
|
||||||
|
|
||||||
|
final stockValue = StockStatusUtils.parseStock(item['current_stock']);
|
||||||
final statusKey = StockStatusUtils.statusFromStock(stockValue);
|
final statusKey = StockStatusUtils.statusFromStock(stockValue);
|
||||||
final unit = item['unit'] ?? 'kg';
|
if (statusKey != StockStatusUtils.statusKritis) continue;
|
||||||
final stockLabel = item['stok']?.toString() ?? '0';
|
|
||||||
|
final category = item['category']?.toString().toLowerCase() ?? '';
|
||||||
|
final unit =
|
||||||
|
item['unit']?.toString() ?? (category == 'barang' ? 'pcs' : 'kg');
|
||||||
lowStockItems.add({
|
lowStockItems.add({
|
||||||
'name': item['nama_bahan']?.toString() ?? '-',
|
'name': item['name']?.toString() ?? '-',
|
||||||
'stock': '$stockLabel $unit',
|
'stock': '${_formatStock(stockValue)} $unit',
|
||||||
|
'statusKey': statusKey,
|
||||||
'status': StockStatusUtils.label(statusKey),
|
'status': StockStatusUtils.label(statusKey),
|
||||||
'statusColor': StockStatusUtils.color(statusKey),
|
'statusColor': StockStatusUtils.color(statusKey),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lowStockItems.sort(
|
||||||
|
(a, b) => a['name'].toString().compareTo(b['name'].toString()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applyDummyPenggunaan() {
|
void _applyDummyPenggunaan() {
|
||||||
|
|
@ -142,34 +141,11 @@ class DashboardController extends ChangeNotifier {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applyDummyLowStock() {
|
String _formatStock(double value) {
|
||||||
lowStockItems
|
if (value % 1 == 0) {
|
||||||
..clear()
|
return value.toInt().toString();
|
||||||
..addAll([
|
}
|
||||||
{
|
|
||||||
'name': 'Tepung Terigu',
|
return value.toStringAsFixed(1);
|
||||||
'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),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ class _DashboardScreenState extends State<DashboardScreen> with RouteAware {
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (_controller.lowStockItems.isNotEmpty)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 4,
|
right: 4,
|
||||||
top: 4,
|
top: 4,
|
||||||
|
|
@ -98,16 +99,19 @@ class _DashboardScreenState extends State<DashboardScreen> with RouteAware {
|
||||||
height: 22,
|
height: 22,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.statusError,
|
color: AppColors.statusError,
|
||||||
borderRadius: BorderRadius.circular(11),
|
borderRadius: BorderRadius.circular(
|
||||||
|
11,
|
||||||
|
),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.primaryBrown,
|
color: AppColors.primaryBrown,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'3',
|
_controller.lowStockItems.length
|
||||||
style: TextStyle(
|
.toString(),
|
||||||
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|
@ -278,6 +282,16 @@ class _DashboardScreenState extends State<DashboardScreen> with RouteAware {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
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) {
|
..._controller.lowStockItems.map((item) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
|
@ -294,7 +308,8 @@ class _DashboardScreenState extends State<DashboardScreen> with RouteAware {
|
||||||
item['name'],
|
item['name'],
|
||||||
style: AppTextStyles.labelLarge
|
style: AppTextStyles.labelLarge
|
||||||
.copyWith(
|
.copyWith(
|
||||||
color: AppColors.textPrimary,
|
color:
|
||||||
|
AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|
@ -302,7 +317,8 @@ class _DashboardScreenState extends State<DashboardScreen> with RouteAware {
|
||||||
'Stok: ${item['stock']}',
|
'Stok: ${item['stock']}',
|
||||||
style: AppTextStyles.bodySmall
|
style: AppTextStyles.bodySmall
|
||||||
.copyWith(
|
.copyWith(
|
||||||
color: AppColors.textTertiary,
|
color:
|
||||||
|
AppColors.textTertiary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -315,7 +331,9 @@ class _DashboardScreenState extends State<DashboardScreen> with RouteAware {
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: item['statusColor'],
|
color: item['statusColor'],
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(
|
||||||
|
6,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
item['status'],
|
item['status'],
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.3),
|
color: Colors.white.withValues(alpha: 0.3),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
|
|
@ -363,7 +363,12 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
||||||
final maxStock = _controller.maxStock;
|
final maxStock = _controller.maxStock;
|
||||||
final stockPercentage = (product.stock / maxStock * 100).toInt();
|
final stockPercentage = (product.stock / maxStock * 100).toInt();
|
||||||
|
|
||||||
return Container(
|
return InkWell(
|
||||||
|
onTap: () => _showProductDetail(product),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.bgWhite,
|
color: AppColors.bgWhite,
|
||||||
|
|
@ -381,7 +386,7 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _controller
|
color: _controller
|
||||||
.getStatusColor(product.status)
|
.getStatusColor(product.status)
|
||||||
.withOpacity(0.12),
|
.withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
|
|
@ -413,7 +418,9 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
||||||
vertical: 3,
|
vertical: 3,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.primaryBrown.withOpacity(0.1),
|
color: AppColors.primaryBrown.withValues(
|
||||||
|
alpha: 0.1,
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -445,12 +452,12 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _controller
|
color: _controller
|
||||||
.getStatusColor(product.status)
|
.getStatusColor(product.status)
|
||||||
.withOpacity(0.15),
|
.withValues(alpha: 0.15),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _controller
|
color: _controller
|
||||||
.getStatusColor(product.status)
|
.getStatusColor(product.status)
|
||||||
.withOpacity(0.3),
|
.withValues(alpha: 0.3),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -529,26 +536,225 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
GestureDetector(
|
Container(
|
||||||
onTap: () {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('${product.name} - Detail'),
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.primaryBrown.withOpacity(0.1),
|
color: AppColors.primaryBrown.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.arrow_forward_ios,
|
Icons.info_outline_rounded,
|
||||||
color: AppColors.primaryBrown,
|
color: AppColors.primaryBrown,
|
||||||
size: 16,
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -557,6 +763,79 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
@override
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:finalproject/services/ml_service.dart';
|
import 'package:finalproject/services/ml_service.dart';
|
||||||
|
import 'package:finalproject/utils/stock_status.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class ReportController extends ChangeNotifier {
|
class ReportController extends ChangeNotifier {
|
||||||
|
|
@ -37,15 +38,15 @@ class ReportController extends ChangeNotifier {
|
||||||
MLService.getReportStock(),
|
MLService.getReportStock(),
|
||||||
MLService.getReportStockIn(),
|
MLService.getReportStockIn(),
|
||||||
MLService.getReportPredictions(),
|
MLService.getReportPredictions(),
|
||||||
MLService.getReportCritical(),
|
|
||||||
MLService.getProducts(),
|
MLService.getProducts(),
|
||||||
|
MLService.getDashboardSummary(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
final stockResponse = results[0] as Map<String, dynamic>;
|
final stockResponse = results[0] as Map<String, dynamic>;
|
||||||
final stockInResponse = results[1] as Map<String, dynamic>;
|
final stockInResponse = results[1] as Map<String, dynamic>;
|
||||||
final predictionResponse = results[2] as Map<String, dynamic>;
|
final predictionResponse = results[2] as Map<String, dynamic>;
|
||||||
final criticalResponse = results[3] as Map<String, dynamic>;
|
final productsResponse = results[3];
|
||||||
final productsResponse = results[4];
|
final dashboardSummary = results[4] as Map<String, dynamic>;
|
||||||
|
|
||||||
if (stockResponse['status'] != true) {
|
if (stockResponse['status'] != true) {
|
||||||
errorMessage = stockResponse['message']?.toString();
|
errorMessage = stockResponse['message']?.toString();
|
||||||
|
|
@ -65,18 +66,22 @@ class ReportController extends ChangeNotifier {
|
||||||
_applyPredictions(predictionResponse['data']);
|
_applyPredictions(predictionResponse['data']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criticalResponse['status'] != true) {
|
|
||||||
errorMessage ??= criticalResponse['message']?.toString();
|
|
||||||
} else {
|
|
||||||
_applyCriticalItems(criticalResponse['data']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (productsResponse is List) {
|
if (productsResponse is List) {
|
||||||
_productCount = productsResponse.length;
|
_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();
|
_rebuildSummary();
|
||||||
_rebuildUsageSummary();
|
|
||||||
_rebuildDemandTrend();
|
_rebuildDemandTrend();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMessage = 'Gagal memuat laporan: $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();
|
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({
|
criticalItems.add({
|
||||||
'name': item['nama_bahan']?.toString() ?? '-',
|
'name': item['name']?.toString() ?? '-',
|
||||||
'stock': _toDouble(item['stok']),
|
'stock': stock,
|
||||||
'status': item['status']?.toString() ?? 'Kritis',
|
'status': StockStatusUtils.label(status),
|
||||||
'unit': item['unit']?.toString() ?? 'kg',
|
'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() {
|
void _rebuildSummary() {
|
||||||
|
|
@ -170,35 +208,17 @@ class ReportController extends ChangeNotifier {
|
||||||
totalKritis = criticalItems.length;
|
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() {
|
void _rebuildDemandTrend() {
|
||||||
final sorted =
|
final sorted =
|
||||||
predictionItems.toList()..sort(
|
predictionItems.toList()..sort(
|
||||||
(a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime),
|
(a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime),
|
||||||
);
|
);
|
||||||
|
final latestItems =
|
||||||
|
sorted.length > 7 ? sorted.skip(sorted.length - 7) : sorted;
|
||||||
|
|
||||||
demandTrend
|
demandTrend
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(sorted.take(7).map((entry) => (entry['prediction'] as double)));
|
..addAll(latestItems.map((entry) => (entry['prediction'] as double)));
|
||||||
}
|
}
|
||||||
|
|
||||||
double _toDouble(dynamic value) {
|
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
|
// 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.18.30:5000';
|
static const String baseUrl = 'http://192.168.1.44:5000';
|
||||||
|
|
||||||
static const int timeoutSeconds = 30;
|
static const int timeoutSeconds = 30;
|
||||||
|
|
||||||
|
|
|
||||||
80
pubspec.lock
80
pubspec.lock
|
|
@ -1,6 +1,14 @@
|
||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
archive:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: archive
|
||||||
|
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.9"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -9,6 +17,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.12.0"
|
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:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -152,6 +176,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
image:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image
|
||||||
|
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.5.4"
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -240,6 +272,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
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:
|
path_provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -288,6 +328,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
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:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -304,6 +360,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
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:
|
share_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -461,6 +533,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.5.0"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.7.0 <4.0.0"
|
dart: ">=3.7.0 <4.0.0"
|
||||||
flutter: ">=3.29.0"
|
flutter: ">=3.29.0"
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ dependencies:
|
||||||
|
|
||||||
# File export
|
# File export
|
||||||
path_provider: ^2.1.4
|
path_provider: ^2.1.4
|
||||||
|
pdf: ^3.11.1
|
||||||
|
|
||||||
# Open file + share
|
# Open file + share
|
||||||
open_filex: ^4.5.0
|
open_filex: ^4.5.0
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue