Coaches, seats, schedules, and pricing related updates

This commit is contained in:
Stephanos A
2026-06-07 17:41:31 +03:00
parent af14535e08
commit bb10e7fdf2
55 changed files with 5119 additions and 2930 deletions

View File

@@ -0,0 +1,309 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, Search } 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 { seatClassesApi, apiClient } from '@/lib/api';
import { formatCurrency } from '@/lib/utils';
export default function ClassesPage() {
const [filters, setFilters] = useState({ search: '' });
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 queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['classes', filters],
queryFn: () => seatClassesApi.getAll(),
});
const { data: coachTypes } = useQuery<any>({
queryKey: ['coach-types'],
queryFn: () => apiClient.get('/fleet/coach-types'),
});
const createMutation = useMutation({
mutationFn: seatClassesApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['classes'] });
setShowModal(false);
setEditingClass(null);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => seatClassesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['classes'] });
setShowModal(false);
setEditingClass(null);
},
});
const deleteMutation = useMutation({
mutationFn: seatClassesApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['classes'] });
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const classData = {
coachTypeId: formData.get('coachTypeId') as string,
name: formData.get('name') as string,
description: formData.get('description') as string,
baseFareMinor: parseInt(formData.get('baseFareMinor') as string) || 0,
};
if (editingClass) {
await updateMutation.mutateAsync({ id: editingClass.id, data: classData });
} else {
await createMutation.mutateAsync(classData);
}
};
const handleDelete = (cls: any) => {
setDeleteConfirm({ isOpen: true, class: cls });
};
const confirmDelete = async () => {
if (deleteConfirm.class) {
await deleteMutation.mutateAsync(deleteConfirm.class.id);
setDeleteConfirm({ isOpen: false, class: null });
}
};
const coachTypesArray = Array.isArray(coachTypes) ? coachTypes : ((coachTypes as any)?.data || (coachTypes as any)?.items || []);
const coachTypeMap = coachTypesArray.reduce((map: any, ct: any) => {
map[ct.id] = ct.name;
return map;
}, {});
const filteredClasses = (data as any)?.items || (Array.isArray(data) ? data : []);
const displayedClasses = filteredClasses.filter((cls: any) => {
if (!filters.search) return true;
const searchLower = filters.search.toLowerCase();
return (
cls.name?.toLowerCase().includes(searchLower) ||
cls.coachType?.name?.toLowerCase().includes(searchLower) ||
cls.description?.toLowerCase().includes(searchLower)
);
});
const columns = [
{
key: 'coachType',
label: 'Coach Type',
render: (cls: any) => (
<span className="text-sm">{cls.coachType?.name || coachTypeMap[cls.coachTypeId] || 'N/A'}</span>
),
},
{
key: 'name',
label: 'Class Name',
render: (cls: any) => <span className="font-medium">{cls.name}</span>,
},
{
key: 'description',
label: 'Description',
render: (cls: any) => (
<span className="text-sm text-muted-foreground">{cls.description || '-'}</span>
),
},
{
key: 'baseFareMinor',
label: 'Base Fare (ETB)',
render: (cls: any) => (
<span className="font-mono text-sm">{formatCurrency(cls.baseFareMinor, 'ETB')}</span>
),
},
{
key: 'isActive',
label: 'Status',
render: (cls: any) => (
<Badge variant="status" status={cls.isActive ? 'CONFIRMED' : 'CANCELLED'}>
{cls.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
];
const actions = [
{
label: 'Edit',
onClick: (cls: any) => {
setEditingClass(cls);
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">Classes</h1>
<p className="text-muted-foreground">Manage class configurations by coach type</p>
</div>
<ActionButton
icon={Plus}
onClick={() => {
setEditingClass(null);
setShowModal(true);
}}
>
Add Class
</ActionButton>
</div>
<div className="card">
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by name, coach type, or description..."
className="input pl-10 w-full"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
</div>
<DataTable
data={displayedClasses}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage={filters.search ? "No classes match your search" : "No classes found"}
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, class: null })}
onConfirm={confirmDelete}
title="Delete Class"
message={`Are you sure you want to delete ${deleteConfirm.class?.name}?`}
confirmText="Delete"
isDanger={true}
warning="This class may be used by coaches and fare rules. Deleting it may impact seat assignments and pricing."
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingClass(null);
}}
title={`${editingClass ? 'Edit' : 'Add'} Class`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div>
<label className="label">Coach Type *</label>
<select
name="coachTypeId"
className="input"
defaultValue={editingClass?.coachTypeId || ''}
required
>
<option value="">Select Coach Type</option>
{coachTypesArray.map((ct: any) => (
<option key={ct.id} value={ct.id}>
{ct.code} - {ct.name}
</option>
))}
</select>
</div>
<div>
<label className="label">Class Name *</label>
<input
type="text"
name="name"
className="input"
defaultValue={editingClass?.name || ''}
required
placeholder="e.g., Economy Regular"
/>
</div>
<div>
<label className="label">Description</label>
<textarea
name="description"
className="input"
rows={3}
defaultValue={editingClass?.description || ''}
placeholder="Describe this class..."
/>
</div>
<div>
<label className="label">Base Fare (ETB cents) *</label>
<input
type="number"
name="baseFareMinor"
className="input"
defaultValue={editingClass?.baseFareMinor || ''}
required
min="0"
placeholder="e.g., 45000 (450 ETB)"
/>
<p className="text-xs text-muted-foreground mt-1">Enter amount in cents (100 cents = 1 ETB)</p>
</div>
<div>
<label className="label">Status</label>
<select
name="isActive"
className="input"
defaultValue={editingClass?.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);
setEditingClass(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{editingClass ? 'Update' : 'Create'} Class
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -2,141 +2,251 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fleetApi } from '@/lib/api';
import { Plus, Search, Grid3x3, Edit, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { Plus, Search, Grid3x3, Train, Edit, Trash2 } from 'lucide-react';
import { fleetApi, apiClient } from '@/lib/api';
type Tab = 'types' | 'coaches';
export default function CoachesPage() {
const [activeTab, setActiveTab] = useState<Tab>('coaches');
const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingCoach, setEditingCoach] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; coach: any | null }>({ isOpen: false, coach: null });
const [editingItem, setEditingItem] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null }>({ isOpen: false, item: null });
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['coaches', search],
queryFn: () => fleetApi.getCoaches({ search }),
// Coach Types Queries
const { data: coachTypesData, isLoading: typesLoading } = useQuery({
queryKey: ['coach-types'],
queryFn: () => apiClient.get('/fleet/coach-types'),
});
const createMutation = useMutation({
// Coaches Queries
const { data: coachesData, isLoading: coachesLoading } = useQuery({
queryKey: ['coaches'],
queryFn: () => fleetApi.getCoaches({}),
});
// Coach Type Mutations
const createCoachTypeMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coach-types'] });
setShowModal(false);
setEditingItem(null);
},
});
const updateCoachTypeMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => apiClient.patch(`/fleet/coach-types/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coach-types'] });
setShowModal(false);
setEditingItem(null);
},
});
const deleteCoachTypeMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/fleet/coach-types/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coach-types'] });
},
});
// Coach Mutations
const createCoachMutation = useMutation({
mutationFn: fleetApi.createCoach,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coaches'] });
setShowModal(false);
setEditingCoach(null);
setEditingItem(null);
},
});
const updateMutation = useMutation({
const updateCoachMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => fleetApi.updateCoach(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coaches'] });
setShowModal(false);
setEditingCoach(null);
setEditingItem(null);
},
});
const deleteMutation = useMutation({
const deleteCoachMutation = useMutation({
mutationFn: fleetApi.deleteCoach,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coaches'] });
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
const handleCoachTypeSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const coachData = {
coachNumber: formData.get('coachNumber') as string,
label: formData.get('label') as string,
seatClassId: formData.get('seatClassId') as string,
coachType: formData.get('coachType') as string,
mode: formData.get('mode') as string,
seatArrangement: formData.get('seatArrangement') as string,
totalUnits: parseInt(formData.get('totalUnits') as string),
isActive: formData.get('isActive') === 'true',
const data = {
code: formData.get('code') as string,
name: formData.get('name') as string,
type: formData.get('type') as string,
};
if (editingCoach) {
await updateMutation.mutateAsync({ id: editingCoach.id, data: coachData });
if (editingItem?.isCoachType) {
await updateCoachTypeMutation.mutateAsync({ id: editingItem.id, data });
} else {
await createMutation.mutateAsync(coachData);
await createCoachTypeMutation.mutateAsync(data);
}
};
const handleDelete = (coach: any) => {
setDeleteConfirm({ isOpen: true, coach });
const handleCoachSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const data = {
number: formData.get('number') as string,
coachTypeId: formData.get('coachTypeId') as string,
arrangement: formData.get('arrangement') as string,
capacity: parseInt(formData.get('capacity') as string),
status: formData.get('status') as string,
};
if (editingItem?.isCoach) {
await updateCoachMutation.mutateAsync({ id: editingItem.id, data });
} else {
await createCoachMutation.mutateAsync(data);
}
};
const handleDelete = (item: any, isCoachType: boolean) => {
setDeleteConfirm({ isOpen: true, item: { ...item, isCoachType } });
};
const confirmDelete = async () => {
if (deleteConfirm.coach) {
await deleteMutation.mutateAsync(deleteConfirm.coach.id);
setDeleteConfirm({ isOpen: false, coach: null });
if (deleteConfirm.item?.isCoachType) {
await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
} else {
await deleteCoachMutation.mutateAsync(deleteConfirm.item.id);
}
setDeleteConfirm({ isOpen: false, item: null });
};
const coaches = data?.items || data?.data || [];
const coachTypesArray = Array.isArray(coachTypesData) ? coachTypesData : (coachTypesData as any)?.items || (coachTypesData as any)?.data || [];
const coaches = coachesData?.items || coachesData?.data || [];
const columns = [
const filteredCoachTypes = coachTypesArray.filter((ct: any) => {
if (!search) return true;
const searchLower = search.toLowerCase();
return (
ct.code?.toLowerCase().includes(searchLower) ||
ct.name?.toLowerCase().includes(searchLower) ||
ct.type?.toLowerCase().includes(searchLower)
);
});
const filteredCoaches = coaches.filter((coach: any) => {
if (!search) return true;
const searchLower = search.toLowerCase();
return (
coach.number?.toLowerCase().includes(searchLower) ||
coach.coachNumber?.toLowerCase().includes(searchLower) ||
coach.coachType?.name?.toLowerCase().includes(searchLower) ||
coach.arrangement?.toLowerCase().includes(searchLower)
);
});
const typeColorMap: Record<string, string> = {
passenger: 'edr-badge-info',
sleeper: 'edr-badge-warning',
dining: 'edr-badge-success',
baggage: 'edr-badge-danger',
};
const statusMap: Record<string, string> = {
ACTIVE: 'edr-badge-success',
MAINTENANCE: 'edr-badge-warning',
INACTIVE: 'edr-badge-danger',
};
// Coach Types Columns
const coachTypeColumns = [
{
key: 'coachNumber',
label: 'Coach Number',
key: 'code',
label: 'Code',
sortable: true,
render: (ct: any) => (
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[rgb(20,113,76)]">
<Grid3x3 className="h-4 w-4 text-white" />
</div>
<span className="font-mono font-medium">{ct.code}</span>
</div>
),
},
{
key: 'name',
label: 'Name',
sortable: true,
render: (ct: any) => <span className="font-medium">{ct.name}</span>,
},
{
key: 'type',
label: 'Type',
render: (ct: any) => (
<span className={`edr-badge ${typeColorMap[ct.type] || 'edr-badge-info'}`}>
{ct.type}
</span>
),
},
{
key: 'coaches',
label: 'Coaches',
render: (ct: any) => (
<span className="text-sm font-medium">{ct.coaches?.length || 0}</span>
),
},
];
// Coaches Columns
const coachColumns = [
{
key: 'number',
label: 'Number',
sortable: true,
render: (coach: any) => (
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[rgb(20,113,76)]">
<Grid3x3 className="h-4 w-4 text-white" />
</div>
<span className="font-medium">{coach.coachNumber}</span>
<span className="font-medium">{coach.number || coach.coachNumber}</span>
</div>
),
},
{
key: 'seatClass',
label: 'Seat Class',
render: (coach: any) => {
const seatClass = coach.seatClass?.name || coach.serviceClass || 'N/A';
const colorMap: Record<string, string> = {
'ECONOMY_REGULAR': 'edr-badge-info',
'ECONOMY_BED': 'edr-badge-warning',
'VIP_BED': 'edr-badge-success',
};
return (
<span className={`edr-badge ${colorMap[seatClass] || 'edr-badge-info'}`}>
{seatClass.replace(/_/g, ' ')}
</span>
);
},
},
{
key: 'totalSeats',
label: 'Total Seats',
key: 'coachType',
label: 'Coach Type',
render: (coach: any) => (
<span className="font-mono text-sm">{coach.totalSeats || coach.totalUnits || 0}</span>
<span className="text-sm">{coach.coachType?.name || 'N/A'}</span>
),
},
{
key: 'layout',
label: 'Layout',
key: 'arrangement',
label: 'Arrangement',
render: (coach: any) => (
<span className="text-sm text-muted-foreground">
{coach.layout || coach.seatLayout || coach.seatArrangement || 'N/A'}
</span>
<span className="text-sm font-mono">{coach.arrangement || 'N/A'}</span>
),
},
{
key: 'capacity',
label: 'Capacity',
render: (coach: any) => (
<span className="font-mono text-sm font-medium">{coach.capacity || 0}</span>
),
},
{
key: 'status',
label: 'Status',
render: (coach: any) => {
const status = coach.isActive ? 'ACTIVE' : 'INACTIVE';
const statusMap: Record<string, string> = {
ACTIVE: 'edr-badge-success',
MAINTENANCE: 'edr-badge-warning',
INACTIVE: 'edr-badge-danger',
};
const status = coach.status || 'ACTIVE';
return (
<span className={`edr-badge ${statusMap[status] || 'edr-badge-info'}`}>
{status}
@@ -146,11 +256,11 @@ export default function CoachesPage() {
},
];
const actions = [
const coachTypeActions = [
{
label: 'Edit',
onClick: (coach: any) => {
setEditingCoach(coach);
onClick: (item: any) => {
setEditingItem({ ...item, isCoachType: true });
setShowModal(true);
},
variant: 'secondary' as const,
@@ -158,7 +268,25 @@ export default function CoachesPage() {
},
{
label: 'Delete',
onClick: handleDelete,
onClick: (item: any) => handleDelete(item, true),
variant: 'danger' as const,
icon: Trash2,
},
];
const coachActions = [
{
label: 'Edit',
onClick: (item: any) => {
setEditingItem({ ...item, isCoach: true });
setShowModal(true);
},
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: (item: any) => handleDelete(item, false),
variant: 'danger' as const,
icon: Trash2,
},
@@ -169,51 +297,112 @@ export default function CoachesPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Coach Management</h1>
<p className="text-muted-foreground mt-1">Manage train coaches and configurations</p>
<p className="text-muted-foreground mt-1">Manage coach types and train coaches</p>
</div>
<ActionButton
icon={Plus}
onClick={() => {
setEditingCoach(null);
setEditingItem(null);
setSearch('');
setShowModal(true);
}}
>
Add Coach
{activeTab === 'types' ? 'Add Coach Type' : 'Add Coach'}
</ActionButton>
</div>
{/* Tabs */}
<div className="card">
<div className="flex items-center gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search coaches..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10"
/>
</div>
<div className="flex border-b border-border">
<button
onClick={() => {
setActiveTab('types');
setSearch('');
}}
className={`px-4 py-3 font-medium transition-colors ${
activeTab === 'types'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Coach Types
</button>
<button
onClick={() => {
setActiveTab('coaches');
setSearch('');
}}
className={`px-4 py-3 font-medium transition-colors ${
activeTab === 'coaches'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Coaches
</button>
</div>
<DataTable
columns={columns}
data={coaches}
actions={actions}
loading={isLoading}
/>
{/* Coach Types Tab */}
{activeTab === 'types' && (
<div className="pt-6 space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by code, name, or type..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10 w-full"
/>
</div>
<DataTable
columns={coachTypeColumns}
data={filteredCoachTypes}
actions={coachTypeActions}
loading={typesLoading}
emptyMessage={search ? "No coach types match your search" : "No coach types found. Create one to get started."}
/>
</div>
)}
{/* Coaches Tab */}
{activeTab === 'coaches' && (
<div className="pt-6 space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by coach number, type, or arrangement..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10 w-full"
/>
</div>
<DataTable
columns={coachColumns}
data={filteredCoaches}
actions={coachActions}
loading={coachesLoading}
emptyMessage={search ? "No coaches match your search" : "No coaches found. Create one to get started."}
/>
</div>
)}
</div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, coach: null })}
onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
onConfirm={confirmDelete}
title="Delete Coach"
message={`Are you sure you want to delete coach ${deleteConfirm.coach?.coachNumber}?`}
title={`Delete ${deleteConfirm.item?.isCoachType ? 'Coach Type' : 'Coach'}`}
message={`Are you sure you want to delete ${deleteConfirm.item?.name || deleteConfirm.item?.number}?`}
confirmText="Delete"
isDanger={true}
warning="This coach may be assigned to schedules and trips. Deleting it may impact these systems."
warning={
deleteConfirm.item?.isCoachType
? 'This coach type may have coaches assigned. Deleting it may impact these systems.'
: 'This coach may be assigned to schedules. Deleting it may impact these systems.'
}
/>
{/* Add/Edit Modal */}
@@ -221,106 +410,170 @@ export default function CoachesPage() {
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingCoach(null);
setEditingItem(null);
}}
title={`${editingCoach ? 'Edit' : 'Add'} Coach`}
size="lg"
title={
activeTab === 'types'
? `${editingItem?.isCoachType ? 'Edit' : 'Add'} Coach Type`
: `${editingItem?.isCoach ? 'Edit' : 'Add'} Coach`
}
size={activeTab === 'types' ? 'md' : 'lg'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Coach Number *</label>
<input
type="text"
name="coachNumber"
className="input"
defaultValue={editingCoach?.coachNumber}
required
placeholder="e.g., C001"
/>
{/* Coach Type Form */}
{activeTab === 'types' && (
<form onSubmit={handleCoachTypeSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div>
<label className="label">Code</label>
<input
type="text"
name="code"
className="input"
defaultValue={editingItem?.code || ''}
required
placeholder="e.g., HSC"
/>
</div>
<div>
<label className="label">Name</label>
<input
type="text"
name="name"
className="input"
defaultValue={editingItem?.name || ''}
required
placeholder="e.g., Hard Seat Coach"
/>
</div>
<div>
<label className="label">Type</label>
<input
type="text"
name="type"
className="input"
defaultValue={editingItem?.type || ''}
required
placeholder="e.g., Regular Seat"
/>
</div>
</div>
<div>
<label className="label">Label *</label>
<input
type="text"
name="label"
className="input"
defaultValue={editingCoach?.label}
required
placeholder="e.g., Coach 1"
/>
</div>
<div>
<label className="label">Coach Type</label>
<select name="coachType" className="input" defaultValue={editingCoach?.coachType}>
<option value="passenger">Passenger</option>
<option value="sleeper">Sleeper</option>
<option value="dining">Dining</option>
<option value="baggage">Baggage</option>
</select>
</div>
<div>
<label className="label">Mode *</label>
<select name="mode" className="input" defaultValue={editingCoach?.mode || 'seat'}>
<option value="seat">Seat</option>
<option value="bed">Bed</option>
<option value="convertible">Convertible</option>
</select>
</div>
<div>
<label className="label">Seat Arrangement</label>
<input
type="text"
name="seatArrangement"
className="input"
defaultValue={editingCoach?.seatArrangement}
placeholder="e.g., 2+2"
/>
</div>
<div>
<label className="label">Total Units *</label>
<input
type="number"
name="totalUnits"
className="input"
defaultValue={editingCoach?.totalUnits}
required
min="1"
placeholder="60"
/>
</div>
<div>
<label className="label">Status</label>
<select
name="isActive"
className="input"
defaultValue={editingCoach?.isActive?.toString() || 'true'}
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingItem(null);
}}
>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createCoachTypeMutation.isPending || updateCoachTypeMutation.isPending}
>
{editingItem?.isCoachType ? 'Update' : 'Create'} Coach Type
</ActionButton>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingCoach(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{editingCoach ? 'Update' : 'Create'} Coach
</ActionButton>
</div>
</form>
</form>
)}
{/* Coach Form */}
{activeTab === 'coaches' && (
<form onSubmit={handleCoachSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Coach Type</label>
<select
name="coachTypeId"
className="input"
defaultValue={editingItem?.coachTypeId || ''}
required
>
<option value="">Select Coach Type</option>
{coachTypesArray.map((ct: any) => (
<option key={ct.id} value={ct.id}>
{ct.code} - {ct.name} - {ct.type}
</option>
))}
</select>
</div>
<div>
<label className="label">Number</label>
<input
type="text"
name="number"
className="input"
defaultValue={editingItem?.number || editingItem?.coachNumber || ''}
required
placeholder="e.g., A-001"
/>
</div>
<div>
<label className="label">Arrangement</label>
<input
type="text"
name="arrangement"
className="input"
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement || '2+2'}
required
placeholder="e.g., 2+2, 3+2"
/>
<p className="text-xs text-muted-foreground mt-1">Format: separate columns with +</p>
</div>
<div>
<label className="label">Capacity</label>
<input
type="number"
name="capacity"
className="input"
defaultValue={editingItem?.capacity || editingItem?.totalUnits || ''}
required
min="1"
placeholder="60"
/>
</div>
<div className="md:col-span-2">
<label className="label">Status</label>
<select
name="status"
className="input"
defaultValue={editingItem?.status || 'ACTIVE'}
>
<option value="ACTIVE">Active</option>
<option value="MAINTENANCE">Maintenance</option>
<option value="INACTIVE">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingItem(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createCoachMutation.isPending || updateCoachMutation.isPending}
>
{editingItem?.isCoach ? 'Update' : 'Create'} Coach
</ActionButton>
</div>
</form>
)}
</Modal>
</div>
);

View File

@@ -6,8 +6,8 @@ import { useAuthStore } from '@/lib/auth-store';
import { Train } from 'lucide-react';
export default function LoginPage() {
const [email, setEmail] = useState('admin@edr-platform.com');
const [password, setPassword] = useState('admin123');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();

View File

@@ -18,6 +18,7 @@ export default function PassengersPage() {
page: 1,
pageSize: 20,
search: '',
role: 'PASSENGER',
});
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });

View File

@@ -1,5 +1,54 @@
import DashboardLayout from '../dashboard/layout';
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { useAuthStore } from '@/lib/auth-store';
export default function PricingLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
const router = useRouter();
const { isAuthenticated } = useAuthStore();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsLoading(false);
}, 100);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, router, isLoading]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-slate-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-edr-green-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return (
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-slate-950">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-slate-950 p-6">
{children}
</main>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, X } from 'lucide-react';
import { Plus, Edit, Trash2, X, Search } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
@@ -26,6 +26,7 @@ export default function RoutesPage() {
const [destinationStationId, setDestinationStationId] = useState('');
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null }>({ isOpen: false, route: null });
const [search, setSearch] = useState('');
const queryClient = useQueryClient();
const { data: routes, isLoading: routesLoading } = useQuery({
@@ -176,6 +177,17 @@ export default function RoutesPage() {
},
];
const filteredRoutes = (routes as any)?.items || (Array.isArray(routes) ? routes : []);
const displayedRoutes = filteredRoutes.filter((route: any) => {
if (!search) return true;
const searchLower = search.toLowerCase();
return (
route.code?.toLowerCase().includes(searchLower) ||
route.name?.toLowerCase().includes(searchLower) ||
route.description?.toLowerCase().includes(searchLower)
);
});
const routeActions = [
{
label: 'Edit',
@@ -238,6 +250,7 @@ export default function RoutesPage() {
setDestinationStationId('');
setDestinationDistance(undefined);
setStops([]);
setSearch('');
setShowModal(true);
}}
>
@@ -245,12 +258,23 @@ export default function RoutesPage() {
</ActionButton>
</div>
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by code, name, or description..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10 w-full"
/>
</div>
<DataTable
data={(routes as any)?.items || (Array.isArray(routes) ? routes : [])}
data={displayedRoutes}
columns={routeColumns}
actions={routeActions}
loading={routesLoading}
emptyMessage="No routes found"
emptyMessage={search ? "No routes match your search" : "No routes found"}
/>
{/* Delete Confirmation */}
@@ -275,6 +299,7 @@ export default function RoutesPage() {
setDestinationStationId('');
setDestinationDistance(undefined);
setStops([]);
setSearch('');
}}
title={`${editingRoute ? 'Edit' : 'Add'} Route`}
size="lg"
@@ -516,6 +541,7 @@ export default function RoutesPage() {
setDestinationStationId('');
setDestinationDistance(undefined);
setStops([]);
setSearch('');
}}
>
Cancel

View File

@@ -1,5 +1,54 @@
import DashboardLayout from '../dashboard/layout';
'use client';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { useAuthStore } from '@/lib/auth-store';
export default function SchedulesLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { isAuthenticated } = useAuthStore();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsLoading(false);
}, 100);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, router, isLoading]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-slate-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-edr-green-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return (
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-slate-950">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-slate-950 p-6">
{children}
</main>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,236 +0,0 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Plus, Edit, Trash2 } 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 { seatClassesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function SeatClassesPage() {
const [filters, setFilters] = useState({ search: '' });
const [showModal, setShowModal] = useState(false);
const [editingSeatClass, setEditingSeatClass] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; seatClass: any | null }>({ isOpen: false, seatClass: null });
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['seat-classes', filters],
queryFn: () => seatClassesApi.getAll(),
});
const createMutation = useMutation({
mutationFn: seatClassesApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seat-classes'] });
setShowModal(false);
setEditingSeatClass(null);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => seatClassesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seat-classes'] });
setShowModal(false);
setEditingSeatClass(null);
},
});
const deleteMutation = useMutation({
mutationFn: seatClassesApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seat-classes'] });
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const seatClassData = {
name: formData.get('name') as string,
description: formData.get('description') as string,
basePrice: Math.round(parseFloat(formData.get('basePrice') as string) * 100), // Convert to minor units
isActive: formData.get('isActive') === 'true',
};
if (editingSeatClass) {
await updateMutation.mutateAsync({ id: editingSeatClass.id, data: seatClassData });
} else {
await createMutation.mutateAsync(seatClassData);
}
};
const handleDelete = (seatClass: any) => {
setDeleteConfirm({ isOpen: true, seatClass });
};
const confirmDelete = async () => {
if (deleteConfirm.seatClass) {
await deleteMutation.mutateAsync(deleteConfirm.seatClass.id);
setDeleteConfirm({ isOpen: false, seatClass: null });
}
};
const columns = [
{ key: 'name', label: 'Name', render: (cls: any) => <span className="font-medium">{cls.name}</span> },
{ key: 'description', label: 'Description', render: (cls: any) => cls.description || 'N/A' },
{ key: 'basePrice', label: 'Base Price', render: (cls: any) => formatCurrency(cls.basePrice, 'ETB') },
{ key: 'isActive', label: 'Status', render: (cls: any) => <Badge variant="status" status={cls.isActive ? 'CONFIRMED' : 'CANCELLED'}>{cls.isActive ? 'Active' : 'Inactive'}</Badge> },
];
const actions = [
{
label: 'Edit',
onClick: (seatClass: any) => {
setEditingSeatClass(seatClass);
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">Classes</h1>
<p className="text-muted-foreground">Manage seat class configurations</p>
</div>
<div className="flex gap-2">
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
<ActionButton
icon={Plus}
onClick={() => {
setEditingSeatClass(null);
setShowModal(true);
}}
>
Add Seat Class
</ActionButton>
</div>
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
</div>
</div>
<DataTable
data={(data as any)?.items || (Array.isArray(data) ? data : [])}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No seat classes found"
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, seatClass: null })}
onConfirm={confirmDelete}
title="Delete Seat Class"
message={`Are you sure you want to delete ${deleteConfirm.seatClass?.name}?`}
confirmText="Delete"
isDanger={true}
warning="This seat class may be used by coaches and trips. Deleting it may impact fare calculations and seat assignments."
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingSeatClass(null);
}}
title={`${editingSeatClass ? 'Edit' : 'Add'} Seat Class`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div>
<label className="label">Class Name *</label>
<input
type="text"
name="name"
className="input"
defaultValue={editingSeatClass?.name}
required
placeholder="e.g., Economy Regular"
/>
</div>
<div>
<label className="label">Description</label>
<textarea
name="description"
className="input"
rows={3}
defaultValue={editingSeatClass?.description}
placeholder="Describe the seat class..."
/>
</div>
<div>
<label className="label">Base Price (ETB) *</label>
<input
type="number"
name="basePrice"
className="input"
defaultValue={editingSeatClass?.basePrice ? (editingSeatClass.basePrice / 100).toFixed(2) : ''}
required
min="0"
step="0.01"
placeholder="e.g., 450.00"
/>
<p className="text-xs text-gray-500 mt-1">Enter amount in ETB (e.g., 450.00)</p>
</div>
<div>
<label className="label">Status</label>
<select
name="isActive"
className="input"
defaultValue={editingSeatClass?.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);
setEditingSeatClass(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{editingSeatClass ? 'Update' : 'Create'} Seat Class
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -4,14 +4,14 @@ import { useState } from 'react';
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 ActionButton from '@/components/ui/ActionButton'
import Badge from '@/components/ui/Badge';
import { Search, Armchair, Lock, Unlock, ChevronRight } from 'lucide-react';
import { Armchair, Lock, Unlock, Bed, X, RotateCcw } from 'lucide-react';
export default function SeatsPage() {
const [search, setSearch] = useState('');
const [selectedSchedule, setSelectedSchedule] = useState('');
const [showBlockModal, setShowBlockModal] = useState(false);
const [showRemoveModal, setShowRemoveModal] = useState(false);
const [selectedSeat, setSelectedSeat] = useState<any>(null);
const [blockReason, setBlockReason] = useState('');
const queryClient = useQueryClient();
@@ -44,6 +44,22 @@ export default function SeatsPage() {
},
});
const removeSeatMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.removeSeat(seatId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
setShowRemoveModal(false);
setSelectedSeat(null);
},
});
const undoRemoveMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.undoRemove(seatId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
},
});
const schedules = schedulesData?.items || schedulesData?.data || [];
const coaches = seatMapData?.coaches || [];
@@ -58,6 +74,17 @@ export default function SeatsPage() {
}
};
const handleRemoveSeat = (seat: any) => {
setSelectedSeat(seat);
setShowRemoveModal(true);
};
const handleUndoRemove = async (seat: any) => {
if (confirm('Restore this removed seat?')) {
await undoRemoveMutation.mutateAsync(seat.id);
}
};
const submitBlock = async () => {
if (!blockReason.trim()) {
alert('Please provide a reason for blocking');
@@ -66,6 +93,10 @@ export default function SeatsPage() {
await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason });
};
const submitRemoveSeat = async () => {
await removeSeatMutation.mutateAsync(selectedSeat.id);
};
const getSeatStatus = (seat: any) => {
if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED';
if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED';
@@ -83,9 +114,221 @@ export default function SeatsPage() {
}
};
const filteredCoaches = coaches.filter((coach: any) =>
search ? coach.coachNumber?.toLowerCase().includes(search.toLowerCase()) : true
);
const parseSeatArrangement = (arrangement: string | null): number[] => {
if (!arrangement) return [2, 2];
const parts = arrangement.split('+').map(p => parseInt(p.trim()));
return parts.length === 2 ? parts : [2, 2];
};
const getBedLabel = (bedPosition: string | null): string => {
if (bedPosition === 'upper') return 'U';
if (bedPosition === 'middle') return 'M';
if (bedPosition === 'lower') return 'L';
return '';
};
const renderCoachSeats = (coach: any, isBedCoach: boolean) => {
const allSeats = coach.seats || [];
const validSeats = allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
const removedSeats = allSeats.filter((s: any) => s.seatNumber && s.seatNumber.startsWith('-'));
if (validSeats.length === 0 && removedSeats.length === 0) {
return <div className="text-xs text-muted-foreground">No seats</div>;
}
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
if (isBedCoach && hasBedPositionData) {
// Render bed coach with flipping effect and bed position labels
const arrangement = parseSeatArrangement(coach.seatArrangement);
const seatsPerRow = arrangement[0] + (arrangement[1] || 0);
const allSeatsForLayout = [...validSeats, ...removedSeats];
const rows = [];
for (let i = 0; i < allSeatsForLayout.length; i += seatsPerRow) {
rows.push(allSeatsForLayout.slice(i, i + seatsPerRow));
}
return (
<div className="space-y-0">
{rows.map((rowSeats: any[], idx: number) => {
const rowNumber = rowSeats[0]?.row || (idx + 1);
const shouldFlipIcon = rowNumber % 2 === 0;
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">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
</div>
))}
</div>
)}
<div className="flex gap-0.5 justify-start">
{rowSeats.map((seat: any) => (
<SeatIcon
key={seat.id}
seat={seat}
coach={coach}
isBedCoach={true}
shouldFlipIcon={shouldFlipIcon}
getSeatStatus={getSeatStatus}
getSeatColor={getSeatColor}
handleBlock={handleBlock}
handleRemoveSeat={handleRemoveSeat}
handleUnblock={handleUnblock}
handleUndoRemove={handleUndoRemove}
hideNumber={true}
/>
))}
</div>
{!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">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
</div>
))}
</div>
)}
{showSpacing && <div className="h-2" />}
</div>
);
})}
</div>
);
}
// Regular armchair layout
const arrangement = parseSeatArrangement(coach.seatArrangement);
const leftCount = arrangement[0];
const rightCount = arrangement[1] || 0;
const rows = [];
const processedRows = new Set();
const allSeatsForLayout = [...validSeats, ...removedSeats];
for (const seat of allSeatsForLayout) {
if (!processedRows.has(seat.row)) {
rows.push(allSeatsForLayout.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;
}));
processedRows.add(seat.row);
}
}
return (
<div className="space-y-0">
{rows.map((rowSeats: any[], rowIdx: number) => {
const leftSeats = rowSeats.slice(0, leftCount);
const rightSeats = rowSeats.slice(leftCount);
const rowNumber = rowSeats[0]?.row || 1;
const shouldFlipArmchair = rowNumber % 2 === 0;
const showSpacing = rowIdx % 2 === 1;
return (
<div key={`row-${rowSeats[0]?.id}`}>
{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 : ''}
</div>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{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 : ''}
</div>
))}
</div>
)}
</div>
)}
<div className="flex gap-0.5 justify-start">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<SeatIcon
key={seat.id}
seat={seat}
coach={coach}
isBedCoach={false}
shouldFlipIcon={shouldFlipArmchair}
getSeatStatus={getSeatStatus}
getSeatColor={getSeatColor}
handleBlock={handleBlock}
handleRemoveSeat={handleRemoveSeat}
handleUnblock={handleUnblock}
handleUndoRemove={handleUndoRemove}
hideNumber={true}
/>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
<SeatIcon
key={seat.id}
seat={seat}
coach={coach}
isBedCoach={false}
shouldFlipIcon={shouldFlipArmchair}
getSeatStatus={getSeatStatus}
getSeatColor={getSeatColor}
handleBlock={handleBlock}
handleRemoveSeat={handleRemoveSeat}
handleUnblock={handleUnblock}
handleUndoRemove={handleUndoRemove}
hideNumber={true}
/>
))}
</div>
)}
</div>
{!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 : ''}
</div>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{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 : ''}
</div>
))}
</div>
)}
</div>
)}
{showSpacing && <div className="h-2" />}
</div>
);
})}
</div>
);
};
const coachesWithSeats = coaches.filter((coach: any) => {
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
return seats.length > 0;
});
return (
<div className="space-y-6">
@@ -97,40 +340,25 @@ export default function SeatsPage() {
</div>
<div className="card">
<div className="flex items-center gap-4 mb-6">
<div className="flex-1">
<label className="label">Select Schedule</label>
<select
value={selectedSchedule}
onChange={(e) => setSelectedSchedule(e.target.value)}
className="input"
>
<option value="">Select a schedule...</option>
{schedules.map((schedule: any) => {
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
const routeName = schedule.route?.name || 'N/A';
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
return (
<option key={schedule.id} value={schedule.id}>
{trainNumber} - {routeName} - {date}
</option>
);
})}
</select>
</div>
<div className="relative flex-1">
<label className="label">Search Coaches</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by coach number..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10"
/>
</div>
</div>
<div className="mb-6">
<label className="label">Select Schedule</label>
<select
value={selectedSchedule}
onChange={(e) => setSelectedSchedule(e.target.value)}
className="input"
>
<option value="">Select a schedule...</option>
{schedules.map((schedule: any) => {
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
const routeName = schedule.route?.name || 'N/A';
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
return (
<option key={schedule.id} value={schedule.id}>
{trainNumber} - {routeName} - {date}
</option>
);
})}
</select>
</div>
{!selectedSchedule ? (
@@ -143,13 +371,12 @@ export default function SeatsPage() {
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground mt-3">Loading seats...</p>
</div>
) : filteredCoaches.length === 0 ? (
) : coachesWithSeats.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>No coaches found for this schedule</p>
<p>No coaches with seats found for this schedule</p>
</div>
) : (
<div className="space-y-6">
{/* Legend */}
<div className="flex items-center gap-6 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-green-500"></div>
@@ -167,86 +394,35 @@ export default function SeatsPage() {
<div className="w-4 h-4 rounded bg-gray-500"></div>
<span className="text-sm">Blocked</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-2 border-dashed border-gray-400"></div>
<span className="text-sm">Removed</span>
</div>
</div>
{/* Coaches */}
{filteredCoaches.map((coach: any) => {
const seats = coach.seats || [];
const seatClass = coach.seatClass?.name || 'N/A';
const availableCount = seats.filter((s: any) => getSeatStatus(s) === 'AVAILABLE').length;
const bookedCount = seats.filter((s: any) => getSeatStatus(s) === 'BOOKED').length;
const blockedCount = seats.filter((s: any) => getSeatStatus(s) === 'BLOCKED').length;
<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 seats = (coach.seats || []).filter((s: any) => s.seatNumber);
return (
<div key={coach.id} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold">
Coach {coach.coachNumber} - {coach.label}
</h3>
<p className="text-sm text-muted-foreground">
{seatClass} {seats.length} seats
</p>
return (
<div key={coach.id} className="border rounded-lg p-3 bg-white dark:bg-card">
<div className="mb-3">
<h3 className="font-semibold text-sm">Coach {coach.coachNumber}</h3>
</div>
<div className="flex gap-3 text-sm">
<span className="text-green-600">Available: {availableCount}</span>
<span className="text-red-600">Booked: {bookedCount}</span>
<span className="text-gray-600">Blocked: {blockedCount}</span>
<div className="bg-gray-50 dark:bg-gray-900/30 py-2 rounded-lg">
{renderCoachSeats(coach, isBedCoach)}
</div>
</div>
<div className="grid grid-cols-8 gap-2">
{seats.map((seat: any) => {
const status = getSeatStatus(seat);
const color = getSeatColor(status);
const canBlock = status === 'AVAILABLE';
const canUnblock = status === 'BLOCKED';
return (
<div
key={seat.id}
className="relative group"
>
<div
className={`${color} text-white rounded-lg p-2 text-center text-sm font-medium cursor-pointer hover:opacity-80 transition-opacity`}
title={`${seat.seatNumber} - ${status}`}
>
{seat.seatNumber}
</div>
{(canBlock || canUnblock) && (
<div className="absolute inset-0 bg-black/60 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1">
{canBlock && (
<button
onClick={() => handleBlock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100"
title="Block seat"
>
<Lock className="h-3 w-3 text-gray-700" />
</button>
)}
{canUnblock && (
<button
onClick={() => handleUnblock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100"
title="Unblock seat"
>
<Unlock className="h-3 w-3 text-gray-700" />
</button>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
);
})}
</div>
</div>
)}
</div>
{/* Block Modal */}
<Modal
isOpen={showBlockModal}
onClose={() => {
@@ -293,6 +469,162 @@ export default function SeatsPage() {
</div>
</div>
</Modal>
<Modal
isOpen={showRemoveModal}
onClose={() => {
setShowRemoveModal(false);
setSelectedSeat(null);
}}
title="Remove Seat"
size="md"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Remove seat <strong>{selectedSeat?.seatNumber}</strong> from Coach{' '}
<strong>{selectedSeat?.coach?.coachNumber}</strong>
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<p className="text-sm text-yellow-800">
This will mark the seat as removed. The seat will show as an empty space on the seat map.
You can undo this action anytime by clicking the undo button on the removed seat.
</p>
</div>
<div className="flex justify-end gap-2">
<ActionButton
variant="secondary"
onClick={() => {
setShowRemoveModal(false);
setSelectedSeat(null);
}}
>
Cancel
</ActionButton>
<ActionButton
variant="danger"
onClick={submitRemoveSeat}
loading={removeSeatMutation.isPending}
>
Remove Seat
</ActionButton>
</div>
</div>
</Modal>
</div>
);
}
interface SeatIconProps {
seat: any;
coach: any;
isBedCoach: boolean;
shouldFlipIcon?: boolean;
hideNumber?: boolean;
getSeatStatus: (seat: any) => string;
getSeatColor: (status: string) => string;
handleBlock: (seat: any) => void;
handleRemoveSeat: (seat: any) => void;
handleUnblock: (seat: any) => void;
handleUndoRemove: (seat: any) => void;
}
function SeatIcon({
seat,
coach,
isBedCoach,
shouldFlipIcon = false,
hideNumber = false,
getSeatStatus,
getSeatColor,
handleBlock,
handleRemoveSeat,
handleUnblock,
handleUndoRemove,
}: SeatIconProps) {
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
if (!seat.seatNumber) {
return <div className="w-7 h-7" />;
}
if (isRemoved) {
return (
<div className="relative group flex flex-col items-center">
<div className="w-11 h-11 rounded border-2 border-dashed border-gray-400 flex items-center justify-center hover:opacity-80 transition-opacity" title="Removed seat">
</div>
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
<button
onClick={() => handleUndoRemove(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Undo remove"
>
<RotateCcw className="h-3 w-3 text-gray-700" />
</button>
</div>
</div>
);
}
const status = getSeatStatus(seat);
const color = getSeatColor(status);
const canBlock = status === 'AVAILABLE';
const canUnblock = status === 'BLOCKED';
return (
<div className="relative group flex flex-col items-center">
{!hideNumber && (
<span className="text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
{seat.seatNumber}
</span>
)}
{isBedCoach ? (
<div
className={`w-11 h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
title={`${seat.seatNumber} - ${seat.bedPosition} - ${status}`}
>
<Bed className="w-7 h-7 text-white" style={shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined} />
</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}`}
>
<Armchair className="w-7 h-7 text-white" style={shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined} />
</div>
)}
{(canBlock || canUnblock) && (
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
{canBlock && (
<>
<button
onClick={() => handleBlock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Block seat"
>
<Lock className="h-3 w-3 text-gray-700" />
</button>
<button
onClick={() => handleRemoveSeat(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Remove seat"
>
<X className="h-3 w-3 text-gray-700" />
</button>
</>
)}
{canUnblock && (
<button
onClick={() => handleUnblock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Unblock seat"
>
<Unlock className="h-3 w-3 text-gray-700" />
</button>
)}
</div>
)}
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, Train } from 'lucide-react';
import { Plus, Edit, Trash2, Train, Search } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
@@ -15,6 +15,7 @@ import { formatDate } from '@/lib/utils';
export default function TrainsPage() {
const [showModal, setShowModal] = useState(false);
const [editingTrain, setEditingTrain] = useState<TrainType | null>(null);
const [search, setSearch] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null }>({ isOpen: false, train: null });
const queryClient = useQueryClient();
@@ -86,6 +87,19 @@ export default function TrainsPage() {
}
};
const trains = trainsData?.items || [];
const filteredTrains = trains.filter((train: any) => {
if (!search) return true;
const searchLower = search.toLowerCase();
return (
train.number.toLowerCase().includes(searchLower) ||
train.name.toLowerCase().includes(searchLower) ||
train.operatorName?.toLowerCase().includes(searchLower) ||
train.description?.toLowerCase().includes(searchLower)
);
});
const trainColumns = [
{
key: 'number',
@@ -168,13 +182,27 @@ export default function TrainsPage() {
</ActionButton>
</div>
{/* Search Filter */}
<div className="mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by number, name, or operator..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10 w-full"
/>
</div>
</div>
{/* Trains Table */}
<DataTable
data={trainsData?.items || []}
data={filteredTrains}
columns={trainColumns}
actions={actions}
loading={trainsLoading}
emptyMessage="No trains found"
emptyMessage={search ? "No trains match your search" : "No trains found"}
/>
{/* Delete Confirmation */}

View File

@@ -57,12 +57,12 @@ const navigationSections = [
title: 'Master Data',
items: [
{ name: 'Stations', href: '/stations', icon: MapPin },
{ name: 'Routes', href: '/routes', icon: Route },
{ name: 'Trains', href: '/trains', icon: Train },
{ name: 'Coaches', href: '/coaches', icon: Grid3x3 },
{ name: 'Seats', href: '/seats', icon: Armchair },
{ name: 'Classes', href: '/classes', icon: Settings },
{ name: 'Routes', href: '/routes', icon: Route },
{ name: 'Schedules', href: '/schedules', icon: Calendar },
{ name: 'Classes', href: '/seat-classes', icon: Settings },
]
},
{

View File

@@ -7,8 +7,12 @@ interface PaginationProps {
}
export default function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
if (totalPages <= 1) {
return null;
}
return (
<div className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6">
<div className="flex items-center justify-between border-t border-border bg-card px-4 py-3 sm:px-6">
<div className="flex flex-1 justify-between sm:hidden">
<button
onClick={() => onPageChange(currentPage - 1)}
@@ -27,7 +31,7 @@ export default function Pagination({ currentPage, totalPages, onPageChange }: Pa
</div>
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
<p className="text-sm text-foreground">
Page <span className="font-medium">{currentPage}</span> of{' '}
<span className="font-medium">{totalPages}</span>
</p>
@@ -51,4 +55,4 @@ export default function Pagination({ currentPage, totalPages, onPageChange }: Pa
</div>
</div>
);
}
}

View File

@@ -0,0 +1,43 @@
import React, { ReactNode } from 'react';
interface TabItem {
id: string;
label: string;
icon?: ReactNode;
content: ReactNode;
}
interface TabsProps {
tabs: TabItem[];
defaultTab?: string;
}
export default function Tabs({ tabs, defaultTab }: TabsProps) {
const [activeTab, setActiveTab] = React.useState(defaultTab || tabs[0]?.id);
return (
<div>
<div className="border-b border-gray-200">
<div className="flex gap-1 -mb-px">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 font-medium text-sm border-b-2 transition-colors ${
activeTab === tab.id
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'
} flex items-center gap-2`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
</div>
<div className="mt-6">
{tabs.find((tab) => tab.id === activeTab)?.content}
</div>
</div>
);
}

