mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
589 lines
26 KiB
TypeScript
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>
|
|
);
|
|
}
|