report profit loss

This commit is contained in:
efrilm
2025-11-03 21:28:36 +07:00
parent 5a7892aa99
commit 07932d688f
23 changed files with 5188 additions and 1 deletions
@@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../application/analytic/dashboard_analytic_loader/dashboard_analytic_loader_bloc.dart';
import '../../../../../application/analytic/payment_method_analytic_loader/payment_method_analytic_loader_bloc.dart';
import '../../../../../application/analytic/product_analytic_loader/product_analytic_loader_bloc.dart';
import '../../../../../application/analytic/profit_loss_analytic_loader/profit_loss_analytic_loader_bloc.dart';
import '../../../../../application/analytic/sales_analytic_loader/sales_analytic_loader_bloc.dart';
import '../../../../../application/report/report_bloc.dart';
import '../../../../../common/data/report_menu.dart';
@@ -18,6 +19,7 @@ import '../../../../router/app_router.gr.dart';
import 'sections/report_dashboard_section.dart';
import 'sections/report_payment_method_section.dart';
import 'sections/report_product_section.dart';
import 'sections/report_profit_loss_section.dart';
import 'sections/report_sales_section.dart';
import 'widgets/report_menu_card.dart';
@@ -167,7 +169,20 @@ class ReportPage extends StatelessWidget implements AutoRouteWrapper {
);
},
),
5 => Text(state.title),
5 =>
BlocBuilder<
ProfitLossAnalyticLoaderBloc,
ProfitLossAnalyticLoaderState
>(
builder: (context, profitLoss) {
return ReportProfitLossSection(
menu: reportMenus[state.selectedMenu],
state: profitLoss,
startDate: state.startDate,
endDate: state.endDate,
);
},
),
6 => Text(state.title),
7 => Text(state.title),
_ => Container(),
@@ -218,6 +233,12 @@ class ReportPage extends StatelessWidget implements AutoRouteWrapper {
);
case 5:
return context.read<ProfitLossAnalyticLoaderBloc>().add(
ProfitLossAnalyticLoaderEvent.fetched(
startDate: state.startDate,
endDate: state.endDate,
),
);
case 6:
case 7:
default:
@@ -265,6 +286,15 @@ class ReportPage extends StatelessWidget implements AutoRouteWrapper {
),
),
),
BlocProvider(
create: (context) => getIt<ProfitLossAnalyticLoaderBloc>()
..add(
ProfitLossAnalyticLoaderEvent.fetched(
startDate: DateTime.now().subtract(const Duration(days: 30)),
endDate: DateTime.now(),
),
),
),
],
child: this,
);
@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../../application/analytic/profit_loss_analytic_loader/profit_loss_analytic_loader_bloc.dart';
import '../../../../../../common/data/report_menu.dart';
import '../../../../../../common/extension/extension.dart';
import '../../../../../../common/theme/theme.dart';
import '../../../../../components/error/analytic_error_state_widget.dart';
import '../../../../../components/loader/loader_with_text.dart';
import '../../../../../components/spaces/space.dart';
import '../../../../../components/widgets/report/report_header.dart';
import '../../../../../components/widgets/report/report_summary_card.dart';
import '../widgets/profit_loss/report_profit_loss_breakdown.dart';
import '../widgets/profit_loss/report_profit_loss_metric.dart';
import '../widgets/profit_loss/report_profit_loss_product_profitability.dart';
import '../widgets/profit_loss/report_profit_loss_trend_chart.dart';
class ReportProfitLossSection extends StatelessWidget {
final ReportMenu menu;
final ProfitLossAnalyticLoaderState state;
final DateTime startDate;
final DateTime endDate;
const ReportProfitLossSection({
super.key,
required this.menu,
required this.state,
required this.startDate,
required this.endDate,
});
@override
Widget build(BuildContext context) {
if (state.isFetching) {
return const Center(child: LoaderWithText());
}
return state.failureOption.fold(
() => RefreshIndicator(
onRefresh: () {
context.read<ProfitLossAnalyticLoaderBloc>().add(
ProfitLossAnalyticLoaderEvent.fetched(
startDate: startDate,
endDate: endDate,
),
);
return Future.value();
},
child: ListView(
padding: EdgeInsets.all(16),
children: [
ReportHeader(menu: menu, endDate: endDate, startDate: startDate),
_buildSummary(),
ReportProfitLossTrendChart(data: state.profitLossAnalytic.data),
Padding(
padding: const EdgeInsets.only(top: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: ReportProfitLossProductProfitability(
data: state.profitLossAnalytic.productData,
),
),
SpaceWidth(12),
Expanded(
flex: 2,
child: ReportProfitLossBreakdown(
data: state.profitLossAnalytic,
),
),
],
),
),
ReportProfitLossMetric(data: state.profitLossAnalytic),
],
),
),
(f) => AnalyticErrorStateWidget(
failure: f,
menu: menu,
onRefresh: () {
context.read<ProfitLossAnalyticLoaderBloc>().add(
ProfitLossAnalyticLoaderEvent.fetched(
startDate: startDate,
endDate: endDate,
),
);
},
),
);
}
Padding _buildSummary() {
return Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
children: [
Row(
children: [
Expanded(
child: ReportSummaryCard(
color: AppColor.success,
icon: Icons.attach_money,
title: 'Jumlah Pendapatan',
value: state
.profitLossAnalytic
.summary
.totalRevenue
.currencyFormatRpV2,
),
),
SpaceWidth(12),
Expanded(
child: ReportSummaryCard(
color: AppColor.info,
icon: Icons.receipt_outlined,
title: 'Jumlah Biaya',
value: state.profitLossAnalytic.summary.totalCost
.toDouble()
.currencyFormatRpV2,
),
),
],
),
SpaceHeight(12),
Row(
children: [
Expanded(
child: ReportSummaryCard(
color: AppColor.warning,
icon: Icons.trending_up_outlined,
title: 'Laba Kotor',
value: state.profitLossAnalytic.summary.grossProfit
.toDouble()
.currencyFormatRpV2,
),
),
SpaceWidth(12),
Expanded(
child: ReportSummaryCard(
color: AppColor.primary,
icon: Icons.account_balance_outlined,
title: 'Laba Bersih',
value: state.profitLossAnalytic.summary.netProfit
.toString()
.currencyFormatRpV2,
),
),
],
),
],
),
);
}
}
@@ -0,0 +1,185 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import '../../../../../../../common/extension/extension.dart';
import '../../../../../../../common/function/app_function.dart';
import '../../../../../../../common/theme/theme.dart';
import '../../../../../../../domain/analytic/analytic.dart';
import '../../../../../../components/spaces/space.dart';
class ReportProfitLossBreakdown extends StatelessWidget {
final ProfitLossAnalytic data;
const ReportProfitLossBreakdown({super.key, required this.data});
@override
Widget build(BuildContext context) {
final breakdownData = [
{
'label': 'Laba Kotor',
'value': data.summary.grossProfit,
'color': AppColor.success,
},
{
'label': 'Pajak',
'value': data.summary.totalTax,
'color': AppColor.warning,
},
{
'label': 'Diskon',
'value': data.summary.totalDiscount,
'color': AppColor.info,
},
{
'label': 'Laba Bersih',
'value': data.summary.netProfit,
'color': AppColor.primary,
},
];
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rincian Keuntungan',
style: AppStyle.xl.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SpaceHeight(4),
Text(
'Komponen pembentuk profit',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
SpaceHeight(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;
final grossProfit = data.summary.grossProfit;
final value = item['value'] as num;
// Handle division by zero
final percentage = grossProfit > 0
? (value / grossProfit * 100)
: 0.0;
return PieChartSectionData(
color: item['color'] as Color,
value: value.toDouble(),
title: '${safeRound(percentage)}%',
radius: 40,
titleStyle: AppStyle.xs.copyWith(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
),
);
}).toList(),
),
),
),
SpaceHeight(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: AppStyle.sm.copyWith(
fontWeight: FontWeight.w500,
),
),
),
Text(
(item['value'] as num).toString().currencyFormatRpV2,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
],
),
);
}).toList(),
),
SpaceHeight(16),
// Rasio Profitabilitas
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColor.primary.withOpacity(0.1),
AppColor.secondary.withOpacity(0.1),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Text(
'Rasio Profitabilitas',
style: AppStyle.sm.copyWith(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColor.textSecondary,
),
),
SpaceHeight(8),
Text(
'${safeRound(data.summary.profitabilityRatio)}%',
style: AppStyle.h5.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.primary,
),
),
SpaceHeight(4),
Text(
'Tingkat Pengembalian Pendapatan',
style: AppStyle.xs.copyWith(
fontSize: 10,
color: AppColor.textSecondary,
),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,300 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../../../../common/extension/extension.dart';
import '../../../../../../../common/function/app_function.dart';
import '../../../../../../../common/theme/theme.dart';
import '../../../../../../../domain/analytic/analytic.dart';
import '../../../../../../components/spaces/space.dart';
class ReportProfitLossMetric extends StatelessWidget {
final ProfitLossAnalytic data;
const ReportProfitLossMetric({super.key, required this.data});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
margin: EdgeInsets.only(top: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Metrik Kinerja Terperinci',
style: AppStyle.xl.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SpaceHeight(16),
Row(
children: [
Expanded(
child: _buildMetricCard(
'Nilai Rata-rata Pesanan',
_safeCalculateAverageOrder().currencyFormatRpV2,
'Per transaksi',
Icons.shopping_cart_outlined,
AppColor.info,
),
),
SpaceWidth(12),
Expanded(
child: _buildMetricCard(
'Keuntungan Rata-rata',
"${data.summary.averageProfit.round()}%",
'Per pesanan',
Icons.trending_up,
AppColor.success,
),
),
SpaceWidth(12),
Expanded(
child: _buildMetricCard(
'Rasio Biaya',
'${_safeCalculateCostRatio()}%',
'Dari total pendapatan',
Icons.pie_chart,
AppColor.error,
),
),
],
),
SpaceHeight(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: AppColor.primary.withOpacity(0.05),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Row(
children: [
Expanded(
flex: 2,
child: Text(
'Tanggal',
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
),
Expanded(
child: Text(
'Pendapatan',
textAlign: TextAlign.center,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
),
Expanded(
child: Text(
'Biaya',
textAlign: TextAlign.center,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
),
Expanded(
child: Text(
'Laba Bersih',
textAlign: TextAlign.center,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
),
Expanded(
child: Text(
'Margin',
textAlign: TextAlign.center,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
),
],
),
),
...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: AppStyle.sm.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
),
),
),
Expanded(
child: Text(
item.revenue.currencyFormatRpV2,
textAlign: TextAlign.center,
style: AppStyle.xs.copyWith(
fontWeight: FontWeight.w500,
color: AppColor.info,
),
),
),
Expanded(
child: Text(
item.cost.toString().currencyFormatRpV2,
textAlign: TextAlign.center,
style: AppStyle.xs.copyWith(
fontWeight: FontWeight.w500,
color: AppColor.error,
),
),
),
Expanded(
child: Text(
item.netProfit.toString().currencyFormatRpV2,
textAlign: TextAlign.center,
style: AppStyle.xs.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.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(
'${safeRound(item.netProfitMargin)}%',
textAlign: TextAlign.center,
style: AppStyle.xs.copyWith(
fontSize: 10,
fontWeight: FontWeight.bold,
color: _getMarginColor(item.netProfitMargin),
),
),
),
),
],
),
);
}),
],
),
),
],
),
);
}
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),
SpaceHeight(8),
Text(
value,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
SpaceHeight(4),
Text(
title,
textAlign: TextAlign.center,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
),
),
SpaceHeight(2),
Text(
subtitle,
textAlign: TextAlign.center,
style: AppStyle.xs.copyWith(
fontSize: 10,
color: AppColor.textSecondary,
),
),
],
),
);
}
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();
}
Color _getMarginColor(double margin) {
final safeMargin = safeDouble(margin);
if (safeMargin >= 25) return AppColor.success;
if (safeMargin >= 15) return AppColor.warning;
return AppColor.error;
}
}
@@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import '../../../../../../../common/extension/extension.dart';
import '../../../../../../../common/function/app_function.dart';
import '../../../../../../../common/theme/theme.dart';
import '../../../../../../../domain/analytic/analytic.dart';
import '../../../../../../components/spaces/space.dart';
class ReportProfitLossProductProfitability extends StatelessWidget {
final List<ProfitLossAnalyticProduct> data;
const ReportProfitLossProductProfitability({super.key, required this.data});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Profitabilitas Produk',
style: AppStyle.xl.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SpaceHeight(4),
Text(
'Analisis margin keuntungan per produk',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
SpaceHeight(12),
Column(
children: data.take(4).map((product) {
return _buildProductItem(product);
}).toList(),
),
],
),
);
}
Widget _buildProductItem(ProfitLossAnalyticProduct product) {
final profitMargin = safeDouble(product.grossProfitMargin);
final profitColor = profitMargin >= 35
? AppColor.success
: profitMargin >= 25
? AppColor.warning
: AppColor.error;
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: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.productName,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w600,
color: Color(0xFF0F172A),
),
),
SpaceHeight(2),
Text(
'${product.quantitySold} unit • ${product.categoryName}',
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
),
),
],
),
),
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(
'${safeRound(profitMargin)}%',
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.bold,
color: profitColor,
),
),
),
SpaceHeight(4),
Text(
product.grossProfit.toString().currencyFormatRpV2,
style: AppStyle.md.copyWith(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColor.primary,
),
),
],
),
],
),
SpaceHeight(12),
Row(
children: [
Expanded(
child: _buildProductMetric(
'Pendapatan',
product.revenue,
AppColor.info,
),
),
SpaceWidth(8),
Expanded(
child: _buildProductMetric(
'Biaya',
product.cost,
AppColor.error,
),
),
SpaceWidth(8),
Expanded(
child: _buildProductMetric(
'Laba/Unit',
product.profitPerUnit,
AppColor.success,
),
),
],
),
],
),
);
}
Widget _buildProductMetric(String label, num 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],
),
),
SpaceHeight(2),
Text(
value.toString().currencyFormatRpV2,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}
}
@@ -0,0 +1,185 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import '../../../../../../../common/theme/theme.dart';
import '../../../../../../../domain/analytic/analytic.dart';
class ReportProfitLossTrendChart extends StatelessWidget {
final List<ProfitLossAnalyticItem> data;
const ReportProfitLossTrendChart({super.key, required this.data});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
margin: EdgeInsets.only(top: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Analisis Tren Keuntungan',
style: AppStyle.xl.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
Row(
children: [
_buildLegendItem('Pendapatan', AppColor.info),
const SizedBox(width: 16),
_buildLegendItem('Biaya', AppColor.error),
const SizedBox(width: 16),
_buildLegendItem('Laba Bersih', AppColor.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) {
final kValue = (value / 1000);
if (kValue.isFinite) {
return Text(
'${kValue.toInt()}K',
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontSize: 10,
),
);
}
return const SizedBox();
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index >= 0 && index < data.length) {
final date = DateTime.parse(data[index].date);
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'${date.day}/${date.month}',
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
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.asMap().entries.map((entry) {
final revenue = entry.value.revenue.toDouble();
return FlSpot(
entry.key.toDouble(),
revenue.isFinite ? revenue : 0,
);
}).toList(),
isCurved: true,
color: AppColor.info,
dotData: const FlDotData(show: false),
),
// Garis Biaya
LineChartBarData(
spots: data.asMap().entries.map((entry) {
final cost = entry.value.cost.toDouble();
return FlSpot(
entry.key.toDouble(),
cost.isFinite ? cost : 0,
);
}).toList(),
isCurved: true,
color: AppColor.error,
dotData: const FlDotData(show: false),
),
// Garis Laba Bersih
LineChartBarData(
spots: data.asMap().entries.map((entry) {
final netProfit = entry.value.netProfit.toDouble();
return FlSpot(
entry.key.toDouble(),
netProfit.isFinite ? netProfit : 0,
);
}).toList(),
isCurved: true,
color: AppColor.success,
dotData: const FlDotData(show: true),
belowBarData: BarAreaData(
show: true,
color: AppColor.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: AppStyle.sm.copyWith(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColor.textPrimary,
),
),
],
);
}
}