mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
'use client';
|
||
|
||
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';
|
||
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 [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
|
||
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'),
|
||
});
|
||
|
||
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('');
|
||
},
|
||
});
|
||
|
||
const updateMutation = useMutation({
|
||
mutationFn: ({ id, data }: { id: string; data: any }) => seatClassesApi.update(id, data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['classes'] });
|
||
setShowModal(false);
|
||
setEditingClass(null);
|
||
setSelectedCoachTypeId('');
|
||
},
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: seatClassesApi.delete,
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['classes'] });
|
||
},
|
||
});
|
||
|
||
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: selectedCoachTypeId,
|
||
name: formData.get('name') as string,
|
||
description: formData.get('description') as string,
|
||
baseFareMinor: Math.round(Number((parseFloat(formData.get('baseFareMinor') as string) * 100).toFixed(10))) || 0,
|
||
premiumMinor: Math.round(Number((parseFloat(formData.get('premiumMinor') as string) * 100).toFixed(10))) || 0,
|
||
insuranceFeeMinor: Math.round(Number((parseFloat(formData.get('insuranceFeeMinor') as string) * 100).toFixed(10))) || 0,
|
||
isActive: formData.get('isActive') === 'true',
|
||
};
|
||
|
||
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: 'baseFareMinor',
|
||
label: 'Base Fare',
|
||
render: (cls: any) => (
|
||
<span className="font-mono text-sm">{(cls.baseFareMinor / 100).toFixed(2)} ETB</span>
|
||
),
|
||
},
|
||
{
|
||
key: 'premiumMinor',
|
||
label: 'Premium',
|
||
render: (cls: any) => (
|
||
<span className="font-mono text-sm">{cls.premiumMinor ? (cls.premiumMinor / 100).toFixed(2) : '0.00'} ETB</span>
|
||
),
|
||
},
|
||
{
|
||
key: 'insuranceFeeMinor',
|
||
label: 'Insurance',
|
||
render: (cls: any) => (
|
||
<span className="font-mono text-sm">{cls.insuranceFeeMinor ? (cls.insuranceFeeMinor / 100).toFixed(2) : '0.00'} ETB</span>
|
||
),
|
||
},
|
||
{
|
||
key: 'isActive',
|
||
label: 'Status',
|
||
render: (cls: any) => (
|
||
<Badge variant="status" status={cls.isActive ? 'CONFIRMED' : 'CANCELLED'}>
|
||
{cls.isActive ? 'Active' : 'Inactive'}
|
||
</Badge>
|
||
),
|
||
},
|
||
];
|
||
|
||
const handleOpenModal = (cls?: any) => {
|
||
if (cls) {
|
||
setEditingClass(cls);
|
||
} else {
|
||
setEditingClass(null);
|
||
}
|
||
setShowModal(true);
|
||
};
|
||
|
||
const actions = [
|
||
{
|
||
label: 'Edit',
|
||
onClick: (cls: any) => handleOpenModal(cls),
|
||
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 with pricing by coach type</p>
|
||
</div>
|
||
<ActionButton
|
||
icon={Plus}
|
||
onClick={() => handleOpenModal()}
|
||
>
|
||
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);
|
||
setSelectedCoachTypeId('');
|
||
}}
|
||
title={`${editingClass ? 'Edit' : 'Add'} Class`}
|
||
size="lg"
|
||
>
|
||
<form key={editingClass?.id ?? 'new'} 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"
|
||
value={selectedCoachTypeId}
|
||
onChange={(e) => setSelectedCoachTypeId(e.target.value)}
|
||
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">Class Name *</label>
|
||
<input
|
||
type="text"
|
||
name="name"
|
||
className="input"
|
||
defaultValue={editingClass?.name || ''}
|
||
required
|
||
placeholder="e.g., Economy Regular"
|
||
/>
|
||
</div>
|
||
|
||
<div className="border-t pt-4">
|
||
<h3 className="font-semibold text-foreground mb-4">Pricing Configuration</h3>
|
||
|
||
<div>
|
||
<label className="label">Base Fare (ETB) *</label>
|
||
<input
|
||
type="number"
|
||
name="baseFareMinor"
|
||
className="input"
|
||
defaultValue={editingClass?.baseFareMinor ? (editingClass.baseFareMinor / 100).toFixed(2) : ''}
|
||
required
|
||
min="0"
|
||
step="0.01"
|
||
placeholder="e.g., 350.00"
|
||
/>
|
||
<p className="text-xs text-muted-foreground mt-1">Per-km distance-based fare rate</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||
<div>
|
||
<label className="label">Premium Fee (ETB)</label>
|
||
<input
|
||
type="number"
|
||
name="premiumMinor"
|
||
className="input"
|
||
defaultValue={editingClass?.premiumMinor ? (editingClass.premiumMinor / 100).toFixed(2) : '0.00'}
|
||
min="0"
|
||
step="0.01"
|
||
placeholder="e.g., 50.00"
|
||
/>
|
||
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., lounge access, extra legroom)</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="label">Insurance Fee (ETB)</label>
|
||
<input
|
||
type="number"
|
||
name="insuranceFeeMinor"
|
||
className="input"
|
||
defaultValue={editingClass?.insuranceFeeMinor ? (editingClass.insuranceFeeMinor / 100).toFixed(2) : '0.00'}
|
||
min="0"
|
||
step="0.01"
|
||
placeholder="e.g., 25.00"
|
||
/>
|
||
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., travel insurance)</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
|
||
<p className="font-medium mb-1">Total Fare Calculation:</p>
|
||
<p>Total = (Base Fare × Distance) + Premium + Insurance</p>
|
||
<p className="mt-2 text-xs">• Premium applies per passenger</p>
|
||
<p className="text-xs">• Insurance applies per passenger</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="label">Status</label>
|
||
<select
|
||
name="isActive"
|
||
className="input"
|
||
defaultValue={editingClass?.isActive !== undefined ? 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);
|
||
setSelectedCoachTypeId('');
|
||
}}
|
||
>
|
||
Cancel
|
||
</ActionButton>
|
||
<ActionButton
|
||
type="submit"
|
||
loading={createMutation.isPending || updateMutation.isPending}
|
||
>
|
||
{editingClass ? 'Update' : 'Create'} Class
|
||
</ActionButton>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|