Files
edr-platform/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx
2026-08-27 22:54:59 +03:00

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>
);
}