mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
Seatmap rendering and other updates
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
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';
|
||||
@@ -16,6 +16,7 @@ export default function ClassesPage() {
|
||||
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({
|
||||
@@ -28,12 +29,19 @@ export default function ClassesPage() {
|
||||
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('');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -43,6 +51,7 @@ export default function ClassesPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['classes'] });
|
||||
setShowModal(false);
|
||||
setEditingClass(null);
|
||||
setSelectedCoachTypeId('');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -55,12 +64,19 @@ export default function ClassesPage() {
|
||||
|
||||
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: formData.get('coachTypeId') as string,
|
||||
coachTypeId: selectedCoachTypeId,
|
||||
name: formData.get('name') as string,
|
||||
description: formData.get('description') as string,
|
||||
baseFareMinor: parseInt(formData.get('baseFareMinor') as string) || 0,
|
||||
isActive: formData.get('isActive') === 'true',
|
||||
};
|
||||
|
||||
if (editingClass) {
|
||||
@@ -136,13 +152,19 @@ export default function ClassesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const handleOpenModal = (cls?: any) => {
|
||||
if (cls) {
|
||||
setEditingClass(cls);
|
||||
} else {
|
||||
setEditingClass(null);
|
||||
}
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
onClick: (cls: any) => {
|
||||
setEditingClass(cls);
|
||||
setShowModal(true);
|
||||
},
|
||||
onClick: (cls: any) => handleOpenModal(cls),
|
||||
variant: 'secondary' as const,
|
||||
icon: Edit,
|
||||
},
|
||||
@@ -163,10 +185,7 @@ export default function ClassesPage() {
|
||||
</div>
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
onClick={() => {
|
||||
setEditingClass(null);
|
||||
setShowModal(true);
|
||||
}}
|
||||
onClick={() => handleOpenModal()}
|
||||
>
|
||||
Add Class
|
||||
</ActionButton>
|
||||
@@ -211,6 +230,7 @@ export default function ClassesPage() {
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setEditingClass(null);
|
||||
setSelectedCoachTypeId('');
|
||||
}}
|
||||
title={`${editingClass ? 'Edit' : 'Add'} Class`}
|
||||
size="lg"
|
||||
@@ -222,7 +242,8 @@ export default function ClassesPage() {
|
||||
<select
|
||||
name="coachTypeId"
|
||||
className="input"
|
||||
defaultValue={editingClass?.coachTypeId || ''}
|
||||
value={selectedCoachTypeId}
|
||||
onChange={(e) => setSelectedCoachTypeId(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Select Coach Type</option>
|
||||
@@ -276,7 +297,7 @@ export default function ClassesPage() {
|
||||
<select
|
||||
name="isActive"
|
||||
className="input"
|
||||
defaultValue={editingClass?.isActive?.toString() || 'true'}
|
||||
defaultValue={editingClass?.isActive !== undefined ? editingClass.isActive.toString() : 'true'}
|
||||
>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
@@ -291,6 +312,7 @@ export default function ClassesPage() {
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
setEditingClass(null);
|
||||
setSelectedCoachTypeId('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -510,7 +510,7 @@ export default function CoachesPage() {
|
||||
className="input"
|
||||
defaultValue={editingItem?.number || editingItem?.coachNumber || ''}
|
||||
required
|
||||
placeholder="e.g., A-001"
|
||||
placeholder="e.g., HSC-0001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function PromosLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
409
apps/edr-passenger-web/backoffice/src/app/promos/page.tsx
Normal file
409
apps/edr-passenger-web/backoffice/src/app/promos/page.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Edit, Trash2, Copy, Check } 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 { promosApi, PromoCode } from '@/lib/api/promos';
|
||||
|
||||
export default function PromosPage() {
|
||||
const [filters, setFilters] = useState({ search: '', active: '', page: 1, pageSize: 10 });
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingPromo, setEditingPromo] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; promo: any | null }>({ isOpen: false, promo: null });
|
||||
const [copiedCode, setCopiedCode] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['promos', filters],
|
||||
queryFn: () => promosApi.getAll(filters),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: promosApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['promos'] });
|
||||
setShowModal(false);
|
||||
setEditingPromo(null);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => promosApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['promos'] });
|
||||
setShowModal(false);
|
||||
setEditingPromo(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: promosApi.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['promos'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
const promoData = {
|
||||
code: formData.get('code') as string,
|
||||
title: formData.get('title') as string,
|
||||
discountType: formData.get('discountType') as 'PERCENTAGE' | 'FIXED',
|
||||
discountValue: parseFloat(formData.get('discountValue') as string),
|
||||
maxDiscount: formData.get('maxDiscount') ? parseFloat(formData.get('maxDiscount') as string) : undefined,
|
||||
minBookingAmount: formData.get('minBookingAmount') ? parseFloat(formData.get('minBookingAmount') as string) : undefined,
|
||||
maxUsagePerUser: formData.get('maxUsagePerUser') ? parseInt(formData.get('maxUsagePerUser') as string) : undefined,
|
||||
totalUsageLimit: formData.get('totalUsageLimit') ? parseInt(formData.get('totalUsageLimit') as string) : undefined,
|
||||
validFrom: formData.get('validFrom') as string,
|
||||
validUntil: formData.get('validUntil') as string,
|
||||
isActive: formData.get('isActive') === 'true',
|
||||
};
|
||||
|
||||
if (editingPromo) {
|
||||
await updateMutation.mutateAsync({ id: editingPromo.id, data: promoData });
|
||||
} else {
|
||||
await createMutation.mutateAsync(promoData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (promo: any) => {
|
||||
setDeleteConfirm({ isOpen: true, promo });
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteConfirm.promo) {
|
||||
await deleteMutation.mutateAsync(deleteConfirm.promo.id);
|
||||
setDeleteConfirm({ isOpen: false, promo: null });
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (code: string) => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopiedCode(code);
|
||||
setTimeout(() => setCopiedCode(null), 2000);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'code',
|
||||
label: 'Promo Code',
|
||||
render: (promo: PromoCode) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono font-semibold text-lg">{promo.code}</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(promo.code)}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
|
||||
title="Copy code"
|
||||
>
|
||||
{copiedCode === promo.code ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
render: (promo: PromoCode) => (
|
||||
<span className="text-sm font-medium">{promo.title || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'discount',
|
||||
label: 'Discount',
|
||||
render: (promo: PromoCode) => (
|
||||
<span className="font-semibold">
|
||||
{promo.discountType === 'PERCENTAGE'
|
||||
? `${promo.discountValue}%`
|
||||
: `ETB ${promo.discountValue}`}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'validity',
|
||||
label: 'Valid Period',
|
||||
render: (promo: PromoCode) => (
|
||||
<div className="text-sm">
|
||||
<div>{new Date(promo.validFrom).toLocaleDateString()}</div>
|
||||
<div className="text-muted-foreground">{new Date(promo.validUntil).toLocaleDateString()}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'usage',
|
||||
label: 'Usage',
|
||||
render: (promo: PromoCode) => (
|
||||
<div className="text-sm">
|
||||
<div>{promo.usageCount} used</div>
|
||||
{promo.totalUsageLimit && (
|
||||
<div className="text-muted-foreground">/ {promo.totalUsageLimit} limit</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (promo: PromoCode) => (
|
||||
<Badge variant="status" status={promo.isActive ? 'CONFIRMED' : 'CANCELLED'}>
|
||||
{promo.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
onClick: (promo: PromoCode) => {
|
||||
setEditingPromo(promo);
|
||||
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">Promo Codes</h1>
|
||||
<p className="text-muted-foreground">Manage promotional codes and discounts</p>
|
||||
</div>
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
onClick={() => {
|
||||
setEditingPromo(null);
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
Add Promo Code
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search promo codes..."
|
||||
className="input"
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.active}
|
||||
onChange={(e) => setFilters({ ...filters, active: e.target.value, page: 1 })}
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Promos Table */}
|
||||
<DataTable
|
||||
data={data?.items || []}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No promo codes found"
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => setDeleteConfirm({ isOpen: false, promo: null })}
|
||||
onConfirm={confirmDelete}
|
||||
title="Delete Promo Code"
|
||||
message={`Are you sure you want to delete promo code "${deleteConfirm.promo?.code}"?`}
|
||||
confirmText="Delete"
|
||||
isDanger={true}
|
||||
/>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setEditingPromo(null);
|
||||
}}
|
||||
title={`${editingPromo ? 'Edit' : 'Create'} Promo Code`}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Promo Code *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="code"
|
||||
className="input uppercase"
|
||||
defaultValue={editingPromo?.code}
|
||||
required
|
||||
placeholder="e.g., SUMMER2024"
|
||||
maxLength={20}
|
||||
disabled={!!editingPromo}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
className="input"
|
||||
defaultValue={editingPromo?.title}
|
||||
required
|
||||
placeholder="e.g., Summer Discount"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Discount Type *</label>
|
||||
<select
|
||||
name="discountType"
|
||||
className="input"
|
||||
defaultValue={editingPromo?.discountType || 'PERCENTAGE'}
|
||||
required
|
||||
>
|
||||
<option value="PERCENTAGE">Percentage (%)</option>
|
||||
<option value="FIXED">Fixed Amount (ETB)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Discount Value *</label>
|
||||
<input
|
||||
type="number"
|
||||
name="discountValue"
|
||||
className="input"
|
||||
defaultValue={editingPromo?.discountValue}
|
||||
required
|
||||
placeholder="e.g., 15"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Max Discount (ETB)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="maxDiscount"
|
||||
className="input"
|
||||
defaultValue={editingPromo?.maxDiscount}
|
||||
placeholder="e.g., 500"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Min Booking Amount (ETB)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="minBookingAmount"
|
||||
className="input"
|
||||
defaultValue={editingPromo?.minBookingAmount}
|
||||
placeholder="e.g., 1000"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Max Usage Per User</label>
|
||||
<input
|
||||
type="number"
|
||||
name="maxUsagePerUser"
|
||||
className="input"
|
||||
defaultValue={editingPromo?.maxUsagePerUser}
|
||||
placeholder="Unlimited if empty"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Total Usage Limit</label>
|
||||
<input
|
||||
type="number"
|
||||
name="totalUsageLimit"
|
||||
className="input"
|
||||
defaultValue={editingPromo?.totalUsageLimit}
|
||||
placeholder="Unlimited if empty"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Valid From *</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="validFrom"
|
||||
className="input"
|
||||
defaultValue={editingPromo?.validFrom?.slice(0, 16)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Valid Until *</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="validUntil"
|
||||
className="input"
|
||||
defaultValue={editingPromo?.validUntil?.slice(0, 16)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select
|
||||
name="isActive"
|
||||
className="input"
|
||||
defaultValue={editingPromo?.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);
|
||||
setEditingPromo(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
type="submit"
|
||||
loading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{editingPromo ? 'Update' : 'Create'} Promo Code
|
||||
</ActionButton>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -82,10 +82,8 @@ export default function RoutesPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort middle stops by distance from origin
|
||||
const sortedMiddleStops = [...stops].sort((a, b) =>
|
||||
(a.distanceFromOrigin || 0) - (b.distanceFromOrigin || 0)
|
||||
);
|
||||
// Keep current stop order (already rearranged by user)
|
||||
const sortedMiddleStops = stops;
|
||||
|
||||
// Calculate distanceKm (distance from previous stop)
|
||||
const stopsArray = [
|
||||
@@ -137,6 +135,30 @@ export default function RoutesPage() {
|
||||
setStops(updated);
|
||||
};
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||
e.dataTransfer.setData('text/plain', index.toString());
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).style.opacity = '0.5';
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
(e.currentTarget as HTMLElement).style.opacity = '1';
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, targetIndex: number) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).style.opacity = '1';
|
||||
const sourceIndex = parseInt(e.dataTransfer.getData('text/plain'));
|
||||
if (sourceIndex === targetIndex) return;
|
||||
const newStops = [...stops];
|
||||
const [draggedStop] = newStops.splice(sourceIndex, 1);
|
||||
newStops.splice(targetIndex, 0, draggedStop);
|
||||
setStops(newStops);
|
||||
};
|
||||
|
||||
const generateRouteCode = (originId: string, destId: string) => {
|
||||
if (!originId || !destId) return '';
|
||||
const origin = stations?.items?.find((s: any) => s.id === originId);
|
||||
@@ -277,7 +299,6 @@ export default function RoutesPage() {
|
||||
emptyMessage={search ? "No routes match your search" : "No routes found"}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => setDeleteConfirm({ isOpen: false, route: null })}
|
||||
@@ -289,7 +310,6 @@ export default function RoutesPage() {
|
||||
warning="This route may be referenced by schedules and bookings. Deleting it may impact these systems."
|
||||
/>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => {
|
||||
@@ -383,7 +403,7 @@ export default function RoutesPage() {
|
||||
className="input"
|
||||
rows={2}
|
||||
defaultValue={editingRoute?.description}
|
||||
placeholder="Main corridor via Dire Dawa"
|
||||
placeholder="Outbound local route from [Origin] to [Destination]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -412,10 +432,10 @@ export default function RoutesPage() {
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="label mb-0">Route Stops</label>
|
||||
<span className="text-xs text-muted-foreground">Drag to rearrange intermediate stops</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* Origin Stop */}
|
||||
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
||||
1
|
||||
@@ -435,9 +455,16 @@ export default function RoutesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Intermediate Stops */}
|
||||
{stops.map((stop, index) => (
|
||||
<div key={index} className="flex gap-2 items-center p-3 bg-muted/50 rounded">
|
||||
<div
|
||||
key={index}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
className="flex gap-2 items-center p-3 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-secondary text-secondary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
||||
{index + 2}
|
||||
</div>
|
||||
@@ -482,7 +509,6 @@ export default function RoutesPage() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add Intermediate Stop Button */}
|
||||
{originStationId && destinationStationId && (
|
||||
<div className="flex justify-center py-2">
|
||||
<ActionButton
|
||||
@@ -497,7 +523,6 @@ export default function RoutesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Destination Stop */}
|
||||
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
||||
{stops.length + 2}
|
||||
|
||||
@@ -48,10 +48,9 @@ export default function SchedulesPage() {
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
|
||||
const [selectedSchedules, setSelectedSchedules] = useState<Set<string>>(new Set());
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean }>({
|
||||
isOpen: false,
|
||||
item: null,
|
||||
});
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean }>(
|
||||
{ isOpen: false, item: null }
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -179,6 +178,10 @@ export default function SchedulesPage() {
|
||||
forNextDays: parseInt(bulkForm.forNextDays),
|
||||
};
|
||||
|
||||
if (bulkForm.coachIds.length > 0) {
|
||||
payload.coachIds = bulkForm.coachIds;
|
||||
}
|
||||
|
||||
await bulkGenerateMutation.mutateAsync(payload);
|
||||
};
|
||||
|
||||
@@ -236,13 +239,13 @@ export default function SchedulesPage() {
|
||||
|
||||
const handleEditClick = (schedule: Schedule) => {
|
||||
setEditingSchedule(schedule);
|
||||
|
||||
|
||||
const dep = new Date(schedule.departureAt);
|
||||
const arr = new Date(schedule.arrivalAt);
|
||||
|
||||
|
||||
const depLocal = new Date(dep.getTime() - dep.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||
const arrLocal = new Date(arr.getTime() - arr.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||
|
||||
|
||||
setEditForm({
|
||||
departureAt: depLocal,
|
||||
arrivalAt: arrLocal,
|
||||
|
||||
@@ -5,7 +5,6 @@ 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 Badge from '@/components/ui/Badge';
|
||||
import { Armchair, Lock, Unlock, Bed, X, RotateCcw } from 'lucide-react';
|
||||
|
||||
export default function SeatsPage() {
|
||||
@@ -144,7 +143,10 @@ export default function SeatsPage() {
|
||||
const seatsPerRow = arrangement[0] + (arrangement[1] || 0);
|
||||
const allSeatsForLayout = [...validSeats, ...removedSeats];
|
||||
const rows = [];
|
||||
|
||||
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || '');
|
||||
const isVipBed = seatClassStr.toLowerCase().includes('vip');
|
||||
const bedWidth = isVipBed ? 'w-24' : 'w-16';
|
||||
|
||||
for (let i = 0; i < allSeatsForLayout.length; i += seatsPerRow) {
|
||||
rows.push(allSeatsForLayout.slice(i, i + seatsPerRow));
|
||||
}
|
||||
@@ -154,14 +156,15 @@ export default function SeatsPage() {
|
||||
{rows.map((rowSeats: any[], idx: number) => {
|
||||
const rowNumber = rowSeats[0]?.row || (idx + 1);
|
||||
const shouldFlipIcon = rowNumber % 2 === 0;
|
||||
const shouldFlipRow = rowNumber % 2 === 1;
|
||||
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">
|
||||
<div key={`num-before-${seat.id}`} className={`${bedWidth} h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground`}>
|
||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
||||
</div>
|
||||
))}
|
||||
@@ -188,7 +191,7 @@ export default function SeatsPage() {
|
||||
{!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">
|
||||
<div key={`num-after-${seat.id}`} className={`${bedWidth} h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground`}>
|
||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
||||
</div>
|
||||
))}
|
||||
@@ -228,6 +231,7 @@ export default function SeatsPage() {
|
||||
const rightSeats = rowSeats.slice(leftCount);
|
||||
const rowNumber = rowSeats[0]?.row || 1;
|
||||
const shouldFlipArmchair = rowNumber % 2 === 0;
|
||||
const shouldFlipRow = rowNumber % 2 === 0;
|
||||
const showSpacing = rowIdx % 2 === 1;
|
||||
|
||||
return (
|
||||
@@ -236,7 +240,7 @@ export default function SeatsPage() {
|
||||
<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">
|
||||
<div key={`num-before-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
|
||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||
</div>
|
||||
))}
|
||||
@@ -245,7 +249,7 @@ export default function SeatsPage() {
|
||||
{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">
|
||||
<div key={`num-before-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
|
||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||
</div>
|
||||
))}
|
||||
@@ -299,7 +303,7 @@ export default function SeatsPage() {
|
||||
<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">
|
||||
<div key={`num-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
|
||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||
</div>
|
||||
))}
|
||||
@@ -308,7 +312,7 @@ export default function SeatsPage() {
|
||||
{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">
|
||||
<div key={`num-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
|
||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||
</div>
|
||||
))}
|
||||
@@ -402,17 +406,17 @@ export default function SeatsPage() {
|
||||
|
||||
<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 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-3 bg-white dark:bg-card">
|
||||
<div key={coach.id} className="flex flex-col gap-4">
|
||||
<div className="mb-3">
|
||||
<h3 className="font-semibold text-sm">Coach {coach.coachNumber}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-900/30 py-2 rounded-lg">
|
||||
<div className="bg-gray-50 dark:bg-gray-900/30 rounded-lg w-64 border border-gray-200 dark:border-gray-700 p-2">
|
||||
{renderCoachSeats(coach, isBedCoach)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -542,7 +546,11 @@ function SeatIcon({
|
||||
handleUndoRemove,
|
||||
}: SeatIconProps) {
|
||||
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
|
||||
|
||||
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
|
||||
const isVipBed = isBedCoach && seatClassStr.toLowerCase().includes('vip');
|
||||
const bedWidth = isVipBed ? 'w-24' : 'w-16';
|
||||
const width = isBedCoach ? bedWidth : 'w-10';
|
||||
|
||||
if (!seat.seatNumber) {
|
||||
return <div className="w-7 h-7" />;
|
||||
}
|
||||
@@ -580,17 +588,19 @@ function SeatIcon({
|
||||
|
||||
{isBedCoach ? (
|
||||
<div
|
||||
className={`w-11 h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
|
||||
className={`${width} h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
|
||||
title={`${seat.seatNumber} - ${seat.bedPosition} - ${status}`}
|
||||
style={seat.row % 2 === 1 ? { transform: 'scaleY(-1)' } : undefined}
|
||||
>
|
||||
<Bed className="w-7 h-7 text-white" style={shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined} />
|
||||
<Bed className="w-7 h-7 text-white" />
|
||||
</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}`}
|
||||
style={seat.row % 2 === 0 ? { transform: 'scaleY(-1)' } : undefined}
|
||||
>
|
||||
<Armchair className="w-7 h-7 text-white" style={shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined} />
|
||||
<Armchair className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,57 +2,388 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Search, Edit, Trash2 } from 'lucide-react';
|
||||
import { Plus, Edit, Trash2, RefreshCw } 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 { usersApi, BackofficeUser } from '@/lib/api/users';
|
||||
|
||||
export default function UserManagementPage() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filters, setFilters] = useState({ search: '', role: '', status: '', page: 1, pageSize: 10 });
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; user: any | null }>({ isOpen: false, user: null });
|
||||
const [resetPasswordModal, setResetPasswordModal] = useState<{ isOpen: boolean; user: any | null }>({ isOpen: false, user: null });
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['users', filters],
|
||||
queryFn: () => usersApi.getAll(filters),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: usersApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
setShowModal(false);
|
||||
setEditingUser(null);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => usersApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
setShowModal(false);
|
||||
setEditingUser(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: usersApi.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
});
|
||||
|
||||
const resetPasswordMutation = useMutation({
|
||||
mutationFn: ({ id, tempPassword }: { id: string; tempPassword: string }) =>
|
||||
usersApi.resetPassword(id, tempPassword),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
setResetPasswordModal({ isOpen: false, user: null });
|
||||
setNewPassword('');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
const userData = {
|
||||
email: formData.get('email') as string,
|
||||
fullName: formData.get('fullName') as string,
|
||||
role: formData.get('role') as string,
|
||||
status: formData.get('status') as 'ACTIVE' | 'INACTIVE',
|
||||
} as any;
|
||||
|
||||
if (!editingUser) {
|
||||
userData.password = formData.get('password') as string;
|
||||
}
|
||||
|
||||
if (editingUser) {
|
||||
await updateMutation.mutateAsync({ id: editingUser.id, data: userData });
|
||||
} else {
|
||||
await createMutation.mutateAsync(userData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (user: any) => {
|
||||
setDeleteConfirm({ isOpen: true, user });
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteConfirm.user) {
|
||||
await deleteMutation.mutateAsync(deleteConfirm.user.id);
|
||||
setDeleteConfirm({ isOpen: false, user: null });
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (resetPasswordModal.user && newPassword) {
|
||||
await resetPasswordMutation.mutateAsync({
|
||||
id: resetPasswordModal.user.id,
|
||||
tempPassword: newPassword,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'fullName',
|
||||
label: 'Full Name',
|
||||
sortable: true,
|
||||
render: (user: BackofficeUser) => (
|
||||
<div>
|
||||
<div className="font-medium">{user.fullName}</div>
|
||||
<div className="text-sm text-muted-foreground">{user.email}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Role',
|
||||
render: (user: BackofficeUser) => (
|
||||
<Badge variant="status" status={user.role}>
|
||||
{user.role}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (user: BackofficeUser) => (
|
||||
<Badge variant="status" status={user.status === 'ACTIVE' ? 'CONFIRMED' : 'CANCELLED'}>
|
||||
{user.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastLogin',
|
||||
label: 'Last Login',
|
||||
render: (user: BackofficeUser) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{user.lastLogin ? new Date(user.lastLogin).toLocaleString() : 'Never'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
onClick: (user: BackofficeUser) => {
|
||||
setEditingUser(user);
|
||||
setShowModal(true);
|
||||
},
|
||||
variant: 'secondary' as const,
|
||||
icon: Edit,
|
||||
},
|
||||
{
|
||||
label: 'Reset Password',
|
||||
onClick: (user: BackofficeUser) => {
|
||||
setResetPasswordModal({ isOpen: true, user });
|
||||
setNewPassword('');
|
||||
},
|
||||
variant: 'secondary' as const,
|
||||
icon: RefreshCw,
|
||||
},
|
||||
{
|
||||
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-3xl font-bold text-foreground">User Management</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage system users and permissions</p>
|
||||
<h1 className="text-2xl font-bold text-foreground">User Management</h1>
|
||||
<p className="text-muted-foreground">Manage backoffice users and their permissions</p>
|
||||
</div>
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
onClick={() => {
|
||||
setEditingUser(null);
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
Add User
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card">
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users by name or email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="input pl-10"
|
||||
placeholder="Search users..."
|
||||
className="input"
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.role}
|
||||
onChange={(e) => setFilters({ ...filters, role: e.target.value, page: 1 })}
|
||||
>
|
||||
<option value="">All Roles</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
<option value="SUPERVISOR">Supervisor</option>
|
||||
<option value="STAFF">Staff</option>
|
||||
<option value="AGENT">Agent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value, page: 1 })}
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="INACTIVE">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Name</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Email</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Role</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">
|
||||
User management coming soon
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{/* Users Table */}
|
||||
<DataTable
|
||||
data={data?.items || []}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No users found"
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => setDeleteConfirm({ isOpen: false, user: null })}
|
||||
onConfirm={confirmDelete}
|
||||
title="Delete User"
|
||||
message={`Are you sure you want to delete ${deleteConfirm.user?.fullName}? This action cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
isDanger={true}
|
||||
/>
|
||||
|
||||
{/* Reset Password Modal */}
|
||||
<Modal
|
||||
isOpen={resetPasswordModal.isOpen}
|
||||
onClose={() => setResetPasswordModal({ isOpen: false, user: null })}
|
||||
title="Reset User Password"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded p-3 text-sm">
|
||||
<p className="font-semibold text-blue-900 dark:text-blue-200">Temporary Password</p>
|
||||
<p className="text-blue-800 dark:text-blue-300 mt-1">
|
||||
Set a temporary password for {resetPasswordModal.user?.fullName}. They will need to change it on first login.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Temporary Password *</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Enter temporary password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setResetPasswordModal({ isOpen: false, user: null })}
|
||||
>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
onClick={handleResetPassword}
|
||||
loading={resetPasswordMutation.isPending}
|
||||
disabled={!newPassword}
|
||||
>
|
||||
Reset Password
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setEditingUser(null);
|
||||
}}
|
||||
title={`${editingUser ? 'Edit' : 'Add'} User`}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Full Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="fullName"
|
||||
className="input"
|
||||
defaultValue={editingUser?.fullName}
|
||||
required
|
||||
placeholder="e.g., John Doe"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
className="input"
|
||||
defaultValue={editingUser?.email}
|
||||
required
|
||||
placeholder="e.g., john@example.com"
|
||||
disabled={!!editingUser}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Role *</label>
|
||||
<select
|
||||
name="role"
|
||||
className="input"
|
||||
defaultValue={editingUser?.role || 'STAFF'}
|
||||
required
|
||||
>
|
||||
<option value="ADMIN">Admin</option>
|
||||
<option value="SUPERVISOR">Supervisor</option>
|
||||
<option value="STAFF">Staff</option>
|
||||
<option value="AGENT">Agent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select
|
||||
name="status"
|
||||
className="input"
|
||||
defaultValue={editingUser?.status || 'ACTIVE'}
|
||||
>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="INACTIVE">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
{!editingUser && (
|
||||
<div>
|
||||
<label className="label">Password *</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
className="input"
|
||||
required
|
||||
placeholder="Minimum 8 characters"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
setEditingUser(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
type="submit"
|
||||
loading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{editingUser ? 'Update' : 'Create'} User
|
||||
</ActionButton>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ export default function StationsPage() {
|
||||
className="input"
|
||||
defaultValue={editingStation?.name}
|
||||
required
|
||||
placeholder="e.g., Addis Ababa"
|
||||
placeholder="e.g., Lebu"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -250,7 +250,7 @@ export default function TrainsPage() {
|
||||
className="input"
|
||||
defaultValue={editingTrain?.number}
|
||||
required
|
||||
placeholder="e.g., EDR-001"
|
||||
placeholder="e.g., EDR-101"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -70,7 +70,7 @@ const navigationSections = [
|
||||
items: [
|
||||
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign },
|
||||
{ name: 'Payments', href: '/payments', icon: CreditCard },
|
||||
{ name: 'Promotions', href: '/promotions', icon: Gift },
|
||||
{ name: 'Promo Codes', href: '/promos', icon: Gift },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -250,6 +250,9 @@ export const promotionsApi = {
|
||||
delete: (id: string) => apiClient.delete(`/promos/${id}`),
|
||||
};
|
||||
|
||||
export { promosApi } from './promos';
|
||||
export { usersApi } from './users';
|
||||
|
||||
// Support API
|
||||
export const supportApi = {
|
||||
getConversations: async (params?: any) => {
|
||||
|
||||
54
apps/edr-passenger-web/backoffice/src/lib/api/promos.ts
Normal file
54
apps/edr-passenger-web/backoffice/src/lib/api/promos.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
export interface PromoCode {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
discountType: 'PERCENTAGE' | 'FIXED';
|
||||
discountValue: number;
|
||||
maxDiscount?: number;
|
||||
minBookingAmount?: number;
|
||||
maxUsagePerUser?: number;
|
||||
totalUsageLimit?: number;
|
||||
usageCount: number;
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export const promosApi = {
|
||||
getAll: (filters?: { search?: string; active?: string; page?: number; pageSize?: number }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
if (filters?.active) params.append('active', filters.active);
|
||||
if (filters?.page) params.append('page', filters.page.toString());
|
||||
if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString());
|
||||
|
||||
return apiClient.get<{ items: PromoCode[]; total: number; page: number; pageSize: number }>(`/promos/all?${params.toString()}`);
|
||||
},
|
||||
|
||||
getById: (id: string) => {
|
||||
return apiClient.get<PromoCode>(`/promos/${id}`);
|
||||
},
|
||||
|
||||
create: (data: Omit<PromoCode, 'id' | 'createdAt' | 'updatedAt' | 'usageCount'>) => {
|
||||
return apiClient.post<PromoCode>('/promos', data);
|
||||
},
|
||||
|
||||
update: (id: string, data: Partial<PromoCode>) => {
|
||||
return apiClient.patch<PromoCode>(`/promos/${id}`, data);
|
||||
},
|
||||
|
||||
delete: (id: string) => {
|
||||
return apiClient.delete<void>(`/promos/${id}`);
|
||||
},
|
||||
|
||||
validate: (code: string, bookingAmount?: number) => {
|
||||
return apiClient.post<{ valid: boolean; message?: string; discount?: number }>('/promos/validate', {
|
||||
code,
|
||||
bookingAmount,
|
||||
});
|
||||
},
|
||||
};
|
||||
59
apps/edr-passenger-web/backoffice/src/lib/api/users.ts
Normal file
59
apps/edr-passenger-web/backoffice/src/lib/api/users.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
export interface BackofficeUser {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
role: 'ADMIN' | 'SUPERVISOR' | 'STAFF' | 'AGENT';
|
||||
status: 'ACTIVE' | 'INACTIVE';
|
||||
lastLogin?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export const usersApi = {
|
||||
getAll: async (filters?: { search?: string; role?: string; status?: string; page?: number; pageSize?: number }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
if (filters?.role) params.append('role', filters.role);
|
||||
if (filters?.status) params.append('status', filters.status);
|
||||
if (filters?.page) params.append('page', filters.page.toString());
|
||||
if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString());
|
||||
|
||||
const response = await apiClient.get<any>(`/auth/users?${params.toString()}`);
|
||||
|
||||
// Handle different response formats
|
||||
if (response && typeof response === 'object') {
|
||||
if ('items' in response) {
|
||||
return response as { items: BackofficeUser[]; total: number };
|
||||
}
|
||||
if (Array.isArray(response)) {
|
||||
return { items: response as BackofficeUser[], total: response.length };
|
||||
}
|
||||
}
|
||||
|
||||
return { items: Array.isArray(response) ? response : [], total: 0 };
|
||||
},
|
||||
|
||||
getById: (id: string) => {
|
||||
return apiClient.get<BackofficeUser>(`/auth/users/${id}`);
|
||||
},
|
||||
|
||||
create: (data: { email: string; fullName: string; role: string; password: string }) => {
|
||||
return apiClient.post<BackofficeUser>('/auth/users', data);
|
||||
},
|
||||
|
||||
update: (id: string, data: Partial<BackofficeUser>) => {
|
||||
return apiClient.patch<BackofficeUser>(`/auth/users/${id}`, data);
|
||||
},
|
||||
|
||||
delete: (id: string) => {
|
||||
return apiClient.delete<void>(`/auth/users/${id}`);
|
||||
},
|
||||
|
||||
resetPassword: (id: string, tempPassword: string) => {
|
||||
return apiClient.post<{ success: boolean; message: string }>(`/auth/users/${id}/reset-password`, {
|
||||
tempPassword,
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user