297 lines
10 KiB
Dart
297 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:intl/intl.dart';
|
||
import '../providers/dashboard_providers.dart';
|
||
import '../../../widgets/error_view.dart';
|
||
|
||
class ReportsScreen extends ConsumerStatefulWidget {
|
||
const ReportsScreen({super.key});
|
||
|
||
@override
|
||
ConsumerState<ReportsScreen> createState() => _ReportsScreenState();
|
||
}
|
||
|
||
class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||
DateTime _start = DateTime.now().subtract(const Duration(days: 30));
|
||
DateTime _end = DateTime.now();
|
||
String? _statusFilter;
|
||
|
||
ReportQuery get _params => (
|
||
startDate:
|
||
'${_start.toIso8601String().substring(0, 10)}T00:00:00.000Z',
|
||
endDate: '${_end.toIso8601String().substring(0, 10)}T23:59:59.000Z',
|
||
status: _statusFilter,
|
||
);
|
||
|
||
Future<void> _pickRange() async {
|
||
final range = await showDateRangePicker(
|
||
context: context,
|
||
firstDate: DateTime(2020),
|
||
lastDate: DateTime.now(),
|
||
initialDateRange: DateTimeRange(start: _start, end: _end),
|
||
);
|
||
if (range != null) {
|
||
setState(() {
|
||
_start = range.start;
|
||
_end = range.end;
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final fmt = DateFormat('MMM d, yyyy');
|
||
final fmtMoney = NumberFormat.currency(symbol: '\$', decimalDigits: 2);
|
||
final reportAsync = ref.watch(reportProvider(_params));
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('Reports'),
|
||
actions: [
|
||
PopupMenuButton<String?>(
|
||
icon: Icon(
|
||
Icons.filter_list,
|
||
color: _statusFilter != null ? const Color(0xFF1A56DB) : null,
|
||
),
|
||
onSelected: (v) => setState(() => _statusFilter = v),
|
||
itemBuilder: (_) => const [
|
||
PopupMenuItem(value: null, child: Text('All statuses')),
|
||
PopupMenuItem(value: 'CONFIRMED', child: Text('Confirmed')),
|
||
PopupMenuItem(value: 'ACTIVE', child: Text('Active')),
|
||
PopupMenuItem(value: 'CLOSED', child: Text('Closed')),
|
||
PopupMenuItem(value: 'CANCELLED', child: Text('Cancelled')),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
body: Column(
|
||
children: [
|
||
_DateRangeBar(
|
||
start: _start,
|
||
end: _end,
|
||
fmt: fmt,
|
||
onTap: _pickRange,
|
||
),
|
||
Expanded(
|
||
child: reportAsync.when(
|
||
loading: () =>
|
||
const Center(child: CircularProgressIndicator()),
|
||
error: (e, _) => ErrorView(
|
||
message: 'Failed to load report',
|
||
onRetry: () => ref.invalidate(reportProvider(_params)),
|
||
),
|
||
data: (report) => CustomScrollView(
|
||
slivers: [
|
||
SliverPadding(
|
||
padding: const EdgeInsets.all(16),
|
||
sliver: SliverToBoxAdapter(
|
||
child: Column(
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _MetricCard(
|
||
label: 'Reservations',
|
||
value:
|
||
report.totalReservations.toString(),
|
||
icon: Icons.event_note,
|
||
color: const Color(0xFF1A56DB),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: _MetricCard(
|
||
label: 'Revenue',
|
||
value: fmtMoney
|
||
.format(report.rentalRevenue / 100),
|
||
icon: Icons.attach_money,
|
||
color: const Color(0xFF0E9F6E),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _MetricCard(
|
||
label: 'Collected',
|
||
value: fmtMoney
|
||
.format(report.totalPaid / 100),
|
||
icon: Icons.check_circle_outline,
|
||
color: const Color(0xFF0E9F6E),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: _MetricCard(
|
||
label: 'Outstanding',
|
||
value: fmtMoney
|
||
.format(report.totalOutstanding / 100),
|
||
icon: Icons.pending_outlined,
|
||
color: const Color(0xFFE02424),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
if (report.items.isNotEmpty) ...[
|
||
const SliverPadding(
|
||
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||
sliver: SliverToBoxAdapter(
|
||
child: Text(
|
||
'Reservations',
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 15,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
SliverPadding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
sliver: SliverList.separated(
|
||
itemCount: report.items.length,
|
||
separatorBuilder: (_, _) =>
|
||
const Divider(height: 1),
|
||
itemBuilder: (_, i) {
|
||
final item = report.items[i];
|
||
return ListTile(
|
||
contentPadding: EdgeInsets.zero,
|
||
title: Text(
|
||
item.customerName ?? 'Walk-in',
|
||
style: const TextStyle(
|
||
fontWeight: FontWeight.w500),
|
||
),
|
||
subtitle: Text(
|
||
'${item.vehicleName ?? 'Vehicle'} • ${fmt.format(item.startDate)} – ${fmt.format(item.endDate)}',
|
||
style: const TextStyle(fontSize: 12),
|
||
),
|
||
trailing: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Text(
|
||
fmtMoney.format(item.totalAmount / 100),
|
||
style: const TextStyle(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
Text(
|
||
item.paymentStatus,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: item.paymentStatus == 'PAID'
|
||
? const Color(0xFF0E9F6E)
|
||
: const Color(0xFF9CA3AF),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DateRangeBar extends StatelessWidget {
|
||
final DateTime start;
|
||
final DateTime end;
|
||
final DateFormat fmt;
|
||
final VoidCallback onTap;
|
||
|
||
const _DateRangeBar({
|
||
required this.start,
|
||
required this.end,
|
||
required this.fmt,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return InkWell(
|
||
onTap: onTap,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||
decoration: const BoxDecoration(
|
||
color: Color(0xFFF9FAFB),
|
||
border: Border(bottom: BorderSide(color: Color(0xFFE5E7EB))),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
const Icon(Icons.date_range_outlined,
|
||
size: 18, color: Color(0xFF6B7280)),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
'${fmt.format(start)} → ${fmt.format(end)}',
|
||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||
),
|
||
const Spacer(),
|
||
const Text('Change',
|
||
style: TextStyle(
|
||
color: Color(0xFF1A56DB), fontSize: 13)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _MetricCard extends StatelessWidget {
|
||
final String label;
|
||
final String value;
|
||
final IconData icon;
|
||
final Color color;
|
||
|
||
const _MetricCard({
|
||
required this.label,
|
||
required this.value,
|
||
required this.icon,
|
||
required this.color,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Icon(icon, size: 20, color: color),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
value,
|
||
style: TextStyle(
|
||
fontSize: 20,
|
||
fontWeight: FontWeight.bold,
|
||
color: color,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
label,
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
color: Color(0xFF6B7280),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|