mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-01 20:33:27 +00:00
267 lines
12 KiB
TypeScript
267 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { Eye } from 'lucide-react';
|
|
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 Pagination from '@/components/ui/Pagination';
|
|
import { packagesApi } from '@/lib/api';
|
|
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
|
import { getErrorMessage } from '@/lib/api-client';
|
|
|
|
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">
|
|
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
|
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
|
</div>
|
|
);
|
|
|
|
const SectionHeader = ({ title }: { title: string }) => (
|
|
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
|
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
|
</h3>
|
|
);
|
|
|
|
export default function PackageBookingsPage() {
|
|
const [filters, setFilters] = useState({ packageId: '', status: '', page: 1, pageSize: 20 });
|
|
const [selected, setSelected] = useState<any>(null);
|
|
|
|
const { data: rawData, isLoading, error } = useQuery({
|
|
queryKey: ['package-bookings', filters],
|
|
queryFn: () => packagesApi.getBookings(filters),
|
|
});
|
|
|
|
// Unwrap in case the interceptor double-wraps: { data: { items, ... } } or { items, ... }
|
|
const data: any = (rawData as any)?.items ? rawData : (rawData as any)?.data ?? rawData;
|
|
|
|
const { data: packagesData } = useQuery({
|
|
queryKey: ['packages-all-simple'],
|
|
queryFn: () => packagesApi.getAll({ pageSize: 100 }),
|
|
});
|
|
|
|
const packages: any[] = packagesData?.items || [];
|
|
|
|
const columns = [
|
|
{
|
|
key: 'bookingRef',
|
|
label: 'Reference',
|
|
sortable: true,
|
|
render: (b: any) => (
|
|
<div>
|
|
<div className="font-mono font-semibold flex items-center gap-1.5">
|
|
{b.bookingRef}
|
|
<span className="inline-flex items-center bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-400 text-xs font-semibold px-1.5 py-0.5 rounded">PKG</span>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">{b.package?.name}</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'tier',
|
|
label: 'Tier',
|
|
render: (b: any) => b.priceTier ? (
|
|
<div>
|
|
<div className="text-sm font-medium">{b.priceTier.label}</div>
|
|
<div className="text-xs text-muted-foreground">{b.priceTier.seatType}</div>
|
|
</div>
|
|
) : <span className="text-muted-foreground">—</span>,
|
|
},
|
|
{
|
|
key: 'passengers',
|
|
label: 'Passengers',
|
|
render: (b: any) => (
|
|
<div>
|
|
<div className="font-semibold">{b.passengerCount}</div>
|
|
<div className="text-xs text-muted-foreground">{b.contactPhone || b.contactEmail || '—'}</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'payment',
|
|
label: 'Payment',
|
|
render: (b: any) => (
|
|
<div>
|
|
<Badge variant="status" status={b.paymentIntent?.status || 'PENDING'}>
|
|
{b.paymentIntent?.status || 'PENDING'}
|
|
</Badge>
|
|
<div className="text-xs text-muted-foreground mt-1">{formatCurrency(b.totalMinor, b.currency || 'ETB')}</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: 'Status',
|
|
render: (b: any) => <Badge variant="status" status={b.status}>{b.status}</Badge>,
|
|
},
|
|
{
|
|
key: 'createdAt',
|
|
label: 'Booked At',
|
|
render: (b: any) => <span className="text-sm text-muted-foreground">{formatDateTime(b.createdAt)}</span>,
|
|
},
|
|
];
|
|
|
|
const actions = [
|
|
{ label: 'View', onClick: (b: any) => setSelected(b), variant: 'secondary' as const, icon: Eye },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-foreground">Package Bookings</h1>
|
|
<p className="text-muted-foreground">All bookings made through travel packages</p>
|
|
</div>
|
|
|
|
<div className="card">
|
|
{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: {getErrorMessage(error)}
|
|
</div>
|
|
)}
|
|
<div className="flex flex-wrap gap-3 mb-4">
|
|
<div className="flex-1 min-w-48">
|
|
<select className="input" value={filters.packageId}
|
|
onChange={(e) => setFilters({ ...filters, packageId: e.target.value, page: 1 })}>
|
|
<option value="">All Packages</option>
|
|
{packages.map((p: any) => (
|
|
<option key={p.id} value={p.id}>{p.name} ({p.code})</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<select className="input w-48" value={filters.status}
|
|
onChange={(e) => setFilters({ ...filters, status: e.target.value, page: 1 })}>
|
|
<option value="">All Statuses</option>
|
|
<option value="PENDING_PAYMENT">Pending Payment</option>
|
|
<option value="CONFIRMED">Confirmed</option>
|
|
<option value="CANCELLED">Cancelled</option>
|
|
</select>
|
|
</div>
|
|
<DataTable data={data?.items || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No package bookings found" />
|
|
{data?.totalPages > 1 && (
|
|
<Pagination currentPage={filters.page} totalPages={data.totalPages}
|
|
onPageChange={(page) => setFilters({ ...filters, page })} />
|
|
)}
|
|
</div>
|
|
|
|
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Package Booking Details" size="xl">
|
|
{selected && (() => {
|
|
const b = selected;
|
|
return (
|
|
<div>
|
|
<div className="-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r from-emerald-600 to-emerald-700 rounded-t-lg">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-emerald-100 text-xs font-semibold uppercase tracking-widest mb-1">Booking Reference</p>
|
|
<p className="text-white text-3xl font-mono font-bold tracking-wider">{b.bookingRef}</p>
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
<Badge variant="status" status={b.status}>{b.status}</Badge>
|
|
<p className="text-emerald-200 text-xs mt-2">{formatDateTime(b.createdAt)}</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
{[b.package?.name, b.priceTier?.label, `${b.passengerCount} passenger${b.passengerCount !== 1 ? 's' : ''}`].filter(Boolean).map((tag) => (
|
|
<span key={tag} className="inline-flex items-center gap-1.5 bg-white/20 text-white text-xs font-medium px-3 py-1 rounded-full">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-200" />{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<section>
|
|
<SectionHeader title="Package" />
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<Field label="Package Name" value={b.package?.name} />
|
|
<Field label="Package Code" value={b.package?.code} mono />
|
|
<Field label="Tier" value={b.priceTier?.label} />
|
|
<Field label="Seat Type" value={b.priceTier?.seatType} />
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<SectionHeader title="Contact" />
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<Field label="Email" value={b.contactEmail} truncate />
|
|
<Field label="Phone" value={b.contactPhone} />
|
|
<Field label="Promo Code" value={b.promoCode || 'None'} />
|
|
<Field label="Passenger ID" value={b.passengerId || 'Guest'} mono truncate />
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<SectionHeader title="Payment" />
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<div className="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-100 dark:border-emerald-800 rounded-lg p-3 col-span-2">
|
|
<p className="text-xs text-emerald-700 dark:text-emerald-400 mb-1">Total Amount</p>
|
|
<p className="text-xl font-bold text-emerald-800 dark:text-emerald-300">{formatCurrency(b.totalMinor, b.currency || 'ETB')}</p>
|
|
{b.displayCurrency && b.displayCurrency !== (b.currency || 'ETB') && (
|
|
<p className="text-xs text-emerald-600 dark:text-emerald-500 mt-0.5">
|
|
≈ {formatCurrency(b.displayTotalMinor ?? b.totalMinor, b.displayCurrency)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="bg-muted/40 rounded-lg p-3">
|
|
<p className="text-xs text-muted-foreground mb-2">Payment Status</p>
|
|
<Badge variant="status" status={b.paymentIntent?.status || 'PENDING'}>{b.paymentIntent?.status || 'PENDING'}</Badge>
|
|
</div>
|
|
<Field label="Method" value={b.paymentIntent?.method || '—'} />
|
|
</div>
|
|
</section>
|
|
|
|
{b.passengers?.length > 0 && (
|
|
<section>
|
|
<SectionHeader title={`Passengers (${b.passengers.length})`} />
|
|
<div className="divide-y divide-muted rounded-lg border border-muted overflow-hidden">
|
|
{b.passengers.map((p: any, i: number) => {
|
|
const isChild = i >= (b.adultCount ?? b.passengerCount);
|
|
return (
|
|
<div key={i} className="flex items-center justify-between px-4 py-3 bg-muted/20 hover:bg-muted/40 transition-colors">
|
|
<div className="flex items-center gap-3">
|
|
<span className="w-6 h-6 rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-400 text-xs font-bold flex items-center justify-center shrink-0">{i + 1}</span>
|
|
<div>
|
|
<p className="text-sm font-semibold">{p.passengerName}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{p.dateOfBirth ? new Date(p.dateOfBirth).toLocaleDateString() : ''}
|
|
{p.idDocumentType ? ` · ${p.idDocumentType}` : ''}
|
|
{p.passportNumber ? ` · ${p.passportNumber}` : ''}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${
|
|
isChild
|
|
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400'
|
|
: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
|
|
}`}>
|
|
{isChild ? 'CHILD' : 'ADULT'}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<section>
|
|
<SectionHeader title="Timestamps" />
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
<Field label="Created" value={formatDateTime(b.createdAt)} />
|
|
<Field label="Last Updated" value={formatDateTime(b.updatedAt)} />
|
|
<Field label="Booking ID" value={b.id} mono truncate />
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
|
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|