Seatmap rendering and other updates

This commit is contained in:
Stephanos A
2026-06-09 15:22:27 +03:00
parent bf8cc7e6cf
commit c2bab6cae8
30 changed files with 2020 additions and 329 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, Search } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
@@ -16,6 +16,7 @@ export default function ClassesPage() {
const [showModal, setShowModal] = useState(false);
const [editingClass, setEditingClass] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null }>({ isOpen: false, class: null });
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
@@ -28,12 +29,19 @@ export default function ClassesPage() {
queryFn: () => apiClient.get('/fleet/coach-types'),
});
useEffect(() => {
if (showModal && editingClass) {
setSelectedCoachTypeId(editingClass.coachTypeId || '');
}
}, [showModal, editingClass]);
const createMutation = useMutation({
mutationFn: seatClassesApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['classes'] });
setShowModal(false);
setEditingClass(null);
setSelectedCoachTypeId('');
},
});
@@ -43,6 +51,7 @@ export default function ClassesPage() {
queryClient.invalidateQueries({ queryKey: ['classes'] });
setShowModal(false);
setEditingClass(null);
setSelectedCoachTypeId('');
},
});
@@ -55,12 +64,19 @@ export default function ClassesPage() {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!selectedCoachTypeId) {
alert('Please select a coach type');
return;
}
const formData = new FormData(e.currentTarget);
const classData = {
coachTypeId: formData.get('coachTypeId') as string,
coachTypeId: selectedCoachTypeId,
name: formData.get('name') as string,
description: formData.get('description') as string,
baseFareMinor: parseInt(formData.get('baseFareMinor') as string) || 0,
isActive: formData.get('isActive') === 'true',
};
if (editingClass) {
@@ -136,13 +152,19 @@ export default function ClassesPage() {
},
];
const handleOpenModal = (cls?: any) => {
if (cls) {
setEditingClass(cls);
} else {
setEditingClass(null);
}
setShowModal(true);
};
const actions = [
{
label: 'Edit',
onClick: (cls: any) => {
setEditingClass(cls);
setShowModal(true);
},
onClick: (cls: any) => handleOpenModal(cls),
variant: 'secondary' as const,
icon: Edit,
},
@@ -163,10 +185,7 @@ export default function ClassesPage() {
</div>
<ActionButton
icon={Plus}
onClick={() => {
setEditingClass(null);
setShowModal(true);
}}
onClick={() => handleOpenModal()}
>
Add Class
</ActionButton>
@@ -211,6 +230,7 @@ export default function ClassesPage() {
onClose={() => {
setShowModal(false);
setEditingClass(null);
setSelectedCoachTypeId('');
}}
title={`${editingClass ? 'Edit' : 'Add'} Class`}
size="lg"
@@ -222,7 +242,8 @@ export default function ClassesPage() {
<select
name="coachTypeId"
className="input"
defaultValue={editingClass?.coachTypeId || ''}
value={selectedCoachTypeId}
onChange={(e) => setSelectedCoachTypeId(e.target.value)}
required
>
<option value="">Select Coach Type</option>
@@ -276,7 +297,7 @@ export default function ClassesPage() {
<select
name="isActive"
className="input"
defaultValue={editingClass?.isActive?.toString() || 'true'}
defaultValue={editingClass?.isActive !== undefined ? editingClass.isActive.toString() : 'true'}
>
<option value="true">Active</option>
<option value="false">Inactive</option>
@@ -291,6 +312,7 @@ export default function ClassesPage() {
onClick={() => {
setShowModal(false);
setEditingClass(null);
setSelectedCoachTypeId('');
}}
>
Cancel

View File

@@ -510,7 +510,7 @@ export default function CoachesPage() {
className="input"
defaultValue={editingItem?.number || editingItem?.coachNumber || ''}
required
placeholder="e.g., A-001"
placeholder="e.g., HSC-0001"
/>
</div>

View File

@@ -0,0 +1,7 @@
'use client';
import DashboardLayout from '../dashboard/layout';
export default function PromosLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,409 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, Copy, Check } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { promosApi, PromoCode } from '@/lib/api/promos';
export default function PromosPage() {
const [filters, setFilters] = useState({ search: '', active: '', page: 1, pageSize: 10 });
const [showModal, setShowModal] = useState(false);
const [editingPromo, setEditingPromo] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; promo: any | null }>({ isOpen: false, promo: null });
const [copiedCode, setCopiedCode] = useState<string | null>(null);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['promos', filters],
queryFn: () => promosApi.getAll(filters),
});
const createMutation = useMutation({
mutationFn: promosApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['promos'] });
setShowModal(false);
setEditingPromo(null);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => promosApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['promos'] });
setShowModal(false);
setEditingPromo(null);
},
});
const deleteMutation = useMutation({
mutationFn: promosApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['promos'] });
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const promoData = {
code: formData.get('code') as string,
title: formData.get('title') as string,
discountType: formData.get('discountType') as 'PERCENTAGE' | 'FIXED',
discountValue: parseFloat(formData.get('discountValue') as string),
maxDiscount: formData.get('maxDiscount') ? parseFloat(formData.get('maxDiscount') as string) : undefined,
minBookingAmount: formData.get('minBookingAmount') ? parseFloat(formData.get('minBookingAmount') as string) : undefined,
maxUsagePerUser: formData.get('maxUsagePerUser') ? parseInt(formData.get('maxUsagePerUser') as string) : undefined,
totalUsageLimit: formData.get('totalUsageLimit') ? parseInt(formData.get('totalUsageLimit') as string) : undefined,
validFrom: formData.get('validFrom') as string,
validUntil: formData.get('validUntil') as string,
isActive: formData.get('isActive') === 'true',
};
if (editingPromo) {
await updateMutation.mutateAsync({ id: editingPromo.id, data: promoData });
} else {
await createMutation.mutateAsync(promoData);
}
};
const handleDelete = (promo: any) => {
setDeleteConfirm({ isOpen: true, promo });
};
const confirmDelete = async () => {
if (deleteConfirm.promo) {
await deleteMutation.mutateAsync(deleteConfirm.promo.id);
setDeleteConfirm({ isOpen: false, promo: null });
}
};
const copyToClipboard = (code: string) => {
navigator.clipboard.writeText(code);
setCopiedCode(code);
setTimeout(() => setCopiedCode(null), 2000);
};
const columns = [
{
key: 'code',
label: 'Promo Code',
render: (promo: PromoCode) => (
<div className="flex items-center gap-2">
<span className="font-mono font-semibold text-lg">{promo.code}</span>
<button
onClick={() => copyToClipboard(promo.code)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
title="Copy code"
>
{copiedCode === promo.code ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4 text-muted-foreground" />
)}
</button>
</div>
),
},
{
key: 'title',
label: 'Title',
render: (promo: PromoCode) => (
<span className="text-sm font-medium">{promo.title || '-'}</span>
),
},
{
key: 'discount',
label: 'Discount',
render: (promo: PromoCode) => (
<span className="font-semibold">
{promo.discountType === 'PERCENTAGE'
? `${promo.discountValue}%`
: `ETB ${promo.discountValue}`}
</span>
),
},
{
key: 'validity',
label: 'Valid Period',
render: (promo: PromoCode) => (
<div className="text-sm">
<div>{new Date(promo.validFrom).toLocaleDateString()}</div>
<div className="text-muted-foreground">{new Date(promo.validUntil).toLocaleDateString()}</div>
</div>
),
},
{
key: 'usage',
label: 'Usage',
render: (promo: PromoCode) => (
<div className="text-sm">
<div>{promo.usageCount} used</div>
{promo.totalUsageLimit && (
<div className="text-muted-foreground">/ {promo.totalUsageLimit} limit</div>
)}
</div>
),
},
{
key: 'status',
label: 'Status',
render: (promo: PromoCode) => (
<Badge variant="status" status={promo.isActive ? 'CONFIRMED' : 'CANCELLED'}>
{promo.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
];
const actions = [
{
label: 'Edit',
onClick: (promo: PromoCode) => {
setEditingPromo(promo);
setShowModal(true);
},
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Promo Codes</h1>
<p className="text-muted-foreground">Manage promotional codes and discounts</p>
</div>
<ActionButton
icon={Plus}
onClick={() => {
setEditingPromo(null);
setShowModal(true);
}}
>
Add Promo Code
</ActionButton>
</div>
{/* Filters */}
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<input
type="text"
placeholder="Search promo codes..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
/>
</div>
<div>
<select
className="input"
value={filters.active}
onChange={(e) => setFilters({ ...filters, active: e.target.value, page: 1 })}
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
</div>
{/* Promos Table */}
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No promo codes found"
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, promo: null })}
onConfirm={confirmDelete}
title="Delete Promo Code"
message={`Are you sure you want to delete promo code "${deleteConfirm.promo?.code}"?`}
confirmText="Delete"
isDanger={true}
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingPromo(null);
}}
title={`${editingPromo ? 'Edit' : 'Create'} Promo Code`}
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">Promo Code *</label>
<input
type="text"
name="code"
className="input uppercase"
defaultValue={editingPromo?.code}
required
placeholder="e.g., SUMMER2024"
maxLength={20}
disabled={!!editingPromo}
/>
</div>
<div>
<label className="label">Title *</label>
<input
type="text"
name="title"
className="input"
defaultValue={editingPromo?.title}
required
placeholder="e.g., Summer Discount"
/>
</div>
<div>
<label className="label">Discount Type *</label>
<select
name="discountType"
className="input"
defaultValue={editingPromo?.discountType || 'PERCENTAGE'}
required
>
<option value="PERCENTAGE">Percentage (%)</option>
<option value="FIXED">Fixed Amount (ETB)</option>
</select>
</div>
<div>
<label className="label">Discount Value *</label>
<input
type="number"
name="discountValue"
className="input"
defaultValue={editingPromo?.discountValue}
required
placeholder="e.g., 15"
min="0"
step="0.01"
/>
</div>
<div>
<label className="label">Max Discount (ETB)</label>
<input
type="number"
name="maxDiscount"
className="input"
defaultValue={editingPromo?.maxDiscount}
placeholder="e.g., 500"
min="0"
step="0.01"
/>
</div>
<div>
<label className="label">Min Booking Amount (ETB)</label>
<input
type="number"
name="minBookingAmount"
className="input"
defaultValue={editingPromo?.minBookingAmount}
placeholder="e.g., 1000"
min="0"
step="0.01"
/>
</div>
<div>
<label className="label">Max Usage Per User</label>
<input
type="number"
name="maxUsagePerUser"
className="input"
defaultValue={editingPromo?.maxUsagePerUser}
placeholder="Unlimited if empty"
min="1"
/>
</div>
<div>
<label className="label">Total Usage Limit</label>
<input
type="number"
name="totalUsageLimit"
className="input"
defaultValue={editingPromo?.totalUsageLimit}
placeholder="Unlimited if empty"
min="1"
/>
</div>
<div>
<label className="label">Valid From *</label>
<input
type="datetime-local"
name="validFrom"
className="input"
defaultValue={editingPromo?.validFrom?.slice(0, 16)}
required
/>
</div>
<div>
<label className="label">Valid Until *</label>
<input
type="datetime-local"
name="validUntil"
className="input"
defaultValue={editingPromo?.validUntil?.slice(0, 16)}
required
/>
</div>
<div>
<label className="label">Status</label>
<select
name="isActive"
className="input"
defaultValue={editingPromo?.isActive?.toString() || 'true'}
>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingPromo(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{editingPromo ? 'Update' : 'Create'} Promo Code
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -82,10 +82,8 @@ export default function RoutesPage() {
return;
}
// Sort middle stops by distance from origin
const sortedMiddleStops = [...stops].sort((a, b) =>
(a.distanceFromOrigin || 0) - (b.distanceFromOrigin || 0)
);
// Keep current stop order (already rearranged by user)
const sortedMiddleStops = stops;
// Calculate distanceKm (distance from previous stop)
const stopsArray = [
@@ -137,6 +135,30 @@ export default function RoutesPage() {
setStops(updated);
};
const handleDragStart = (e: React.DragEvent, index: number) => {
e.dataTransfer.setData('text/plain', index.toString());
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
(e.currentTarget as HTMLElement).style.opacity = '0.5';
};
const handleDragLeave = (e: React.DragEvent) => {
(e.currentTarget as HTMLElement).style.opacity = '1';
};
const handleDrop = (e: React.DragEvent, targetIndex: number) => {
e.preventDefault();
(e.currentTarget as HTMLElement).style.opacity = '1';
const sourceIndex = parseInt(e.dataTransfer.getData('text/plain'));
if (sourceIndex === targetIndex) return;
const newStops = [...stops];
const [draggedStop] = newStops.splice(sourceIndex, 1);
newStops.splice(targetIndex, 0, draggedStop);
setStops(newStops);
};
const generateRouteCode = (originId: string, destId: string) => {
if (!originId || !destId) return '';
const origin = stations?.items?.find((s: any) => s.id === originId);
@@ -277,7 +299,6 @@ export default function RoutesPage() {
emptyMessage={search ? "No routes match your search" : "No routes found"}
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, route: null })}
@@ -289,7 +310,6 @@ export default function RoutesPage() {
warning="This route may be referenced by schedules and bookings. Deleting it may impact these systems."
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => {
@@ -383,7 +403,7 @@ export default function RoutesPage() {
className="input"
rows={2}
defaultValue={editingRoute?.description}
placeholder="Main corridor via Dire Dawa"
placeholder="Outbound local route from [Origin] to [Destination]"
/>
</div>
@@ -412,10 +432,10 @@ export default function RoutesPage() {
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-3">
<label className="label mb-0">Route Stops</label>
<span className="text-xs text-muted-foreground">Drag to rearrange intermediate stops</span>
</div>
<div className="space-y-2">
{/* Origin Stop */}
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
1
@@ -435,9 +455,16 @@ export default function RoutesPage() {
</div>
</div>
{/* Intermediate Stops */}
{stops.map((stop, index) => (
<div key={index} className="flex gap-2 items-center p-3 bg-muted/50 rounded">
<div
key={index}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
className="flex gap-2 items-center p-3 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors"
>
<div className="flex-shrink-0 w-8 h-8 bg-secondary text-secondary-foreground rounded-full flex items-center justify-center text-sm font-medium">
{index + 2}
</div>
@@ -482,7 +509,6 @@ export default function RoutesPage() {
</div>
))}
{/* Add Intermediate Stop Button */}
{originStationId && destinationStationId && (
<div className="flex justify-center py-2">
<ActionButton
@@ -497,7 +523,6 @@ export default function RoutesPage() {
</div>
)}
{/* Destination Stop */}
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
{stops.length + 2}

View File

@@ -48,10 +48,9 @@ export default function SchedulesPage() {
const [showEditModal, setShowEditModal] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
const [selectedSchedules, setSelectedSchedules] = useState<Set<string>>(new Set());
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean }>({
isOpen: false,
item: null,
});
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean }>(
{ isOpen: false, item: null }
);
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
@@ -179,6 +178,10 @@ export default function SchedulesPage() {
forNextDays: parseInt(bulkForm.forNextDays),
};
if (bulkForm.coachIds.length > 0) {
payload.coachIds = bulkForm.coachIds;
}
await bulkGenerateMutation.mutateAsync(payload);
};
@@ -236,13 +239,13 @@ export default function SchedulesPage() {
const handleEditClick = (schedule: Schedule) => {
setEditingSchedule(schedule);
const dep = new Date(schedule.departureAt);
const arr = new Date(schedule.arrivalAt);
const depLocal = new Date(dep.getTime() - dep.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
const arrLocal = new Date(arr.getTime() - arr.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
setEditForm({
departureAt: depLocal,
arrivalAt: arrLocal,

View File

@@ -5,7 +5,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { seatsApi, schedulesApi } from '@/lib/api';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton'
import Badge from '@/components/ui/Badge';
import { Armchair, Lock, Unlock, Bed, X, RotateCcw } from 'lucide-react';
export default function SeatsPage() {
@@ -144,7 +143,10 @@ export default function SeatsPage() {
const seatsPerRow = arrangement[0] + (arrangement[1] || 0);
const allSeatsForLayout = [...validSeats, ...removedSeats];
const rows = [];
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || '');
const isVipBed = seatClassStr.toLowerCase().includes('vip');
const bedWidth = isVipBed ? 'w-24' : 'w-16';
for (let i = 0; i < allSeatsForLayout.length; i += seatsPerRow) {
rows.push(allSeatsForLayout.slice(i, i + seatsPerRow));
}
@@ -154,14 +156,15 @@ export default function SeatsPage() {
{rows.map((rowSeats: any[], idx: number) => {
const rowNumber = rowSeats[0]?.row || (idx + 1);
const shouldFlipIcon = rowNumber % 2 === 0;
const shouldFlipRow = rowNumber % 2 === 1;
const showSpacing = idx % 2 === 1;
return (
<div key={`bed-row-${idx}`}>
{shouldFlipIcon && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
{rowSeats.map((seat: any) => (
<div key={`num-before-${seat.id}`} className="w-12 h-4 flex items-center justify-center">
<div key={`num-before-${seat.id}`} className={`${bedWidth} h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground`}>
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
</div>
))}
@@ -188,7 +191,7 @@ export default function SeatsPage() {
{!shouldFlipIcon && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
{rowSeats.map((seat: any) => (
<div key={`num-after-${seat.id}`} className="w-12 h-4 flex items-center justify-center">
<div key={`num-after-${seat.id}`} className={`${bedWidth} h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground`}>
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
</div>
))}
@@ -228,6 +231,7 @@ export default function SeatsPage() {
const rightSeats = rowSeats.slice(leftCount);
const rowNumber = rowSeats[0]?.row || 1;
const shouldFlipArmchair = rowNumber % 2 === 0;
const shouldFlipRow = rowNumber % 2 === 0;
const showSpacing = rowIdx % 2 === 1;
return (
@@ -236,7 +240,7 @@ export default function SeatsPage() {
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<div key={`num-before-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
<div key={`num-before-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
@@ -245,7 +249,7 @@ export default function SeatsPage() {
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
<div key={`num-before-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
<div key={`num-before-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
@@ -299,7 +303,7 @@ export default function SeatsPage() {
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<div key={`num-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
<div key={`num-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
@@ -308,7 +312,7 @@ export default function SeatsPage() {
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
<div key={`num-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
<div key={`num-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
@@ -402,17 +406,17 @@ export default function SeatsPage() {
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{coachesWithSeats.map((coach: any) => {
const isBedCoach = (coach.seatClass && coach.seatClass.toLowerCase().includes('bed')) ||
(coach.mode && coach.mode.toLowerCase().includes('bed'));
const isBedCoach = (coach.seatClass && coach.seatClass.toLowerCase().includes('bed')) ||
(coach.mode && coach.mode.toLowerCase().includes('bed'));
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
return (
<div key={coach.id} className="border rounded-lg p-3 bg-white dark:bg-card">
<div key={coach.id} className="flex flex-col gap-4">
<div className="mb-3">
<h3 className="font-semibold text-sm">Coach {coach.coachNumber}</h3>
</div>
<div className="bg-gray-50 dark:bg-gray-900/30 py-2 rounded-lg">
<div className="bg-gray-50 dark:bg-gray-900/30 rounded-lg w-64 border border-gray-200 dark:border-gray-700 p-2">
{renderCoachSeats(coach, isBedCoach)}
</div>
</div>
@@ -542,7 +546,11 @@ function SeatIcon({
handleUndoRemove,
}: SeatIconProps) {
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
const isVipBed = isBedCoach && seatClassStr.toLowerCase().includes('vip');
const bedWidth = isVipBed ? 'w-24' : 'w-16';
const width = isBedCoach ? bedWidth : 'w-10';
if (!seat.seatNumber) {
return <div className="w-7 h-7" />;
}
@@ -580,17 +588,19 @@ function SeatIcon({
{isBedCoach ? (
<div
className={`w-11 h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
className={`${width} h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
title={`${seat.seatNumber} - ${seat.bedPosition} - ${status}`}
style={seat.row % 2 === 1 ? { transform: 'scaleY(-1)' } : undefined}
>
<Bed className="w-7 h-7 text-white" style={shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined} />
<Bed className="w-7 h-7 text-white" />
</div>
) : (
<div
className={`w-11 h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
title={`${seat.seatNumber} - ${status}`}
style={seat.row % 2 === 0 ? { transform: 'scaleY(-1)' } : undefined}
>
<Armchair className="w-7 h-7 text-white" style={shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined} />
<Armchair className="w-7 h-7 text-white" />
</div>
)}

View File

@@ -2,57 +2,388 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Search, Edit, Trash2 } from 'lucide-react';
import { Plus, Edit, Trash2, RefreshCw } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { usersApi, BackofficeUser } from '@/lib/api/users';
export default function UserManagementPage() {
const [searchTerm, setSearchTerm] = useState('');
const [filters, setFilters] = useState({ search: '', role: '', status: '', page: 1, pageSize: 10 });
const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; user: any | null }>({ isOpen: false, user: null });
const [resetPasswordModal, setResetPasswordModal] = useState<{ isOpen: boolean; user: any | null }>({ isOpen: false, user: null });
const [newPassword, setNewPassword] = useState('');
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['users', filters],
queryFn: () => usersApi.getAll(filters),
});
const createMutation = useMutation({
mutationFn: usersApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setShowModal(false);
setEditingUser(null);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => usersApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setShowModal(false);
setEditingUser(null);
},
});
const deleteMutation = useMutation({
mutationFn: usersApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
const resetPasswordMutation = useMutation({
mutationFn: ({ id, tempPassword }: { id: string; tempPassword: string }) =>
usersApi.resetPassword(id, tempPassword),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setResetPasswordModal({ isOpen: false, user: null });
setNewPassword('');
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const userData = {
email: formData.get('email') as string,
fullName: formData.get('fullName') as string,
role: formData.get('role') as string,
status: formData.get('status') as 'ACTIVE' | 'INACTIVE',
} as any;
if (!editingUser) {
userData.password = formData.get('password') as string;
}
if (editingUser) {
await updateMutation.mutateAsync({ id: editingUser.id, data: userData });
} else {
await createMutation.mutateAsync(userData);
}
};
const handleDelete = (user: any) => {
setDeleteConfirm({ isOpen: true, user });
};
const confirmDelete = async () => {
if (deleteConfirm.user) {
await deleteMutation.mutateAsync(deleteConfirm.user.id);
setDeleteConfirm({ isOpen: false, user: null });
}
};
const handleResetPassword = async () => {
if (resetPasswordModal.user && newPassword) {
await resetPasswordMutation.mutateAsync({
id: resetPasswordModal.user.id,
tempPassword: newPassword,
});
}
};
const columns = [
{
key: 'fullName',
label: 'Full Name',
sortable: true,
render: (user: BackofficeUser) => (
<div>
<div className="font-medium">{user.fullName}</div>
<div className="text-sm text-muted-foreground">{user.email}</div>
</div>
),
},
{
key: 'role',
label: 'Role',
render: (user: BackofficeUser) => (
<Badge variant="status" status={user.role}>
{user.role}
</Badge>
),
},
{
key: 'status',
label: 'Status',
render: (user: BackofficeUser) => (
<Badge variant="status" status={user.status === 'ACTIVE' ? 'CONFIRMED' : 'CANCELLED'}>
{user.status}
</Badge>
),
},
{
key: 'lastLogin',
label: 'Last Login',
render: (user: BackofficeUser) => (
<span className="text-sm text-muted-foreground">
{user.lastLogin ? new Date(user.lastLogin).toLocaleString() : 'Never'}
</span>
),
},
];
const actions = [
{
label: 'Edit',
onClick: (user: BackofficeUser) => {
setEditingUser(user);
setShowModal(true);
},
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Reset Password',
onClick: (user: BackofficeUser) => {
setResetPasswordModal({ isOpen: true, user });
setNewPassword('');
},
variant: 'secondary' as const,
icon: RefreshCw,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">User Management</h1>
<p className="text-muted-foreground mt-1">Manage system users and permissions</p>
<h1 className="text-2xl font-bold text-foreground">User Management</h1>
<p className="text-muted-foreground">Manage backoffice users and their permissions</p>
</div>
<ActionButton
icon={Plus}
onClick={() => {
setEditingUser(null);
setShowModal(true);
}}
>
Add User
</ActionButton>
</div>
{/* Filters */}
<div className="card">
<div className="flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<input
type="text"
placeholder="Search users by name or email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="input pl-10"
placeholder="Search users..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
/>
</div>
<div>
<select
className="input"
value={filters.role}
onChange={(e) => setFilters({ ...filters, role: e.target.value, page: 1 })}
>
<option value="">All Roles</option>
<option value="ADMIN">Admin</option>
<option value="SUPERVISOR">Supervisor</option>
<option value="STAFF">Staff</option>
<option value="AGENT">Agent</option>
</select>
</div>
<div>
<select
className="input"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value, page: 1 })}
>
<option value="">All Status</option>
<option value="ACTIVE">Active</option>
<option value="INACTIVE">Inactive</option>
</select>
</div>
</div>
</div>
<div className="card">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Name</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Email</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Role</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Status</th>
</tr>
</thead>
<tbody>
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">
User management coming soon
</td>
</tr>
</tbody>
</table>
{/* Users Table */}
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No users found"
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, user: null })}
onConfirm={confirmDelete}
title="Delete User"
message={`Are you sure you want to delete ${deleteConfirm.user?.fullName}? This action cannot be undone.`}
confirmText="Delete"
isDanger={true}
/>
{/* Reset Password Modal */}
<Modal
isOpen={resetPasswordModal.isOpen}
onClose={() => setResetPasswordModal({ isOpen: false, user: null })}
title="Reset User Password"
>
<div className="space-y-4">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded p-3 text-sm">
<p className="font-semibold text-blue-900 dark:text-blue-200">Temporary Password</p>
<p className="text-blue-800 dark:text-blue-300 mt-1">
Set a temporary password for {resetPasswordModal.user?.fullName}. They will need to change it on first login.
</p>
</div>
<div>
<label className="label">Temporary Password *</label>
<input
type="password"
className="input"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter temporary password"
required
/>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => setResetPasswordModal({ isOpen: false, user: null })}
>
Cancel
</ActionButton>
<ActionButton
onClick={handleResetPassword}
loading={resetPasswordMutation.isPending}
disabled={!newPassword}
>
Reset Password
</ActionButton>
</div>
</div>
</div>
</Modal>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingUser(null);
}}
title={`${editingUser ? 'Edit' : 'Add'} User`}
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">Full Name *</label>
<input
type="text"
name="fullName"
className="input"
defaultValue={editingUser?.fullName}
required
placeholder="e.g., John Doe"
/>
</div>
<div>
<label className="label">Email *</label>
<input
type="email"
name="email"
className="input"
defaultValue={editingUser?.email}
required
placeholder="e.g., john@example.com"
disabled={!!editingUser}
/>
</div>
<div>
<label className="label">Role *</label>
<select
name="role"
className="input"
defaultValue={editingUser?.role || 'STAFF'}
required
>
<option value="ADMIN">Admin</option>
<option value="SUPERVISOR">Supervisor</option>
<option value="STAFF">Staff</option>
<option value="AGENT">Agent</option>
</select>
</div>
<div>
<label className="label">Status</label>
<select
name="status"
className="input"
defaultValue={editingUser?.status || 'ACTIVE'}
>
<option value="ACTIVE">Active</option>
<option value="INACTIVE">Inactive</option>
</select>
</div>
{!editingUser && (
<div>
<label className="label">Password *</label>
<input
type="password"
name="password"
className="input"
required
placeholder="Minimum 8 characters"
minLength={8}
/>
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingUser(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{editingUser ? 'Update' : 'Create'} User
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -279,7 +279,7 @@ export default function StationsPage() {
className="input"
defaultValue={editingStation?.name}
required
placeholder="e.g., Addis Ababa"
placeholder="e.g., Lebu"
/>
</div>
<div>

View File

@@ -250,7 +250,7 @@ export default function TrainsPage() {
className="input"
defaultValue={editingTrain?.number}
required
placeholder="e.g., EDR-001"
placeholder="e.g., EDR-101"
/>
</div>
<div>

View File

@@ -70,7 +70,7 @@ const navigationSections = [
items: [
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign },
{ name: 'Payments', href: '/payments', icon: CreditCard },
{ name: 'Promotions', href: '/promotions', icon: Gift },
{ name: 'Promo Codes', href: '/promos', icon: Gift },
]
},
{

View File

@@ -250,6 +250,9 @@ export const promotionsApi = {
delete: (id: string) => apiClient.delete(`/promos/${id}`),
};
export { promosApi } from './promos';
export { usersApi } from './users';
// Support API
export const supportApi = {
getConversations: async (params?: any) => {

View File

@@ -0,0 +1,54 @@
import { apiClient } from '@/lib/api-client';
export interface PromoCode {
id: string;
code: string;
title: string;
discountType: 'PERCENTAGE' | 'FIXED';
discountValue: number;
maxDiscount?: number;
minBookingAmount?: number;
maxUsagePerUser?: number;
totalUsageLimit?: number;
usageCount: number;
validFrom: string;
validUntil: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export const promosApi = {
getAll: (filters?: { search?: string; active?: string; page?: number; pageSize?: number }) => {
const params = new URLSearchParams();
if (filters?.search) params.append('search', filters.search);
if (filters?.active) params.append('active', filters.active);
if (filters?.page) params.append('page', filters.page.toString());
if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString());
return apiClient.get<{ items: PromoCode[]; total: number; page: number; pageSize: number }>(`/promos/all?${params.toString()}`);
},
getById: (id: string) => {
return apiClient.get<PromoCode>(`/promos/${id}`);
},
create: (data: Omit<PromoCode, 'id' | 'createdAt' | 'updatedAt' | 'usageCount'>) => {
return apiClient.post<PromoCode>('/promos', data);
},
update: (id: string, data: Partial<PromoCode>) => {
return apiClient.patch<PromoCode>(`/promos/${id}`, data);
},
delete: (id: string) => {
return apiClient.delete<void>(`/promos/${id}`);
},
validate: (code: string, bookingAmount?: number) => {
return apiClient.post<{ valid: boolean; message?: string; discount?: number }>('/promos/validate', {
code,
bookingAmount,
});
},
};

View File

@@ -0,0 +1,59 @@
import { apiClient } from '@/lib/api-client';
export interface BackofficeUser {
id: string;
email: string;
fullName: string;
role: 'ADMIN' | 'SUPERVISOR' | 'STAFF' | 'AGENT';
status: 'ACTIVE' | 'INACTIVE';
lastLogin?: string;
createdAt: string;
updatedAt: string;
}
export const usersApi = {
getAll: async (filters?: { search?: string; role?: string; status?: string; page?: number; pageSize?: number }) => {
const params = new URLSearchParams();
if (filters?.search) params.append('search', filters.search);
if (filters?.role) params.append('role', filters.role);
if (filters?.status) params.append('status', filters.status);
if (filters?.page) params.append('page', filters.page.toString());
if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString());
const response = await apiClient.get<any>(`/auth/users?${params.toString()}`);
// Handle different response formats
if (response && typeof response === 'object') {
if ('items' in response) {
return response as { items: BackofficeUser[]; total: number };
}
if (Array.isArray(response)) {
return { items: response as BackofficeUser[], total: response.length };
}
}
return { items: Array.isArray(response) ? response : [], total: 0 };
},
getById: (id: string) => {
return apiClient.get<BackofficeUser>(`/auth/users/${id}`);
},
create: (data: { email: string; fullName: string; role: string; password: string }) => {
return apiClient.post<BackofficeUser>('/auth/users', data);
},
update: (id: string, data: Partial<BackofficeUser>) => {
return apiClient.patch<BackofficeUser>(`/auth/users/${id}`, data);
},
delete: (id: string) => {
return apiClient.delete<void>(`/auth/users/${id}`);
},
resetPassword: (id: string, tempPassword: string) => {
return apiClient.post<{ success: boolean; message: string }>(`/auth/users/${id}/reset-password`, {
tempPassword,
});
},
};

View File

@@ -5,9 +5,9 @@ import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { useBookingStore } from '@/lib/booking-store';
import { Schedule } from '@/types';
import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Loader2, Check, ChevronDown, ChevronUp, MapPin } from 'lucide-react';
import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Loader2, Check, ChevronDown, ChevronUp, MapPin, Gift } from 'lucide-react';
import { format } from 'date-fns';
import { useState } from 'react';
import { useState, useEffect } from 'react';
export default function ResultsPage() {
const router = useRouter();
@@ -15,6 +15,7 @@ export default function ResultsPage() {
const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule);
const [selectedClasses, setSelectedClasses] = useState<Record<string, string>>({});
const [expandedSchedules, setExpandedSchedules] = useState<Record<string, boolean>>({});
const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null);
const searchData = {
originStationId: searchParams.get('origin') || '',
@@ -23,8 +24,28 @@ export default function ResultsPage() {
adultCount: parseInt(searchParams.get('adults') || '1'),
childCount: parseInt(searchParams.get('children') || '0'),
nationality: searchParams.get('nationality') || 'ETHIOPIAN',
promoCode: searchParams.get('promoCode') || '',
};
useEffect(() => {
if (searchData.promoCode) {
apiClient
.post('/promos/validate', { code: searchData.promoCode })
.then((response: any) => {
if (response.applicable || response.valid) {
setPromoData({
code: searchData.promoCode,
discount: response.message || 'Discount applied',
message: response.message || 'Promo code applied successfully!',
});
}
})
.catch((err) => {
console.error('Promo validation failed:', err);
});
}
}, [searchData.promoCode]);
const buildSearchUrl = () => {
const params = new URLSearchParams({
origin: searchData.originStationId,
@@ -44,6 +65,9 @@ export default function ResultsPage() {
const response = await apiClient.post('/search', searchData) as Schedule[];
console.log('Search results:', response);
console.log('Number of results:', response?.length || 0);
if (response?.length > 0) {
console.log('First schedule availabilityByClass:', response[0].availabilityByClass);
}
return response;
},
enabled: !!searchData.originStationId && !!searchData.destinationStationId,
@@ -156,6 +180,21 @@ export default function ResultsPage() {
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
{/* Promo Notification */}
{promoData && (
<div className="mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
<Check className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-green-900 dark:text-green-200">Promo code applied!</h3>
<p className="text-sm text-green-800 dark:text-green-300 mt-1">
<span className="font-mono font-bold">{promoData.code}</span> - {promoData.message}
</p>
</div>
</div>
)}
<div className="mb-8">
<button
onClick={() => router.push(buildSearchUrl())}
@@ -174,6 +213,12 @@ export default function ResultsPage() {
<Users className="w-4 h-4" />
<span>{searchData.adultCount} adult(s), {searchData.childCount} child(ren)</span>
</div>
{searchData.promoCode && (
<div className="flex items-center gap-2 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 px-3 py-1 rounded-full text-sm">
<Gift className="w-4 h-4" />
<span>{searchData.promoCode}</span>
</div>
)}
</div>
</div>
@@ -287,6 +332,7 @@ export default function ResultsPage() {
const isSelected = selectedClass === fareClass.seatClassName;
const availableSeats = schedule.availabilityByClass?.[fareClass.seatClassName] || 0;
const isAvailable = availableSeats > 0;
const isBedClass = fareClass.seatClassName.toLowerCase().includes('bed');
return (
<button
@@ -315,7 +361,7 @@ export default function ResultsPage() {
<div className="text-xs text-gray-600 dark:text-gray-400">
{isAvailable ? (
<span className="text-green-600 dark:text-green-400 font-medium">
{availableSeats} seat{availableSeats !== 1 ? 's' : ''} available
{availableSeats} {isBedClass ? 'bed' : 'seat'}{availableSeats !== 1 ? 's' : ''} available
</span>
) : (
<span className="text-red-600 dark:text-red-400 font-medium">Sold out</span>

View File

@@ -9,7 +9,7 @@ import { useAuthStore } from '@/lib/auth-store';
import { apiClient } from '@/lib/api-client';
import { useBookingStore } from '@/lib/booking-store';
import { Station } from '@/types';
import { Train, MapPin, ArrowRight, Plus, Minus, Search, Users, ChevronDown } from 'lucide-react';
import { Train, MapPin, ArrowRight, Plus, Minus, Search, Users, ChevronDown, Gift, Check } from 'lucide-react';
import { useEffect, useState } from 'react';
import ModernDatePicker from '@/components/ModernDatePicker';
@@ -20,6 +20,7 @@ const searchSchema = z.object({
adultCount: z.number().min(1).max(9),
childCount: z.number().min(0).max(9),
nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']),
promoCode: z.string().optional(),
}).refine((data) => data.originStationId !== data.destinationStationId, {
message: 'Origin and destination must be different',
path: ['destinationStationId'],
@@ -33,6 +34,9 @@ export default function SearchPage() {
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
const { user, isAuthenticated } = useAuthStore();
const [isPassengerOpen, setIsPassengerOpen] = useState(false);
const [promoCode, setPromoCode] = useState('');
const [promoValidation, setPromoValidation] = useState<{ valid: boolean; message: string; discount?: string } | null>(null);
const [promoLoading, setPromoLoading] = useState(false);
const { data: stations, isLoading, error } = useQuery<Station[]>({
queryKey: ['stations'],
@@ -49,6 +53,7 @@ export default function SearchPage() {
childCount: 0,
nationality: 'ETHIOPIAN',
departureDate: new Date().toISOString().split('T')[0],
promoCode: '',
},
});
@@ -85,6 +90,36 @@ export default function SearchPage() {
const adultCount = watch('adultCount');
const childCount = watch('childCount');
const handleValidatePromo = async () => {
if (!promoCode.trim()) {
setPromoValidation(null);
return;
}
setPromoLoading(true);
try {
const response = await apiClient.post('/promos/validate', { code: promoCode }) as any;
setPromoValidation({
valid: response.applicable || response.valid,
message: response.message || (response.applicable ? 'Promo code applied successfully!' : 'Invalid promo code'),
discount: response.message,
});
if (response.applicable || response.valid) {
setValue('promoCode', promoCode);
} else {
setPromoCode('');
}
} catch (err: any) {
setPromoValidation({
valid: false,
message: err?.response?.data?.message || 'Promo code is invalid or expired',
});
setPromoCode('');
} finally {
setPromoLoading(false);
}
};
const onSubmit = (data: SearchForm) => {
setSearchCriteria(data);
const params = new URLSearchParams({
@@ -94,6 +129,7 @@ export default function SearchPage() {
adults: data.adultCount.toString(),
children: data.childCount.toString(),
nationality: data.nationality,
...(data.promoCode && { promoCode: data.promoCode }),
});
router.push(`/booking/results?${params}`);
};
@@ -122,8 +158,6 @@ export default function SearchPage() {
{ from: 'Diredawa', to: 'Nagad', duration: '4h' },
];
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Search Section */}
@@ -215,7 +249,7 @@ export default function SearchPage() {
</div>
</div>
{/* Second Row: Passengers, Nationality, Promo Code */}
{/* Second Row: Passengers, Nationality */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end mb-4">
{/* Passengers Dropdown */}
<div className="space-y-2 relative z-20">
@@ -321,14 +355,39 @@ export default function SearchPage() {
</select>
</div>
{/* Promo Code */}
{/* Promo Code with Validation */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Promo Code (Optional)</label>
<input
type="text"
placeholder="Enter promo code"
className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400"
/>
<div className="flex gap-2">
<div className="flex-1 relative">
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
<input
type="text"
value={promoCode}
onChange={(e) => {
setPromoCode(e.target.value.toUpperCase());
if (promoValidation) setPromoValidation(null);
}}
placeholder="Enter code"
className="w-full pl-10 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400"
onKeyPress={(e) => e.key === 'Enter' && handleValidatePromo()}
/>
</div>
<button
type="button"
onClick={handleValidatePromo}
disabled={!promoCode || promoLoading}
className="px-4 py-3.5 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
{promoLoading ? '...' : 'Apply'}
</button>
</div>
{promoValidation && (
<div className={`flex items-center gap-2 text-sm ${promoValidation.valid ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
{promoValidation.valid && <Check className="w-4 h-4" />}
<span>{promoValidation.message}</span>
</div>
)}
</div>
</div>

View File

@@ -11,18 +11,17 @@ import { Armchair, Bed, ChevronLeft } from 'lucide-react';
import CustomModal from '@/components/CustomModal';
const SeatButton = memo(({ seat, isSelected, onToggle, isBedCoach, bedLabel }: any) => {
const SeatButton = memo(({ seat, isSelected, onToggle, isBedCoach, bedLabel, coachSeatClass }: any) => {
const seatLabel = seat.number || seat.label || seat.seatNumber || '?';
const bedWidth = 'w-24';
const width = isBedCoach ? bedWidth : 'w-10';
return (
<div className="flex flex-col items-center">
<span className="text-xs font-bold mb-0.5 text-gray-900 dark:text-gray-100">
{seatLabel}{bedLabel}
</span>
<button
onClick={() => onToggle(seat.id)}
disabled={seat.status !== 'AVAILABLE'}
className={`w-11 h-11 rounded flex items-center justify-center transition-all ${
className={`${width} h-11 rounded flex items-center justify-center transition-all ${
isSelected
? 'bg-[rgb(20_113_76)] text-white shadow-md scale-105'
: seat.status === 'AVAILABLE'
@@ -31,12 +30,13 @@ const SeatButton = memo(({ seat, isSelected, onToggle, isBedCoach, bedLabel }: a
? 'bg-yellow-500 text-white cursor-not-allowed opacity-75'
: 'bg-gray-500 text-white cursor-not-allowed opacity-60'
}`}
title={`Seat ${seatLabel}${bedLabel} - ${seat.status}`}
title={`Seat ${seatLabel}${bedLabel} - ${seat.status} - ${coachSeatClass}`}
style={isBedCoach ? (seat.row % 2 === 1 ? { transform: 'scaleY(-1)' } : undefined) : (seat.row % 2 === 0 ? { transform: 'scaleY(-1)' } : undefined)}
>
{isBedCoach ? (
<Bed className="w-7 h-7" style={seat.bedFlip ? { transform: 'scaleY(-1)' } : undefined} />
<Bed className="w-7 h-7" />
) : (
<Armchair className="w-7 h-7" style={seat.armchairFlip ? { transform: 'scaleY(-1)' } : undefined} />
<Armchair className="w-7 h-7" />
)}
</button>
</div>
@@ -85,14 +85,13 @@ export default function SeatsPage() {
},
});
// Mutation to book seats permanently (called after payment)
const bookSeatsMutation = useMutation({
mutationFn: async (seatIds: string[]) => {
return Promise.all(
seatIds.map((seatId) =>
apiClient.patch(`/seats/${seatId}`, {
status: 'BOOKED',
}).catch(() => null) // Ignore errors, seats are already booked via booking system
}).catch(() => null)
)
);
},
@@ -101,35 +100,59 @@ export default function SeatsPage() {
const coaches = useMemo(() => (seatMapData as any)?.coaches || [], [seatMapData]);
const filteredCoaches = useMemo(() => {
let filtered = selectedSchedule?.selectedSeatClass
? coaches.filter((c: any) => {
const seatClassName = typeof c.seatClass === 'string' ? c.seatClass : (c.seatClass?.name || c.coachClass || '');
return seatClassName === selectedSchedule.selectedSeatClass ||
seatClassName.replace(/_/g, ' ').toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase() ||
seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase();
})
: coaches;
if (!selectedSchedule?.selectedSeatClass) {
return coaches.filter((c: any) => c.seats && c.seats.length > 0);
}
let filtered = coaches.filter((c: any) => {
const seatClasses = c.seatClasses || [c.seatClass] || [];
return seatClasses.some((seatClassName: string) =>
seatClassName === selectedSchedule.selectedSeatClass ||
seatClassName.replace(/_/g, ' ').toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase() ||
seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase()
);
});
return filtered.filter((c: any) => c.seats && c.seats.length > 0);
}, [coaches, selectedSchedule?.selectedSeatClass]);
const selectedCoachData = useMemo(() => filteredCoaches.find((c: any) => c.id === selectedCoach), [filteredCoaches, selectedCoach]);
const allSeats = useMemo(() => selectedCoachData?.seats || [], [selectedCoachData]);
const validSeats = useMemo(() => allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')), [allSeats]);
useEffect(() => {
if (filteredCoaches && filteredCoaches.length > 0 && !selectedCoach) {
if (filteredCoaches.length > 0 && !selectedCoach) {
setSelectedCoach(filteredCoaches[0].id);
}
}, [filteredCoaches, selectedCoach]);
const toggleSeat = useCallback((seatId: string) => {
setSelectedSeats(prev => {
if (prev.includes(seatId)) {
return prev.filter(id => id !== seatId);
} else if (prev.length < passengers.length) {
return [...prev, seatId];
const selectedCoachData = useMemo(() => filteredCoaches.find((c: any) => c.id === selectedCoach), [filteredCoaches, selectedCoach]);
const allSeats = useMemo(() => selectedCoachData?.seats || [], [selectedCoachData]);
const getBedPosition = (selectedClass: string): string | null => {
const lowerClass = selectedClass.toLowerCase();
if (lowerClass.includes('upper')) return 'upper';
if (lowerClass.includes('middle')) return 'middle';
if (lowerClass.includes('lower')) return 'lower';
return null;
};
const validSeats = useMemo(() => {
let seats = allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed');
if (isBedCoach && selectedSchedule?.selectedSeatClass) {
const selectedBedPosition = getBedPosition(selectedSchedule.selectedSeatClass);
if (selectedBedPosition) {
seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition);
}
}
return seats;
}, [allSeats, selectedCoachData, selectedSchedule?.selectedSeatClass]);
const handleSeatClick = useCallback((seatId: string) => {
setSelectedSeats(prev => {
if (prev.length < passengers.length) {
return [...prev, seatId];
} else {
return [seatId];
}
return prev;
});
}, [passengers.length]);
@@ -197,7 +220,6 @@ export default function SeatsPage() {
}
}, [selectedSchedule, passengers.length, router]);
// Auto-book seats when booking is confirmed (after payment)
useEffect(() => {
if (bookingId && selectedSeats.length > 0) {
bookSeatsMutation.mutate(selectedSeats);
@@ -221,11 +243,55 @@ export default function SeatsPage() {
const arrangement = parseSeatArrangement(coach.seatArrangement);
const leftCount = arrangement[0];
if (validSeats.length === 0) {
return <div className="text-xs text-muted-foreground">No seats</div>;
}
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
const seatClassStr = typeof selectedCoachData?.seatClass === 'string' ? selectedCoachData.seatClass : (selectedCoachData?.seatClass?.name || '');
if (isBedCoach && hasBedPositionData) {
return (
<div className="space-y-2 w-40">
{validSeats.map((seat: any) => {
const rowNumber = seat.row || 1;
const shouldFlipIcon = rowNumber % 2 === 0;
return (
<div key={seat.id}>
{shouldFlipIcon && (
<div className="w-24 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
</div>
)}
<div className="flex">
<SeatButton
key={seat.id}
seat={seat}
isSelected={selectedSeats.includes(seat.id)}
onToggle={handleSeatClick}
isBedCoach={true}
bedLabel={getBedLabel(seat.bedPosition)}
coachSeatClass={seatClassStr}
/>
</div>
{!shouldFlipIcon && (
<div className="w-24 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
</div>
)}
</div>
);
})}
</div>
);
}
const rows = [];
const processedRows = new Set();
for (const seat of allSeats) {
for (const seat of validSeats) {
if (!processedRows.has(seat.row)) {
rows.push(allSeats.filter((s: any) => s.row === seat.row).sort((a: any, b: any) => {
rows.push(validSeats.filter((s: any) => s.row === seat.row).sort((a: any, b: any) => {
const colA = a.col.charCodeAt(0);
const colB = b.col.charCodeAt(0);
return colA - colB;
@@ -240,17 +306,17 @@ export default function SeatsPage() {
const leftSeats = rowSeats.slice(0, leftCount);
const rightSeats = rowSeats.slice(leftCount);
const rowNumber = rowSeats[0]?.row || 1;
const shouldFlipIcon = rowNumber % 2 === 0;
const shouldFlipArmchair = rowNumber % 2 === 0;
const showSpacing = rowIdx % 2 === 1;
return (
<div key={`row-${rowSeats[0]?.id}`}>
{shouldFlipIcon && (
<div className="flex gap-0.5 justify-center text-xs text-muted-foreground mb-1">
{shouldFlipArmchair && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<div key={`num-before-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
<div key={`num-before-left-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
</div>
@@ -258,58 +324,50 @@ export default function SeatsPage() {
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
<div key={`num-before-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
<div key={`num-before-right-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
</div>
)}
</div>
)}
<div className="flex gap-0.5 justify-center">
<div className="flex gap-0.5 justify-start">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
seat.seatNumber && !seat.seatNumber.startsWith('-') ? (
<SeatButton
key={seat.id}
seat={seat}
isSelected={selectedSeats.includes(seat.id)}
onToggle={toggleSeat}
isBedCoach={isBedCoach}
bedLabel={getBedLabel(seat.bedPosition)}
/>
) : (
<div key={seat.id} className="w-11 h-11" />
)
<SeatButton
key={seat.id}
seat={seat}
isSelected={selectedSeats.includes(seat.id)}
onToggle={handleSeatClick}
isBedCoach={false}
bedLabel=""
/>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
seat.seatNumber && !seat.seatNumber.startsWith('-') ? (
<SeatButton
key={seat.id}
seat={seat}
isSelected={selectedSeats.includes(seat.id)}
onToggle={toggleSeat}
isBedCoach={isBedCoach}
bedLabel={getBedLabel(seat.bedPosition)}
/>
) : (
<div key={seat.id} className="w-11 h-11" />
)
<SeatButton
key={seat.id}
seat={seat}
isSelected={selectedSeats.includes(seat.id)}
onToggle={handleSeatClick}
isBedCoach={false}
bedLabel=""
/>
))}
</div>
)}
</div>
{!shouldFlipIcon && (
<div className="flex gap-0.5 justify-center text-xs text-muted-foreground mb-1">
{!shouldFlipArmchair && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<div key={`num-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
<div key={`num-left-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
</div>
@@ -317,8 +375,8 @@ export default function SeatsPage() {
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
<div key={`num-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
<div key={`num-right-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
</div>
@@ -334,8 +392,6 @@ export default function SeatsPage() {
);
};
const isBedCoach = selectedCoachData && (selectedCoachData.seatClass?.toLowerCase().includes('bed') || selectedCoachData.mode?.toLowerCase().includes('bed'));
if (!selectedSchedule || !passengers.length) return null;
return (
@@ -362,64 +418,74 @@ export default function SeatsPage() {
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Select seats</h1>
<div className="grid lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 mb-4">
<h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select coach</h3>
{selectedSchedule?.selectedSeatClassName && (
<div className="mb-3 text-sm text-gray-600 dark:text-gray-400">
Showing coaches for: <span className="font-semibold text-[rgb(20_113_76)]">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</span>
</div>
)}
<div className="flex gap-2 overflow-x-auto pb-2">
{filteredCoaches?.map((coach: any) => {
const availableCount = coach.seats?.filter((s: any) => s.status === 'AVAILABLE').length || 0;
const seatClassName = typeof coach.seatClass === 'string' ? coach.seatClass : (coach.seatClass?.name || coach.coachClass || '');
return (
<button
key={coach.id}
onClick={() => setSelectedCoach(coach.id)}
className={`px-4 py-2 rounded whitespace-nowrap transition-all ${
selectedCoach === coach.id
? 'bg-[rgb(20_113_76)] text-white shadow-lg'
: 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100'
}`}
>
<div className="font-semibold">{coach.label || coach.name || coach.coachNumber}</div>
<div className="text-xs opacity-75">{seatClassName}</div>
<div className="text-xs opacity-75">{availableCount} available</div>
</button>
);
})}
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">
Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}
</h3>
{selectedCoachData && (
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
Arrangement: {selectedCoachData.seatArrangement} Total: {selectedCoachData.totalSeats} seats
</p>
)}
{isLoading ? (
{isLoading ? (
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p>Loading seats...</p>
</div>
) : error ? (
</div>
) : error ? (
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
<div className="text-center py-8 text-red-500 dark:text-red-400">
<p>Error loading seats</p>
<p className="text-sm mt-2">{error?.message || 'Please try again'}</p>
</div>
) : validSeats.length === 0 ? (
</div>
) : filteredCoaches.length === 0 ? (
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No seats available in this coach</p>
<p className="text-sm mt-2">Please select a different coach</p>
<p>No coaches available for {selectedSchedule?.selectedSeatClass}</p>
<p className="text-sm mt-2">Please select a different seat class</p>
</div>
) : (
<>
</div>
) : (
<div className="space-y-6">
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
<h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select coach</h3>
<div className="flex flex-row gap-2">
{filteredCoaches?.map((coach: any) => {
const coachSeats = coach.seats?.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')) || [];
const isBedCoach = coach.seatClass?.toLowerCase().includes('bed') || coach.mode?.toLowerCase().includes('bed');
let filteredSeats = coachSeats;
if (isBedCoach && selectedSchedule?.selectedSeatClass) {
const bedPos = getBedPosition(selectedSchedule.selectedSeatClass);
if (bedPos) {
filteredSeats = coachSeats.filter((s: any) => s.bedPosition === bedPos);
}
}
const availableCount = filteredSeats.filter((s: any) => s.status === 'AVAILABLE').length || 0;
const seatClassName = selectedSchedule?.selectedSeatClass || (typeof coach.seatClass === 'string' ? coach.seatClass : (coach.seatClass?.name || coach.coachClass || ''));
return (
<button
key={coach.id}
onClick={() => setSelectedCoach(coach.id)}
className={`px-4 py-2 rounded transition-all text-left ${
selectedCoach === coach.id
? 'bg-[rgb(20_113_76)] text-white shadow-lg'
: 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100'
}`}
>
<div className="font-semibold">{coach.label || coach.name || coach.coachNumber}</div>
<div className="text-xs opacity-75">{seatClassName}</div>
<div className="text-xs opacity-75">{availableCount} available</div>
</button>
);
})}
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">
Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}
</h3>
{selectedCoachData && (
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
Arrangement: {selectedCoachData.seatArrangement} Total: {selectedCoachData.totalSeats} seats
</p>
)}
<div className="flex flex-wrap gap-4 mb-6 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-500 rounded"></div>
@@ -439,16 +505,23 @@ export default function SeatsPage() {
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-700/30 p-6 rounded-lg overflow-x-auto">
{renderCoachSeats(selectedCoachData, isBedCoach)}
<div className="bg-gray-50 dark:bg-gray-700/30 p-6 rounded-lg overflow-x-auto border border-gray-200 dark:border-gray-700 w-fit">
{validSeats.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No seats available in this coach</p>
<p className="text-sm mt-2">Please select a different coach</p>
</div>
) : (
renderCoachSeats(selectedCoachData, (selectedCoachData.seatClass?.toLowerCase().includes('bed') || selectedCoachData.mode?.toLowerCase().includes('bed')))
)}
</div>
</>
)}
</div>
</div>
</div>
)}
</div>
<div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 sticky top-4">
<div className="lg:col-span-1">
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 sticky top-6">
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Selection summary</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Select {passengers.length} seat(s) for your passengers

View File

@@ -526,7 +526,7 @@ export default function ProfilePage() {
value={settings.preferredOrigin}
onChange={(e) => setSettings({ ...settings, preferredOrigin: e.target.value })}
className="input-field"
placeholder="e.g., Addis Ababa"
placeholder="e.g., Lebu"
/>
</div>
<div>