mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
830 lines
38 KiB
TypeScript
830 lines
38 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Plus, Edit, CheckCircle, Eye, Layers, Trash2, ImagePlus, ImageOff, X } 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, seatClassesApi } from '@/lib/api';
|
|
import { getErrorMessage } from '@/lib/api-client';
|
|
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
|
|
|
// Mirrors the backend's own limits (packages/package-image-upload.options.ts) so a bad file is
|
|
// rejected instantly client-side instead of round-tripping to the server first.
|
|
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
|
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
|
|
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 [search, setSearch] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState('');
|
|
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
|
const [dateFrom, setDateFrom] = useState('');
|
|
const [dateTo, setDateTo] = useState('');
|
|
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({ seatClassId: '', 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 [deletePackageCascade, setDeletePackageCascade] = useState(false);
|
|
// Image upload: `imageFile`/`imagePreviewUrl` track a newly-selected-but-not-yet-uploaded file
|
|
// (local object URL preview); `existingImageUrl` is the package's current server-side image
|
|
// when editing, shown until/unless the admin picks a replacement.
|
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
|
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
|
const [existingImageUrl, setExistingImageUrl] = useState<string | null>(null);
|
|
const [imageError, setImageError] = useState<string | null>(null);
|
|
const [imageUploadError, setImageUploadError] = useState<string | null>(null);
|
|
const [removeImageConfirm, setRemoveImageConfirm] = useState<any>(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 { data: seatClassesData } = useQuery({
|
|
queryKey: ['seat-classes-all'],
|
|
queryFn: () => seatClassesApi.getAll(),
|
|
});
|
|
|
|
const stations: any[] = stationsData?.items || stationsData?.data || (Array.isArray(stationsData) ? stationsData : []);
|
|
const schedules: any[] = schedulesData?.items || schedulesData?.data || (Array.isArray(schedulesData) ? schedulesData : []);
|
|
const seatClasses: any[] = Array.isArray(seatClassesData) ? seatClassesData : (seatClassesData as any)?.items || (seatClassesData as any)?.data || [];
|
|
|
|
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 = { seatClassId: '', 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, cascade }: { id: string; cascade: boolean }) => packagesApi.remove(id, cascade),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['packages'] });
|
|
setDeletePackageConfirm(null);
|
|
setDeletePackageError(null);
|
|
setDeletePackageCascade(false);
|
|
},
|
|
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 uploadImageMutation = useMutation({
|
|
mutationFn: ({ id, file }: { id: string; file: File }) => packagesApi.uploadImage(id, file),
|
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setImageUploadError(null); },
|
|
onError: (e: any) => setImageUploadError(getErrorMessage(e, 'Failed to upload package image')),
|
|
});
|
|
|
|
const removeImageMutation = useMutation({
|
|
mutationFn: (id: string) => packagesApi.removeImage(id),
|
|
onSuccess: (updated: any) => {
|
|
queryClient.invalidateQueries({ queryKey: ['packages'] });
|
|
setRemoveImageConfirm(null);
|
|
setExistingImageUrl(updated?.imageUrl ?? null);
|
|
setViewPackage((prev: any) => (prev && prev.id === updated?.id ? { ...prev, imageUrl: null } : prev));
|
|
},
|
|
onError: (e: any) => setImageUploadError(getErrorMessage(e, 'Failed to remove package image')),
|
|
});
|
|
|
|
const openEditTier = (tier: any) => {
|
|
setEditingTier(tier);
|
|
setTierForm({ seatClassId: tier.seatClassId ?? '', 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: any = {
|
|
seatType: tierForm.seatType,
|
|
label: tierForm.label,
|
|
priceMinor: parseInt(tierForm.priceMinor),
|
|
availableSeats: parseInt(tierForm.availableSeats),
|
|
...(tierForm.seatClassId ? { seatClassId: tierForm.seatClassId } : {}),
|
|
};
|
|
if (editingTier) {
|
|
await updateTierMutation.mutateAsync({ tierId: editingTier.id, data: payload });
|
|
} else {
|
|
await addTierMutation.mutateAsync({ packageId: tiersPackage.id, data: payload });
|
|
}
|
|
};
|
|
|
|
// Deliberately does not touch imageUploadError — that's shown in a page-level banner (outside
|
|
// this modal) precisely because it can still be set after the modal has already auto-closed
|
|
// (see handleSubmit), and clearing it here would wipe it out before the user ever sees it.
|
|
const resetImageSelection = () => {
|
|
setImageFile(null);
|
|
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
|
setImagePreviewUrl(null);
|
|
setImageError(null);
|
|
};
|
|
|
|
const openCreate = () => {
|
|
setForm(emptyForm);
|
|
setEditingId(null);
|
|
resetImageSelection();
|
|
setImageUploadError(null);
|
|
setExistingImageUrl(null);
|
|
setModalMode('create');
|
|
};
|
|
|
|
const openEdit = (pkg: any) => {
|
|
resetImageSelection();
|
|
setImageUploadError(null);
|
|
setExistingImageUrl(pkg.imageUrl ?? null);
|
|
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: [],
|
|
};
|
|
// The image is uploaded as a separate follow-up call (the DTO here carries no image field —
|
|
// see packages.service.ts's uploadImage) so it must run after the package itself exists.
|
|
let targetId = editingId;
|
|
if (modalMode === 'edit' && editingId) {
|
|
await updateMutation.mutateAsync({ id: editingId, data: payload });
|
|
} else {
|
|
const created = await createMutation.mutateAsync(payload);
|
|
targetId = created?.id ?? null;
|
|
}
|
|
if (imageFile && targetId) {
|
|
try {
|
|
await uploadImageMutation.mutateAsync({ id: targetId, file: imageFile });
|
|
} catch {
|
|
// surfaced via imageUploadError banner — the package itself was already saved successfully
|
|
}
|
|
}
|
|
resetImageSelection();
|
|
};
|
|
|
|
const field = (key: keyof typeof form) => ({
|
|
value: form[key],
|
|
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
|
|
setForm((f) => ({ ...f, [key]: e.target.value })),
|
|
});
|
|
|
|
const handleImageFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
e.target.value = ''; // allow re-selecting the same file after a validation error
|
|
if (!file) return;
|
|
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
|
setImageError('Image must be JPEG, PNG, WEBP, or GIF.');
|
|
return;
|
|
}
|
|
if (file.size > MAX_IMAGE_BYTES) {
|
|
setImageError(`Image must be ${Math.round(MAX_IMAGE_BYTES / (1024 * 1024))}MB or smaller.`);
|
|
return;
|
|
}
|
|
setImageError(null);
|
|
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
|
setImageFile(file);
|
|
setImagePreviewUrl(URL.createObjectURL(file));
|
|
};
|
|
|
|
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: 'image', label: '',
|
|
render: (p: any) => (
|
|
p.imageUrl ? (
|
|
<img src={p.imageUrl} alt="" className="h-10 w-10 rounded object-cover border border-border" />
|
|
) : (
|
|
<div className="h-10 w-10 rounded border border-border bg-muted flex items-center justify-center">
|
|
<ImageOff className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
)
|
|
),
|
|
},
|
|
{ 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({ seatClassId: '', 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); setDeletePackageCascade(false); setDeletePackageConfirm(p); },
|
|
},
|
|
];
|
|
|
|
const isPending = createMutation.isPending || updateMutation.isPending || uploadImageMutation.isPending;
|
|
|
|
const allItems: any[] = data?.items || [];
|
|
const filteredItems = allItems.filter((p) => {
|
|
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) && !p.code.toLowerCase().includes(search.toLowerCase())) return false;
|
|
if (statusFilter && p.status !== statusFilter) return false;
|
|
if (dateFrom && new Date(p.validFrom).toISOString().split('T')[0] < dateFrom) return false;
|
|
if (dateTo && new Date(p.validUntil).toISOString().split('T')[0] > dateTo) return false;
|
|
return true;
|
|
});
|
|
|
|
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>
|
|
|
|
{/* The package itself may already be saved and this modal closed by the time an image
|
|
upload/removal fails (see handleSubmit) — surfaced here rather than inside the modal
|
|
so it's never silently lost. */}
|
|
{imageUploadError && (
|
|
<div className="flex items-center justify-between 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">
|
|
<span>{imageUploadError}</span>
|
|
<button type="button" className="ml-3 shrink-0" onClick={() => setImageUploadError(null)}>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="card">
|
|
<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 or code..." className="input"
|
|
value={search} onChange={(e) => setSearch(e.target.value)} />
|
|
</div>
|
|
<select className="input w-44" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
|
<option value="">All Status</option>
|
|
<option value="DRAFT">Draft</option>
|
|
<option value="ACTIVE">Active</option>
|
|
<option value="SOLD_OUT">Sold Out</option>
|
|
<option value="EXPIRED">Expired</option>
|
|
<option value="CANCELLED">Cancelled</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>
|
|
{showExtraFilters && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
|
|
<div>
|
|
<label className="label">Valid From</label>
|
|
<input type="date" className="input" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<label className="label">Valid Until</label>
|
|
<input type="date" className="input" value={dateTo} onChange={(e) => setDateTo(e.target.value)} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<DataTable
|
|
data={filteredItems}
|
|
columns={columns}
|
|
actions={actions}
|
|
loading={isLoading}
|
|
emptyMessage="No packages found"
|
|
/>
|
|
</div>
|
|
|
|
{/* View Modal */}
|
|
<Modal isOpen={!!viewPackage} onClose={() => setViewPackage(null)} title="Package Details" size="lg">
|
|
{viewPackage && (
|
|
<div className="space-y-4 text-sm">
|
|
{viewPackage.imageUrl ? (
|
|
<img src={viewPackage.imageUrl} alt={viewPackage.name} className="w-full max-h-56 rounded-lg object-cover border border-border" />
|
|
) : (
|
|
<div className="w-full h-32 rounded-lg border border-dashed border-border bg-muted flex items-center justify-center gap-2 text-muted-foreground">
|
|
<ImageOff className="h-5 w-5" />
|
|
<span>No image</span>
|
|
</div>
|
|
)}
|
|
<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.seatType}</span>
|
|
{t.seatClassId && (
|
|
<span className="ml-2 text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded font-medium">
|
|
{seatClasses.find((sc: any) => sc.id === t.seatClassId)?.coachType.type ?? 'Linked'}
|
|
</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 className="col-span-2">
|
|
<label className="label">Seat Class *</label>
|
|
<select
|
|
className="input"
|
|
required
|
|
value={tierForm.seatClassId}
|
|
onChange={(e) => {
|
|
const sc = seatClasses.find((c: any) => c.id === e.target.value);
|
|
setTierForm((f) => ({
|
|
...f,
|
|
seatClassId: e.target.value,
|
|
seatType: sc?.name ?? f.seatType,
|
|
label: sc?.name ?? f.label,
|
|
}));
|
|
}}
|
|
>
|
|
<option value="">Select seat class</option>
|
|
{seatClasses.map((sc: any) => (
|
|
<option key={sc.id} value={sc.id}>
|
|
{sc.name}{sc.description ? ` — ${sc.description}` : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</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({ seatClassId: '', 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); setDeletePackageCascade(false); }}
|
|
onConfirm={() => deletePackageMutation.mutate({ id: deletePackageConfirm.id, cascade: deletePackageCascade })}
|
|
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}
|
|
cascadeWarning={deletePackageError ? 'This package has active bookings or related records. Check the box below to force delete everything.' : undefined}
|
|
cascadeChecked={deletePackageCascade}
|
|
onCascadeChange={setDeletePackageCascade}
|
|
/>
|
|
|
|
{/* 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}
|
|
/>
|
|
|
|
{/* Remove Image Confirmation */}
|
|
<ConfirmDialog
|
|
isOpen={!!removeImageConfirm}
|
|
onClose={() => setRemoveImageConfirm(null)}
|
|
onConfirm={() => removeImageMutation.mutate(removeImageConfirm.id)}
|
|
title="Remove Package Image"
|
|
message={`Remove the image for "${removeImageConfirm?.name}"? The package itself will not be deleted.`}
|
|
confirmText="Remove Image"
|
|
isDanger
|
|
isLoading={removeImageMutation.isPending}
|
|
error={imageUploadError ?? undefined}
|
|
/>
|
|
|
|
{/* Create / Edit Modal */}
|
|
<Modal
|
|
isOpen={modalMode !== null}
|
|
onClose={() => { setModalMode(null); resetImageSelection(); }}
|
|
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 className="col-span-2">
|
|
<label className="label">Package Image</label>
|
|
<div className="flex items-start gap-4">
|
|
{imagePreviewUrl ? (
|
|
<img src={imagePreviewUrl} alt="Preview" className="h-24 w-24 rounded-lg object-cover border border-border" />
|
|
) : existingImageUrl ? (
|
|
<img src={existingImageUrl} alt="Current" className="h-24 w-24 rounded-lg object-cover border border-border" />
|
|
) : (
|
|
<div className="h-24 w-24 rounded-lg border border-dashed border-border bg-muted flex items-center justify-center">
|
|
<ImagePlus className="h-6 w-6 text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
<div className="flex-1 space-y-2">
|
|
<input type="file" accept="image/jpeg,image/png,image/webp,image/gif" className="input" onChange={handleImageFileChange} />
|
|
<p className="text-xs text-muted-foreground">JPEG, PNG, WEBP, or GIF. Max 5MB.</p>
|
|
{imageError && <p className="text-xs text-red-600 dark:text-red-400">{imageError}</p>}
|
|
{imageFile && (
|
|
<button type="button" className="text-xs text-primary underline" onClick={resetImageSelection}>
|
|
<X className="h-3 w-3 inline -mt-0.5 mr-0.5" />Clear selected file
|
|
</button>
|
|
)}
|
|
{!imageFile && modalMode === 'edit' && existingImageUrl && (
|
|
<button
|
|
type="button"
|
|
className="text-xs text-red-600 dark:text-red-400 underline block"
|
|
onClick={() => { setImageUploadError(null); setRemoveImageConfirm({ id: editingId, name: form.name }); }}
|
|
>
|
|
Remove current image
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</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); resetImageSelection(); }}>Cancel</ActionButton>
|
|
<ActionButton type="submit" loading={isPending}>
|
|
{modalMode === 'edit' ? 'Update Package' : 'Create Package'}
|
|
</ActionButton>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|