UAT issues resolution

This commit is contained in:
Stephanos A
2026-07-06 16:30:48 +03:00
parent 7b9f15fa58
commit 9b6423392b
18 changed files with 467 additions and 233 deletions

View File

@@ -147,6 +147,7 @@ export default function CoachesPage() {
const [editingItem, setEditingItem] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
const [isBedCoach, setIsBedCoach] = useState(false);
const [exportUtilModalOpen, setExportUtilModalOpen] = useState(false);
const [exportUtilFormat, setExportUtilFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
@@ -454,6 +455,7 @@ export default function CoachesPage() {
onClick: (item: any) => {
setEditingItem({ ...item, isCoach: true });
setSelectedCoachTypeId(item.coachTypeId || '');
setIsBedCoach(!!(item.bedCategory || item.coachType?.name?.toLowerCase().includes('bed')));
setShowModal(true);
},
variant: 'secondary' as const,
@@ -479,6 +481,7 @@ export default function CoachesPage() {
onClick={() => {
setEditingItem(null);
setSelectedCoachTypeId('');
setIsBedCoach(false);
setSearch('');
setShowModal(true);
}}
@@ -700,6 +703,7 @@ export default function CoachesPage() {
setShowModal(false);
setEditingItem(null);
setSelectedCoachTypeId('');
setIsBedCoach(false);
}}
title={
activeTab === 'types'
@@ -779,8 +783,14 @@ export default function CoachesPage() {
<select
name="coachTypeId"
className="input"
defaultValue={editingItem?.coachTypeId || ''}
onChange={(e) => setSelectedCoachTypeId(e.target.value)}
value={selectedCoachTypeId}
onChange={(e) => {
const id = e.target.value;
setSelectedCoachTypeId(id);
const ct = coachTypesArray.find((c: any) => c.id === id);
const name = ct?.name?.toLowerCase() ?? '';
setIsBedCoach(name.includes('bed') || name.includes('sleeper'));
}}
required
>
<option value="">Select Coach Type</option>
@@ -804,54 +814,35 @@ export default function CoachesPage() {
/>
</div>
{(() => {
const selectedCoachType = coachTypesArray.find((ct: any) => ct.id === (selectedCoachTypeId || editingItem?.coachTypeId));
const isBedType = selectedCoachType &&
(selectedCoachType.name?.toLowerCase().includes('bed') ||
selectedCoachType.name?.toLowerCase().includes('sleeper') ||
selectedCoachType.type?.toLowerCase().includes('sleeper'));
const derivedBedCategory = editingItem?.isCoach && selectedCoachType
? (selectedCoachType.name?.toLowerCase().includes('vip') ? 'VIP_BED' : 'ECONOMY_BED')
: (editingItem?.bedCategory || '');
return isBedType ? (
<>
<div>
<label className="label">Bed Category</label>
<select
name="bedCategory"
className="input"
defaultValue={derivedBedCategory}
>
<option value="">Select bed category</option>
<option value="ECONOMY_BED">Economy Bed</option>
<option value="VIP_BED">VIP Bed</option>
</select>
<p className="text-xs text-muted-foreground mt-1">
Select if this is a bed coach
</p>
</div>
<div>
<label className="label">Beds Per Room</label>
<select
name="bedsPerRoom"
className="input"
defaultValue={editingItem?.bedsPerRoom || ''}
>
<option value="">Auto (VIP: 4, Economy: 6)</option>
<option value="2">2 beds per room</option>
<option value="4">4 beds per room</option>
<option value="6">6 beds per room</option>
</select>
<p className="text-xs text-muted-foreground mt-1">
Only applies to bed coaches
</p>
</div>
</>
) : null;
})()}
{isBedCoach && (
<>
<div>
<label className="label">Bed Category *</label>
<select
name="bedCategory"
className="input"
defaultValue={editingItem?.bedCategory || ''}
required
>
<option value="">Select bed category</option>
<option value="ECONOMY_BED">Economy Bed (3 cols × 2 rows)</option>
<option value="VIP_BED">VIP Bed (2 cols × 2 rows)</option>
</select>
</div>
<div>
<label className="label">Beds Per Room</label>
<select
name="bedsPerRoom"
className="input"
defaultValue={editingItem?.bedsPerRoom || ''}
>
<option value="">Auto (VIP: 4, Economy: 6)</option>
<option value="4">4 beds per room (VIP)</option>
<option value="6">6 beds per room (Economy)</option>
</select>
</div>
</>
)}
<div>
<label className="label">Arrangement *</label>
@@ -903,6 +894,7 @@ export default function CoachesPage() {
setShowModal(false);
setEditingItem(null);
setSelectedCoachTypeId('');
setIsBedCoach(false);
}}
>
Cancel

View File

@@ -2,10 +2,11 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Edit, Loader2, RefreshCw } from 'lucide-react';
import { Edit, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client';
interface CurrencyRate {
@@ -29,6 +30,9 @@ export default function CurrenciesPage() {
const [editingRate, setEditingRate] = useState<CurrencyRate | null>(null);
const [rateInput, setRateInput] = useState('');
const [error, setError] = useState<string | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [addForm, setAddForm] = useState({ code: '', name: '', symbol: '', exchangeRate: '' });
const [deleteConfirm, setDeleteConfirm] = useState<CurrencyRate | null>(null);
const queryClient = useQueryClient();
const { data: currencies = [], isLoading } = useQuery<CurrencyRate[]>({
@@ -49,6 +53,26 @@ export default function CurrenciesPage() {
},
});
const createMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/currencies', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setShowAddModal(false);
setAddForm({ code: '', name: '', symbol: '', exchangeRate: '' });
setError(null);
},
onError: (err: any) => setError(err.response?.data?.message || 'Failed to add currency'),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/currencies/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setDeleteConfirm(null);
},
onError: (err: any) => setError(err.response?.data?.message || 'Failed to delete currency'),
});
const syncMutation = useMutation({
mutationFn: () => apiClient.post('/currencies/sync-rates', {}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['currencies'] }),
@@ -121,12 +145,8 @@ export default function CurrenciesPage() {
];
const actions = [
{
label: 'Edit Rate',
onClick: handleEdit,
variant: 'secondary' as const,
icon: Edit,
},
{ label: 'Edit', onClick: handleEdit, variant: 'secondary' as const, icon: Edit },
{ label: 'Delete', onClick: (c: CurrencyRate) => setDeleteConfirm(c), variant: 'danger' as const, icon: Trash2 },
];
return (
@@ -138,14 +158,17 @@ export default function CurrenciesPage() {
Manage ETB exchange rates for display currencies (DJF, USD)
</p>
</div>
<ActionButton
icon={RefreshCw}
variant="secondary"
onClick={() => syncMutation.mutate()}
loading={syncMutation.isPending}
>
Sync Rates
</ActionButton>
<div className="flex gap-2">
<ActionButton icon={Plus} onClick={() => { setError(null); setShowAddModal(true); }}>Add Currency</ActionButton>
<ActionButton
icon={RefreshCw}
variant="secondary"
onClick={() => syncMutation.mutate()}
loading={syncMutation.isPending}
>
Sync Rates
</ActionButton>
</div>
</div>
{error && !editingRate && (
@@ -204,6 +227,68 @@ export default function CurrenciesPage() {
<p> Rates apply globally; changes take effect immediately on the next booking or fare quote</p>
</div>
<Modal
isOpen={showAddModal}
onClose={() => { setShowAddModal(false); setError(null); }}
title="Add Currency"
size="sm"
>
<div className="space-y-4">
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">{error}</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Code *</label>
<input className="input uppercase" placeholder="e.g., EUR" maxLength={5}
value={addForm.code} onChange={(e) => setAddForm({ ...addForm, code: e.target.value.toUpperCase() })} />
</div>
<div>
<label className="label">Symbol *</label>
<input className="input" placeholder="e.g., €"
value={addForm.symbol} onChange={(e) => setAddForm({ ...addForm, symbol: e.target.value })} />
</div>
</div>
<div>
<label className="label">Name *</label>
<input className="input" placeholder="e.g., Euro"
value={addForm.name} onChange={(e) => setAddForm({ ...addForm, name: e.target.value })} />
</div>
<div>
<label className="label">Exchange Rate (1 ETB = ? {addForm.code || '...'}) *</label>
<input type="number" min="0.0001" step="0.0001" className="input" placeholder="e.g., 0.018"
value={addForm.exchangeRate} onChange={(e) => setAddForm({ ...addForm, exchangeRate: e.target.value })} />
</div>
<div className="flex gap-2 justify-end pt-2">
<ActionButton variant="secondary" onClick={() => { setShowAddModal(false); setError(null); }}>Cancel</ActionButton>
<ActionButton
loading={createMutation.isPending}
onClick={() => {
if (!addForm.code || !addForm.name || !addForm.symbol || !addForm.exchangeRate) {
setError('All fields are required'); return;
}
const rate = parseFloat(addForm.exchangeRate);
if (isNaN(rate) || rate <= 0) { setError('Exchange rate must be a positive number'); return; }
createMutation.mutate({ code: addForm.code, name: addForm.name, symbol: addForm.symbol, exchangeRate: rate });
}}
>
Add Currency
</ActionButton>
</div>
</div>
</Modal>
<ConfirmDialog
isOpen={!!deleteConfirm}
onClose={() => setDeleteConfirm(null)}
onConfirm={() => deleteMutation.mutate(deleteConfirm!.id)}
title="Delete Currency"
message={`Delete ${deleteConfirm?.code} (${CURRENCY_META[deleteConfirm?.code ?? '']?.name ?? deleteConfirm?.code})? This will remove the exchange rate record.`}
confirmText="Delete"
isDanger
isLoading={deleteMutation.isPending}
/>
<Modal
isOpen={!!editingRate}
onClose={() => { setEditingRate(null); setError(null); }}

View File

@@ -214,21 +214,31 @@ export default function PackageBookingsPage() {
<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) => (
<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>
{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>
))}
);
})}
</div>
</section>
)}

View File

@@ -8,7 +8,7 @@ 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 { packagesApi, stationsApi, schedulesApi, seatClassesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
const toLocal = (iso?: string) => {
@@ -42,7 +42,7 @@ export default function PackagesPage() {
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 [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);
@@ -64,8 +64,14 @@ export default function PackagesPage() {
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,
@@ -87,7 +93,7 @@ export default function PackagesPage() {
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setDeactivateConfirm(null); },
});
const emptyTierForm = { seatType: '', label: '', priceMinor: '', availableSeats: '' };
const emptyTierForm = { seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' };
const addTierMutation = useMutation({
mutationFn: ({ packageId, data }: { packageId: string; data: any }) => packagesApi.addTier(packageId, data),
@@ -135,13 +141,19 @@ export default function PackagesPage() {
const openEditTier = (tier: any) => {
setEditingTier(tier);
setTierForm({ seatType: tier.seatType, label: tier.label, priceMinor: String(tier.priceMinor), availableSeats: String(tier.availableSeats) });
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 = { seatType: tierForm.seatType, label: tierForm.label, priceMinor: parseInt(tierForm.priceMinor), availableSeats: parseInt(tierForm.availableSeats) };
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 {
@@ -267,7 +279,7 @@ export default function PackagesPage() {
{ 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); },
onClick: (p: any) => { setTiersPackage(p); setEditingTier(null); setTierForm({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); },
},
{
label: 'Activate', icon: CheckCircle, variant: 'primary' as const,
@@ -432,8 +444,12 @@ export default function PackagesPage() {
{(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>
<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>
@@ -454,16 +470,31 @@ export default function PackagesPage() {
<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 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
@@ -476,7 +507,7 @@ export default function PackagesPage() {
</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="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'}

View File

@@ -22,6 +22,7 @@ interface Schedule {
originStation?: { id: string; name: string };
destinationStation?: { id: string; name: string };
coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>;
isPackageOnly?: boolean;
}
interface Train {
@@ -105,6 +106,7 @@ export default function SchedulesPage() {
arrivalAt: '',
status: 'SCHEDULED',
coachIds: [] as string[],
isPackageOnly: false,
});
const [filters, setFilters] = useState({
@@ -279,6 +281,7 @@ export default function SchedulesPage() {
departureAt: depLocal.toISOString(),
arrivalAt: arrLocal.toISOString(),
status: editForm.status,
isPackageOnly: editForm.isPackageOnly,
coaches: editForm.coachIds.map((coachId: string, idx: number) => ({
coachId,
positionNumber: idx + 1,
@@ -336,6 +339,7 @@ export default function SchedulesPage() {
arrivalAt: arrStr,
status: schedule.status,
coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [],
isPackageOnly: schedule.isPackageOnly ?? false,
});
setError(null);
setShowEditModal(true);
@@ -455,9 +459,14 @@ export default function SchedulesPage() {
key: 'status',
label: 'Status',
render: (schedule: Schedule) => (
<span className={`edr-badge ${statusMap[schedule.status] || 'edr-badge-info'}`}>
{schedule.status}
</span>
<div className="flex items-center gap-2">
<span className={`edr-badge ${statusMap[schedule.status] || 'edr-badge-info'}`}>
{schedule.status}
</span>
{schedule.isPackageOnly && (
<span className="edr-badge edr-badge-warning">PKG</span>
)}
</div>
),
},
] as any;
@@ -1040,6 +1049,20 @@ export default function SchedulesPage() {
</select>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg border border-border">
<input
type="checkbox"
id="isPackageOnly"
checked={editForm.isPackageOnly}
onChange={(e) => setEditForm({ ...editForm, isPackageOnly: e.target.checked })}
className="w-4 h-4 rounded"
/>
<label htmlFor="isPackageOnly" className="text-sm cursor-pointer">
<span className="font-medium">Package Only</span>
<span className="block text-xs text-muted-foreground">Hide from public search reserved for package bookings</span>
</label>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="label">Coaches (Optional)</label>