refactor: code efficiency improvements (Issues #9-13, #15, #17-20)

Backend:
- Extract shared hard-delete authorization utility (#9)
- Extract Prisma include constants per entity (#11)
- Fix N+1 query pattern in events findAll (#12)
- Extract shared date utility functions (#13)
- Move vehicle utilization filtering to DB query (#15)
- Add ParseBooleanPipe for query params
- Add CurrentDriver decorator + ResolveDriverInterceptor (#20)

Frontend:
- Extract shared form utilities (toDatetimeLocal) and enum labels (#17)
- Replace browser confirm() with styled ConfirmModal (#18)
- Add centralized query-keys.ts constants (#19)
- Clean up unused imports, add useMemo where needed (#19)
- Standardize filter button styling across list pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 16:07:19 +01:00
parent 806b67954e
commit f2b3f34a72
38 changed files with 1042 additions and 463 deletions

View File

@@ -0,0 +1,90 @@
import { AlertTriangle } from 'lucide-react';
interface ConfirmModalProps {
isOpen: boolean;
onConfirm: () => void;
onCancel: () => void;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: 'destructive' | 'warning' | 'default';
}
export function ConfirmModal({
isOpen,
onConfirm,
onCancel,
title,
description,
confirmLabel = 'Delete',
cancelLabel = 'Cancel',
variant = 'destructive',
}: ConfirmModalProps) {
if (!isOpen) return null;
const getConfirmButtonStyles = () => {
switch (variant) {
case 'destructive':
return 'bg-red-600 hover:bg-red-700 text-white';
case 'warning':
return 'bg-yellow-600 hover:bg-yellow-700 text-white';
case 'default':
return 'bg-primary hover:bg-primary/90 text-white';
default:
return 'bg-red-600 hover:bg-red-700 text-white';
}
};
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
onClick={onCancel}
>
<div
className="bg-card rounded-lg shadow-xl w-full max-w-md"
onClick={(e) => e.stopPropagation()}
>
{/* Header with icon */}
<div className="flex items-start gap-4 p-6 pb-4">
<div className={`flex-shrink-0 ${
variant === 'destructive' ? 'text-red-600' :
variant === 'warning' ? 'text-yellow-600' :
'text-primary'
}`}>
<AlertTriangle className="h-6 w-6" />
</div>
<div className="flex-1">
<h2 className="text-lg font-semibold text-foreground mb-2">
{title}
</h2>
<p className="text-sm text-muted-foreground">
{description}
</p>
</div>
</div>
{/* Actions */}
<div className="flex gap-3 p-6 pt-4 border-t border-border">
<button
onClick={onCancel}
className="flex-1 bg-card text-foreground py-2.5 px-4 rounded-md hover:bg-accent font-medium border border-input transition-colors"
style={{ minHeight: '44px' }}
>
{cancelLabel}
</button>
<button
onClick={() => {
onConfirm();
onCancel();
}}
className={`flex-1 py-2.5 px-4 rounded-md font-medium transition-colors ${getConfirmButtonStyles()}`}
style={{ minHeight: '44px' }}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { DEPARTMENT_LABELS } from '@/lib/enum-labels';
interface DriverFormProps {
driver?: Driver | null;
@@ -112,9 +113,11 @@ export function DriverForm({ driver, onSubmit, onCancel, isSubmitting }: DriverF
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="">Select Department</option>
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
<option value="ADMIN">Admin</option>
<option value="OTHER">Other</option>
{Object.entries(DEPARTMENT_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>

View File

@@ -1,10 +1,13 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useQuery } from '@tanstack/react-query';
import { X, AlertTriangle, Users, Car, Link2 } from 'lucide-react';
import { api } from '@/lib/api';
import { ScheduleEvent, VIP, Driver, Vehicle } from '@/types';
import { useFormattedDate } from '@/hooks/useFormattedDate';
import { toDatetimeLocal } from '@/lib/utils';
import { EVENT_TYPE_LABELS, EVENT_STATUS_LABELS } from '@/lib/enum-labels';
import { queryKeys } from '@/lib/query-keys';
interface EventFormProps {
event?: ScheduleEvent | null;
@@ -41,18 +44,6 @@ interface ScheduleConflict {
export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraActions }: EventFormProps) {
const { formatDateTime } = useFormattedDate();
// Helper to convert ISO datetime to datetime-local format
const toDatetimeLocal = (isoString: string | null | undefined) => {
if (!isoString) return '';
const date = new Date(isoString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
const [formData, setFormData] = useState<EventFormData>({
vipIds: event?.vipIds || [],
title: event?.title || '',
@@ -77,7 +68,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
// Fetch VIPs for selection
const { data: vips } = useQuery<VIP[]>({
queryKey: ['vips'],
queryKey: queryKeys.vips.all,
queryFn: async () => {
const { data } = await api.get('/vips');
return data;
@@ -86,7 +77,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
// Fetch Drivers for dropdown
const { data: drivers } = useQuery<Driver[]>({
queryKey: ['drivers'],
queryKey: queryKeys.drivers.all,
queryFn: async () => {
const { data } = await api.get('/drivers');
return data;
@@ -95,7 +86,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
// Fetch Vehicles for dropdown
const { data: vehicles } = useQuery<Vehicle[]>({
queryKey: ['vehicles'],
queryKey: queryKeys.vehicles.all,
queryFn: async () => {
const { data } = await api.get('/vehicles');
return data;
@@ -104,7 +95,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
// Fetch all events (for master event selector)
const { data: allEvents } = useQuery<ScheduleEvent[]>({
queryKey: ['events'],
queryKey: queryKeys.events.all,
queryFn: async () => {
const { data } = await api.get('/events');
return data;
@@ -219,10 +210,12 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
});
};
const selectedVipNames = vips
?.filter(vip => formData.vipIds.includes(vip.id))
.map(vip => vip.partySize > 1 ? `${vip.name} (+${vip.partySize - 1})` : vip.name)
.join(', ') || 'None selected';
const selectedVipNames = useMemo(() => {
return vips
?.filter(vip => formData.vipIds.includes(vip.id))
.map(vip => vip.partySize > 1 ? `${vip.name} (+${vip.partySize - 1})` : vip.name)
.join(', ') || 'None selected';
}, [vips, formData.vipIds]);
return (
<>
@@ -452,11 +445,11 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
onChange={handleChange}
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="TRANSPORT">Transport</option>
<option value="MEETING">Meeting</option>
<option value="EVENT">Event</option>
<option value="MEAL">Meal</option>
<option value="ACCOMMODATION">Accommodation</option>
{Object.entries(EVENT_TYPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div>
@@ -470,10 +463,11 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
onChange={handleChange}
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="SCHEDULED">Scheduled</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="COMPLETED">Completed</option>
<option value="CANCELLED">Cancelled</option>
{Object.entries(EVENT_STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
</div>

View File

@@ -1,5 +1,7 @@
import { useState } from 'react';
import { X, ChevronDown, ChevronUp, ClipboardList } from 'lucide-react';
import { toDatetimeLocal } from '@/lib/utils';
import { DEPARTMENT_LABELS, ARRIVAL_MODE_LABELS } from '@/lib/enum-labels';
interface VIPFormProps {
vip?: VIP | null;
@@ -44,18 +46,6 @@ export interface VIPFormData {
}
export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps) {
// Helper to convert ISO datetime to datetime-local format
const toDatetimeLocal = (isoString: string | null) => {
if (!isoString) return '';
const date = new Date(isoString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
const [formData, setFormData] = useState<VIPFormData>({
name: vip?.name || '',
organization: vip?.organization || '',
@@ -194,9 +184,11 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
className="w-full px-4 py-3 text-base bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
style={{ minHeight: '44px' }}
>
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
<option value="ADMIN">Admin</option>
<option value="OTHER">Other</option>
{Object.entries(DEPARTMENT_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
@@ -213,8 +205,11 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
className="w-full px-4 py-3 text-base bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
style={{ minHeight: '44px' }}
>
<option value="FLIGHT">Flight</option>
<option value="SELF_DRIVING">Self Driving</option>
{Object.entries(ARRIVAL_MODE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>

View File

@@ -2,10 +2,11 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Flight, FlightBudget } from '@/types';
import toast from 'react-hot-toast';
import { queryKeys } from '@/lib/query-keys';
export function useFlights() {
return useQuery<Flight[]>({
queryKey: ['flights'],
queryKey: queryKeys.flights.all,
queryFn: async () => {
const { data } = await api.get('/flights');
return data;
@@ -16,7 +17,7 @@ export function useFlights() {
export function useFlightBudget() {
return useQuery<FlightBudget>({
queryKey: ['flights', 'budget'],
queryKey: queryKeys.flights.budget,
queryFn: async () => {
const { data } = await api.get('/flights/tracking/budget');
return data;
@@ -34,8 +35,8 @@ export function useRefreshFlight() {
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['flights'] });
queryClient.invalidateQueries({ queryKey: ['flights', 'budget'] });
queryClient.invalidateQueries({ queryKey: queryKeys.flights.all });
queryClient.invalidateQueries({ queryKey: queryKeys.flights.budget });
const status = data.status || 'unknown';
toast.success(`Flight updated: ${data.flightNumber} (${status})`);
},
@@ -54,8 +55,8 @@ export function useRefreshActiveFlights() {
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['flights'] });
queryClient.invalidateQueries({ queryKey: ['flights', 'budget'] });
queryClient.invalidateQueries({ queryKey: queryKeys.flights.all });
queryClient.invalidateQueries({ queryKey: queryKeys.flights.budget });
toast.success(`Refreshed ${data.refreshed} flights (${data.budgetRemaining} API calls remaining)`);
},
onError: (error: any) => {

View File

@@ -11,6 +11,7 @@ import type {
DeviceQrInfo,
} from '@/types/gps';
import toast from 'react-hot-toast';
import { queryKeys } from '@/lib/query-keys';
// ============================================
// Admin GPS Hooks
@@ -21,7 +22,7 @@ import toast from 'react-hot-toast';
*/
export function useGpsStatus() {
return useQuery<GpsStatus>({
queryKey: ['gps', 'status'],
queryKey: queryKeys.gps.status,
queryFn: async () => {
const { data } = await api.get('/gps/status');
return data;
@@ -35,7 +36,7 @@ export function useGpsStatus() {
*/
export function useGpsSettings() {
return useQuery<GpsSettings>({
queryKey: ['gps', 'settings'],
queryKey: queryKeys.gps.settings,
queryFn: async () => {
const { data } = await api.get('/gps/settings');
return data;
@@ -55,8 +56,8 @@ export function useUpdateGpsSettings() {
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['gps', 'settings'] });
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.settings });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
toast.success('GPS settings updated');
},
onError: (error: any) => {
@@ -70,7 +71,7 @@ export function useUpdateGpsSettings() {
*/
export function useGpsDevices() {
return useQuery<GpsDevice[]>({
queryKey: ['gps', 'devices'],
queryKey: queryKeys.gps.devices,
queryFn: async () => {
const { data } = await api.get('/gps/devices');
return data;
@@ -84,7 +85,7 @@ export function useGpsDevices() {
*/
export function useDeviceQr(driverId: string | null) {
return useQuery<DeviceQrInfo>({
queryKey: ['gps', 'devices', driverId, 'qr'],
queryKey: driverId ? queryKeys.gps.deviceQr(driverId) : ['gps', 'devices', null, 'qr'],
queryFn: async () => {
const { data } = await api.get(`/gps/devices/${driverId}/qr`);
return data;
@@ -98,7 +99,7 @@ export function useDeviceQr(driverId: string | null) {
*/
export function useDriverLocations() {
return useQuery<DriverLocation[]>({
queryKey: ['gps', 'locations'],
queryKey: queryKeys.gps.locations.all,
queryFn: async () => {
const { data } = await api.get('/gps/locations');
return data;
@@ -112,7 +113,7 @@ export function useDriverLocations() {
*/
export function useDriverLocation(driverId: string) {
return useQuery<DriverLocation>({
queryKey: ['gps', 'locations', driverId],
queryKey: queryKeys.gps.locations.detail(driverId),
queryFn: async () => {
const { data } = await api.get(`/gps/locations/${driverId}`);
return data;
@@ -127,7 +128,7 @@ export function useDriverLocation(driverId: string) {
*/
export function useDriverStats(driverId: string, from?: string, to?: string) {
return useQuery<DriverStats>({
queryKey: ['gps', 'stats', driverId, from, to],
queryKey: queryKeys.gps.stats(driverId, from, to),
queryFn: async () => {
const params = new URLSearchParams();
if (from) params.append('from', from);
@@ -151,9 +152,9 @@ export function useEnrollDriver() {
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['gps', 'devices'] });
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
queryClient.invalidateQueries({ queryKey: ['drivers'] });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.devices });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
queryClient.invalidateQueries({ queryKey: queryKeys.drivers.all });
if (data.signalMessageSent) {
toast.success('Driver enrolled! Setup instructions sent via Signal.');
} else {
@@ -178,10 +179,10 @@ export function useUnenrollDriver() {
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['gps', 'devices'] });
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
queryClient.invalidateQueries({ queryKey: ['gps', 'locations'] });
queryClient.invalidateQueries({ queryKey: ['drivers'] });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.devices });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.locations.all });
queryClient.invalidateQueries({ queryKey: queryKeys.drivers.all });
toast.success('Driver unenrolled from GPS tracking');
},
onError: (error: any) => {
@@ -199,7 +200,7 @@ export function useUnenrollDriver() {
*/
export function useMyGpsStatus() {
return useQuery<MyGpsStatus>({
queryKey: ['gps', 'me'],
queryKey: queryKeys.gps.me.status,
queryFn: async () => {
const { data } = await api.get('/gps/me');
return data;
@@ -212,7 +213,7 @@ export function useMyGpsStatus() {
*/
export function useMyGpsStats(from?: string, to?: string) {
return useQuery<DriverStats>({
queryKey: ['gps', 'me', 'stats', from, to],
queryKey: queryKeys.gps.me.stats(from, to),
queryFn: async () => {
const params = new URLSearchParams();
if (from) params.append('from', from);
@@ -228,7 +229,7 @@ export function useMyGpsStats(from?: string, to?: string) {
*/
export function useMyLocation() {
return useQuery<DriverLocation>({
queryKey: ['gps', 'me', 'location'],
queryKey: queryKeys.gps.me.location,
queryFn: async () => {
const { data } = await api.get('/gps/me/location');
return data;
@@ -249,7 +250,7 @@ export function useUpdateGpsConsent() {
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['gps', 'me'] });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.me.status });
toast.success(data.message);
},
onError: (error: any) => {
@@ -267,7 +268,7 @@ export function useUpdateGpsConsent() {
*/
export function useTraccarSetupStatus() {
return useQuery<{ needsSetup: boolean; isAvailable: boolean }>({
queryKey: ['gps', 'traccar', 'status'],
queryKey: queryKeys.gps.traccar.status,
queryFn: async () => {
const { data } = await api.get('/gps/traccar/status');
return data;
@@ -287,8 +288,8 @@ export function useTraccarSetup() {
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['gps', 'traccar', 'status'] });
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.traccar.status });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
toast.success('Traccar setup complete!');
},
onError: (error: any) => {
@@ -320,7 +321,7 @@ export function useSyncAdminsToTraccar() {
*/
export function useTraccarAdminUrl() {
return useQuery<{ url: string; directAccess: boolean }>({
queryKey: ['gps', 'traccar', 'admin-url'],
queryKey: queryKeys.gps.traccar.adminUrl,
queryFn: async () => {
const { data } = await api.get('/gps/traccar/admin-url');
return data;

View File

@@ -1,13 +1,14 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../lib/api';
import { PdfSettings, UpdatePdfSettingsDto } from '../types/settings';
import { queryKeys } from '../lib/query-keys';
/**
* Fetch PDF settings
*/
export function usePdfSettings() {
return useQuery<PdfSettings>({
queryKey: ['settings', 'pdf'],
queryKey: queryKeys.settings.pdf,
queryFn: async () => {
const { data } = await api.get('/settings/pdf');
return data;
@@ -27,7 +28,7 @@ export function useUpdatePdfSettings() {
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] });
queryClient.invalidateQueries({ queryKey: queryKeys.settings.pdf });
},
});
}
@@ -51,7 +52,7 @@ export function useUploadLogo() {
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] });
queryClient.invalidateQueries({ queryKey: queryKeys.settings.pdf });
},
});
}
@@ -68,7 +69,7 @@ export function useDeleteLogo() {
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] });
queryClient.invalidateQueries({ queryKey: queryKeys.settings.pdf });
},
});
}

View File

@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../lib/api';
import { queryKeys } from '../lib/query-keys';
export interface SignalMessage {
id: string;
@@ -19,7 +20,7 @@ export interface UnreadCounts {
*/
export function useDriverMessages(driverId: string | null, enabled = true) {
return useQuery({
queryKey: ['signal-messages', driverId],
queryKey: driverId ? queryKeys.signal.messages(driverId) : ['signal-messages', null],
queryFn: async () => {
if (!driverId) return [];
const { data } = await api.get<SignalMessage[]>(`/signal/messages/driver/${driverId}`);
@@ -35,7 +36,7 @@ export function useDriverMessages(driverId: string | null, enabled = true) {
*/
export function useUnreadCounts() {
return useQuery({
queryKey: ['signal-unread-counts'],
queryKey: queryKeys.signal.unreadCounts,
queryFn: async () => {
const { data } = await api.get<UnreadCounts>('/signal/messages/unread');
return data;
@@ -54,8 +55,10 @@ export function useDriverResponseCheck(
// Only include events that have a driver
const eventsWithDrivers = events.filter((e) => e.driver?.id);
const eventIds = eventsWithDrivers.map((e) => e.id).join(',');
return useQuery({
queryKey: ['signal-driver-responses', eventsWithDrivers.map((e) => e.id).join(',')],
queryKey: queryKeys.signal.driverResponses(eventIds),
queryFn: async () => {
if (eventsWithDrivers.length === 0) {
return new Set<string>();
@@ -97,11 +100,11 @@ export function useSendMessage() {
onSuccess: (data, variables) => {
// Add the new message to the cache immediately
queryClient.setQueryData<SignalMessage[]>(
['signal-messages', variables.driverId],
queryKeys.signal.messages(variables.driverId),
(old) => [...(old || []), data]
);
// Also invalidate to ensure consistency
queryClient.invalidateQueries({ queryKey: ['signal-messages', variables.driverId] });
queryClient.invalidateQueries({ queryKey: queryKeys.signal.messages(variables.driverId) });
},
});
}
@@ -120,7 +123,7 @@ export function useMarkMessagesAsRead() {
onSuccess: (_, driverId) => {
// Update the unread counts cache
queryClient.setQueryData<UnreadCounts>(
['signal-unread-counts'],
queryKeys.signal.unreadCounts,
(old) => {
if (!old) return {};
const updated = { ...old };
@@ -130,7 +133,7 @@ export function useMarkMessagesAsRead() {
);
// Mark messages as read in the messages cache
queryClient.setQueryData<SignalMessage[]>(
['signal-messages', driverId],
queryKeys.signal.messages(driverId),
(old) => old?.map((msg) => ({ ...msg, isRead: true })) || []
);
},

View File

@@ -0,0 +1,41 @@
/**
* Enum Display Labels
* Centralized mapping of enum values to human-readable labels
*/
export const DEPARTMENT_LABELS: Record<string, string> = {
OFFICE_OF_DEVELOPMENT: 'Office of Development',
ADMIN: 'Admin',
OTHER: 'Other',
};
export const ARRIVAL_MODE_LABELS: Record<string, string> = {
FLIGHT: 'Flight',
SELF_DRIVING: 'Self Driving',
};
export const EVENT_TYPE_LABELS: Record<string, string> = {
TRANSPORT: 'Transport',
MEETING: 'Meeting',
EVENT: 'Event',
MEAL: 'Meal',
ACCOMMODATION: 'Accommodation',
};
export const EVENT_STATUS_LABELS: Record<string, string> = {
SCHEDULED: 'Scheduled',
IN_PROGRESS: 'In Progress',
COMPLETED: 'Completed',
CANCELLED: 'Cancelled',
};
/**
* Helper function to get a label for any enum value
* Falls back to the value itself if no mapping is found
*/
export function getEnumLabel(
value: string,
labels: Record<string, string>
): string {
return labels[value] || value;
}

View File

@@ -0,0 +1,93 @@
/**
* Query key factory constants for TanStack Query
*
* This file provides typed, centralized query key management for all entities.
* Using factory functions ensures consistent keys across the application.
*
* @see https://tkdodo.eu/blog/effective-react-query-keys
*/
export const queryKeys = {
// VIPs
vips: {
all: ['vips'] as const,
detail: (id: string) => ['vip', id] as const,
forSchedule: (vipIds: string) => ['vips-for-schedule', vipIds] as const,
},
// Drivers
drivers: {
all: ['drivers'] as const,
myProfile: ['my-driver-profile'] as const,
schedule: (driverId: string, date: string) => ['driver-schedule', driverId, date] as const,
},
// Events/Schedule
events: {
all: ['events'] as const,
},
// Vehicles
vehicles: {
all: ['vehicles'] as const,
},
// Flights
flights: {
all: ['flights'] as const,
budget: ['flights', 'budget'] as const,
},
// Users
users: {
all: ['users'] as const,
},
// GPS/Location Tracking
gps: {
status: ['gps', 'status'] as const,
settings: ['gps', 'settings'] as const,
devices: ['gps', 'devices'] as const,
deviceQr: (driverId: string) => ['gps', 'devices', driverId, 'qr'] as const,
locations: {
all: ['gps', 'locations'] as const,
detail: (driverId: string) => ['gps', 'locations', driverId] as const,
},
stats: (driverId: string, from?: string, to?: string) =>
['gps', 'stats', driverId, from, to] as const,
me: {
status: ['gps', 'me'] as const,
stats: (from?: string, to?: string) => ['gps', 'me', 'stats', from, to] as const,
location: ['gps', 'me', 'location'] as const,
},
traccar: {
status: ['gps', 'traccar', 'status'] as const,
adminUrl: ['gps', 'traccar', 'admin-url'] as const,
},
},
// Settings
settings: {
pdf: ['settings', 'pdf'] as const,
timezone: ['settings', 'timezone'] as const,
},
// Signal Messages
signal: {
messages: (driverId: string) => ['signal-messages', driverId] as const,
unreadCounts: ['signal-unread-counts'] as const,
driverResponses: (eventIds: string) => ['signal-driver-responses', eventIds] as const,
status: ['signal-status'] as const,
messageStats: ['signal-message-stats'] as const,
},
// Admin
admin: {
stats: ['admin-stats'] as const,
},
// Features
features: {
all: ['features'] as const,
},
} as const;

View File

@@ -35,3 +35,18 @@ export function formatTime(date: string | Date, timeZone?: string): string {
...(timeZone && { timeZone }),
});
}
/**
* Convert ISO datetime string to datetime-local input format (YYYY-MM-DDTHH:mm)
* Used for populating datetime-local inputs in forms
*/
export function toDatetimeLocal(isoString: string | null | undefined): string {
if (!isoString) return '';
const date = new Date(isoString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}

View File

@@ -8,11 +8,13 @@ import { DriverForm, DriverFormData } from '@/components/DriverForm';
import { TableSkeleton, CardSkeleton } from '@/components/Skeleton';
import { FilterModal } from '@/components/FilterModal';
import { FilterChip } from '@/components/FilterChip';
import { ConfirmModal } from '@/components/ConfirmModal';
import { useDebounce } from '@/hooks/useDebounce';
import { DriverChatBubble } from '@/components/DriverChatBubble';
import { DriverChatModal } from '@/components/DriverChatModal';
import { DriverScheduleModal } from '@/components/DriverScheduleModal';
import { useUnreadCounts } from '@/hooks/useSignalMessages';
import { DEPARTMENT_LABELS } from '@/lib/enum-labels';
export function DriverList({ embedded = false }: { embedded?: boolean }) {
const queryClient = useQueryClient();
@@ -31,6 +33,9 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
// Schedule modal state
const [scheduleDriver, setScheduleDriver] = useState<Driver | null>(null);
// Confirm delete modal state
const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null);
// Sort state
const [sortColumn, setSortColumn] = useState<'name' | 'phone' | 'department'>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
@@ -193,12 +198,7 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
};
const getFilterLabel = (value: string) => {
const labels = {
'OFFICE_OF_DEVELOPMENT': 'Office of Development',
'ADMIN': 'Admin',
'OTHER': 'Other',
};
return labels[value as keyof typeof labels] || value;
return DEPARTMENT_LABELS[value] || value;
};
const handleAdd = () => {
@@ -212,8 +212,13 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
};
const handleDelete = (id: string, name: string) => {
if (confirm(`Delete driver "${name}"? This action cannot be undone.`)) {
deleteMutation.mutate(id);
setDeleteConfirm({ id, name });
};
const confirmDelete = () => {
if (deleteConfirm) {
deleteMutation.mutate(deleteConfirm.id);
setDeleteConfirm(null);
}
};
@@ -539,11 +544,7 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
filterGroups={[
{
label: 'Department',
options: [
{ value: 'OFFICE_OF_DEVELOPMENT', label: 'Office of Development' },
{ value: 'ADMIN', label: 'Admin' },
{ value: 'OTHER', label: 'Other' },
],
options: Object.entries(DEPARTMENT_LABELS).map(([value, label]) => ({ value, label })),
selectedValues: selectedDepartments,
onToggle: handleDepartmentToggle,
},
@@ -565,6 +566,17 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
isOpen={!!scheduleDriver}
onClose={() => setScheduleDriver(null)}
/>
{/* Confirm Delete Modal */}
<ConfirmModal
isOpen={!!deleteConfirm}
onConfirm={confirmDelete}
onCancel={() => setDeleteConfirm(null)}
title="Delete Driver"
description={`Are you sure you want to delete "${deleteConfirm?.name}"? This action cannot be undone.`}
confirmLabel="Delete"
variant="destructive"
/>
</div>
);
}

View File

@@ -8,7 +8,9 @@ import { Plus, Edit, Trash2, Search, ArrowUpDown, ArrowUp, ArrowDown } from 'luc
import { EventForm, EventFormData } from '@/components/EventForm';
import { Loading } from '@/components/Loading';
import { InlineDriverSelector } from '@/components/InlineDriverSelector';
import { ConfirmModal } from '@/components/ConfirmModal';
import { useFormattedDate } from '@/hooks/useFormattedDate';
import { queryKeys } from '@/lib/query-keys';
type ActivityFilter = 'ALL' | EventType;
type SortField = 'title' | 'type' | 'startTime' | 'status' | 'vips';
@@ -18,7 +20,7 @@ export function EventList() {
const queryClient = useQueryClient();
const location = useLocation();
const navigate = useNavigate();
const { formatDate, formatDateTime, formatTime } = useFormattedDate();
const { formatDateTime } = useFormattedDate();
const [showForm, setShowForm] = useState(false);
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -27,8 +29,11 @@ export function EventList() {
const [sortField, setSortField] = useState<SortField>('startTime');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
// Confirm delete modal state
const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; title: string } | null>(null);
const { data: events, isLoading } = useQuery<ScheduleEvent[]>({
queryKey: ['events'],
queryKey: queryKeys.events.all,
queryFn: async () => {
const { data } = await api.get('/events');
return data;
@@ -54,7 +59,7 @@ export function EventList() {
await api.post('/events', data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
queryClient.invalidateQueries({ queryKey: queryKeys.events.all });
setShowForm(false);
setIsSubmitting(false);
toast.success('Event created successfully');
@@ -71,7 +76,7 @@ export function EventList() {
await api.patch(`/events/${id}`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
queryClient.invalidateQueries({ queryKey: queryKeys.events.all });
setShowForm(false);
setEditingEvent(null);
setIsSubmitting(false);
@@ -89,7 +94,7 @@ export function EventList() {
await api.delete(`/events/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
queryClient.invalidateQueries({ queryKey: queryKeys.events.all });
toast.success('Event deleted successfully');
},
onError: (error: any) => {
@@ -109,8 +114,13 @@ export function EventList() {
};
const handleDelete = (id: string, title: string) => {
if (confirm(`Delete event "${title}"? This action cannot be undone.`)) {
deleteMutation.mutate(id);
setDeleteConfirm({ id, title });
};
const confirmDelete = () => {
if (deleteConfirm) {
deleteMutation.mutate(deleteConfirm.id);
setDeleteConfirm(null);
}
};
@@ -504,6 +514,17 @@ export function EventList() {
isSubmitting={isSubmitting}
/>
)}
{/* Confirm Delete Modal */}
<ConfirmModal
isOpen={!!deleteConfirm}
onConfirm={confirmDelete}
onCancel={() => setDeleteConfirm(null)}
title="Delete Activity"
description={`Are you sure you want to delete "${deleteConfirm?.title}"? This action cannot be undone.`}
confirmLabel="Delete"
variant="destructive"
/>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { useFormattedDate } from '@/hooks/useFormattedDate';
import { Check, X, UserCheck, UserX, Shield, Trash2, Car } from 'lucide-react';
import { useState } from 'react';
import { Loading } from '@/components/Loading';
import { ConfirmModal } from '@/components/ConfirmModal';
interface User {
id: string;
@@ -19,11 +20,21 @@ interface User {
} | null;
}
type ConfirmAction = 'approve' | 'delete' | 'changeRole';
export function UserList() {
const queryClient = useQueryClient();
const { formatDate } = useFormattedDate();
const [processingUser, setProcessingUser] = useState<string | null>(null);
// Confirm modal state
const [confirmState, setConfirmState] = useState<{
action: ConfirmAction;
userId: string;
userName: string;
newRole?: string;
} | null>(null);
const { data: users, isLoading } = useQuery<User[]>({
queryKey: ['users'],
queryFn: async () => {
@@ -93,23 +104,84 @@ export function UserList() {
},
});
const handleRoleChange = (userId: string, newRole: string) => {
if (confirm(`Change user role to ${newRole}?`)) {
changeRoleMutation.mutate({ userId, role: newRole });
}
const handleRoleChange = (userId: string, userName: string, newRole: string) => {
setConfirmState({
action: 'changeRole',
userId,
userName,
newRole,
});
};
const handleApprove = (userId: string) => {
if (confirm('Approve this user?')) {
setProcessingUser(userId);
approveMutation.mutate(userId);
}
const handleApprove = (userId: string, userName: string) => {
setConfirmState({
action: 'approve',
userId,
userName,
});
};
const handleDeny = (userId: string) => {
if (confirm('Delete this user? This action cannot be undone.')) {
setProcessingUser(userId);
deleteUserMutation.mutate(userId);
const handleDeny = (userId: string, userName: string) => {
setConfirmState({
action: 'delete',
userId,
userName,
});
};
const handleConfirm = () => {
if (!confirmState) return;
const { action, userId, newRole } = confirmState;
switch (action) {
case 'approve':
setProcessingUser(userId);
approveMutation.mutate(userId);
break;
case 'delete':
setProcessingUser(userId);
deleteUserMutation.mutate(userId);
break;
case 'changeRole':
if (newRole) {
changeRoleMutation.mutate({ userId, role: newRole });
}
break;
}
setConfirmState(null);
};
const getConfirmModalProps = () => {
if (!confirmState) return null;
const { action, userName, newRole } = confirmState;
switch (action) {
case 'approve':
return {
title: 'Approve User',
description: `Are you sure you want to approve ${userName}?`,
confirmLabel: 'Approve',
variant: 'default' as const,
};
case 'delete':
return {
title: 'Delete User',
description: `Are you sure you want to delete ${userName}? This action cannot be undone.`,
confirmLabel: 'Delete',
variant: 'destructive' as const,
};
case 'changeRole':
return {
title: 'Change User Role',
description: `Are you sure you want to change ${userName}'s role to ${newRole}?`,
confirmLabel: 'Change Role',
variant: 'warning' as const,
};
default:
return null;
}
};
@@ -175,7 +247,7 @@ export function UserList() {
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex gap-2">
<button
onClick={() => handleApprove(user.id)}
onClick={() => handleApprove(user.id, user.name || user.email)}
disabled={processingUser === user.id}
className="inline-flex items-center px-3 py-1 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 transition-colors"
>
@@ -183,7 +255,7 @@ export function UserList() {
Approve
</button>
<button
onClick={() => handleDeny(user.id)}
onClick={() => handleDeny(user.id, user.name || user.email)}
disabled={processingUser === user.id}
className="inline-flex items-center px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 transition-colors"
>
@@ -286,7 +358,7 @@ export function UserList() {
<div className="flex items-center gap-2">
<select
value={user.role}
onChange={(e) => handleRoleChange(user.id, e.target.value)}
onChange={(e) => handleRoleChange(user.id, user.name || user.email, e.target.value)}
className="text-sm border border-input bg-background rounded px-2 py-1 focus:ring-primary focus:border-primary transition-colors"
>
<option value="DRIVER">Driver</option>
@@ -294,7 +366,7 @@ export function UserList() {
<option value="ADMINISTRATOR">Administrator</option>
</select>
<button
onClick={() => handleDeny(user.id)}
onClick={() => handleDeny(user.id, user.name || user.email)}
className="inline-flex items-center px-2 py-1 text-red-600 hover:text-red-800 hover:bg-red-50 dark:hover:bg-red-950/20 rounded transition-colors"
title="Delete user"
>
@@ -309,6 +381,16 @@ export function UserList() {
</table>
</div>
</div>
{/* Confirm Modal */}
{confirmState && getConfirmModalProps() && (
<ConfirmModal
isOpen={true}
onConfirm={handleConfirm}
onCancel={() => setConfirmState(null)}
{...getConfirmModalProps()!}
/>
)}
</div>
);
}

View File

@@ -9,7 +9,9 @@ import { VIPForm, VIPFormData } from '@/components/VIPForm';
import { VIPCardSkeleton, TableSkeleton } from '@/components/Skeleton';
import { FilterModal } from '@/components/FilterModal';
import { FilterChip } from '@/components/FilterChip';
import { ConfirmModal } from '@/components/ConfirmModal';
import { useDebounce } from '@/hooks/useDebounce';
import { DEPARTMENT_LABELS, ARRIVAL_MODE_LABELS } from '@/lib/enum-labels';
export function VIPList() {
const queryClient = useQueryClient();
@@ -31,6 +33,9 @@ export function VIPList() {
// Roster-only toggle (hidden by default)
const [showRosterOnly, setShowRosterOnly] = useState(false);
// Confirm delete modal state
const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null);
// Debounce search term for better performance
const debouncedSearchTerm = useDebounce(searchTerm, 300);
@@ -188,18 +193,8 @@ export function VIPList() {
};
const getFilterLabel = (value: string, type: 'department' | 'arrivalMode') => {
const labels = {
department: {
'OFFICE_OF_DEVELOPMENT': 'Office of Development',
'ADMIN': 'Admin',
'OTHER': 'Other',
},
arrivalMode: {
'FLIGHT': 'Flight',
'SELF_DRIVING': 'Self Driving',
},
};
return labels[type][value as keyof typeof labels[typeof type]] || value;
const labels = type === 'department' ? DEPARTMENT_LABELS : ARRIVAL_MODE_LABELS;
return labels[value] || value;
};
const handleAdd = () => {
@@ -213,8 +208,13 @@ export function VIPList() {
};
const handleDelete = (id: string, name: string) => {
if (confirm(`Delete VIP "${name}"? This action cannot be undone.`)) {
deleteMutation.mutate(id);
setDeleteConfirm({ id, name });
};
const confirmDelete = () => {
if (deleteConfirm) {
deleteMutation.mutate(deleteConfirm.id);
setDeleteConfirm(null);
}
};
@@ -290,7 +290,7 @@ export function VIPList() {
{/* Filter Button */}
<button
onClick={() => setFilterModalOpen(true)}
className="inline-flex items-center px-4 py-2 border border-input rounded-md text-foreground bg-background hover:bg-accent font-medium transition-colors"
className="inline-flex items-center px-4 py-2 border border-input rounded-md text-foreground bg-card hover:bg-accent hover:text-accent-foreground font-medium transition-colors"
style={{ minHeight: '44px' }}
>
<Filter className="h-5 w-5 mr-2" />
@@ -544,20 +544,13 @@ export function VIPList() {
filterGroups={[
{
label: 'Department',
options: [
{ value: 'OFFICE_OF_DEVELOPMENT', label: 'Office of Development' },
{ value: 'ADMIN', label: 'Admin' },
{ value: 'OTHER', label: 'Other' },
],
options: Object.entries(DEPARTMENT_LABELS).map(([value, label]) => ({ value, label })),
selectedValues: selectedDepartments,
onToggle: handleDepartmentToggle,
},
{
label: 'Arrival Mode',
options: [
{ value: 'FLIGHT', label: 'Flight' },
{ value: 'SELF_DRIVING', label: 'Self Driving' },
],
options: Object.entries(ARRIVAL_MODE_LABELS).map(([value, label]) => ({ value, label })),
selectedValues: selectedArrivalModes,
onToggle: handleArrivalModeToggle,
},
@@ -565,6 +558,17 @@ export function VIPList() {
onClear={handleClearFilters}
onApply={() => {}}
/>
{/* Confirm Delete Modal */}
<ConfirmModal
isOpen={!!deleteConfirm}
onConfirm={confirmDelete}
onCancel={() => setDeleteConfirm(null)}
title="Delete VIP"
description={`Are you sure you want to delete "${deleteConfirm?.name}"? This action cannot be undone.`}
confirmLabel="Delete"
variant="destructive"
/>
</div>
);
}