1175 lines
39 KiB
Dart
Raw Normal View History

2025-08-06 13:38:49 +07:00
import 'package:enaklo_pos/data/models/response/profit_loss_response_model.dart';
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
// Warna Aplikasi
class AppColorProfitLoss {
static const primary = Color(0xff36175e);
static const secondary = Color(0xff7c3aed);
static const success = Color(0xff10b981);
static const warning = Color(0xfff59e0b);
static const danger = Color(0xffef4444);
static const info = Color(0xff3b82f6);
}
class ProfitLossWidget extends StatelessWidget {
final ProfitLossData data;
final String title;
final String searchDateFormatted;
const ProfitLossWidget({
super.key,
required this.data,
required this.title,
required this.searchDateFormatted,
});
@override
Widget build(BuildContext context) {
return Container(
color: const Color(0xFFF1F5F9),
padding: const EdgeInsets.all(20.0),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
const SizedBox(height: 20),
_buildSummaryCards(),
const SizedBox(height: 20),
_buildProfitTrendChart(),
const SizedBox(height: 20),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: _buildProductProfitability(),
),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: _buildProfitBreakdown(),
),
],
),
const SizedBox(height: 20),
_buildDetailedMetrics(),
],
),
),
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey[300]!),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF0F172A),
),
),
const SizedBox(height: 6),
Text(
'Analisis profitabilitas dan margin keuntungan',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColorProfitLoss.primary,
AppColorProfitLoss.secondary
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.date_range, color: Colors.white, size: 18),
const SizedBox(width: 8),
Text(
searchDateFormatted,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
);
}
// Fungsi untuk menghitung tren berdasarkan data historis
String _calculateTrend(String metric) {
if (data.data.length < 2) return '+0.0%';
double current = 0;
double previous = 0;
// Ambil data dari beberapa hari terakhir untuk perhitungan tren
final dataLength = data.data.length;
final splitPoint =
(dataLength * 0.6).round(); // 60% data lama, 40% data baru
final recentData = data.data.skip(splitPoint).toList();
final olderData = data.data.take(splitPoint).toList();
if (recentData.isEmpty || olderData.isEmpty) return '+0.0%';
switch (metric) {
case 'revenue':
current = recentData.fold(0.0, (sum, item) => sum + item.revenue) /
recentData.length;
previous = olderData.fold(0.0, (sum, item) => sum + item.revenue) /
olderData.length;
break;
case 'cost':
current = recentData.fold(0.0, (sum, item) => sum + item.cost) /
recentData.length;
previous = olderData.fold(0.0, (sum, item) => sum + item.cost) /
olderData.length;
break;
case 'grossProfit':
current = recentData.fold(0.0, (sum, item) => sum + item.grossProfit) /
recentData.length;
previous = olderData.fold(0.0, (sum, item) => sum + item.grossProfit) /
olderData.length;
break;
case 'netProfit':
current = recentData.fold(0.0, (sum, item) => sum + item.netProfit) /
recentData.length;
previous = olderData.fold(0.0, (sum, item) => sum + item.netProfit) /
olderData.length;
break;
}
2025-08-13 23:27:52 +07:00
// Handle division by zero and invalid values
if (previous == 0 || previous.isNaN || previous.isInfinite) return '+0.0%';
if (current.isNaN || current.isInfinite) return '+0.0%';
2025-08-06 13:38:49 +07:00
final trendPercentage = ((current - previous) / previous) * 100;
2025-08-13 23:27:52 +07:00
// Check if trendPercentage is valid
if (trendPercentage.isNaN || trendPercentage.isInfinite) return '+0.0%';
final sign = trendPercentage >= 0 ? '+' : '';
return '$sign${trendPercentage.round()}%';
2025-08-06 13:38:49 +07:00
}
Widget _buildSummaryCards() {
final summaryItems = [
{
'title': 'Jumlah Pendapatan',
'value': _formatCurrency(data.summary.totalRevenue),
'subtitle': '${data.summary.totalOrders} pesanan',
'icon': Icons.attach_money,
'color': AppColorProfitLoss.success,
'trend': _calculateTrend('revenue'),
},
{
'title': 'Jumlah Biaya',
'value': _formatCurrency(data.summary.totalCost),
'subtitle': 'HPP & Biaya',
'icon': Icons.receipt,
'color': AppColorProfitLoss.danger,
'trend': _calculateTrend('cost'),
},
{
'title': 'Laba Kotor',
'value': _formatCurrency(data.summary.grossProfit),
2025-08-13 23:27:52 +07:00
'subtitle': '${_safeRound(data.summary.grossProfitMargin)}% margin',
2025-08-06 13:38:49 +07:00
'icon': Icons.trending_up,
'color': AppColorProfitLoss.primary,
'trend': _calculateTrend('grossProfit'),
},
{
'title': 'Laba Bersih',
'value': _formatCurrency(data.summary.netProfit),
2025-08-13 23:27:52 +07:00
'subtitle': '${_safeRound(data.summary.netProfitMargin)}% margin',
2025-08-06 13:38:49 +07:00
'icon': Icons.account_balance,
'color': AppColorProfitLoss.info,
'trend': _calculateTrend('netProfit'),
},
];
return Row(
children: summaryItems.map((item) {
final trendValue = item['trend'] as String;
final isPositive = !trendValue.startsWith('-');
final trendColor =
isPositive ? AppColorProfitLoss.success : AppColorProfitLoss.danger;
return Expanded(
child: Container(
margin: const EdgeInsets.only(right: 12),
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.grey[200]!),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: (item['color'] as Color).withOpacity(0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
item['icon'] as IconData,
color: item['color'] as Color,
size: 22,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: trendColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
trendValue,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: trendColor,
),
),
),
],
),
const SizedBox(height: 14),
Text(
item['value'] as String,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF0F172A),
),
),
const SizedBox(height: 4),
Text(
item['title'] as String,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF475569),
),
),
const SizedBox(height: 2),
Text(
item['subtitle'] as String,
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
),
),
],
),
),
);
}).toList(),
);
}
Widget _buildProfitTrendChart() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey[200]!),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Analisis Tren Keuntungan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF0F172A),
),
),
Row(
children: [
_buildLegendItem('Pendapatan', AppColorProfitLoss.info),
const SizedBox(width: 16),
_buildLegendItem('Biaya', AppColorProfitLoss.danger),
const SizedBox(width: 16),
_buildLegendItem('Laba Bersih', AppColorProfitLoss.success),
],
),
],
),
const SizedBox(height: 20),
SizedBox(
height: 220,
child: LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawHorizontalLine: true,
drawVerticalLine: false,
horizontalInterval: 100000,
getDrawingHorizontalLine: (value) {
return FlLine(
color: Colors.grey[100]!,
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 50,
getTitlesWidget: (value, meta) {
2025-08-13 23:27:52 +07:00
final kValue = (value / 1000);
if (kValue.isFinite) {
return Text(
'${kValue.toInt()}K',
style: TextStyle(
color: Colors.grey[600],
fontSize: 10,
),
);
}
return const SizedBox();
2025-08-06 13:38:49 +07:00
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index >= 0 && index < data.data.length) {
final date = DateTime.parse(data.data[index].date);
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'${date.day}/${date.month}',
style: TextStyle(
color: Colors.grey[600],
fontSize: 10,
),
),
);
}
return const SizedBox();
},
),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
),
borderData: FlBorderData(show: false),
lineBarsData: [
// Garis Pendapatan
LineChartBarData(
spots: data.data.asMap().entries.map((entry) {
2025-08-13 23:27:52 +07:00
final revenue = entry.value.revenue.toDouble();
2025-08-06 13:38:49 +07:00
return FlSpot(
2025-08-13 23:27:52 +07:00
entry.key.toDouble(), revenue.isFinite ? revenue : 0);
2025-08-06 13:38:49 +07:00
}).toList(),
isCurved: true,
color: AppColorProfitLoss.info,
dotData: const FlDotData(show: false),
),
// Garis Biaya
LineChartBarData(
spots: data.data.asMap().entries.map((entry) {
2025-08-13 23:27:52 +07:00
final cost = entry.value.cost.toDouble();
2025-08-06 13:38:49 +07:00
return FlSpot(
2025-08-13 23:27:52 +07:00
entry.key.toDouble(), cost.isFinite ? cost : 0);
2025-08-06 13:38:49 +07:00
}).toList(),
isCurved: true,
color: AppColorProfitLoss.danger,
dotData: const FlDotData(show: false),
),
// Garis Laba Bersih
LineChartBarData(
spots: data.data.asMap().entries.map((entry) {
2025-08-13 23:27:52 +07:00
final netProfit = entry.value.netProfit.toDouble();
2025-08-06 13:38:49 +07:00
return FlSpot(entry.key.toDouble(),
2025-08-13 23:27:52 +07:00
netProfit.isFinite ? netProfit : 0);
2025-08-06 13:38:49 +07:00
}).toList(),
isCurved: true,
color: AppColorProfitLoss.success,
dotData: const FlDotData(show: true),
belowBarData: BarAreaData(
show: true,
color: AppColorProfitLoss.success.withOpacity(0.1),
),
),
],
),
),
),
],
),
);
}
Widget _buildLegendItem(String label, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Color(0xFF64748B),
),
),
],
);
}
Widget _buildProductProfitability() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey[200]!),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Profitabilitas Produk',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF0F172A),
),
),
const SizedBox(height: 4),
Text(
'Analisis margin keuntungan per produk',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(height: 16),
Column(
children: data.productData.take(4).map((product) {
return _buildProductItem(product);
}).toList(),
),
],
),
);
}
Widget _buildProductItem(ProfitLossProduct product) {
2025-08-13 23:27:52 +07:00
final profitMargin = _safeDouble(product.grossProfitMargin);
final profitColor = profitMargin >= 35
2025-08-06 13:38:49 +07:00
? AppColorProfitLoss.success
2025-08-13 23:27:52 +07:00
: profitMargin >= 25
2025-08-06 13:38:49 +07:00
? AppColorProfitLoss.warning
: AppColorProfitLoss.danger;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[100]!),
),
child: Column(
children: [
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppColorProfitLoss.primary.withOpacity(0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
_getProductIcon(product.categoryName),
color: AppColorProfitLoss.primary,
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.productName,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
color: Color(0xFF0F172A),
),
),
const SizedBox(height: 2),
Text(
'${product.quantitySold} unit • ${product.categoryName}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: profitColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
2025-08-13 23:27:52 +07:00
'${_safeRound(profitMargin)}%',
2025-08-06 13:38:49 +07:00
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: profitColor,
),
),
),
const SizedBox(height: 4),
Text(
_formatCurrency(product.grossProfit),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF0F172A),
),
),
],
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildProductMetric(
'Pendapatan', product.revenue, AppColorProfitLoss.info),
),
const SizedBox(width: 8),
Expanded(
child: _buildProductMetric(
'Biaya', product.cost, AppColorProfitLoss.danger),
),
const SizedBox(width: 8),
Expanded(
child: _buildProductMetric('Laba/Unit', product.profitPerUnit,
AppColorProfitLoss.success),
),
],
),
],
),
);
}
Widget _buildProductMetric(String label, int value, Color color) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: Colors.grey[600],
),
),
const SizedBox(height: 2),
Text(
_formatCurrency(value),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}
Widget _buildProfitBreakdown() {
final breakdownData = [
{
'label': 'Laba Kotor',
'value': data.summary.grossProfit,
'color': AppColorProfitLoss.success
},
{
'label': 'Pajak',
'value': data.summary.totalTax,
'color': AppColorProfitLoss.warning
},
{
'label': 'Diskon',
'value': data.summary.totalDiscount,
'color': AppColorProfitLoss.info
},
{
'label': 'Laba Bersih',
'value': data.summary.netProfit,
'color': AppColorProfitLoss.primary
},
];
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey[200]!),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Rincian Keuntungan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF0F172A),
),
),
const SizedBox(height: 4),
Text(
'Komponen pembentuk profit',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(height: 20),
// Grafik Donut
SizedBox(
height: 160,
child: PieChart(
PieChartData(
sectionsSpace: 3,
centerSpaceRadius: 50,
startDegreeOffset: -90,
sections: breakdownData.asMap().entries.map((entry) {
final item = entry.value;
2025-08-13 23:27:52 +07:00
final grossProfit = data.summary.grossProfit;
final value = item['value'] as int;
// Handle division by zero
2025-08-06 13:38:49 +07:00
final percentage =
2025-08-13 23:27:52 +07:00
grossProfit > 0 ? (value / grossProfit * 100) : 0.0;
2025-08-06 13:38:49 +07:00
return PieChartSectionData(
color: item['color'] as Color,
2025-08-13 23:27:52 +07:00
value: value.toDouble(),
title: '${_safeRound(percentage)}%',
2025-08-06 13:38:49 +07:00
radius: 40,
titleStyle: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
),
);
}).toList(),
),
),
),
const SizedBox(height: 16),
// Legenda
Column(
children: breakdownData.map((item) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: item['color'] as Color,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
item['label'] as String,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
Text(
_formatCurrency(item['value'] as int),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFF0F172A),
),
),
],
),
);
}).toList(),
),
const SizedBox(height: 16),
// Rasio Profitabilitas
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColorProfitLoss.primary.withOpacity(0.1),
AppColorProfitLoss.secondary.withOpacity(0.1),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
const Text(
'Rasio Profitabilitas',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF475569),
),
),
const SizedBox(height: 8),
Text(
2025-08-13 23:27:52 +07:00
'${_safeRound(data.summary.profitabilityRatio)}%',
2025-08-06 13:38:49 +07:00
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppColorProfitLoss.primary,
),
),
const SizedBox(height: 4),
Text(
'Tingkat Pengembalian Pendapatan',
style: TextStyle(
fontSize: 10,
color: Colors.grey[600],
),
),
],
),
),
],
),
);
}
Widget _buildDetailedMetrics() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey[200]!),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Metrik Kinerja Terperinci',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF0F172A),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildMetricCard(
'Nilai Rata-rata Pesanan',
2025-08-13 23:27:52 +07:00
_formatCurrency(_safeCalculateAverageOrder()),
2025-08-06 13:38:49 +07:00
'Per transaksi',
Icons.shopping_cart_outlined,
AppColorProfitLoss.info,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildMetricCard(
'Keuntungan Rata-rata',
_formatCurrency(data.summary.averageProfit),
'Per pesanan',
Icons.trending_up,
AppColorProfitLoss.success,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildMetricCard(
'Rasio Biaya',
2025-08-13 23:27:52 +07:00
'${_safeCalculateCostRatio()}%',
2025-08-06 13:38:49 +07:00
'Dari total pendapatan',
Icons.pie_chart,
AppColorProfitLoss.danger,
),
),
],
),
const SizedBox(height: 16),
// Tabel rincian harian
Container(
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[100]!),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColorProfitLoss.primary.withOpacity(0.05),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: const Row(
children: [
Expanded(
flex: 2,
child: Text(
'Tanggal',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFF475569),
),
),
),
Expanded(
child: Text(
'Pendapatan',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFF475569),
),
),
),
Expanded(
child: Text(
'Biaya',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFF475569),
),
),
),
Expanded(
child: Text(
'Laba Bersih',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFF475569),
),
),
),
Expanded(
child: Text(
'Margin',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFF475569),
),
),
),
],
),
),
...data.data.map((item) {
final date = DateTime.parse(item.date);
final dateStr = DateFormat('dd MMM').format(date);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.grey[100]!),
),
),
child: Row(
children: [
Expanded(
flex: 2,
child: Text(
dateStr,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF0F172A),
),
),
),
Expanded(
child: Text(
_formatCurrencyShort(item.revenue),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: AppColorProfitLoss.info,
),
),
),
Expanded(
child: Text(
_formatCurrencyShort(item.cost),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: AppColorProfitLoss.danger,
),
),
),
Expanded(
child: Text(
_formatCurrencyShort(item.netProfit),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColorProfitLoss.success,
),
),
),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: _getMarginColor(item.netProfitMargin)
.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
2025-08-13 23:27:52 +07:00
'${_safeRound(item.netProfitMargin)}%',
2025-08-06 13:38:49 +07:00
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: _getMarginColor(item.netProfitMargin),
),
),
),
),
],
),
);
}).toList(),
],
),
),
],
),
);
}
Widget _buildMetricCard(
String title, String value, String subtitle, IconData icon, Color color) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withOpacity(0.06),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.2)),
),
child: Column(
children: [
Icon(icon, color: color, size: 24),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 4),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF475569),
),
),
const SizedBox(height: 2),
Text(
subtitle,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10,
color: Colors.grey[600],
),
),
],
),
);
}
2025-08-13 23:27:52 +07:00
// Helper methods for safe calculations
int _safeRound(double value) {
if (value.isNaN || value.isInfinite) return 0;
return value.round();
}
double _safeDouble(double value) {
if (value.isNaN || value.isInfinite) return 0.0;
return value;
}
int _safeCalculateAverageOrder() {
if (data.summary.totalOrders == 0) return 0;
final average = data.summary.totalRevenue / data.summary.totalOrders;
if (average.isNaN || average.isInfinite) return 0;
return average.round();
}
int _safeCalculateCostRatio() {
if (data.summary.totalRevenue == 0) return 0;
final ratio = (data.summary.totalCost / data.summary.totalRevenue) * 100;
if (ratio.isNaN || ratio.isInfinite) return 0;
return ratio.round();
}
2025-08-06 13:38:49 +07:00
IconData _getProductIcon(String category) {
switch (category.toLowerCase()) {
case 'coffee':
case 'kopi':
return Icons.local_cafe;
case 'pastry':
case 'kue':
return Icons.cake;
case 'food':
case 'makanan':
return Icons.restaurant;
default:
return Icons.inventory;
}
}
Color _getMarginColor(double margin) {
2025-08-13 23:27:52 +07:00
final safeMargin = _safeDouble(margin);
if (safeMargin >= 25) return AppColorProfitLoss.success;
if (safeMargin >= 15) return AppColorProfitLoss.warning;
2025-08-06 13:38:49 +07:00
return AppColorProfitLoss.danger;
}
String _formatCurrency(int amount) {
final formatter = NumberFormat.currency(
locale: 'id_ID',
symbol: 'Rp ',
decimalDigits: 0,
);
return formatter.format(amount);
}
String _formatCurrencyShort(int amount) {
if (amount >= 1000000) {
2025-08-13 23:27:52 +07:00
return 'Rp ${(amount / 1000000).round()}M';
2025-08-06 13:38:49 +07:00
} else if (amount >= 1000) {
return 'Rp ${(amount / 1000).toStringAsFixed(0)}K';
}
return 'Rp $amount';
}
}