Backoffice UAT results addressed, packages and other updates

This commit is contained in:
Stephanos A
2026-06-25 20:10:36 +03:00
parent 9e84a022df
commit cfbbd437e9
28 changed files with 1042 additions and 211 deletions

View File

@@ -1,14 +1,15 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus, Edit, DollarSign, Clock, Eye } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Eye } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Badge from '@/components/ui/Badge';
import Modal from '@/components/ui/Modal';
import { agentsApi } from '@/lib/api';
import { agentsApi, apiClient } from '@/lib/api';
import { formatCurrency, formatDateTime } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
@@ -24,8 +25,51 @@ const SectionHeader = ({ title }: { title: string }) => (
);
export default function AgentsPage() {
const { user } = useAuthStore();
const queryClient = useQueryClient();
const [filters, setFilters] = useState({ search: '', active: '' });
const [selected, setSelected] = useState<any>(null);
const [createModal, setCreateModal] = useState(false);
const [createForm, setCreateForm] = useState({ iamUserId: '', agentCode: '', commissionRate: '5' });
const [createError, setCreateError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/agents', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agents'] });
setCreateModal(false);
setCreateError(null);
},
onError: (e: any) => setCreateError(e?.response?.data?.message || e?.message || 'Failed to create agent'),
});
const [editModal, setEditModal] = useState(false);
const [editForm, setEditForm] = useState({ agentCode: '', commissionRate: '5', active: true });
const [editingAgent, setEditingAgent] = useState<any>(null);
const [editError, setEditError] = useState<string | null>(null);
const editMutation = useMutation({
mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agents'] });
setEditModal(false);
setEditError(null);
},
onError: (e: any) => setEditError(e?.response?.data?.message || e?.message || 'Failed to update agent'),
});
const openCreateModal = () => {
setCreateForm({ iamUserId: '', agentCode: '', commissionRate: '5' });
setCreateError(null);
setCreateModal(true);
};
const openEditModal = (agent: any) => {
setEditingAgent(agent);
setEditForm({ agentCode: agent.agentCode, commissionRate: String(agent.commissionRate ?? 5), active: agent.active });
setEditError(null);
setEditModal(true);
};
const { data, isLoading } = useQuery({
queryKey: ['agents', filters],
@@ -67,39 +111,27 @@ export default function AgentsPage() {
const actions = [
{
label: 'View Details',
label: 'Edit',
onClick: (agent: any) => openEditModal(agent),
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Details',
onClick: (agent: any) => setSelected(agent),
variant: 'secondary' as const,
icon: Eye,
},
{
label: 'View Shifts',
onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/shifts`; },
variant: 'secondary' as const,
icon: Clock,
},
{
label: 'View Commissions',
onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/commissions`; },
variant: 'secondary' as const,
icon: DollarSign,
},
{
label: 'Edit',
onClick: (agent: any) => console.log('Edit', agent),
variant: 'secondary' as const,
icon: Edit,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Agent Operations</h1>
<p className="text-muted-foreground">Manage booking agents and their operations</p>
<h1 className="text-2xl font-bold">Agents</h1>
<p className="text-muted-foreground">Manage agents and their operations</p>
</div>
<ActionButton icon={Plus}>Add Agent</ActionButton>
<ActionButton icon={Plus} onClick={openCreateModal}>Add Agent</ActionButton>
</div>
<div className="card">
@@ -227,6 +259,100 @@ export default function AgentsPage() {
);
})()}
</Modal>
{/* Create Agent Modal */}
<Modal isOpen={createModal} onClose={() => setCreateModal(false)} title="Add Agent Profile" size="md">
<div className="space-y-4">
{createError && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">
{createError}
</div>
)}
<div>
<label className="label">IAM User ID *</label>
<input
className="input"
value={createForm.iamUserId}
onChange={(e) => setCreateForm({ ...createForm, iamUserId: e.target.value })}
placeholder="IAM user UUID"
/>
{user?.id && createForm.iamUserId === user.id && (
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1"> Pre-filled with your logged-in user ID</p>
)}
<p className="text-xs text-muted-foreground mt-1">Links this agent profile to an IAM back-office user</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Agent Code (optional)</label>
<input
className="input"
value={createForm.agentCode}
onChange={(e) => setCreateForm({ ...createForm, agentCode: e.target.value })}
placeholder="e.g. AG0002 (auto-generated if empty)"
/>
</div>
<div>
<label className="label">Commission Rate (%)</label>
<input
type="number" min="0" max="100" className="input"
value={createForm.commissionRate}
onChange={(e) => setCreateForm({ ...createForm, commissionRate: e.target.value })}
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => setCreateModal(false)}>Cancel</ActionButton>
<ActionButton
loading={createMutation.isPending}
onClick={() => createMutation.mutate({
iamUserId: createForm.iamUserId,
agentCode: createForm.agentCode || undefined,
commissionRate: parseInt(createForm.commissionRate) || 5,
})}
>
Create Agent
</ActionButton>
</div>
</div>
</Modal>
{/* Edit Agent Modal */}
<Modal isOpen={editModal} onClose={() => setEditModal(false)} title="Edit Agent" size="md">
<div className="space-y-4">
{editError && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">
{editError}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Agent Code</label>
<input className="input" value={editForm.agentCode}
onChange={(e) => setEditForm({ ...editForm, agentCode: e.target.value })} />
</div>
<div>
<label className="label">Commission Rate (%)</label>
<input type="number" min="0" max="100" className="input" value={editForm.commissionRate}
onChange={(e) => setEditForm({ ...editForm, commissionRate: e.target.value })} />
</div>
</div>
<div>
<label className="label">Status</label>
<select className="input" value={editForm.active ? 'true' : 'false'}
onChange={(e) => setEditForm({ ...editForm, active: e.target.value === 'true' })}>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => setEditModal(false)}>Cancel</ActionButton>
<ActionButton loading={editMutation.isPending} onClick={() => editMutation.mutate({
id: editingAgent.id,
agentCode: editForm.agentCode,
commissionRate: parseInt(editForm.commissionRate) || 5,
active: editForm.active,
})}>Save Changes</ActionButton>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -28,16 +28,19 @@ const SectionHeader = ({ title }: { title: string }) => (
export default function BookingsPage() {
const [filters, setFilters] = useState<BookingFilters>({ page: 1, pageSize: 20, search: '', status: '' });
const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' });
const [showExtraFilters, setShowExtraFilters] = useState(false);
const [selectedBooking, setSelectedBooking] = useState<any>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState('');
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
bookingRef: true, bookingType: false, passengerNames: true, contactPhone: true,
bookingRef: true, bookingType: true, passengerNames: true, contactPhone: true,
contactEmail: true, passengerCount: false, paymentStatus: true, totalMinor: true, status: true, createdAt: true,
});
@@ -87,43 +90,56 @@ export default function BookingsPage() {
{ key: 'status', label: 'Status' }, { key: 'createdAt', label: 'Created At' },
];
const confirmExport = () => {
const confirmExport = async () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
if (!cols.length) { alert('Please select at least one column'); return; }
const exportItems = (data?.items || []).filter((b: any) => {
// Fetch all records (not just current page)
const allData = await bookingsApi.getAll({ ...filters, page: 1, pageSize: 9999 });
const exportItems = (allData?.items || []).filter((b: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [
BOOKING_COLS.map(c => `"${c.label}"`).join(','),
...exportItems.map((booking: any) => {
const values = BOOKING_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'bookingRef': return booking.bookingRef;
case 'journeyType': return booking.bookingType || 'N/A';
case 'passengerNames': return booking.passengerNames?.join(', ') || 'N/A';
case 'contactPhone': return booking.contactPhone || 'N/A';
case 'contactEmail': return booking.contactEmail || 'N/A';
case 'passengerCount': return (booking.adultCount ?? 0) + (booking.childCount ?? 0);
case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING';
case 'totalMinor': return formatCurrency(booking.totalMinor, booking.currency);
case 'status': return booking.status;
case 'createdAt': return booking.createdAt ? formatDateTime(booking.createdAt) : '';
default: return '';
}
});
return values.map(v => `"${v}"`).join(',');
}),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
const rows = exportItems.map((booking: any) =>
BOOKING_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'bookingRef': return booking.bookingRef;
case 'journeyType': return booking.bookingType || 'N/A';
case 'passengerNames': return booking.passengerNames?.join(', ') || 'N/A';
case 'contactPhone': return booking.contactPhone || 'N/A';
case 'contactEmail': return booking.contactEmail || 'N/A';
case 'passengerCount': return String((booking.adultCount ?? 0) + (booking.childCount ?? 0));
case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING';
case 'totalMinor': return formatCurrency(booking.totalMinor, booking.currency);
case 'status': return booking.status;
case 'createdAt': return booking.createdAt ? formatDateTime(booking.createdAt) : '';
default: return '';
}
})
);
const headers = BOOKING_COLS.filter(c => cols.includes(c.key)).map(c => c.label);
const dateStr = new Date().toISOString().split('T')[0];
if (exportFormat === 'pdf') {
const w = window.open('', '_blank')!;
w.document.write(`<!DOCTYPE html><html><head><title>Bookings Export</title><style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>`);
w.document.write(`<h2>Bookings Export — ${dateStr}</h2><table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>`);
rows.forEach(r => { w.document.write(`<tr>${r.map(v => `<td>${v}</td>`).join('')}</tr>`); });
w.document.write('</tbody></table></body></html>');
w.document.close();
w.print();
} else if (exportFormat === 'excel') {
const tsv = [headers.join('\t'), ...rows.map(r => r.join('\t'))].join('\n');
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `bookings-${dateStr}.xls`; a.click();
} else {
const csv = [headers.map(h => `"${h}"`).join(','), ...rows.map(r => r.map(v => `"${v}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `bookings-${dateStr}.csv`; a.click();
}
setExportModalOpen(false);
};
@@ -205,19 +221,60 @@ export default function BookingsPage() {
Error loading bookings: {error instanceof Error ? error.message : 'Unknown error'}
</div>
)}
<div className="mb-4 flex flex-wrap gap-4">
<div className="flex-1">
<input type="text" placeholder="Search by reference, email, or phone..." className="input"
value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
<div className="mb-4 space-y-3">
<div className="flex flex-wrap gap-3">
<div className="flex-1 min-w-48">
<input type="text" placeholder="Search by reference, email, or phone..." className="input"
value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
</div>
<select className="input w-44" value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}>
<option value="">All Status</option>
<option value="PENDING_PAYMENT">Pending Payment</option>
<option value="CONFIRMED">Confirmed</option>
<option value="CANCELLED">Cancelled</option>
<option value="BOARDED">Boarded</option>
</select>
<button type="button" className="input w-auto px-4 text-sm font-medium text-primary border-primary/40"
onClick={() => setShowExtraFilters(v => !v)}>
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
</button>
</div>
<select className="input w-48" value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}>
<option value="">All Status</option>
<option value="PENDING_PAYMENT">Pending Payment</option>
<option value="CONFIRMED">Confirmed</option>
<option value="CANCELLED">Cancelled</option>
<option value="BOARDED">Boarded</option>
</select>
{showExtraFilters && (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 pt-1">
<div>
<label className="label">Booking Type</label>
<select className="input" value={extraFilters.bookingType}
onChange={(e) => setExtraFilters({ ...extraFilters, bookingType: e.target.value })}>
<option value="">All Types</option>
<option value="ONE_WAY">One Way</option>
<option value="ROUND_TRIP">Round Trip</option>
<option value="ROUND_TRIP_TRANSIT">Round Trip Transit</option>
</select>
</div>
<div>
<label className="label">Payment Status</label>
<select className="input" value={extraFilters.paymentStatus}
onChange={(e) => setExtraFilters({ ...extraFilters, paymentStatus: e.target.value })}>
<option value="">All Payments</option>
<option value="PENDING">Pending</option>
<option value="PAID">Paid</option>
<option value="FAILED">Failed</option>
<option value="REFUNDED">Refunded</option>
</select>
</div>
<div>
<label className="label">Created From</label>
<input type="date" className="input" value={extraFilters.dateFrom}
onChange={(e) => setExtraFilters({ ...extraFilters, dateFrom: e.target.value })} />
</div>
<div>
<label className="label">Created To</label>
<input type="date" className="input" value={extraFilters.dateTo}
onChange={(e) => setExtraFilters({ ...extraFilters, dateTo: e.target.value })} />
</div>
</div>
)}
</div>
<DataTable data={data?.items || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No bookings found" />
{data?.meta && (
@@ -397,9 +454,21 @@ export default function BookingsPage() {
))}
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Export Format</p>
<div className="flex gap-3">
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="exportFormat" value={fmt} checked={exportFormat === fmt}
onChange={() => setExportFormat(fmt)} className="w-4 h-4" />
<span className="text-sm font-medium capitalize">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
<ActionButton onClick={confirmExport}>Export</ActionButton>
</div>
</div>
</Modal>

View File

@@ -39,6 +39,8 @@ export default function PackagesPage() {
const [tierForm, setTierForm] = useState({ seatType: '', label: '', priceMinor: '', availableSeats: '' });
const [deleteTierConfirm, setDeleteTierConfirm] = useState<any>(null);
const [tierError, setTierError] = useState<string | null>(null);
const [deletePackageConfirm, setDeletePackageConfirm] = useState<any>(null);
const [deletePackageError, setDeletePackageError] = useState<string | null>(null);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
@@ -100,6 +102,16 @@ export default function PackagesPage() {
onError: (e: any) => setTierError(e?.response?.data?.message || e?.message || 'Failed to update tier'),
});
const deletePackageMutation = useMutation({
mutationFn: (id: string) => packagesApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['packages'] });
setDeletePackageConfirm(null);
setDeletePackageError(null);
},
onError: (e: any) => setDeletePackageError(e?.response?.data?.message || e?.message || 'Failed to delete package'),
});
const deleteTierMutation = useMutation({
mutationFn: (tierId: string) => packagesApi.deleteTier(tierId),
onSuccess: (_, tierId) => {
@@ -251,6 +263,10 @@ export default function PackagesPage() {
onClick: (p: any) => setActivateConfirm(p),
hidden: (p: any) => p.status === 'ACTIVE',
},
{
label: 'Delete', icon: Trash2, variant: 'danger' as const,
onClick: (p: any) => { setDeletePackageError(null); setDeletePackageConfirm(p); },
},
];
const isPending = createMutation.isPending || updateMutation.isPending;
@@ -401,6 +417,19 @@ export default function PackagesPage() {
)}
</Modal>
{/* Delete Package Confirmation */}
<ConfirmDialog
isOpen={!!deletePackageConfirm}
onClose={() => { setDeletePackageConfirm(null); setDeletePackageError(null); }}
onConfirm={() => deletePackageMutation.mutate(deletePackageConfirm.id)}
title="Delete Package"
message={`Delete "${deletePackageConfirm?.name}"? This will also remove all price tiers and cannot be undone.`}
confirmText="Delete"
isDanger
isLoading={deletePackageMutation.isPending}
error={deletePackageError ?? undefined}
/>
{/* Delete Tier Confirmation */}
<ConfirmDialog
isOpen={!!deleteTierConfirm}

View File

@@ -35,10 +35,13 @@ const TIER_COLORS: Record<string, string> = {
export default function PassengersPage() {
const [filters, setFilters] = useState<PassengerFilters>({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' });
const [showExtraFilters, setShowExtraFilters] = useState(false);
const [extraFilters, setExtraFilters] = useState({ gender: '', nationality: '', dateFrom: '', dateTo: '' });
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
const [deleteError, setDeleteError] = useState<string | null>(null);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
@@ -70,40 +73,52 @@ export default function PassengersPage() {
{ key: 'nationality', label: 'Nationality' }, { key: 'verified', label: 'Verified' },
];
const confirmExportPassengers = () => {
const confirmExportPassengers = async () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
if (!cols.length) { alert('Please select at least one column'); return; }
const exportItems = (data?.items || []).filter((p: any) => {
// Fetch all records
const allData = await passengersApi.getAll({ ...filters, page: 1, pageSize: 9999 });
const exportItems = (allData?.items || []).filter((p: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [
PASSENGER_COLS.map(c => `"${c.label}"`).join(','),
...exportItems.map((p: any) => {
const values = PASSENGER_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'fullName': return p.fullName;
case 'email': return p.email || '';
case 'phone': return p.phone || '';
case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : '';
case 'gender': return p.gender || '';
case 'nationality': return p.nationality || '';
case 'verified': return p.faydaVerified ? 'Yes' : 'No';
default: return '';
}
});
return values.map(v => `"${v}"`).join(',');
}),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `passengers-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
const headers = PASSENGER_COLS.filter(c => cols.includes(c.key)).map(c => c.label);
const rows = exportItems.map((p: any) =>
PASSENGER_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'fullName': return p.fullName || '';
case 'email': return p.email || '';
case 'phone': return p.phone || '';
case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : '';
case 'gender': return p.gender || '';
case 'nationality': return p.nationality || '';
case 'verified': return p.faydaVerified ? 'Yes' : 'No';
default: return '';
}
})
);
const dateStr = new Date().toISOString().split('T')[0];
if (exportFormat === 'pdf') {
const w = window.open('', '_blank')!;
w.document.write(`<!DOCTYPE html><html><head><title>Passengers Export</title><style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>`);
w.document.write(`<h2>Passengers Export — ${dateStr}</h2><table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>`);
rows.forEach(r => { w.document.write(`<tr>${r.map(v => `<td>${v}</td>`).join('')}</tr>`); });
w.document.write('</tbody></table></body></html>');
w.document.close(); w.print();
} else if (exportFormat === 'excel') {
const tsv = [headers.join('\t'), ...rows.map(r => r.join('\t'))].join('\n');
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `passengers-${dateStr}.xls`; a.click();
} else {
const csv = [headers.map(h => `"${h}"`).join(','), ...rows.map(r => r.map(v => `"${v}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `passengers-${dateStr}.csv`; a.click();
}
setExportModalOpen(false);
};
@@ -152,17 +167,52 @@ export default function PassengersPage() {
Error loading passengers: {error instanceof Error ? error.message : 'Unknown error'}
</div>
)}
<div className="mb-4 flex flex-wrap gap-4">
<div className="flex-1">
<input type="text" placeholder="Search by name, email, or phone..." className="input"
value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
<div className="mb-4 space-y-3">
<div className="flex flex-wrap gap-3">
<div className="flex-1 min-w-48">
<input type="text" placeholder="Search by name, email, or phone..." className="input"
value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
</div>
<select className="input w-44" value={filters.verified?.toString() || ''}
onChange={(e) => setFilters({ ...filters, verified: e.target.value ? e.target.value === 'true' : undefined, page: 1 })}>
<option value="">All Passengers</option>
<option value="true">Verified</option>
<option value="false">Unverified</option>
</select>
<button type="button" className="input w-auto px-4 text-sm font-medium text-primary border-primary/40"
onClick={() => setShowExtraFilters(v => !v)}>
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
</button>
</div>
<select className="input w-48" value={filters.verified?.toString() || ''}
onChange={(e) => setFilters({ ...filters, verified: e.target.value ? e.target.value === 'true' : undefined, page: 1 })}>
<option value="">All Passengers</option>
<option value="true">Verified</option>
<option value="false">Unverified</option>
</select>
{showExtraFilters && (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 pt-1">
<div>
<label className="label">Gender</label>
<select className="input" value={extraFilters.gender}
onChange={(e) => setExtraFilters({ ...extraFilters, gender: e.target.value })}>
<option value="">All Genders</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
</select>
</div>
<div>
<label className="label">Nationality</label>
<input type="text" className="input" placeholder="e.g. Ethiopian"
value={extraFilters.nationality}
onChange={(e) => setExtraFilters({ ...extraFilters, nationality: e.target.value })} />
</div>
<div>
<label className="label">Registered From</label>
<input type="date" className="input" value={extraFilters.dateFrom}
onChange={(e) => setExtraFilters({ ...extraFilters, dateFrom: e.target.value })} />
</div>
<div>
<label className="label">Registered To</label>
<input type="date" className="input" value={extraFilters.dateTo}
onChange={(e) => setExtraFilters({ ...extraFilters, dateTo: e.target.value })} />
</div>
</div>
)}
</div>
<DataTable data={data?.items || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No passengers found" />
{data?.meta && (
@@ -367,9 +417,21 @@ export default function PassengersPage() {
))}
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Export Format</p>
<div className="flex gap-3">
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="exportFormatP" value={fmt} checked={exportFormat === fmt}
onChange={() => setExportFormat(fmt)} className="w-4 h-4" />
<span className="text-sm font-medium capitalize">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExportPassengers}>Export CSV</ActionButton>
<ActionButton onClick={confirmExportPassengers}>Export</ActionButton>
</div>
</div>
</Modal>

View File

@@ -34,7 +34,7 @@ interface SeatClass {
}
export default function PricingPage() {
const [tab, setTab] = useState<'schedule' | 'segment'>('schedule');
const [tab, setTab] = useState<'schedule' | 'segment' | 'baggage'>('schedule');
const [showModal, setShowModal] = useState(false);
const [selectedSchedule, setSelectedSchedule] = useState<string>('');
const [selectedRoute, setSelectedRoute] = useState<string>('');
@@ -67,6 +67,17 @@ export default function PricingPage() {
validUntil: '',
});
const [baggageForm, setBaggageForm] = useState({
seatClassId: '',
maxWeightKg: '',
maxPiecesCount: '',
excessFeePerKg: '',
});
const [editingAllowance, setEditingAllowance] = useState<any>(null);
const [baggageError, setBaggageError] = useState<string | null>(null);
const [baggageModal, setBaggageModal] = useState(false);
const [deleteAllowanceConfirm, setDeleteAllowanceConfirm] = useState<{ isOpen: boolean; id: string | null }>({ isOpen: false, id: null });
const { data: schedules = [] } = useQuery({
queryKey: ['schedules'],
queryFn: () => apiClient.get('/schedules'),
@@ -109,6 +120,48 @@ export default function PricingPage() {
enabled: !!selectedRoute && tab === 'segment',
});
const { data: allowances = [], isLoading: allowancesLoading, refetch: refetchAllowances } = useQuery({
queryKey: ['baggage-allowances'],
queryFn: () => apiClient.get<any[]>('/agents/excess-baggage/allowances'),
enabled: tab === 'baggage',
});
const createAllowanceMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/agents/excess-baggage/allowances', data),
onSuccess: () => { refetchAllowances(); setBaggageModal(false); setBaggageError(null); },
onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to save'),
});
const updateAllowanceMutation = useMutation({
mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/excess-baggage/allowances/${id}`, data),
onSuccess: () => { refetchAllowances(); setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); },
onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to update'),
});
const deleteAllowanceMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/agents/excess-baggage/allowances/${id}`),
onSuccess: () => { refetchAllowances(); setDeleteAllowanceConfirm({ isOpen: false, id: null }); },
onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to delete'),
});
const handleSaveAllowance = async () => {
setBaggageError(null);
if (!baggageForm.seatClassId || !baggageForm.maxWeightKg || !baggageForm.maxPiecesCount || !baggageForm.excessFeePerKg) {
setBaggageError('All fields are required'); return;
}
const payload = {
seatClassId: baggageForm.seatClassId,
maxWeightKg: parseInt(baggageForm.maxWeightKg),
maxPiecesCount: parseInt(baggageForm.maxPiecesCount),
excessFeePerKg: Math.round(parseFloat(baggageForm.excessFeePerKg) * 100),
};
if (editingAllowance) {
await updateAllowanceMutation.mutateAsync({ id: editingAllowance.id, ...payload });
} else {
await createAllowanceMutation.mutateAsync(payload);
}
};
const createFareMutation = useMutation({
mutationFn: (data: any) => apiClient.post(`/schedules/fares`, data),
onSuccess: () => {
@@ -346,6 +399,7 @@ export default function PricingPage() {
const stationsArray = Array.isArray(stations) ? stations : (stations as any)?.items || [];
const faresArray = Array.isArray(fares) ? fares : (fares as any)?.items || [];
const segmentFaresArray = Array.isArray(segmentFares) ? segmentFares : (segmentFares as any)?.items || [];
const allowancesArray = Array.isArray(allowances) ? allowances : (allowances as any)?.items || [];
const currentRoute = routesArray.find((r: Route) => r.id === selectedRoute);
const fareColumns = [
@@ -498,7 +552,12 @@ export default function PricingPage() {
onClick={() => {
setError(null);
setEditingFare(null);
if (tab === 'schedule') {
if (tab === 'baggage') {
setBaggageForm({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '' });
setEditingAllowance(null);
setBaggageError(null);
setBaggageModal(true);
} else if (tab === 'schedule') {
setFareForm({
seatClassId: '',
baseFare: '',
@@ -523,7 +582,7 @@ export default function PricingPage() {
setShowModal(true);
}}
>
Add Fare Rule
{tab === 'baggage' ? 'Add Allowance Rule' : 'Add Fare Rule'}
</ActionButton>
</div>
@@ -541,16 +600,21 @@ export default function PricingPage() {
Schedule Fares
</button>
<button
onClick={() => {
setTab('segment');
setError(null);
}}
onClick={() => { setTab('segment'); setError(null); }}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
tab === 'segment' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}
>
Segment Fares
</button>
<button
onClick={() => { setTab('baggage'); setError(null); }}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
tab === 'baggage' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}
>
Excess Baggage Rates
</button>
</div>
<div className="space-y-6">
@@ -658,6 +722,48 @@ export default function PricingPage() {
)}
</>
)}
{tab === 'baggage' && (
<>
{allowancesLoading ? (
<div className="flex items-center justify-center py-8"><Loader2 className="h-6 w-6 animate-spin" /></div>
) : allowancesArray.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No baggage allowance rules defined. Click "Add Allowance Rule" to create one.
</div>
) : (
<DataTable
data={allowancesArray}
columns={[
{ key: 'seatClass', label: 'Seat Class', render: (a: any) => <span className="font-medium">{a.seatClass?.name ?? a.seatClassId}</span> },
{ key: 'maxWeightKg', label: 'Free Allowance', render: (a: any) => <span>{a.maxWeightKg} kg, {a.maxPiecesCount} pcs</span> },
{ key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: any) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} ETB</span> },
]}
actions={[
{
label: 'Edit', icon: Edit, variant: 'secondary' as const,
onClick: (a: any) => {
setEditingAllowance(a);
setBaggageForm({
seatClassId: a.seatClassId,
maxWeightKg: String(a.maxWeightKg),
maxPiecesCount: String(a.maxPiecesCount),
excessFeePerKg: (a.excessFeePerKg / 100).toFixed(2),
});
setBaggageError(null);
setBaggageModal(true);
},
},
{
label: 'Delete', icon: Trash2, variant: 'danger' as const,
onClick: (a: any) => setDeleteAllowanceConfirm({ isOpen: true, id: a.id }),
},
]}
loading={false}
emptyMessage="No allowance rules found."
/>
)}
</>
)}
</div>
</div>
@@ -1009,6 +1115,54 @@ export default function PricingPage() {
</div>
</div>
</Modal>
{/* Baggage Allowance Modal */}
<Modal isOpen={baggageModal} onClose={() => { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }} title={editingAllowance ? 'Edit Allowance Rule' : 'Add Allowance Rule'} size="md">
<div className="space-y-4">
{baggageError && <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">{baggageError}</div>}
<div>
<label className="label">Seat Class *</label>
<select value={baggageForm.seatClassId} onChange={(e) => setBaggageForm({ ...baggageForm, seatClassId: e.target.value })} className="input w-full" disabled={!!editingAllowance}>
<option value="">Select seat class...</option>
{seatClassesArray.map((sc: SeatClass) => <option key={sc.id} value={sc.id}>{sc.name}</option>)}
</select>
{editingAllowance && <p className="text-xs text-muted-foreground mt-1">Seat class cannot be changed. Delete and recreate to change.</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Free Allowance (kg) *</label>
<input type="number" min="0" className="input w-full" placeholder="e.g. 20" value={baggageForm.maxWeightKg} onChange={(e) => setBaggageForm({ ...baggageForm, maxWeightKg: e.target.value })} />
</div>
<div>
<label className="label">Max Pieces *</label>
<input type="number" min="1" className="input w-full" placeholder="e.g. 2" value={baggageForm.maxPiecesCount} onChange={(e) => setBaggageForm({ ...baggageForm, maxPiecesCount: e.target.value })} />
</div>
</div>
<div>
<label className="label">Excess Fee per kg (ETB) *</label>
<input type="number" min="0" step="0.01" className="input w-full" placeholder="e.g. 50.00" value={baggageForm.excessFeePerKg} onChange={(e) => setBaggageForm({ ...baggageForm, excessFeePerKg: e.target.value })} />
<p className="text-xs text-muted-foreground mt-1">Amount charged per kg above the free allowance</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }}>Cancel</ActionButton>
<ActionButton onClick={handleSaveAllowance} loading={createAllowanceMutation.isPending || updateAllowanceMutation.isPending}>
{editingAllowance ? 'Update' : 'Save'}
</ActionButton>
</div>
</div>
</Modal>
{/* Delete Allowance Confirm */}
<ConfirmDialog
isOpen={deleteAllowanceConfirm.isOpen}
onClose={() => setDeleteAllowanceConfirm({ isOpen: false, id: null })}
onConfirm={() => deleteAllowanceMutation.mutateAsync(deleteAllowanceConfirm.id!)}
title="Delete Allowance Rule"
message="Are you sure you want to delete this baggage allowance rule?"
confirmText="Delete"
isDanger
isLoading={deleteAllowanceMutation.isPending}
warning="The excess baggage fallback rate (50 ETB/kg) will apply until a new rule is created."
/>
</div>
);
}

View File

@@ -107,6 +107,7 @@ export default function RoutesPage() {
code: formData.get('code') as string,
name: formData.get('name') as string,
description: formData.get('description') as string || undefined,
active: !editingRoute ? (formData.get('active') !== 'false') : undefined,
effectiveFrom: formData.get('effectiveFrom') as string,
effectiveUntil: formData.get('effectiveUntil') as string || undefined,
stops: stopsArray,
@@ -403,6 +404,16 @@ export default function RoutesPage() {
/>
</div>
{!editingRoute && (
<div>
<label className="label">Status</label>
<select name="active" className="input" defaultValue="true">
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Effective From *</label>

View File

@@ -25,6 +25,7 @@ export default function StationsPage() {
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 [formError, setFormError] = useState<string | null>(null);
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
@@ -38,7 +39,9 @@ export default function StationsPage() {
queryClient.invalidateQueries({ queryKey: ['stations'] });
setShowModal(false);
setEditingStation(null);
setFormError(null);
},
onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to create station'),
});
const updateMutation = useMutation({
@@ -47,7 +50,9 @@ export default function StationsPage() {
queryClient.invalidateQueries({ queryKey: ['stations'] });
setShowModal(false);
setEditingStation(null);
setFormError(null);
},
onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to update station'),
});
const deleteMutation = useMutation({
@@ -152,6 +157,7 @@ export default function StationsPage() {
label: 'Edit',
onClick: (station: any) => {
setEditingStation(station);
setFormError(null);
setShowModal(true);
},
variant: 'secondary' as const,
@@ -176,6 +182,7 @@ export default function StationsPage() {
icon={Plus}
onClick={() => {
setEditingStation(null);
setFormError(null);
setShowModal(true);
}}
>
@@ -247,11 +254,17 @@ export default function StationsPage() {
onClose={() => {
setShowModal(false);
setEditingStation(null);
setFormError(null);
}}
title={`${editingStation ? 'Edit' : 'Add'} Station`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
{formError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-800 dark:text-red-200">
{formError}
</div>
)}
{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>

View File

@@ -14,7 +14,7 @@ import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils
import { useAuthStore } from '@/lib/auth-store';
export default function TicketsPage() {
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' });
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '', dateFrom: '', dateTo: '' });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -26,9 +26,10 @@ export default function TicketsPage() {
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null);
const { user } = useAuthStore();
const [showExtraFilters, setShowExtraFilters] = useState(false);
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
// Excess baggage state
const { user } = useAuthStore();
const [excessModalOpen, setExcessModalOpen] = useState(false);
const [excessTicket, setExcessTicket] = useState<any>(null);
const [excessKg, setExcessKg] = useState('');
@@ -88,7 +89,8 @@ export default function TicketsPage() {
});
const boardMutation = useMutation({
mutationFn: ({ ticketId }: any) => ticketsApi.validate(ticketId, { status: 'USED', boardedAt: new Date().toISOString() }),
mutationFn: ({ ticketId, leg }: { ticketId: string; leg?: 'outbound' | 'inbound' }) =>
ticketsApi.validate(ticketId, { status: 'USED', boardedAt: new Date().toISOString(), leg: leg === 'inbound' ? 'RETURN' : 'OUTBOUND' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
setBoardConfirmOpen(false);
@@ -147,6 +149,16 @@ export default function TicketsPage() {
},
});
const restoreMutation = useMutation({
mutationFn: (id: string) => apiClient.patch(`/tickets/${id}/restore`, {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
setSuccessMessage('Ticket restored successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => alert(error?.response?.data?.message || error?.message || 'Failed to restore ticket'),
});
const handleBoard = (ticket: any) => {
setTicketToBoard(ticket);
setBoardConfirmOpen(true);
@@ -154,8 +166,11 @@ export default function TicketsPage() {
const handleConfirmBoard = async () => {
if (!ticketToBoard) return;
await boardMutation.mutateAsync({ ticketId: ticketToBoard.id });
printBoardingPass(ticketToBoard, 'outbound');
const isRoundTrip = ticketToBoard.booking?.bookingType === 'ROUND_TRIP' || ticketToBoard.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
const outboundDone = !!ticketToBoard.validatedAt || !!ticketToBoard.booking?.outboundBoardedAt;
const leg: 'outbound' | 'inbound' = isRoundTrip && outboundDone ? 'inbound' : 'outbound';
await boardMutation.mutateAsync({ ticketId: ticketToBoard.id, leg });
printBoardingPass(ticketToBoard, leg);
};
const printBoardingPass = (ticket: any, leg: 'outbound' | 'inbound' = 'outbound') => {
@@ -249,54 +264,54 @@ export default function TicketsPage() {
{ key: 'boarded', label: 'Boarded' },
];
const confirmExport = () => {
const cols = Object.entries(selectedColumns)
.filter(([, selected]) => selected)
.map(([col]) => col);
if (cols.length === 0) {
alert('Please select at least one column');
return;
}
const exportItems = (data?.items || []).filter((ticket: any) => {
const confirmExport = async () => {
const cols = Object.entries(selectedColumns).filter(([, v]) => v).map(([k]) => k);
if (!cols.length) { alert('Please select at least one column'); return; }
const allData = await ticketsApi.getAll({ search: filters.search || undefined, status: filters.status || undefined, skip: 0, take: 9999 });
const exportItems = (allData?.items || []).filter((ticket: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = ticket.schedule?.arrivalAt
? new Date(ticket.schedule.arrivalAt).toISOString().split('T')[0]
: null;
const d = ticket.schedule?.arrivalAt ? new Date(ticket.schedule.arrivalAt).toISOString().split('T')[0] : null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [
TICKET_COLS.map(c => `"${c.label}"`).join(','),
...exportItems.map((ticket: any) => {
const values = TICKET_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'ticketNumber': return ticket.ticketNumber || 'N/A';
case 'booking': return ticket.booking?.bookingRef || 'N/A';
case 'passenger': return ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A';
case 'trip': return `${ticket.schedule?.originStation?.name || 'N/A'} - ${ticket.schedule?.destinationStation?.name || 'N/A'}`;
case 'coach': return ticket.seat?.coach?.number || 'N/A';
case 'seat': return ticket.seat?.seatNumber || 'N/A';
case 'seatClass': return ticket.seat?.coach?.coachType?.type || 'N/A';
case 'amount': return formatCurrency((ticket.booking?.totalMinor || 0), ticket.booking?.currency || 'ETB');
case 'status': return ticket.status || 'N/A';
case 'boarded': return ticket.boardedAt ? 'Yes' : 'No';
default: return '';
}
});
return values.map(v => `"${v}"`).join(',');
}),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `tickets-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
const headers = TICKET_COLS.filter(c => cols.includes(c.key)).map(c => c.label);
const rows = exportItems.map((ticket: any) =>
TICKET_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'ticketNumber': return ticket.ticketNumber || 'N/A';
case 'booking': return ticket.booking?.bookingRef || 'N/A';
case 'passenger': return ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A';
case 'trip': return (ticket.schedule?.originStation?.name || 'N/A') + ' - ' + (ticket.schedule?.destinationStation?.name || 'N/A');
case 'coach': return ticket.seat?.coach?.number || 'N/A';
case 'seat': return ticket.seat?.seatNumber || 'N/A';
case 'seatClass': return ticket.seat?.coach?.coachType?.type || 'N/A';
case 'amount': return formatCurrency((ticket.booking?.totalMinor || 0), ticket.booking?.currency || 'ETB');
case 'status': return ticket.status || 'N/A';
case 'boarded': return ticket.boardedAt ? 'Yes' : 'No';
default: return '';
}
})
);
const dateStr = new Date().toISOString().split('T')[0];
if (exportFormat === 'pdf') {
const w = window.open('', '_blank')!;
w.document.write('<!DOCTYPE html><html><head><title>Tickets Export</title><style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>');
w.document.write('<h2>Tickets Export - ' + dateStr + '</h2><table><thead><tr>' + headers.map(h => '<th>' + h + '</th>').join('') + '</tr></thead><tbody>');
rows.forEach(r => { w.document.write('<tr>' + r.map(v => '<td>' + v + '</td>').join('') + '</tr>'); });
w.document.write('</tbody></table></body></html>');
w.document.close(); w.print();
} else if (exportFormat === 'excel') {
const tsv = [headers.join(' '), ...rows.map(r => r.join(' '))].join('');
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'tickets-' + dateStr + '.xls'; a.click();
} else {
const csv = [headers.map(h => '"' + h + '"').join(','), ...rows.map(r => r.map(v => '"' + v + '"').join(','))].join('');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'tickets-' + dateStr + '.csv'; a.click();
}
setExportModalOpen(false);
};
@@ -358,6 +373,13 @@ export default function TicketsPage() {
</div>
),
},
{
key: 'arrivalDate',
label: 'Arrival Date',
render: (ticket: any) => (
<span className="text-sm">{ticket.schedule?.arrivalAt ? new Date(ticket.schedule.arrivalAt).toLocaleDateString() : '—'}</span>
),
},
{
key: 'boardingTimes',
label: 'Boarding Times',
@@ -422,6 +444,13 @@ export default function TicketsPage() {
variant: 'secondary' as const,
icon: ListCollapse,
},
{
label: 'Restore',
onClick: (ticket: any) => restoreMutation.mutate(ticket.id),
variant: 'secondary' as const,
icon: ListCollapse,
show: (ticket: any) => ticket.status === 'CANCELLED',
},
{
label: 'Delete',
onClick: handleDeleteClick,
@@ -836,9 +865,20 @@ export default function TicketsPage() {
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Export Format</p>
<div className="flex gap-4">
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="ticketExportFmt" value={fmt} checked={exportFormat === fmt} onChange={() => setExportFormat(fmt)} className="w-4 h-4" />
<span className="text-sm font-medium">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
<ActionButton onClick={confirmExport}>Export</ActionButton>
</div>
</div>
</Modal>

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, Train, Search } from 'lucide-react';
import { Plus, Edit, Trash2, Train, Search, RotateCcw } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
@@ -59,6 +59,17 @@ export default function TrainsPage() {
},
});
const restoreTrainMutation = useMutation({
mutationFn: (id: string) => fleetApi.restoreTrain(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trains'] });
alert('Train restored successfully');
},
onError: (error: any) => {
alert('Error restoring train: ' + (error?.response?.data?.message || 'Unknown error'));
},
});
const handleDelete = (train: TrainType) => {
setDeleteConfirm({ isOpen: true, train });
};
@@ -156,6 +167,13 @@ export default function TrainsPage() {
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Restore',
onClick: (train: TrainType) => restoreTrainMutation.mutate(train.id),
variant: 'secondary' as const,
icon: RotateCcw,
show: (train: TrainType) => !train.isActive,
},
{
label: 'Delete',
onClick: handleDelete,

View File

@@ -109,6 +109,7 @@ export const fleetApi = {
createTrain: (data: any) => apiClient.post<any>('/fleet/trains', data),
updateTrain: (id: string, data: any) => apiClient.patch<any>(`/fleet/trains/${id}`, data),
deleteTrain: (id: string) => apiClient.delete(`/fleet/trains/${id}`),
restoreTrain: (id: string) => apiClient.patch<any>(`/fleet/trains/${id}/restore`, {}),
createCoach: (data: any) => apiClient.post<any>('/fleet/coaches', data),
updateCoach: (id: string, data: any) => apiClient.patch<any>(`/fleet/coaches/${id}`, data),
deleteCoach: (id: string) => apiClient.delete(`/fleet/coaches/${id}`),
@@ -389,6 +390,7 @@ export const packagesApi = {
create: (data: any) => apiClient.post<any>('/packages', data),
update: (id: string, data: any) => apiClient.patch<any>(`/packages/${id}`, data),
activate: (id: string) => apiClient.patch<any>(`/packages/${id}/activate`, {}),
remove: (id: string) => apiClient.delete(`/packages/${id}`),
addTier: (packageId: string, data: any) => apiClient.post<any>(`/packages/${packageId}/tiers`, data),
updateTier: (tierId: string, data: any) => apiClient.patch<any>(`/packages/tiers/${tierId}`, data),
deleteTier: (tierId: string) => apiClient.delete(`/packages/tiers/${tierId}`),