Files
edr-platform/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx
2026-06-26 12:17:01 +03:00

589 lines
26 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, CheckCircle, Eye, Layers, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal';
import { packagesApi, stationsApi, schedulesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
const toLocal = (iso?: string) => {
if (!iso) return '';
const d = new Date(iso);
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
};
const emptyForm = {
code: '', name: '', description: '',
outboundScheduleId: '', returnScheduleId: '',
originStationId: '', destinationStationId: '',
boardingTime: '', departureTime: '', arrivalTime: '',
totalCapacity: '', coachConfiguration: '',
includedServices: '', busTransferIncluded: 'false', busTransferRoute: '',
validFrom: '', validUntil: '',
};
export default function PackagesPage() {
const [page] = useState(1);
const [form, setForm] = useState(emptyForm);
const [modalMode, setModalMode] = useState<'create' | 'edit' | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [viewPackage, setViewPackage] = useState<any>(null);
const [activateConfirm, setActivateConfirm] = useState<any>(null);
const [deactivateConfirm, setDeactivateConfirm] = useState<any>(null);
const [tiersPackage, setTiersPackage] = useState<any>(null);
const [editingTier, setEditingTier] = useState<any>(null);
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({
queryKey: ['packages', page],
queryFn: () => packagesApi.getAll({ page, pageSize: 20 }),
});
const { data: stationsData } = useQuery({
queryKey: ['stations-all'],
queryFn: () => stationsApi.getAll(),
});
const { data: schedulesData } = useQuery({
queryKey: ['schedules-all'],
queryFn: () => schedulesApi.getAll(),
});
const stations: any[] = stationsData?.items || stationsData?.data || (Array.isArray(stationsData) ? stationsData : []);
const schedules: any[] = schedulesData?.items || schedulesData?.data || (Array.isArray(schedulesData) ? schedulesData : []);
const createMutation = useMutation({
mutationFn: packagesApi.create,
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setModalMode(null); },
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => packagesApi.update(id, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setModalMode(null); },
});
const activateMutation = useMutation({
mutationFn: packagesApi.activate,
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setActivateConfirm(null); },
});
const deactivateMutation = useMutation({
mutationFn: packagesApi.deactivate,
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setDeactivateConfirm(null); },
});
const emptyTierForm = { seatType: '', label: '', priceMinor: '', availableSeats: '' };
const addTierMutation = useMutation({
mutationFn: ({ packageId, data }: { packageId: string; data: any }) => packagesApi.addTier(packageId, data),
onSuccess: (newTier) => {
setTiersPackage((prev: any) => prev ? { ...prev, priceTiers: [...(prev.priceTiers || []), newTier] } : prev);
queryClient.invalidateQueries({ queryKey: ['packages'] });
setEditingTier(null);
setTierForm(emptyTierForm);
setTierError(null);
},
onError: (e: any) => setTierError(e?.response?.data?.message || e?.message || 'Failed to add tier'),
});
const updateTierMutation = useMutation({
mutationFn: ({ tierId, data }: { tierId: string; data: any }) => packagesApi.updateTier(tierId, data),
onSuccess: (updated) => {
setTiersPackage((prev: any) => prev ? { ...prev, priceTiers: prev.priceTiers.map((t: any) => t.id === updated.id ? updated : t) } : prev);
queryClient.invalidateQueries({ queryKey: ['packages'] });
setEditingTier(null);
setTierForm(emptyTierForm);
setTierError(null);
},
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) => {
setTiersPackage((prev: any) => prev ? { ...prev, priceTiers: prev.priceTiers.filter((t: any) => t.id !== tierId) } : prev);
queryClient.invalidateQueries({ queryKey: ['packages'] });
setDeleteTierConfirm(null);
},
onError: (e: any) => setTierError(e?.response?.data?.message || e?.message || 'Failed to delete tier'),
});
const openEditTier = (tier: any) => {
setEditingTier(tier);
setTierForm({ seatType: tier.seatType, label: tier.label, priceMinor: String(tier.priceMinor), availableSeats: String(tier.availableSeats) });
setTierError(null);
};
const handleTierSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const payload = { seatType: tierForm.seatType, label: tierForm.label, priceMinor: parseInt(tierForm.priceMinor), availableSeats: parseInt(tierForm.availableSeats) };
if (editingTier) {
await updateTierMutation.mutateAsync({ tierId: editingTier.id, data: payload });
} else {
await addTierMutation.mutateAsync({ packageId: tiersPackage.id, data: payload });
}
};
const openCreate = () => {
setForm(emptyForm);
setEditingId(null);
setModalMode('create');
};
const openEdit = (pkg: any) => {
setForm({
code: pkg.code ?? '',
name: pkg.name ?? '',
description: pkg.description ?? '',
outboundScheduleId: pkg.outboundScheduleId ?? '',
returnScheduleId: pkg.returnScheduleId ?? '',
originStationId: pkg.originStationId ?? '',
destinationStationId: pkg.destinationStationId ?? '',
boardingTime: toLocal(pkg.boardingTime),
departureTime: toLocal(pkg.departureTime),
arrivalTime: toLocal(pkg.arrivalTime),
totalCapacity: String(pkg.totalCapacity ?? ''),
coachConfiguration: pkg.coachConfiguration ?? '',
includedServices: (pkg.includedServices ?? []).join('\n'),
busTransferIncluded: pkg.busTransferIncluded ? 'true' : 'false',
busTransferRoute: pkg.busTransferRoute ?? '',
validFrom: toLocal(pkg.validFrom),
validUntil: toLocal(pkg.validUntil),
});
setEditingId(pkg.id);
setModalMode('edit');
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const services = form.includedServices.split('\n').map((s) => s.trim()).filter(Boolean);
const payload = {
code: form.code,
name: form.name,
description: form.description || undefined,
outboundScheduleId: form.outboundScheduleId,
returnScheduleId: form.returnScheduleId,
originStationId: form.originStationId,
destinationStationId: form.destinationStationId,
boardingTime: form.boardingTime,
departureTime: form.departureTime,
arrivalTime: form.arrivalTime,
totalCapacity: parseInt(form.totalCapacity),
coachConfiguration: form.coachConfiguration || undefined,
includedServices: services,
busTransferIncluded: form.busTransferIncluded === 'true',
busTransferRoute: form.busTransferRoute || undefined,
validFrom: form.validFrom,
validUntil: form.validUntil,
priceTiers: [],
};
if (modalMode === 'edit' && editingId) {
await updateMutation.mutateAsync({ id: editingId, data: payload });
} else {
await createMutation.mutateAsync(payload);
}
};
const field = (key: keyof typeof form) => ({
value: form[key],
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
setForm((f) => ({ ...f, [key]: e.target.value })),
});
const scheduleLabel = (s: any) => {
const from = s.originStation?.name ?? s.originStationId ?? '?';
const to = s.destinationStation?.name ?? s.destinationStationId ?? '?';
const dep = s.departureAt ? new Date(s.departureAt).toLocaleString() : '';
return `${from}${to}${dep ? ' | ' + dep : ''}`;
};
const columns = [
{ key: 'code', label: 'Package',
render: (pkg: any) => (
<div className="text-sm">
<div>{pkg.code}</div>
<div className="text-muted-foreground">{pkg.name}</div>
</div>
), },
{
key: 'capacity', label: 'Capacity',
render: (p: any) => (
<div className="text-sm">
<div>{p.totalCapacity} seats</div>
{p.priceTiers?.length > 0 && <div className="text-muted-foreground">{p.priceTiers.length} tiers</div>}
</div>
),
},
{
key: 'validity', label: 'Valid Period',
render: (p: any) => (
<div className="text-sm">
<div>{new Date(p.validFrom).toLocaleDateString()}</div>
<div className="text-muted-foreground"> {new Date(p.validUntil).toLocaleDateString()}</div>
</div>
),
},
{
key: 'status', label: 'Status',
render: (p: any) => (
<Badge variant="status" status={p.status === 'ACTIVE' ? 'CONFIRMED' : p.status === 'DRAFT' ? 'PENDING' : 'CANCELLED'}>
{p.status}
</Badge>
),
},
{
key: 'createdAt', label: 'Created',
render: (p: any) => <span className="text-sm text-muted-foreground">{formatDateTime(p.createdAt)}</span>,
},
];
const actions = [
{ label: 'View', onClick: (p: any) => setViewPackage(p), variant: 'secondary' as const, icon: Eye },
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit },
{
label: 'Tiers', icon: Layers, variant: 'secondary' as const,
onClick: (p: any) => { setTiersPackage(p); setEditingTier(null); setTierForm({ seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); },
},
{
label: 'Activate', icon: CheckCircle, variant: 'primary' as const,
onClick: (p: any) => setActivateConfirm(p),
show: (p: any) => p.status !== 'ACTIVE',
},
{
label: 'Deactivate', icon: CheckCircle, variant: 'secondary' as const,
onClick: (p: any) => setDeactivateConfirm(p),
show: (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;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Packages</h1>
<p className="text-muted-foreground">Manage travel packages and pilgrimages</p>
</div>
<ActionButton icon={Plus} onClick={openCreate}>New Package</ActionButton>
</div>
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No packages found"
/>
{/* View Modal */}
<Modal isOpen={!!viewPackage} onClose={() => setViewPackage(null)} title="Package Details" size="lg">
{viewPackage && (
<div className="space-y-4 text-sm">
<div className="grid grid-cols-2 gap-4">
<div><span className="label">Code</span><p className="font-mono font-semibold">{viewPackage.code}</p></div>
<div><span className="label">Status</span><p>{viewPackage.status}</p></div>
<div className="col-span-2"><span className="label">Name</span><p className="font-medium">{viewPackage.name}</p></div>
{viewPackage.description && <div className="col-span-2"><span className="label">Description</span><p>{viewPackage.description}</p></div>}
<div><span className="label">Total Capacity</span><p>{viewPackage.totalCapacity}</p></div>
<div><span className="label">Coach Config</span><p>{viewPackage.coachConfiguration || '—'}</p></div>
<div><span className="label">Boarding</span><p>{formatDateTime(viewPackage.boardingTime)}</p></div>
<div><span className="label">Departure</span><p>{formatDateTime(viewPackage.departureTime)}</p></div>
<div><span className="label">Arrival</span><p>{formatDateTime(viewPackage.arrivalTime)}</p></div>
<div><span className="label">Bus Transfer</span><p>{viewPackage.busTransferIncluded ? `Yes — ${viewPackage.busTransferRoute || ''}` : 'No'}</p></div>
<div><span className="label">Valid From</span><p>{new Date(viewPackage.validFrom).toLocaleDateString()}</p></div>
<div><span className="label">Valid Until</span><p>{new Date(viewPackage.validUntil).toLocaleDateString()}</p></div>
</div>
{viewPackage.includedServices?.length > 0 && (
<div>
<span className="label">Included Services</span>
<ul className="mt-1 list-disc list-inside space-y-0.5">
{viewPackage.includedServices.map((s: string, i: number) => <li key={i}>{s}</li>)}
</ul>
</div>
)}
{viewPackage.priceTiers?.length > 0 && (
<div>
<span className="label">Price Tiers</span>
<div className="mt-1 space-y-1">
{viewPackage.priceTiers.map((t: any) => (
<div key={t.id} className="flex justify-between rounded border border-border px-3 py-2">
<span>{t.label} ({t.seatType})</span>
<span className="font-semibold">{formatCurrency(t.priceMinor, 'ETB')} {t.bookedSeats}/{t.availableSeats} booked</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</Modal>
{/* Activate Confirmation */}
<ConfirmDialog
isOpen={!!activateConfirm}
onClose={() => setActivateConfirm(null)}
onConfirm={() => activateMutation.mutate(activateConfirm.id)}
title="Activate Package"
message={`Activate "${activateConfirm?.name}"? It will become publicly available for booking.`}
confirmText="Activate"
isDanger={false}
/>
{/* Deactivate Confirmation */}
<ConfirmDialog
isOpen={!!deactivateConfirm}
onClose={() => setDeactivateConfirm(null)}
onConfirm={() => deactivateMutation.mutate(deactivateConfirm.id)}
title="Deactivate Package"
message={`Deactivate "${deactivateConfirm?.name}"? It will no longer be available for booking.`}
confirmText="Deactivate"
isDanger={false}
isLoading={deactivateMutation.isPending}
/>
{/* Tiers Modal */}
<Modal isOpen={!!tiersPackage} onClose={() => { setTiersPackage(null); setEditingTier(null); setTierError(null); }} title={`Price Tiers — ${tiersPackage?.name ?? ''}`} size="lg">
{tiersPackage && (
<div className="space-y-4">
{tierError && (
<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-300">
{tierError}
</div>
)}
{/* Existing tiers list */}
<div className="space-y-2">
{(tiersPackage.priceTiers ?? []).length === 0 && (
<p className="text-sm text-muted-foreground">No tiers yet. Add one below.</p>
)}
{(tiersPackage.priceTiers ?? []).map((t: any) => (
<div key={t.id} className="flex items-center justify-between rounded border border-border px-3 py-2">
<div>
<span className="font-medium text-sm">{t.label}</span>
<span className="ml-2 text-xs text-muted-foreground">({t.seatType})</span>
<div className="text-xs text-muted-foreground mt-0.5">
{formatCurrency(t.priceMinor, 'ETB')} · {t.bookedSeats}/{t.availableSeats} booked
</div>
</div>
<div className="flex gap-2">
<ActionButton variant="secondary" icon={Edit} onClick={() => openEditTier(t)}>Edit</ActionButton>
<ActionButton
variant="danger" icon={Trash2}
onClick={() => { setTierError(null); setDeleteTierConfirm(t); }}
disabled={t.bookedSeats > 0}
>Delete</ActionButton>
</div>
</div>
))}
</div>
{/* Add / Edit tier form */}
<div className="border-t border-border pt-4">
<p className="text-sm font-semibold mb-3">{editingTier ? 'Edit Tier' : 'Add New Tier'}</p>
<form onSubmit={handleTierSubmit} className="grid grid-cols-2 gap-3">
<div>
<label className="label">Seat Type *</label>
<input className="input" placeholder="e.g., HSC" required
value={tierForm.seatType} onChange={(e) => setTierForm((f) => ({ ...f, seatType: e.target.value }))} />
</div>
<div>
<label className="label">Label *</label>
<input className="input" placeholder="e.g., Regular Seat (HSC)" required
value={tierForm.label} onChange={(e) => setTierForm((f) => ({ ...f, label: e.target.value }))} />
</div>
<div>
<label className="label">Price (minor/cents) *</label>
<input type="number" min="0" className="input" placeholder="e.g., 1023200" required
value={tierForm.priceMinor} onChange={(e) => setTierForm((f) => ({ ...f, priceMinor: e.target.value }))} />
</div>
<div>
<label className="label">Available Seats *</label>
<input type="number" min="0" className="input" placeholder="e.g., 100" required
value={tierForm.availableSeats} onChange={(e) => setTierForm((f) => ({ ...f, availableSeats: e.target.value }))} />
</div>
<div className="col-span-2 flex justify-end gap-2">
{editingTier && (
<ActionButton type="button" variant="secondary" onClick={() => { setEditingTier(null); setTierForm({ seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); }}>Cancel</ActionButton>
)}
<ActionButton type="submit" loading={addTierMutation.isPending || updateTierMutation.isPending}>
{editingTier ? 'Update Tier' : 'Add Tier'}
</ActionButton>
</div>
</form>
</div>
</div>
)}
</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}
onClose={() => { setDeleteTierConfirm(null); setTierError(null); }}
onConfirm={() => deleteTierMutation.mutate(deleteTierConfirm.id)}
title="Delete Tier"
message={`Delete tier "${deleteTierConfirm?.label}"? This cannot be undone.`}
confirmText="Delete" isDanger
isLoading={deleteTierMutation.isPending}
error={tierError ?? undefined}
/>
{/* Create / Edit Modal */}
<Modal
isOpen={modalMode !== null}
onClose={() => setModalMode(null)}
title={modalMode === 'edit' ? 'Edit Package' : 'New Package'}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Code *</label>
<input className="input uppercase" placeholder="e.g., KULUBBI-2025" required {...field('code')} />
</div>
<div>
<label className="label">Name *</label>
<input className="input" placeholder="Package name" required {...field('name')} />
</div>
<div className="col-span-2">
<label className="label">Description</label>
<textarea className="input" rows={2} placeholder="Optional description" {...field('description')} />
</div>
<div>
<label className="label">Origin Station *</label>
<select className="input" required {...field('originStationId')}>
<option value="">Select station</option>
{stations.map((s: any) => (
<option key={s.id} value={s.id}>{s.name} ({s.code})</option>
))}
</select>
</div>
<div>
<label className="label">Destination Station *</label>
<select className="input" required {...field('destinationStationId')}>
<option value="">Select station</option>
{stations.map((s: any) => (
<option key={s.id} value={s.id}>{s.name} ({s.code})</option>
))}
</select>
</div>
<div>
<label className="label">Outbound Schedule *</label>
<select className="input" required {...field('outboundScheduleId')}>
<option value="">Select schedule</option>
{schedules.map((s: any) => (
<option key={s.id} value={s.id}>{scheduleLabel(s)}</option>
))}
</select>
</div>
<div>
<label className="label">Return Schedule *</label>
<select className="input" required {...field('returnScheduleId')}>
<option value="">Select schedule</option>
{schedules.map((s: any) => (
<option key={s.id} value={s.id}>{scheduleLabel(s)}</option>
))}
</select>
</div>
<div>
<label className="label">Boarding Time *</label>
<input type="datetime-local" className="input" required {...field('boardingTime')} />
</div>
<div>
<label className="label">Departure Time *</label>
<input type="datetime-local" className="input" required {...field('departureTime')} />
</div>
<div>
<label className="label">Arrival Time *</label>
<input type="datetime-local" className="input" required {...field('arrivalTime')} />
</div>
<div>
<label className="label">Total Capacity *</label>
<input type="number" min="1" className="input" placeholder="e.g., 912" required {...field('totalCapacity')} />
</div>
<div>
<label className="label">Coach Configuration</label>
<input className="input" placeholder="e.g., 1 Loco + 6HSC" {...field('coachConfiguration')} />
</div>
<div>
<label className="label">Bus Transfer</label>
<select className="input" {...field('busTransferIncluded')}>
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</div>
{form.busTransferIncluded === 'true' && (
<div className="col-span-2">
<label className="label">Bus Transfer Route</label>
<input className="input" placeholder="e.g., Addis Ababa → Kulubbi" {...field('busTransferRoute')} />
</div>
)}
<div>
<label className="label">Valid From *</label>
<input type="datetime-local" className="input" required {...field('validFrom')} />
</div>
<div>
<label className="label">Valid Until *</label>
<input type="datetime-local" className="input" required {...field('validUntil')} />
</div>
<div className="col-span-2">
<label className="label">Included Services (one per line)</label>
<textarea className="input" rows={3} placeholder={'Round trip train ticket\nBus transfer\nMeal on board'} {...field('includedServices')} />
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<ActionButton type="button" variant="secondary" onClick={() => setModalMode(null)}>Cancel</ActionButton>
<ActionButton type="submit" loading={isPending}>
{modalMode === 'edit' ? 'Update Package' : 'Create Package'}
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}