feat: item sales report

This commit is contained in:
efrilm
2025-08-06 12:06:34 +07:00
parent fd254c22fd
commit 2a457ea5f6
9 changed files with 809 additions and 203 deletions
@@ -1,24 +1,16 @@
import 'dart:developer';
import 'package:enaklo_pos/core/components/spaces.dart';
import 'package:enaklo_pos/core/constants/colors.dart';
import 'package:enaklo_pos/core/extensions/int_ext.dart';
import 'package:enaklo_pos/core/utils/helper_pdf_service.dart';
import 'package:enaklo_pos/presentation/report/widgets/report_page_title.dart';
import 'package:enaklo_pos/data/models/response/sales_analytic_response_model.dart';
import 'package:flutter/material.dart';
import 'package:enaklo_pos/core/utils/item_sales_invoice.dart';
import 'package:enaklo_pos/core/utils/permession_handler.dart';
import 'package:enaklo_pos/data/models/response/item_sales_response_model.dart';
import 'package:horizontal_data_table/horizontal_data_table.dart';
import 'package:intl/intl.dart';
class ItemSalesReportWidget extends StatelessWidget {
final String title;
final String searchDateFormatted;
final List<ItemSales> itemSales;
final SalesAnalyticData sales;
final List<Widget>? headerWidgets;
const ItemSalesReportWidget({
super.key,
required this.itemSales,
required this.sales,
required this.title,
required this.searchDateFormatted,
required this.headerWidgets,
@@ -26,123 +18,522 @@ class ItemSalesReportWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
ReportPageTitle(
title: title,
searchDateFormatted: searchDateFormatted,
onExport: () async {
try {
final status = await PermessionHelper().checkPermission();
if (status) {
final pdfFile = await ItemSalesInvoice.generate(
itemSales, searchDateFormatted);
log("pdfFile: $pdfFile");
await HelperPdfService.openFile(pdfFile);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Storage permission is required to save PDF'),
backgroundColor: Colors.red,
),
);
}
} catch (e) {
log("Error generating PDF: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to generate PDF: $e'),
backgroundColor: Colors.red,
),
);
}
},
// Proses data untuk mendapatkan insights
final insights = _processSalesData(sales);
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
border: Border(
left: BorderSide(
color: const Color(0xFFE5E7EB),
width: 1,
),
),
const SpaceHeight(16.0),
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: HorizontalDataTable(
leftHandSideColumnWidth: 80,
rightHandSideColumnWidth: 670,
isFixedHeader: true,
headerWidgets: headerWidgets,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
_buildHeader(),
// isFixedFooter: true,
// footerWidgets: _getTitleWidget(),
leftSideItemBuilder: (context, index) {
return Container(
width: 80,
height: 52,
alignment: Alignment.centerLeft,
child: Center(child: Text(itemSales[index].id.toString())),
);
},
rightSideItemBuilder: (context, index) {
return Row(
children: <Widget>[
Container(
width: 100,
height: 52,
alignment: Alignment.centerLeft,
child: Center(
child: Text(itemSales[index].orderId.toString())),
),
Container(
width: 200,
height: 52,
alignment: Alignment.centerLeft,
child:
Center(child: Text(itemSales[index].productName!)),
),
Container(
width: 60,
height: 52,
alignment: Alignment.centerLeft,
child: Center(
child: Text(itemSales[index].quantity.toString())),
),
Container(
width: 150,
height: 52,
padding: const EdgeInsets.fromLTRB(5, 0, 0, 0),
alignment: Alignment.centerLeft,
child: Center(
child: Text(
itemSales[index].price!.currencyFormatRp,
)),
),
Container(
width: 160,
height: 52,
padding: const EdgeInsets.fromLTRB(5, 0, 0, 0),
alignment: Alignment.centerLeft,
child: Center(
child: Text(
(itemSales[index].price! * itemSales[index].quantity!)
.currencyFormatRp,
)),
),
],
);
},
itemCount: itemSales.length,
rowSeparatorWidget: const Divider(
color: Colors.black38,
height: 1.0,
thickness: 0.0,
),
leftHandSideColBackgroundColor: AppColors.white,
rightHandSideColBackgroundColor: AppColors.white,
const SizedBox(height: 24),
itemExtent: 55,
// Metrics menggunakan data dari summary
_buildMetrics(),
const SizedBox(height: 24),
// Daily Performance Section
Text(
'Daily Performance',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: const Color(0xFF111827),
),
),
const SizedBox(height: 16),
// Daily Performance List dengan data dinamis
ListView.builder(
itemCount: insights.sortedDailyData.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
final dayData = insights.sortedDailyData[index];
return _buildDailyPerformanceItem(
date: dayData.formattedDate,
sales: dayData.formattedSales,
orders: dayData.orders,
items: dayData.items,
isHighest: dayData == insights.highestRevenueDay,
);
},
),
const SizedBox(height: 16),
// Summary Footer dengan data dinamis
_buildSummaryFooter(insights.highestRevenueDay),
],
),
),
);
}
// Method untuk memproses data dan mendapatkan insights
SalesInsights _processSalesData(SalesAnalyticData data) {
// Sort data by sales (descending) untuk ranking
List<SalesAnalyticItem> sortedData = List.from(data.data);
sortedData.sort((a, b) => b.sales.compareTo(a.sales));
// Find highest revenue day
SalesAnalyticItem? highestRevenueDay;
if (sortedData.isNotEmpty) {
highestRevenueDay = sortedData.first;
}
return SalesInsights(
originalData: data.data,
sortedDailyData: sortedData,
highestRevenueDay: highestRevenueDay,
summary: data.summary,
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: const Color(0xFFE5E7EB),
width: 1,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sales Analytics',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: const Color(0xFF111827),
),
),
const SizedBox(height: 4),
Text(
searchDateFormatted,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: const Color(0xFF6B7280),
),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFF10B981),
borderRadius: BorderRadius.circular(24),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.trending_up,
size: 16,
color: Colors.white,
),
const SizedBox(width: 6),
Text(
'Growing',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
],
),
);
}
Widget _buildMetrics() {
final summary = sales.summary;
return Column(
children: [
Row(
children: [
Expanded(
child: _buildMetricCard(
title: 'Total Sales',
value: summary.totalSales.currencyFormatRpV2,
subtitle: 'Net Sales',
color: const Color(0xFF3B82F6),
backgroundColor: const Color(0xFFEFF6FF),
),
),
const SizedBox(width: 16),
Expanded(
child: _buildMetricCard(
title: 'Total Orders',
value: '${summary.totalOrders}',
subtitle: '${summary.totalItems} Items',
color: const Color(0xFF8B5CF6),
backgroundColor: const Color(0xFFF3E8FF),
),
),
],
),
const SizedBox(height: 16),
_buildFullWidthMetricCard(
title: 'Average Order Value',
value: summary.averageOrderValue.round().currencyFormatRpV2,
subtitle: 'Per transaction',
color: const Color(0xFFEF4444),
backgroundColor: const Color(0xFFFEF2F2),
),
],
);
}
Widget _buildSummaryFooter(SalesAnalyticItem? highestDay) {
if (highestDay == null) {
return Container();
}
final dateFormat = DateFormat('dd MMM');
final formattedDate = dateFormat.format(highestDay.date);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF9FAFB),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFFE5E7EB),
width: 1,
),
),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: const Color(0xFF10B981),
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Peak performance on $formattedDate with ${_formatCurrency(highestDay.sales)} revenue (${highestDay.orders} orders)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: const Color(0xFF374151),
),
),
),
],
),
);
}
// Helper method untuk format currency
String _formatCurrency(int amount) {
if (amount >= 1000000) {
return 'Rp ${(amount / 1000000).toStringAsFixed(1)}JT';
} else if (amount >= 1000) {
return 'Rp ${(amount / 1000).toStringAsFixed(0)}RB';
} else {
return 'Rp ${NumberFormat('#,###').format(amount)}';
}
}
Widget _buildMetricCard({
required String title,
required String value,
required String subtitle,
required Color color,
required Color backgroundColor,
}) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withOpacity(0.2),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: color,
),
),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: const Color(0xFF111827),
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w400,
color: const Color(0xFF6B7280),
),
),
],
),
);
}
Widget _buildFullWidthMetricCard({
required String title,
required String value,
required String subtitle,
required Color color,
required Color backgroundColor,
}) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withOpacity(0.2),
width: 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: color,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w400,
color: const Color(0xFF6B7280),
),
),
],
),
Text(
value,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: const Color(0xFF111827),
),
),
],
),
);
}
Widget _buildDailyPerformanceItem({
required String date,
required String sales,
required int orders,
required int items,
required bool isHighest,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isHighest ? const Color(0xFF10B981) : const Color(0xFFE5E7EB),
width: isHighest ? 2 : 1,
),
),
child: Row(
children: [
Container(
width: 60,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
date,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: const Color(0xFF111827),
),
),
if (isHighest)
Container(
margin: const EdgeInsets.only(top: 4),
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFF10B981),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'TOP',
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
],
),
),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
sales,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: const Color(0xFF111827),
),
),
Text(
'Revenue',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w400,
color: const Color(0xFF6B7280),
),
),
],
),
),
Expanded(
flex: 1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'$orders orders',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: const Color(0xFF374151),
),
),
Text(
'$items items',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w400,
color: const Color(0xFF6B7280),
),
),
],
),
),
],
),
);
}
}
extension SalesAnalyticItemExtension on SalesAnalyticItem {
String get formattedDate {
final dateFormat = DateFormat('dd MMM');
return dateFormat.format(date);
}
String get formattedSales {
if (sales >= 1000000) {
return 'Rp ${(sales / 1000000).toStringAsFixed(1)}JT';
} else if (sales >= 1000) {
return 'Rp ${(sales / 1000).toStringAsFixed(0)}RB';
} else {
return 'Rp ${NumberFormat('#,###').format(sales)}';
}
}
double get averageOrderValue {
return orders > 0 ? sales / orders : 0.0;
}
}
// ReportPageTitle(
// title: title,
// searchDateFormatted: searchDateFormatted,
// onExport: () async {
// try {
// final status = await PermessionHelper().checkPermission();
// if (status) {
// final pdfFile = await ItemSalesInvoice.generate(
// itemSales, searchDateFormatted);
// log("pdfFile: $pdfFile");
// await HelperPdfService.openFile(pdfFile);
// } else {
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(
// content:
// Text('Storage permission is required to save PDF'),
// backgroundColor: Colors.red,
// ),
// );
// }
// } catch (e) {
// log("Error generating PDF: $e");
// ScaffoldMessenger.of(context).showSnackBar(
// SnackBar(
// content: Text('Failed to generate PDF: $e'),
// backgroundColor: Colors.red,
// ),
// );
// }
// },
// ),