Passenger apss UI and UX updates

This commit is contained in:
Stephanos A
2026-06-04 08:19:36 +03:00
parent 8a9cc8afff
commit d9ae1c7f76
34 changed files with 1071 additions and 113 deletions

View File

@@ -2,12 +2,14 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Filter, Download, Eye, XCircle } from 'lucide-react';
import { Filter, Download, Eye, XCircle, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import Pagination from '@/components/ui/Pagination';
import ActionButton from '@/components/ui/ActionButton';
import { bookingsApi } from '@/lib/api';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { bookingsApi, apiClient } from '@/lib/api';
import { formatCurrency, formatDateTime } from '@/lib/utils';
import { BookingFilters } from '@/types';
@@ -18,6 +20,10 @@ export default function BookingsPage() {
search: '',
status: '',
});
const [selectedBooking, setSelectedBooking] = useState<any>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
const [successMessage, setSuccessMessage] = useState('');
const queryClient = useQueryClient();
@@ -34,16 +40,46 @@ export default function BookingsPage() {
mutationFn: ({ id, reason }: { id: string; reason?: string }) => bookingsApi.cancel(id, reason),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bookings'] });
alert('Booking cancelled successfully');
setSuccessMessage('Booking cancelled successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
alert(`Error: ${error.message || 'Failed to cancel booking'}`);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/bookings/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bookings'] });
setDeleteConfirmOpen(false);
setBookingToDelete(null);
setSuccessMessage('Booking deleted successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
setDeleteConfirmOpen(false);
alert(`Error: ${error.message || 'Failed to delete booking'}`);
},
});
const handleCancel = async (booking: any) => {
if (confirm(`Are you sure you want to cancel booking ${booking.bookingRef}?`)) {
if (window.confirm(`Are you sure you want to cancel booking ${booking.bookingRef}? This will process a refund.`)) {
await cancelMutation.mutateAsync({ id: booking.id, reason: 'Cancelled by admin' });
}
};
const handleDeleteClick = (booking: any) => {
setBookingToDelete(booking);
setDeleteConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (bookingToDelete) {
await deleteMutation.mutateAsync(bookingToDelete.id);
}
};
const columns = [
{
key: 'bookingRef',
@@ -94,13 +130,12 @@ export default function BookingsPage() {
];
const actions = [
// TODO: Create booking detail page
// {
// label: 'View Details',
// onClick: (booking: any) => window.location.href = `/bookings/${booking.id}`,
// variant: 'secondary' as const,
// icon: Eye,
// },
{
label: 'View Details',
onClick: (booking: any) => setSelectedBooking(booking),
variant: 'secondary' as const,
icon: Eye,
},
{
label: 'Cancel Booking',
onClick: handleCancel,
@@ -108,6 +143,12 @@ export default function BookingsPage() {
icon: XCircle,
show: (booking: any) => booking.status !== 'CANCELLED' && booking.status !== 'COMPLETED',
},
{
label: 'Delete',
onClick: handleDeleteClick,
variant: 'danger' as const,
icon: Trash2,
},
];
return (
@@ -121,6 +162,11 @@ export default function BookingsPage() {
</div>
<div className="card">
{successMessage && (
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">
{successMessage}
</div>
)}
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
Error loading bookings: {error instanceof Error ? error.message : 'Unknown error'}
@@ -166,6 +212,163 @@ export default function BookingsPage() {
/>
)}
</div>
{/* Booking Details Modal */}
<Modal
isOpen={!!selectedBooking}
onClose={() => setSelectedBooking(null)}
title="Booking Details"
size="xl"
>
{selectedBooking && (
<div className="space-y-6">
{/* Booking Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Booking Reference</label>
<p className="text-lg font-semibold font-mono">{selectedBooking.bookingRef}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Status</label>
<div className="mt-1">
<Badge variant="status" status={selectedBooking.status}>
{selectedBooking.status}
</Badge>
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Booking Type</label>
<p className="text-lg font-semibold">{selectedBooking.bookingType || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Created</label>
<p className="text-lg font-semibold">{formatDateTime(selectedBooking.createdAt)}</p>
</div>
</div>
<hr className="border-muted" />
{/* Passenger Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Passenger Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Name</label>
<p className="text-lg font-semibold">{selectedBooking.passenger?.fullName || selectedBooking.contactEmail || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Email</label>
<p className="text-lg font-semibold">{selectedBooking.contactEmail || selectedBooking.passenger?.email || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Phone</label>
<p className="text-lg font-semibold">{selectedBooking.contactPhone || selectedBooking.passenger?.phone || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Passenger ID</label>
<p className="text-sm font-mono">{selectedBooking.passengerId || 'N/A'}</p>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Booking Details */}
<div>
<h3 className="text-lg font-semibold mb-3">Journey Details</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Adults</label>
<p className="text-lg font-semibold">{selectedBooking.adultCount || 0}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Children</label>
<p className="text-lg font-semibold">{selectedBooking.childCount || 0}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Schedule ID</label>
<p className="text-sm font-mono">{selectedBooking.scheduleId || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Promo Code</label>
<p className="text-lg font-semibold">{selectedBooking.promoCode || 'None'}</p>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Payment Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Payment Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Amount</label>
<p className="text-lg font-semibold">{formatCurrency(selectedBooking.totalMinor, selectedBooking.currency)}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Payment Status</label>
<div className="mt-1">
<Badge variant="status" status={selectedBooking.paymentIntent?.status || 'PENDING'}>
{selectedBooking.paymentIntent?.status || 'PENDING'}
</Badge>
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Paid At</label>
<p className="text-lg font-semibold">{selectedBooking.paidAt ? formatDateTime(selectedBooking.paidAt) : 'Not paid'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Display Currency</label>
<p className="text-lg font-semibold">{selectedBooking.displayCurrency || selectedBooking.currency}</p>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Additional Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Additional Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Source</label>
<p className="text-lg font-semibold">{selectedBooking.source || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Last Updated</label>
<p className="text-lg font-semibold">{formatDateTime(selectedBooking.updatedAt)}</p>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
variant="secondary"
onClick={() => setSelectedBooking(null)}
>
Close
</ActionButton>
</div>
</div>
)}
</Modal>
{/* Delete Confirmation Dialog */}
<ConfirmDialog
isOpen={deleteConfirmOpen}
onClose={() => {
setDeleteConfirmOpen(false);
setBookingToDelete(null);
}}
onConfirm={handleConfirmDelete}
title="Delete Booking"
message={`Are you sure you want to permanently delete booking ${bookingToDelete?.bookingRef}? This action cannot be undone and will release all associated seats.`}
confirmText="Delete"
cancelText="Cancel"
isLoading={deleteMutation.isPending}
isDanger={true}
/>
</div>
);
}

View File

@@ -6,12 +6,14 @@ import { fleetApi } from '@/lib/api';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { Plus, Search, Grid3x3, Train, Edit, Trash2 } from 'lucide-react';
export default function CoachesPage() {
const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingCoach, setEditingCoach] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; coach: any | null }>({ isOpen: false, coach: null });
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
@@ -65,9 +67,14 @@ export default function CoachesPage() {
}
};
const handleDelete = async (coach: any) => {
if (confirm(`Are you sure you want to delete coach ${coach.coachNumber}?`)) {
await deleteMutation.mutateAsync(coach.id);
const handleDelete = (coach: any) => {
setDeleteConfirm({ isOpen: true, coach });
};
const confirmDelete = async () => {
if (deleteConfirm.coach) {
await deleteMutation.mutateAsync(deleteConfirm.coach.id);
setDeleteConfirm({ isOpen: false, coach: null });
}
};
@@ -197,6 +204,18 @@ export default function CoachesPage() {
/>
</div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, coach: null })}
onConfirm={confirmDelete}
title="Delete Coach"
message={`Are you sure you want to delete coach ${deleteConfirm.coach?.coachNumber}?`}
confirmText="Delete"
isDanger={true}
warning="This coach may be assigned to schedules and trips. Deleting it may impact these systems."
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}

View File

@@ -93,7 +93,7 @@ export default function LoginPage() {
disabled={loading}
className="btn btn-primary w-full disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign In'}
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>

View File

@@ -1,14 +1,16 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { UserPlus, Download, Eye } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Eye, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import Pagination from '@/components/ui/Pagination';
import ActionButton from '@/components/ui/ActionButton';
import { passengersApi } from '@/lib/api';
import { formatDate } from '@/lib/utils';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { passengersApi, apiClient } from '@/lib/api';
import { formatDate, formatDateTime } from '@/lib/utils';
import { PassengerFilters } from '@/types';
export default function PassengersPage() {
@@ -17,6 +19,28 @@ export default function PassengersPage() {
pageSize: 20,
search: '',
});
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/passengers/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['passengers'] });
},
});
const handleDelete = (passenger: any) => {
setDeleteConfirm({ isOpen: true, passenger });
};
const confirmDelete = async () => {
if (deleteConfirm.passenger) {
await deleteMutation.mutateAsync(deleteConfirm.passenger.id);
setDeleteConfirm({ isOpen: false, passenger: null });
}
};
const { data, isLoading, error } = useQuery({
queryKey: ['passengers', filters],
@@ -65,14 +89,19 @@ export default function PassengersPage() {
},
];
const actions: any[] = [
// TODO: Create passenger detail page
// {
// label: 'View Details',
// onClick: (passenger: any) => window.location.href = `/passengers/${passenger.id}`,
// variant: 'secondary' as const,
// icon: Eye,
// },
const actions = [
{
label: 'View Details',
onClick: (passenger: any) => setSelectedPassenger(passenger),
variant: 'secondary' as const,
icon: Eye,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
];
return (
@@ -130,6 +159,184 @@ export default function PassengersPage() {
/>
)}
</div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, passenger: null })}
onConfirm={confirmDelete}
title="Delete Passenger"
message={`Are you sure you want to delete ${deleteConfirm.passenger?.fullName}?`}
confirmText="Delete"
isDanger={true}
warning="This passenger may have active bookings, loyalty points, and wallet balance. Deleting will impact these systems and records."
/>
{/* Passenger Details Modal */}
<Modal
isOpen={!!selectedPassenger}
onClose={() => setSelectedPassenger(null)}
title="Passenger Details"
size="xl"
>
{selectedPassenger && (
<div className="space-y-6">
{/* Personal Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Personal Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Full Name</label>
<p className="text-lg font-semibold">{selectedPassenger.fullName}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Date of Birth</label>
<p className="text-lg font-semibold">
{selectedPassenger.dateOfBirth ? formatDate(selectedPassenger.dateOfBirth) : 'N/A'}
</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Gender</label>
<p className="text-lg font-semibold">{selectedPassenger.gender || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Nationality</label>
<p className="text-lg font-semibold">{selectedPassenger.nationality || 'N/A'}</p>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Contact Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Contact Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Email</label>
<p className="text-lg font-semibold">{selectedPassenger.email || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Phone</label>
<p className="text-lg font-semibold">{selectedPassenger.phone || 'N/A'}</p>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Identification */}
<div>
<h3 className="text-lg font-semibold mb-3">Identification</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">National ID</label>
<p className="text-lg font-mono font-semibold">{selectedPassenger.nationalId || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Passport Number</label>
<p className="text-lg font-mono font-semibold">{selectedPassenger.passportNumber || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Passport Country</label>
<p className="text-lg font-semibold">{selectedPassenger.passportCountry || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Verification Status</label>
<div className="mt-1">
<Badge
variant="status"
status={selectedPassenger.nationalId ? 'CONFIRMED' : 'PENDING'}
>
{selectedPassenger.nationalId ? 'Verified' : 'Unverified'}
</Badge>
</div>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Account Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Account Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Passenger ID</label>
<p className="text-sm font-mono">{selectedPassenger.id}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">User ID</label>
<p className="text-sm font-mono">{selectedPassenger.userId || 'N/A'}</p>
</div>
</div>
</div>
{/* Loyalty & Wallet (if available) */}
{(selectedPassenger.loyalty || selectedPassenger.wallet) && (
<>
<hr className="border-muted" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{selectedPassenger.loyalty && (
<div>
<h3 className="text-lg font-semibold mb-2">Loyalty Account</h3>
<div className="space-y-2">
<div>
<label className="text-sm font-medium text-muted-foreground">Tier</label>
<p className="text-lg font-semibold">{selectedPassenger.loyalty.tier || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Points Balance</label>
<p className="text-lg font-semibold">{selectedPassenger.loyalty.pointsBalance || 0}</p>
</div>
</div>
</div>
)}
{selectedPassenger.wallet && (
<div>
<h3 className="text-lg font-semibold mb-2">Wallet</h3>
<div className="space-y-2">
<div>
<label className="text-sm font-medium text-muted-foreground">Balance</label>
<p className="text-lg font-semibold">
{(selectedPassenger.wallet.balanceMinor / 100).toFixed(2)} {selectedPassenger.wallet.currency}
</p>
</div>
</div>
</div>
)}
</div>
</>
)}
<hr className="border-muted" />
{/* Timestamps */}
<div>
<h3 className="text-lg font-semibold mb-3">Timestamps</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Created</label>
<p className="text-sm">{selectedPassenger.createdAt ? formatDateTime(selectedPassenger.createdAt) : 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Last Updated</label>
<p className="text-sm">{selectedPassenger.updatedAt ? formatDateTime(selectedPassenger.updatedAt) : 'N/A'}</p>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
variant="secondary"
onClick={() => setSelectedPassenger(null)}
>
Close
</ActionButton>
</div>
</div>
)}
</Modal>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { routesApi } from '@/lib/api/routes';
import { stationsApi } from '@/lib/api';
@@ -24,6 +25,7 @@ export default function RoutesPage() {
const [originStationId, setOriginStationId] = useState('');
const [destinationStationId, setDestinationStationId] = useState('');
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null }>({ isOpen: false, route: null });
const queryClient = useQueryClient();
const { data: routes, isLoading: routesLoading } = useQuery({
@@ -148,9 +150,14 @@ export default function RoutesPage() {
return origin && dest ? `${origin.name} - ${dest.name}` : '';
};
const handleDelete = async (route: any) => {
if (confirm(`Are you sure you want to delete ${route.name}?`)) {
await deleteMutation.mutateAsync(route.id);
const handleDelete = (route: any) => {
setDeleteConfirm({ isOpen: true, route });
};
const confirmDelete = async () => {
if (deleteConfirm.route) {
await deleteMutation.mutateAsync(deleteConfirm.route.id);
setDeleteConfirm({ isOpen: false, route: null });
}
};
@@ -246,6 +253,18 @@ export default function RoutesPage() {
emptyMessage="No routes found"
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, route: null })}
onConfirm={confirmDelete}
title="Delete Route"
message={`Are you sure you want to delete ${deleteConfirm.route?.name}?`}
confirmText="Delete"
isDanger={true}
warning="This route may be referenced by schedules and bookings. Deleting it may impact these systems."
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
@@ -261,6 +280,12 @@ export default function RoutesPage() {
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
{editingRoute && (
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-3 text-sm text-yellow-800 dark:text-yellow-200">
<p className="font-semibold"> Warning</p>
<p className="mt-1">Editing this route may impact schedules, trips, and bookings that reference it. Proceed with caution.</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Origin Station *</label>

View File

@@ -7,6 +7,7 @@ import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { seatClassesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
@@ -14,6 +15,7 @@ export default function SeatClassesPage() {
const [filters, setFilters] = useState({ search: '' });
const [showModal, setShowModal] = useState(false);
const [editingSeatClass, setEditingSeatClass] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; seatClass: any | null }>({ isOpen: false, seatClass: null });
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
@@ -63,9 +65,14 @@ export default function SeatClassesPage() {
}
};
const handleDelete = async (seatClass: any) => {
if (confirm(`Are you sure you want to delete ${seatClass.name}?`)) {
await deleteMutation.mutateAsync(seatClass.id);
const handleDelete = (seatClass: any) => {
setDeleteConfirm({ isOpen: true, seatClass });
};
const confirmDelete = async () => {
if (deleteConfirm.seatClass) {
await deleteMutation.mutateAsync(deleteConfirm.seatClass.id);
setDeleteConfirm({ isOpen: false, seatClass: null });
}
};
@@ -98,7 +105,7 @@ export default function SeatClassesPage() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Seat Classes</h1>
<h1 className="text-2xl font-bold text-foreground">Classes</h1>
<p className="text-muted-foreground">Manage seat class configurations</p>
</div>
<div className="flex gap-2">
@@ -132,6 +139,18 @@ export default function SeatClassesPage() {
emptyMessage="No seat classes found"
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, seatClass: null })}
onConfirm={confirmDelete}
title="Delete Seat Class"
message={`Are you sure you want to delete ${deleteConfirm.seatClass?.name}?`}
confirmText="Delete"
isDanger={true}
warning="This seat class may be used by coaches and trips. Deleting it may impact fare calculations and seat assignments."
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}

View File

@@ -7,6 +7,7 @@ import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { stationsApi } from '@/lib/api';
import { Station } from '@/types';
@@ -14,6 +15,7 @@ export default function StationsPage() {
const [filters, setFilters] = useState({ search: '', country: '', operational: '' });
const [showModal, setShowModal] = useState(false);
const [editingStation, setEditingStation] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; station: any | null }>({ isOpen: false, station: null });
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
@@ -67,9 +69,14 @@ export default function StationsPage() {
}
};
const handleDelete = async (station: any) => {
if (confirm(`Are you sure you want to delete ${station.name}?`)) {
await deleteMutation.mutateAsync(station.id);
const handleDelete = (station: any) => {
setDeleteConfirm({ isOpen: true, station });
};
const confirmDelete = async () => {
if (deleteConfirm.station) {
await deleteMutation.mutateAsync(deleteConfirm.station.id);
setDeleteConfirm({ isOpen: false, station: null });
}
};
@@ -223,6 +230,18 @@ export default function StationsPage() {
emptyMessage="No stations found"
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, station: null })}
onConfirm={confirmDelete}
title="Delete Station"
message={`Are you sure you want to delete ${deleteConfirm.station?.name}?`}
confirmText="Delete"
isDanger={true}
warning="This station may be referenced by routes, schedules, and bookings. Deleting it may impact these systems."
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
@@ -234,6 +253,12 @@ export default function StationsPage() {
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
{editingStation && (
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-3 text-sm text-yellow-800 dark:text-yellow-200">
<p className="font-semibold"> Warning</p>
<p className="mt-1">Editing this station may impact routes, schedules, and bookings that reference it. Proceed with caution.</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Station Code *</label>

View File

@@ -2,27 +2,39 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Eye, RefreshCw, CheckCircle } from 'lucide-react';
import { Download, RefreshCw, CheckCircle, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import { ticketsApi } from '@/lib/api';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { ticketsApi, apiClient } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
export default function TicketsPage() {
const [filters, setFilters] = useState({ search: '', status: '' });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [successMessage, setSuccessMessage] = useState('');
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
const { data, isLoading, error } = useQuery({
queryKey: ['tickets', filters],
queryFn: () => ticketsApi.getAll(filters),
});
if (error) {
console.error('Tickets API Error:', error);
}
const regenerateMutation = useMutation({
mutationFn: ticketsApi.regenerate,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
alert('Ticket regenerated successfully');
setSuccessMessage('Ticket regenerated successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
alert(`Error: ${error.message || 'Failed to regenerate ticket'}`);
},
});
@@ -30,12 +42,31 @@ export default function TicketsPage() {
mutationFn: ({ ticketId, data }: any) => ticketsApi.validate(ticketId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
alert('Ticket validated successfully');
setSuccessMessage('Ticket validated successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
alert(`Error: ${error.message || 'Failed to validate ticket'}`);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/tickets/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
setDeleteConfirmOpen(false);
setTicketToDelete(null);
setSuccessMessage('Ticket deleted successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
setDeleteConfirmOpen(false);
alert(`Error: ${error.message || 'Failed to delete ticket'}`);
},
});
const handleRegenerate = async (ticket: any) => {
if (confirm(`Regenerate ticket ${ticket.ticketNumber}?`)) {
if (window.confirm(`Regenerate QR code for ticket ${ticket.ticketNumber}?`)) {
await regenerateMutation.mutateAsync(ticket.id);
}
};
@@ -47,6 +78,17 @@ export default function TicketsPage() {
});
};
const handleDeleteClick = (ticket: any) => {
setTicketToDelete(ticket);
setDeleteConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (ticketToDelete) {
await deleteMutation.mutateAsync(ticketToDelete.id);
}
};
const columns = [
{
key: 'ticketNumber',
@@ -121,15 +163,8 @@ export default function TicketsPage() {
];
const actions = [
// TODO: Create ticket detail page
// {
// label: 'View Details',
// onClick: (ticket: any) => window.location.href = `/tickets/${ticket.id}`,
// variant: 'secondary' as const,
// icon: Eye,
// },
{
label: 'Validate',
label: 'Check-in',
onClick: handleValidate,
variant: 'primary' as const,
icon: CheckCircle,
@@ -141,6 +176,12 @@ export default function TicketsPage() {
variant: 'secondary' as const,
icon: RefreshCw,
},
{
label: 'Delete',
onClick: handleDeleteClick,
variant: 'danger' as const,
icon: Trash2,
},
];
return (
@@ -155,6 +196,16 @@ export default function TicketsPage() {
{/* Filters */}
<div className="card">
{successMessage && (
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">
{successMessage}
</div>
)}
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
Error loading tickets: {error instanceof Error ? error.message : 'Unknown error'}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
@@ -191,6 +242,22 @@ export default function TicketsPage() {
loading={isLoading}
emptyMessage="No tickets found"
/>
{/* Delete Confirmation Dialog */}
<ConfirmDialog
isOpen={deleteConfirmOpen}
onClose={() => {
setDeleteConfirmOpen(false);
setTicketToDelete(null);
}}
onConfirm={handleConfirmDelete}
title="Delete Ticket"
message={`Are you sure you want to permanently delete ticket ${ticketToDelete?.ticketNumber}? This action cannot be undone.`}
confirmText="Delete"
cancelText="Cancel"
isLoading={deleteMutation.isPending}
isDanger={true}
/>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { Plus, Edit, Trash2, Train } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Badge from '@/components/ui/Badge';
import { fleetApi } from '@/lib/api';
import { Train as TrainType } from '@/types';
@@ -14,6 +15,7 @@ import { formatDate } from '@/lib/utils';
export default function TrainsPage() {
const [showModal, setShowModal] = useState(false);
const [editingTrain, setEditingTrain] = useState<TrainType | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null }>({ isOpen: false, train: null });
const queryClient = useQueryClient();
@@ -28,6 +30,10 @@ export default function TrainsPage() {
queryClient.invalidateQueries({ queryKey: ['trains'] });
setShowModal(false);
setEditingTrain(null);
alert('Train created successfully');
},
onError: (error: any) => {
alert('Error creating train: ' + (error?.response?.data?.message || 'Unknown error'));
},
});
@@ -37,9 +43,32 @@ export default function TrainsPage() {
queryClient.invalidateQueries({ queryKey: ['trains'] });
setShowModal(false);
setEditingTrain(null);
alert('Train updated successfully');
},
onError: (error: any) => {
alert('Error updating train: ' + (error?.response?.data?.message || 'Unknown error'));
},
});
const deleteTrainMutation = useMutation({
mutationFn: (id: string) => fleetApi.deleteTrain(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trains'] });
alert('Train deleted successfully');
},
});
const handleDelete = (train: TrainType) => {
setDeleteConfirm({ isOpen: true, train });
};
const confirmDelete = async () => {
if (deleteConfirm.train) {
await deleteTrainMutation.mutateAsync(deleteConfirm.train.id);
setDeleteConfirm({ isOpen: false, train: null });
}
};
const handleSubmit = async (formData: FormData) => {
const trainData = {
number: formData.get('number') as string,
@@ -113,6 +142,12 @@ export default function TrainsPage() {
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
];
return (
@@ -142,6 +177,18 @@ export default function TrainsPage() {
emptyMessage="No trains found"
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, train: null })}
onConfirm={confirmDelete}
title="Delete Train"
message={`Are you sure you want to delete train ${deleteConfirm.train?.number}?`}
confirmText="Delete"
isDanger={true}
warning="This train may be assigned to schedules and trips. Deleting it may impact these systems and associated bookings."
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
@@ -160,6 +207,12 @@ export default function TrainsPage() {
}}
className="space-y-4"
>
{editingTrain && (
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-3 text-sm text-yellow-800 dark:text-yellow-200">
<p className="font-semibold"> Warning</p>
<p className="mt-1">Editing this train may impact schedules and bookings that reference it. Proceed with caution.</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Train Number *</label>

View File

@@ -62,7 +62,7 @@ const navigationSections = [
{ name: 'Coaches', href: '/coaches', icon: Grid3x3 },
{ name: 'Seats', href: '/seats', icon: Armchair },
{ name: 'Schedules', href: '/schedules', icon: Calendar },
{ name: 'Seat Classes', href: '/seat-classes', icon: Settings },
{ name: 'Classes', href: '/seat-classes', icon: Settings },
]
},
{
@@ -70,7 +70,6 @@ const navigationSections = [
items: [
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign },
{ name: 'Payments', href: '/payments', icon: CreditCard },
{ name: 'Wallet Management', href: '/wallet', icon: Wallet },
{ name: 'Promotions', href: '/promotions', icon: Gift },
]
},

View File

@@ -0,0 +1,65 @@
'use client';
import { AlertCircle, AlertTriangle } from 'lucide-react';
import Modal from './Modal';
import ActionButton from './ActionButton'
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
isLoading?: boolean;
isDanger?: boolean;
warning?: string;
}
export default function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
isLoading = false,
isDanger = false,
warning,
}: ConfirmDialogProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
<div className="space-y-4">
<div className="flex gap-3">
{isDanger && (
<AlertCircle className="h-6 w-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
)}
<p className="text-foreground">{message}</p>
</div>
{warning && (
<div className="rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 flex gap-3">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-amber-900 dark:text-amber-200 text-sm">Warning</p>
<p className="text-amber-800 dark:text-amber-300 text-sm mt-1">{warning}</p>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<ActionButton variant="secondary" onClick={onClose} disabled={isLoading}>
{cancelText}
</ActionButton>
<ActionButton
variant={isDanger ? 'danger' : 'primary'}
onClick={onConfirm}
disabled={isLoading}
>
{isLoading ? 'Processing...' : confirmText}
</ActionButton>
</div>
</div>
</Modal>
);
}

View File

@@ -35,8 +35,8 @@ export default function Modal({ isOpen, onClose, title, children, size = 'md' }:
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={onClose} />
<div className={`relative w-full ${sizeClasses[size]} rounded-lg bg-background p-6 shadow-xl`}>
<div className="mb-4 flex items-center justify-between">
<div className={`relative w-full ${sizeClasses[size]} rounded-lg bg-background shadow-xl flex flex-col max-h-[90vh]`}>
<div className="sticky top-0 bg-background border-b border-muted px-6 py-4 flex items-center justify-between z-10">
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
<button
onClick={onClose}
@@ -45,7 +45,9 @@ export default function Modal({ isOpen, onClose, title, children, size = 'md' }:
<X className="h-5 w-5 text-muted-foreground" />
</button>
</div>
<div>{children}</div>
<div className="overflow-y-auto flex-1 px-6 py-4">
{children}
</div>
</div>
</div>
);

View File

@@ -1,6 +1,9 @@
import { apiClient } from '@/lib/api-client';
import { PaginatedResponse } from '@edr/types';
// Export apiClient for direct use
export { apiClient };
// Bookings API
export const bookingsApi = {
getAll: async (params?: any) => {
@@ -17,6 +20,7 @@ export const bookingsApi = {
getById: (id: string) => apiClient.get<any>(`/bookings/${id}`),
cancel: (id: string, data?: any) => apiClient.post<any>(`/bookings/${id}/cancel`, data),
modify: (id: string, data: any) => apiClient.patch<any>(`/bookings/${id}`, data),
checkUsage: (id: string) => apiClient.get<any>(`/bookings/${id}/usage`),
};
// Passengers API

View File

@@ -8,16 +8,25 @@ export const formatCurrency = (amount: number, currency: string = 'ETB'): string
}).format(amount / 100);
};
export const formatDate = (date: string | Date, formatStr: string = 'MMM dd, yyyy'): string => {
return format(new Date(date), formatStr);
export const formatDate = (date?: string | Date | null, formatStr: string = 'MMM dd, yyyy'): string => {
if (!date) return 'N/A';
const d = new Date(date);
if (isNaN(d.getTime())) return 'N/A';
return format(d, formatStr);
};
export const formatDateTime = (date: string | Date): string => {
return format(new Date(date), 'MMM dd, yyyy HH:mm');
export const formatDateTime = (date?: string | Date | null): string => {
if (!date) return 'N/A';
const d = new Date(date);
if (isNaN(d.getTime())) return 'N/A';
return format(d, 'MMM dd, yyyy HH:mm');
};
export const formatDateTimeLocal = (date: string | Date): string => {
return format(new Date(date), 'MMM dd, yyyy HH:mm');
export const formatDateTimeLocal = (date?: string | Date | null): string => {
if (!date) return 'N/A';
const d = new Date(date);
if (isNaN(d.getTime())) return 'N/A';
return format(d, 'MMM dd, yyyy HH:mm');
};
export const getStatusColor = (status: string): string => {