fix phone dashboard
This commit is contained in:
@@ -15,6 +15,33 @@ class _ContractsScreenState extends ConsumerState<ContractsScreen> {
|
||||
final _searchCtrl = TextEditingController();
|
||||
String _search = '';
|
||||
|
||||
List<Reservation> _visibleContracts(List<Reservation> reservations) {
|
||||
final query = _search.trim().toLowerCase();
|
||||
|
||||
return reservations.where((reservation) {
|
||||
if (reservation.contractNumber == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (query.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final customer = reservation.customer;
|
||||
final vehicle = reservation.vehicle;
|
||||
final haystack = [
|
||||
customer?.fullName,
|
||||
customer?.email,
|
||||
vehicle?.displayName,
|
||||
vehicle?.licensePlate,
|
||||
reservation.contractNumber,
|
||||
reservation.invoiceNumber,
|
||||
].whereType<String>().join(' ').toLowerCase();
|
||||
|
||||
return haystack.contains(query);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
@@ -23,12 +50,14 @@ class _ContractsScreenState extends ConsumerState<ContractsScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final params = {
|
||||
'page': 1,
|
||||
'pageSize': 100,
|
||||
'status': 'CONFIRMED,ACTIVE,COMPLETED,CLOSED',
|
||||
if (_search.isNotEmpty) 'search': _search,
|
||||
};
|
||||
final ReservationQuery params = (
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
status: null,
|
||||
source: null,
|
||||
search: null,
|
||||
customerId: null,
|
||||
);
|
||||
|
||||
final asyncData = ref.watch(reservationsProvider(params));
|
||||
|
||||
@@ -69,9 +98,7 @@ class _ContractsScreenState extends ConsumerState<ContractsScreen> {
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Error: $e')),
|
||||
data: (response) {
|
||||
final contracts = response.data
|
||||
.where((r) => r.contractNumber != null)
|
||||
.toList();
|
||||
final contracts = _visibleContracts(response.data);
|
||||
|
||||
if (contracts.isEmpty) {
|
||||
return const Center(
|
||||
|
||||
@@ -17,7 +17,14 @@ class CustomerDetailScreen extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final async = ref.watch(customerDetailProvider(id));
|
||||
final reservationsAsync = ref.watch(
|
||||
reservationsProvider({'customerId': id, 'page': 1, 'pageSize': 10}),
|
||||
reservationsProvider((
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
status: null,
|
||||
source: null,
|
||||
search: null,
|
||||
customerId: id,
|
||||
)),
|
||||
);
|
||||
|
||||
return async.when(
|
||||
|
||||
@@ -25,11 +25,11 @@ class _CustomersScreenState extends ConsumerState<CustomersScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Map<String, dynamic> get _params => {
|
||||
'page': 1,
|
||||
'pageSize': 50,
|
||||
if (_search.isNotEmpty) 'search': _search,
|
||||
};
|
||||
CustomerQuery get _params => (
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
search: _search.isNotEmpty ? _search : null,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -24,7 +24,10 @@ class DashboardShell extends ConsumerWidget {
|
||||
final employee = ref.watch(authProvider).employee;
|
||||
|
||||
int currentIndex = 0;
|
||||
if (location.startsWith('/dashboard/vehicles')) currentIndex = 1;
|
||||
if (location.startsWith('/dashboard/fleet') ||
|
||||
location.startsWith('/dashboard/vehicles')) {
|
||||
currentIndex = 1;
|
||||
}
|
||||
if (location.startsWith('/dashboard/reservations')) currentIndex = 2;
|
||||
if (location.startsWith('/dashboard/customers')) currentIndex = 3;
|
||||
|
||||
@@ -37,15 +40,14 @@ class DashboardShell extends ConsumerWidget {
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: (i) {
|
||||
switch (i) {
|
||||
case 0:
|
||||
context.go('/dashboard');
|
||||
case 1:
|
||||
context.go('/dashboard/vehicles');
|
||||
case 2:
|
||||
context.go('/dashboard/reservations');
|
||||
case 3:
|
||||
context.go('/dashboard/customers');
|
||||
if (i == 0) {
|
||||
context.go('/dashboard');
|
||||
} else if (i == 1) {
|
||||
context.go('/dashboard/fleet');
|
||||
} else if (i == 2) {
|
||||
context.go('/dashboard/reservations');
|
||||
} else if (i == 3) {
|
||||
context.go('/dashboard/customers');
|
||||
}
|
||||
},
|
||||
destinations: const [
|
||||
@@ -57,7 +59,7 @@ class DashboardShell extends ConsumerWidget {
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.directions_car_outlined),
|
||||
selectedIcon: Icon(Icons.directions_car),
|
||||
label: 'Vehicles',
|
||||
label: 'Fleet',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.event_note_outlined),
|
||||
@@ -141,10 +143,10 @@ class _Drawer extends ConsumerWidget {
|
||||
),
|
||||
_NavItem(
|
||||
icon: Icons.directions_car_outlined,
|
||||
label: 'Vehicles',
|
||||
label: 'Fleet',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.go('/dashboard/vehicles');
|
||||
context.go('/dashboard/fleet');
|
||||
},
|
||||
),
|
||||
_NavItem(
|
||||
@@ -166,7 +168,7 @@ class _Drawer extends ConsumerWidget {
|
||||
const Divider(),
|
||||
_NavItem(
|
||||
icon: Icons.public_outlined,
|
||||
label: 'Online Requests',
|
||||
label: 'Online Reservations',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.push('/dashboard/online-reservations');
|
||||
|
||||
@@ -1,31 +1,47 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../core/models/analytics.dart';
|
||||
import '../../../core/models/reservation.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../providers/dashboard_providers.dart';
|
||||
import 'dashboard_shell.dart';
|
||||
import '../../../widgets/stat_card.dart';
|
||||
import '../../../widgets/error_view.dart';
|
||||
import '../../../widgets/reservation_card.dart';
|
||||
import '../../../widgets/stat_card.dart';
|
||||
import '../providers/dashboard_providers.dart';
|
||||
import 'dashboard_shell.dart';
|
||||
|
||||
class HomeScreen extends ConsumerWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final employee = ref.watch(authProvider).employee;
|
||||
final canViewRevenue = employee?.role != 'AGENT';
|
||||
final metricsAsync = ref.watch(dashboardMetricsProvider);
|
||||
final recentAsync = ref.watch(
|
||||
reservationsProvider({'page': 1, 'pageSize': 5}),
|
||||
final fallbackRecentAsync = ref.watch(
|
||||
reservationsProvider((
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
status: null,
|
||||
source: null,
|
||||
search: null,
|
||||
customerId: null,
|
||||
)),
|
||||
);
|
||||
final onlineAsync = ref.watch(
|
||||
reservationsProvider({
|
||||
'status': 'DRAFT',
|
||||
'source': 'MARKETPLACE',
|
||||
'page': 1,
|
||||
'pageSize': 100,
|
||||
}),
|
||||
reservationsProvider((
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
status: 'DRAFT',
|
||||
source: 'MARKETPLACE',
|
||||
search: null,
|
||||
customerId: null,
|
||||
)),
|
||||
);
|
||||
final pendingOnline = onlineAsync.valueOrNull?.data.length ?? 0;
|
||||
final unreadCount = ref.watch(unreadCountProvider).valueOrNull ?? 0;
|
||||
@@ -35,13 +51,13 @@ class HomeScreen extends ConsumerWidget {
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Hello, ${employee?.firstName ?? 'there'}',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
const Text(
|
||||
'Dashboard',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
DateFormat('EEEE, MMMM d').format(DateTime.now()),
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280)),
|
||||
style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -63,97 +79,290 @@ class HomeScreen extends ConsumerWidget {
|
||||
ref.invalidate(dashboardMetricsProvider);
|
||||
ref.invalidate(reservationsProvider);
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
metricsAsync.when(
|
||||
loading: () => const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => ErrorView(
|
||||
message: 'Could not load metrics',
|
||||
child: metricsAsync.when(
|
||||
loading: () => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
SizedBox(height: 240, child: Center(child: CircularProgressIndicator())),
|
||||
],
|
||||
),
|
||||
error: (e, _) => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
ErrorView(
|
||||
message: 'Could not load dashboard',
|
||||
onRetry: () => ref.invalidate(dashboardMetricsProvider),
|
||||
),
|
||||
data: (metrics) => Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: StatCard(
|
||||
title: 'Total Vehicles',
|
||||
value: '${metrics.totalVehicles}',
|
||||
icon: Icons.directions_car,
|
||||
color: const Color(0xFF1A56DB),
|
||||
subtitle: '${metrics.availableVehicles} available',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: StatCard(
|
||||
title: 'Active Rentals',
|
||||
value: '${metrics.activeReservations}',
|
||||
icon: Icons.event_available,
|
||||
color: const Color(0xFF0E9F6E),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
data: (metrics) => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (metrics.subscription != null &&
|
||||
metrics.subscription!.status != 'ACTIVE')
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: _SubscriptionBanner(
|
||||
subscription: metrics.subscription!,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: StatCard(
|
||||
title: 'Revenue',
|
||||
value:
|
||||
'\$${NumberFormat.compact().format(metrics.totalRevenue / 100)}',
|
||||
icon: Icons.attach_money,
|
||||
color: const Color(0xFF5521B5),
|
||||
),
|
||||
_KpiGrid(
|
||||
kpis: metrics.kpis,
|
||||
canViewRevenue: canViewRevenue,
|
||||
),
|
||||
if (pendingOnline > 0) ...[
|
||||
const SizedBox(height: 16),
|
||||
InkWell(
|
||||
onTap: () => context.push('/dashboard/online-reservations'),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3CD),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFFF6B00)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.public,
|
||||
color: Color(0xFFFF6B00),
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: StatCard(
|
||||
title: 'Customers',
|
||||
value: '${metrics.totalCustomers}',
|
||||
icon: Icons.people,
|
||||
color: const Color(0xFFB45309),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$pendingOnline online reservation${pendingOnline == 1 ? '' : 's'} pending approval',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF92400E),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Icon(Icons.chevron_right, color: Color(0xFFFF6B00)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_SourceBreakdownSection(
|
||||
sourceBreakdown: metrics.sourceBreakdown,
|
||||
canViewRevenue: canViewRevenue,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_RecentReservationsSection(
|
||||
analyticsReservations: metrics.recentReservations,
|
||||
fallbackReservations: fallbackRecentAsync.valueOrNull?.data ?? const [],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KpiGrid extends StatelessWidget {
|
||||
final DashboardKpis kpis;
|
||||
final bool canViewRevenue;
|
||||
|
||||
const _KpiGrid({
|
||||
required this.kpis,
|
||||
required this.canViewRevenue,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cards = <Widget>[
|
||||
StatCard(
|
||||
title: 'Total Bookings',
|
||||
value: NumberFormat.decimalPattern().format(kpis.totalBookings),
|
||||
icon: Icons.calendar_today_outlined,
|
||||
color: const Color(0xFF1A56DB),
|
||||
change: kpis.bookingsChange,
|
||||
footerLabel: 'vs last month',
|
||||
),
|
||||
StatCard(
|
||||
title: 'Active Vehicles',
|
||||
value: NumberFormat.decimalPattern().format(kpis.activeVehicles),
|
||||
icon: Icons.directions_car_outlined,
|
||||
color: const Color(0xFF0E9F6E),
|
||||
change: kpis.vehiclesChange,
|
||||
footerLabel: 'vs last month',
|
||||
),
|
||||
if (canViewRevenue)
|
||||
StatCard(
|
||||
title: 'Monthly Revenue',
|
||||
value: _formatMoney(kpis.monthlyRevenue),
|
||||
icon: Icons.attach_money,
|
||||
color: const Color(0xFFFF6B00),
|
||||
change: kpis.revenueChange,
|
||||
footerLabel: 'vs last month',
|
||||
),
|
||||
StatCard(
|
||||
title: 'Total Customers',
|
||||
value: NumberFormat.decimalPattern().format(kpis.totalCustomers),
|
||||
icon: Icons.people_outline,
|
||||
color: const Color(0xFF7C3AED),
|
||||
change: kpis.customersChange,
|
||||
footerLabel: 'vs last month',
|
||||
),
|
||||
];
|
||||
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisExtent: 170,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: cards.length,
|
||||
itemBuilder: (_, i) => cards[i],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SourceBreakdownSection extends StatelessWidget {
|
||||
final List<DashboardSourceBreakdown> sourceBreakdown;
|
||||
final bool canViewRevenue;
|
||||
|
||||
const _SourceBreakdownSection({
|
||||
required this.sourceBreakdown,
|
||||
required this.canViewRevenue,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final maxCount = sourceBreakdown.isEmpty
|
||||
? 1
|
||||
: sourceBreakdown.map((e) => e.count).reduce(math.max);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Booking Sources',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
if (sourceBreakdown.isEmpty)
|
||||
Text(
|
||||
'No booking data yet.',
|
||||
style: TextStyle(color: cs.onSurfaceVariant),
|
||||
)
|
||||
else
|
||||
...sourceBreakdown.map(
|
||||
(item) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Fleet Occupancy',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF374151),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_sourceLabel(item.source),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${item.count} bookings',
|
||||
style: TextStyle(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(
|
||||
value: metrics.occupancyRate / 100,
|
||||
backgroundColor: const Color(0xFFE5E7EB),
|
||||
value: maxCount == 0 ? 0 : item.count / maxCount,
|
||||
minHeight: 8,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
Color(0xFF1A56DB),
|
||||
),
|
||||
minHeight: 8,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${metrics.occupancyRate.toStringAsFixed(1)}% occupied',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF6B7280),
|
||||
if (canViewRevenue) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_formatMoney(item.revenue),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (sourceBreakdown.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Quick Stats',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...sourceBreakdown.map(
|
||||
(item) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_sourceLabel(item.source),
|
||||
style: TextStyle(color: cs.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${item.count}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
if (canViewRevenue) ...[
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
_formatMoney(item.revenue),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -161,54 +370,41 @@ class HomeScreen extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (pendingOnline > 0) ...[
|
||||
const SizedBox(height: 16),
|
||||
InkWell(
|
||||
onTap: () => context.push('/dashboard/online-reservations'),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3CD),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFFF6B00)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.public,
|
||||
color: Color(0xFFFF6B00),
|
||||
size: 22,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$pendingOnline online request${pendingOnline == 1 ? '' : 's'} pending approval',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF92400E),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: Color(0xFFFF6B00)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentReservationsSection extends StatelessWidget {
|
||||
final List<DashboardRecentReservation> analyticsReservations;
|
||||
final List<Reservation> fallbackReservations;
|
||||
|
||||
const _RecentReservationsSection({
|
||||
required this.analyticsReservations,
|
||||
required this.fallbackReservations,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
Text(
|
||||
'Recent Reservations',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF111928),
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
@@ -218,37 +414,31 @@ class HomeScreen extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
recentAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => const ErrorView(
|
||||
message: 'Could not load recent reservations',
|
||||
if (analyticsReservations.isNotEmpty)
|
||||
...analyticsReservations.map(
|
||||
(reservation) => _DashboardRecentReservationCard(
|
||||
reservation: reservation,
|
||||
),
|
||||
)
|
||||
else if (fallbackReservations.isNotEmpty)
|
||||
...fallbackReservations.map(
|
||||
(reservation) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: ReservationCard(
|
||||
reservation: reservation,
|
||||
onTap: () =>
|
||||
context.push('/dashboard/reservations/${reservation.id}'),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
'No reservations yet',
|
||||
style: TextStyle(color: cs.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
data: (res) => res.data.isEmpty
|
||||
? const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'No reservations yet',
|
||||
style: TextStyle(color: Color(0xFF9CA3AF)),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: res.data
|
||||
.map(
|
||||
(r) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: ReservationCard(
|
||||
reservation: r,
|
||||
onTap: () => context.push(
|
||||
'/dashboard/reservations/${r.id}',
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -256,6 +446,206 @@ class HomeScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _DashboardRecentReservationCard extends StatelessWidget {
|
||||
final DashboardRecentReservation reservation;
|
||||
|
||||
const _DashboardRecentReservationCard({required this.reservation});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final dateFmt = DateFormat('MMM d');
|
||||
|
||||
return InkWell(
|
||||
onTap: () => context.push('/dashboard/reservations/${reservation.id}'),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: cs.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'#${reservation.bookingRef}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF1A56DB),
|
||||
),
|
||||
),
|
||||
),
|
||||
_StatusPill(status: reservation.status),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
reservation.customerName,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
reservation.vehicleName,
|
||||
style: TextStyle(color: cs.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${dateFmt.format(reservation.startDate)} - ${DateFormat('MMM d, yyyy').format(reservation.endDate)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatMoney(reservation.totalAmount),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SubscriptionBanner extends StatelessWidget {
|
||||
final DashboardSubscription subscription;
|
||||
|
||||
const _SubscriptionBanner({required this.subscription});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final trialEnds = subscription.trialEndsAt;
|
||||
late final Color borderColor;
|
||||
late final Color backgroundColor;
|
||||
late final Color foregroundColor;
|
||||
late final IconData icon;
|
||||
late final String message;
|
||||
|
||||
switch (subscription.status) {
|
||||
case 'TRIALING':
|
||||
borderColor = const Color(0xFF93C5FD);
|
||||
backgroundColor = const Color(0xFFEFF6FF);
|
||||
foregroundColor = const Color(0xFF1D4ED8);
|
||||
icon = Icons.schedule_rounded;
|
||||
message = trialEnds == null
|
||||
? 'Trial active'
|
||||
: 'Trial ends ${DateFormat('MMM d, yyyy').format(trialEnds)}';
|
||||
break;
|
||||
case 'PAST_DUE':
|
||||
borderColor = const Color(0xFFFBBF24);
|
||||
backgroundColor = const Color(0xFFFFFBEB);
|
||||
foregroundColor = const Color(0xFFB45309);
|
||||
icon = Icons.warning_amber_rounded;
|
||||
message = 'Subscription payment is past due';
|
||||
break;
|
||||
default:
|
||||
borderColor = const Color(0xFFFCA5A5);
|
||||
backgroundColor = const Color(0xFFFEF2F2);
|
||||
foregroundColor = const Color(0xFFB91C1C);
|
||||
icon = Icons.block_rounded;
|
||||
message = 'Subscription is suspended';
|
||||
break;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: borderColor),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: foregroundColor),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: foregroundColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusPill extends StatelessWidget {
|
||||
final String status;
|
||||
|
||||
const _StatusPill({required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final normalized = status.toUpperCase();
|
||||
late final Color bg;
|
||||
late final Color fg;
|
||||
|
||||
switch (normalized) {
|
||||
case 'CONFIRMED':
|
||||
bg = const Color(0xFFDBEAFE);
|
||||
fg = const Color(0xFF1D4ED8);
|
||||
break;
|
||||
case 'PENDING':
|
||||
case 'DRAFT':
|
||||
bg = const Color(0xFFFEF3C7);
|
||||
fg = const Color(0xFFB45309);
|
||||
break;
|
||||
case 'ACTIVE':
|
||||
bg = const Color(0xFFDCFCE7);
|
||||
fg = const Color(0xFF15803D);
|
||||
break;
|
||||
case 'CANCELLED':
|
||||
bg = const Color(0xFFFEE2E2);
|
||||
fg = const Color(0xFFB91C1C);
|
||||
break;
|
||||
default:
|
||||
bg = const Color(0xFFE5E7EB);
|
||||
fg = const Color(0xFF374151);
|
||||
break;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
normalized,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: fg,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AvatarMenu extends ConsumerWidget {
|
||||
final dynamic employee;
|
||||
|
||||
@@ -263,6 +653,7 @@ class _AvatarMenu extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final initial = employee?.firstName.substring(0, 1).toUpperCase() ?? 'U';
|
||||
final name = employee?.fullName ?? 'Employee';
|
||||
final role = employee?.role ?? '';
|
||||
@@ -309,18 +700,18 @@ class _AvatarMenu extends ConsumerWidget {
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF111928),
|
||||
color: cs.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
if (role.isNotEmpty)
|
||||
Text(
|
||||
role,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF6B7280),
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@@ -355,3 +746,24 @@ class _AvatarMenu extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatMoney(double amount) {
|
||||
return NumberFormat.currency(
|
||||
name: 'MAD',
|
||||
symbol: 'MAD ',
|
||||
decimalDigits: 2,
|
||||
).format(amount / 100);
|
||||
}
|
||||
|
||||
String _sourceLabel(String source) {
|
||||
switch (source.toUpperCase()) {
|
||||
case 'MARKETPLACE':
|
||||
return 'Marketplace';
|
||||
case 'WALK_IN':
|
||||
return 'Walk-in';
|
||||
case 'DIRECT':
|
||||
return 'Direct';
|
||||
default:
|
||||
return source.replaceAll('_', ' ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ import '../../../core/models/reservation.dart';
|
||||
import '../../../widgets/error_view.dart';
|
||||
import '../../../widgets/status_badge.dart';
|
||||
|
||||
const _params = {
|
||||
'status': 'DRAFT',
|
||||
'source': 'MARKETPLACE',
|
||||
'page': 1,
|
||||
'pageSize': 100,
|
||||
};
|
||||
const ReservationQuery _params = (
|
||||
status: 'DRAFT',
|
||||
source: 'MARKETPLACE',
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
search: null,
|
||||
customerId: null,
|
||||
);
|
||||
|
||||
class OnlineReservationsScreen extends ConsumerWidget {
|
||||
const OnlineReservationsScreen({super.key});
|
||||
|
||||
@@ -16,12 +16,12 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
DateTime _end = DateTime.now();
|
||||
String? _statusFilter;
|
||||
|
||||
Map<String, String> get _params => {
|
||||
'startDate':
|
||||
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,
|
||||
};
|
||||
endDate: '${_end.toIso8601String().substring(0, 10)}T23:59:59.000Z',
|
||||
status: _statusFilter,
|
||||
);
|
||||
|
||||
Future<void> _pickRange() async {
|
||||
final range = await showDateRangePicker(
|
||||
|
||||
@@ -52,12 +52,14 @@ class _ReservationsScreenState extends ConsumerState<ReservationsScreen>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _params(int tabIndex) => {
|
||||
'page': 1,
|
||||
'pageSize': 50,
|
||||
if (_statuses[tabIndex] != null) 'status': _statuses[tabIndex],
|
||||
if (_search.isNotEmpty) 'search': _search,
|
||||
};
|
||||
ReservationQuery _params(int tabIndex) => (
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
status: _statuses[tabIndex],
|
||||
source: null,
|
||||
search: _search.isNotEmpty ? _search : null,
|
||||
customerId: null,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -116,7 +118,7 @@ class _ReservationsScreenState extends ConsumerState<ReservationsScreen>
|
||||
}
|
||||
|
||||
class _ReservationTab extends ConsumerWidget {
|
||||
final Map<String, dynamic> params;
|
||||
final ReservationQuery params;
|
||||
final void Function(String id) onTap;
|
||||
|
||||
const _ReservationTab({required this.params, required this.onTap});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../../core/models/vehicle.dart';
|
||||
import '../providers/dashboard_providers.dart';
|
||||
import '../../../widgets/vehicle_card.dart';
|
||||
import '../../../widgets/loading_list.dart';
|
||||
@@ -26,12 +28,45 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Map<String, dynamic> get _params => {
|
||||
'page': 1,
|
||||
'pageSize': 50,
|
||||
if (_statusFilter != null) 'status': _statusFilter,
|
||||
if (_search.isNotEmpty) 'search': _search,
|
||||
};
|
||||
VehicleQuery get _params => (
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
status: null,
|
||||
);
|
||||
|
||||
List<Vehicle> _filterVehicles(List<Vehicle> vehicles) {
|
||||
final query = _search.trim().toLowerCase();
|
||||
final status = _statusFilter;
|
||||
|
||||
return vehicles.where((vehicle) {
|
||||
if (status != null && vehicle.status != status) {
|
||||
return false;
|
||||
}
|
||||
if (query.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
final haystack = [
|
||||
vehicle.displayName,
|
||||
vehicle.licensePlate,
|
||||
vehicle.status,
|
||||
vehicle.category,
|
||||
].join(' ').toLowerCase();
|
||||
return haystack.contains(query);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
String _errorMessage(Object error) {
|
||||
if (error is DioException) {
|
||||
final data = error.response?.data;
|
||||
if (data is Map<String, dynamic>) {
|
||||
final message = data['message'];
|
||||
if (message is String && message.isNotEmpty) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'Failed to load vehicles';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -48,7 +83,7 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
appBar: AppBar(
|
||||
title: const Text('Vehicles'),
|
||||
title: const Text('Fleet'),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(60),
|
||||
child: Padding(
|
||||
@@ -86,34 +121,40 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
|
||||
body: vehiclesAsync.when(
|
||||
loading: () => const LoadingList(itemHeight: 240),
|
||||
error: (e, _) => ErrorView(
|
||||
message: 'Failed to load vehicles',
|
||||
message: _errorMessage(e),
|
||||
onRetry: () => ref.invalidate(vehiclesProvider(_params)),
|
||||
),
|
||||
data: (res) => res.data.isEmpty
|
||||
? const Center(
|
||||
data: (res) {
|
||||
final filteredVehicles = _filterVehicles(res.data);
|
||||
|
||||
return filteredVehicles.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'No vehicles found',
|
||||
style: TextStyle(color: Color(0xFF9CA3AF)),
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async =>
|
||||
ref.invalidate(vehiclesProvider(_params)),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 1,
|
||||
mainAxisExtent: 240,
|
||||
mainAxisSpacing: 12,
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async =>
|
||||
ref.invalidate(vehiclesProvider(_params)),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 1,
|
||||
mainAxisExtent: 240,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: filteredVehicles.length,
|
||||
itemBuilder: (_, i) => VehicleCard(
|
||||
vehicle: filteredVehicles[i],
|
||||
onTap: () => context.push(
|
||||
'/dashboard/fleet/${filteredVehicles[i].id}',
|
||||
),
|
||||
),
|
||||
),
|
||||
itemCount: res.data.length,
|
||||
itemBuilder: (_, i) => VehicleCard(
|
||||
vehicle: res.data[i],
|
||||
onTap: () =>
|
||||
context.push('/dashboard/vehicles/${res.data[i].id}'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user