Files
edr-platform/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx
2026-07-08 18:16:57 +03:00

368 lines
13 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; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ 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: ({ id, cascade }: { id: string; cascade?: boolean }) => seatClassesApi.delete(id, cascade),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['classes'] });
},
onError: (e: any) => {
const msg = e?.response?.data?.message || e?.message || 'Failed to delete class';
const isFkError = msg?.includes('Cannot delete') || e?.response?.status === 400;
if (isFkError && !deleteConfirm.cascade) {
setDeleteConfirm(prev => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg }));
} else {
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg }));
}
},
});
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, error: undefined });
};
const confirmDelete = async () => {
if (!deleteConfirm.class) return;
try {
await deleteMutation.mutateAsync({ id: deleteConfirm.class.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked });
setDeleteConfirm({ isOpen: false, class: null });
} catch {
// error is set by onError handler
}
};
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}
isLoading={deleteMutation.isPending}
error={deleteConfirm.error}
warning={!deleteConfirm.cascade ? "This class may be used by coaches and fare rules. Deleting it may impact seat assignments and pricing." : undefined}
cascadeWarning={deleteConfirm.cascade ? "This class has related fare rules that will also be permanently deleted." : undefined}
cascadeChecked={deleteConfirm.cascadeChecked}
onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))}
/>
{/* 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 *</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>
<input type="hidden" name="premiumMinor" value="0" />
<div className="mt-4">
<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>
<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>
);
}