mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 09:30:59 +00:00
410 lines
13 KiB
TypeScript
410 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Globe, 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 { stationsApi } from '@/lib/api';
|
|
import { Station } from '@/types';
|
|
|
|
const TIMEZONES = [
|
|
'Africa/Addis_Ababa',
|
|
'Africa/Johannesburg',
|
|
'Africa/Cairo',
|
|
'Africa/Lagos',
|
|
'Asia/Kolkata',
|
|
'UTC',
|
|
];
|
|
|
|
export default function StationsPage() {
|
|
const [filters, setFilters] = useState({ search: '', country: '', operational: '' });
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [editingStation, setEditingStation] = useState<any>(null);
|
|
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; station: any | null }>({ isOpen: false, station: null });
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ['stations', filters],
|
|
queryFn: () => stationsApi.getAll(filters),
|
|
});
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: stationsApi.create,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['stations'] });
|
|
setShowModal(false);
|
|
setEditingStation(null);
|
|
},
|
|
});
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: ({ id, data }: { id: string; data: any }) => stationsApi.update(id, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['stations'] });
|
|
setShowModal(false);
|
|
setEditingStation(null);
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: stationsApi.delete,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['stations'] });
|
|
},
|
|
});
|
|
|
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.currentTarget);
|
|
const sequence = parseInt(formData.get('sequence') as string);
|
|
|
|
if (isNaN(sequence)) {
|
|
alert('Sequence Number is required');
|
|
return;
|
|
}
|
|
|
|
const stationData = {
|
|
code: formData.get('code') as string,
|
|
name: formData.get('name') as string,
|
|
city: formData.get('city') as string,
|
|
countryCode: formData.get('countryCode') as string,
|
|
lat: parseFloat(formData.get('lat') as string) || null,
|
|
lng: parseFloat(formData.get('lng') as string) || null,
|
|
timezone: formData.get('timezone') as string,
|
|
distance: parseFloat(formData.get('distance') as string) || 0,
|
|
sequence,
|
|
isOperational: formData.get('isOperational') === 'true',
|
|
};
|
|
|
|
if (editingStation) {
|
|
await updateMutation.mutateAsync({ id: editingStation.id, data: stationData });
|
|
} else {
|
|
await createMutation.mutateAsync(stationData);
|
|
}
|
|
};
|
|
|
|
const handleDelete = (station: any) => {
|
|
setDeleteConfirm({ isOpen: true, station });
|
|
};
|
|
|
|
const confirmDelete = async () => {
|
|
if (deleteConfirm.station) {
|
|
await deleteMutation.mutateAsync(deleteConfirm.station.id);
|
|
setDeleteConfirm({ isOpen: false, station: null });
|
|
}
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
key: 'sequence',
|
|
label: 'Sequence',
|
|
sortable: true,
|
|
render: (station: any) => (
|
|
<span className="font-mono font-semibold text-sm">{station.sequence || 0}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'code',
|
|
label: 'Code',
|
|
sortable: true,
|
|
render: (station: any) => (
|
|
<span className="font-mono font-semibold">{station.code || 'N/A'}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'name',
|
|
label: 'Station Name',
|
|
sortable: true,
|
|
render: (station: any) => (
|
|
<div>
|
|
<div className="font-medium">{station.name || 'N/A'}</div>
|
|
<div className="text-sm text-muted-foreground">{station.city || 'N/A'}</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'countryCode',
|
|
label: 'Country',
|
|
render: (station: any) => (
|
|
<div className="flex items-center gap-2">
|
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
|
<span>{station.countryCode || 'N/A'}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'distance',
|
|
label: 'Distance (km)',
|
|
render: (station: any) => (
|
|
<span className="font-mono text-sm">{station.distance ? `${station.distance}` : '0'}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'isOperational',
|
|
label: 'Status',
|
|
render: (station: any) => (
|
|
<Badge variant="status" status={station.isOperational ? 'CONFIRMED' : 'CANCELLED'}>
|
|
{station.isOperational ? 'Operational' : 'Closed'}
|
|
</Badge>
|
|
),
|
|
},
|
|
];
|
|
|
|
const actions = [
|
|
{
|
|
label: 'Edit',
|
|
onClick: (station: any) => {
|
|
setEditingStation(station);
|
|
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">Stations</h1>
|
|
<p className="text-muted-foreground">Manage railway stations and their operational status</p>
|
|
</div>
|
|
<ActionButton
|
|
icon={Plus}
|
|
onClick={() => {
|
|
setEditingStation(null);
|
|
setShowModal(true);
|
|
}}
|
|
>
|
|
Add Station
|
|
</ActionButton>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="card">
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div>
|
|
<input
|
|
type="text"
|
|
placeholder="Search stations..."
|
|
className="input"
|
|
value={filters.search}
|
|
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<select
|
|
className="input"
|
|
value={filters.country}
|
|
onChange={(e) => setFilters({ ...filters, country: e.target.value })}
|
|
>
|
|
<option value="">All Countries</option>
|
|
<option value="ET">Ethiopia</option>
|
|
<option value="DJ">Djibouti</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<select
|
|
className="input"
|
|
value={filters.operational}
|
|
onChange={(e) => setFilters({ ...filters, operational: e.target.value })}
|
|
>
|
|
<option value="">All Status</option>
|
|
<option value="true">Operational</option>
|
|
<option value="false">Closed</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stations Table */}
|
|
<DataTable
|
|
data={data?.items || []}
|
|
columns={columns}
|
|
actions={actions}
|
|
loading={isLoading}
|
|
emptyMessage="No stations found"
|
|
/>
|
|
|
|
{/* Delete Confirmation */}
|
|
<ConfirmDialog
|
|
isOpen={deleteConfirm.isOpen}
|
|
onClose={() => setDeleteConfirm({ isOpen: false, station: null })}
|
|
onConfirm={confirmDelete}
|
|
title="Delete Station"
|
|
message={`Are you sure you want to delete ${deleteConfirm.station?.name}?`}
|
|
confirmText="Delete"
|
|
isDanger={true}
|
|
warning="This station may be referenced by routes, schedules, and bookings. Deleting it may impact these systems."
|
|
/>
|
|
|
|
{/* Add/Edit Modal */}
|
|
<Modal
|
|
isOpen={showModal}
|
|
onClose={() => {
|
|
setShowModal(false);
|
|
setEditingStation(null);
|
|
}}
|
|
title={`${editingStation ? 'Edit' : 'Add'} Station`}
|
|
size="lg"
|
|
>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{editingStation && (
|
|
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-3 text-sm text-yellow-800 dark:text-yellow-200">
|
|
<p className="font-semibold">⚠ Warning</p>
|
|
<p className="mt-1">Editing this station may impact routes, schedules, and bookings that reference it. Proceed with caution.</p>
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="label">Station Code *</label>
|
|
<input
|
|
type="text"
|
|
name="code"
|
|
className="input"
|
|
defaultValue={editingStation?.code}
|
|
required
|
|
placeholder="e.g., ADD"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label">Station Name *</label>
|
|
<input
|
|
type="text"
|
|
name="name"
|
|
className="input"
|
|
defaultValue={editingStation?.name}
|
|
required
|
|
placeholder="e.g., Lebu"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label">City *</label>
|
|
<input
|
|
type="text"
|
|
name="city"
|
|
className="input"
|
|
defaultValue={editingStation?.city}
|
|
required
|
|
placeholder="e.g., Addis Ababa"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label">Country Code *</label>
|
|
<select
|
|
name="countryCode"
|
|
className="input"
|
|
defaultValue={editingStation?.countryCode || 'ET'}
|
|
required
|
|
>
|
|
<option value="ET">Ethiopia (ET)</option>
|
|
<option value="DJ">Djibouti (DJ)</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="label">Latitude</label>
|
|
<input
|
|
type="number"
|
|
name="lat"
|
|
className="input"
|
|
defaultValue={editingStation?.lat}
|
|
step="0.0001"
|
|
placeholder="e.g., 9.0320"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label">Longitude</label>
|
|
<input
|
|
type="number"
|
|
name="lng"
|
|
className="input"
|
|
defaultValue={editingStation?.lng}
|
|
step="0.0001"
|
|
placeholder="e.g., 38.7469"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label">Timezone *</label>
|
|
<select
|
|
name="timezone"
|
|
className="input"
|
|
defaultValue={editingStation?.timezone || 'Africa/Addis_Ababa'}
|
|
required
|
|
>
|
|
{TIMEZONES.map((tz) => (
|
|
<option key={tz} value={tz}>{tz}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="label">Distance from Previous (km)</label>
|
|
<input
|
|
type="number"
|
|
name="distance"
|
|
className="input"
|
|
defaultValue={editingStation?.distance || 0}
|
|
min="0"
|
|
step="0.1"
|
|
placeholder="e.g., 150.5"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label">Sequence Number *</label>
|
|
<input
|
|
type="number"
|
|
name="sequence"
|
|
className="input"
|
|
defaultValue={editingStation?.sequence || 0}
|
|
min="0"
|
|
required
|
|
placeholder="e.g., 1"
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">Used for ordering stations in routes</p>
|
|
</div>
|
|
<div>
|
|
<label className="label">Status</label>
|
|
<select
|
|
name="isOperational"
|
|
className="input"
|
|
defaultValue={editingStation?.isOperational?.toString() || 'true'}
|
|
>
|
|
<option value="true">Operational</option>
|
|
<option value="false">Closed</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 pt-4">
|
|
<ActionButton
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setShowModal(false);
|
|
setEditingStation(null);
|
|
}}
|
|
>
|
|
Cancel
|
|
</ActionButton>
|
|
<ActionButton
|
|
type="submit"
|
|
loading={createMutation.isPending || updateMutation.isPending}
|
|
>
|
|
{editingStation ? 'Update' : 'Create'} Station
|
|
</ActionButton>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|