Files
edr-platform/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx
2026-07-02 16:20:15 +03:00

372 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
}