View File

@@ -147,6 +147,8 @@ export const seatsApi = {
release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
block: (seatId: string, data: any) => apiClient.post<any>(`/seats/${seatId}/block`, data),
unblock: (seatId: string) => apiClient.delete(`/seats/${seatId}/block`),
removeSeat: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/remove`, {}),
undoRemove: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/undo-remove`, {}),
};
// Payments API
@@ -332,13 +334,13 @@ export const liveApi = {
getCrowdSignals: () => apiClient.get<any[]>('/live/crowd-signals'),
};
// Seat Classes API
// Classes API (formerly Seat Classes)
export const seatClassesApi = {
getAll: () => apiClient.get<any[]>('/seat-classes'),
getById: (id: string) => apiClient.get<any>(`/seat-classes/${id}`),
create: (data: any) => apiClient.post<any>('/seat-classes', data),
update: (id: string, data: any) => apiClient.patch<any>(`/seat-classes/${id}`, data),
delete: (id: string) => apiClient.delete(`/seat-classes/${id}`),
getAll: () => apiClient.get<any[]>('/fleet/classes'),
getById: (id: string) => apiClient.get<any>(`/fleet/classes/${id}`),
create: (data: any) => apiClient.post<any>('/fleet/classes', data),
update: (id: string, data: any) => apiClient.patch<any>(`/fleet/classes/${id}`, data),
delete: (id: string) => apiClient.delete(`/fleet/classes/${id}`),
};
// Food & Dining API

View File

@@ -7,6 +7,7 @@ export const passengersApi = {
const params = new URLSearchParams();
if (filters?.search) params.append('search', filters.search);
if (filters?.verified !== undefined) params.append('verified', filters.verified.toString());
if (filters?.role) params.append('role', filters.role);
if (filters?.page) params.append('page', filters.page.toString());
if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString());

View File

@@ -56,6 +56,7 @@ export interface BookingFilters {
export interface PassengerFilters {
search?: string;
verified?: boolean;
role?: string;
page?: number;
pageSize?: number;
}