refactor: complete code efficiency pass (Issues #10, #14, #16)

Backend:
- Add Prisma soft-delete middleware for automatic deletedAt filtering (#10)
- Split 2758-line copilot.service.ts into focused sub-services (#14):
  - copilot-schedule.service.ts (schedule/event tools)
  - copilot-reports.service.ts (reporting/analytics tools)
  - copilot-fleet.service.ts (vehicle/driver tools)
  - copilot-vip.service.ts (VIP management tools)
  - copilot.service.ts now thin orchestrator
- Remove manual deletedAt: null from 50+ queries

Frontend:
- Create SortableHeader component for reusable table sorting (#16)
- Create useListPage hook for shared search/filter/sort state (#16)
- Update VipList, DriverList, EventList to use shared infrastructure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 16:34:18 +01:00
parent f2b3f34a72
commit 3bc9cd0bca
23 changed files with 2975 additions and 2443 deletions

View File

@@ -14,7 +14,7 @@ import { Callback } from '@/pages/Callback';
import { PendingApproval } from '@/pages/PendingApproval';
import { Dashboard } from '@/pages/Dashboard';
import { CommandCenter } from '@/pages/CommandCenter';
import { VIPList } from '@/pages/VipList';
import { VIPList } from '@/pages/VIPList';
import { VIPSchedule } from '@/pages/VIPSchedule';
import { FleetPage } from '@/pages/FleetPage';
import { EventList } from '@/pages/EventList';

View File

@@ -0,0 +1,36 @@
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
interface SortableHeaderProps<T extends string> {
column: T;
label: string;
currentSort: {
key: string;
direction: 'asc' | 'desc';
};
onSort: (key: T) => void;
className?: string;
}
export function SortableHeader<T extends string>({ column, label, currentSort, onSort, className = '' }: SortableHeaderProps<T>) {
const isActive = currentSort.key === column;
return (
<th
className={`px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-accent transition-colors select-none ${className}`}
onClick={() => onSort(column)}
>
<div className="flex items-center gap-2">
{label}
{isActive ? (
currentSort.direction === 'asc' ? (
<ArrowUp className="h-4 w-4 text-primary" />
) : (
<ArrowDown className="h-4 w-4 text-primary" />
)
) : (
<ArrowUpDown className="h-4 w-4 text-muted-foreground/50" />
)}
</div>
</th>
);
}

View File

@@ -0,0 +1,55 @@
import { useState, useMemo } from 'react';
import { useDebounce } from './useDebounce';
interface UseListPageOptions<T extends string> {
defaultSortKey: T;
defaultSortDirection?: 'asc' | 'desc';
}
export function useListPage<T extends string>(options: UseListPageOptions<T>) {
const { defaultSortKey, defaultSortDirection = 'asc' } = options;
// Search state
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
// Sort state
const [sortKey, setSortKey] = useState<T>(defaultSortKey);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(defaultSortDirection);
// Generic filter state (key-value pairs)
const [filters, setFiltersState] = useState<Record<string, any>>({});
const handleSort = (key: T) => {
if (sortKey === key) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(key);
setSortDirection('asc');
}
};
const setFilter = (key: string, value: any) => {
setFiltersState((prev) => ({
...prev,
[key]: value,
}));
};
const clearFilters = () => {
setSearch('');
setFiltersState({});
};
return {
search,
setSearch,
debouncedSearch,
sortKey,
sortDirection,
handleSort,
filters,
setFilter,
clearFilters,
};
}

View File

@@ -3,13 +3,14 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { api } from '@/lib/api';
import { Driver } from '@/types';
import { Plus, Edit, Trash2, Search, X, Filter, ArrowUpDown, Send, Eye } from 'lucide-react';
import { Plus, Edit, Trash2, Search, X, Filter, Send, Eye } from 'lucide-react';
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 { SortableHeader } from '@/components/SortableHeader';
import { useListPage } from '@/hooks/useListPage';
import { DriverChatBubble } from '@/components/DriverChatBubble';
import { DriverChatModal } from '@/components/DriverChatModal';
import { DriverScheduleModal } from '@/components/DriverScheduleModal';
@@ -22,8 +23,19 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
const [editingDriver, setEditingDriver] = useState<Driver | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Search and filter state
const [searchTerm, setSearchTerm] = useState('');
// List page state management
const {
search: searchTerm,
setSearch: setSearchTerm,
debouncedSearch: debouncedSearchTerm,
sortKey: sortColumn,
sortDirection,
handleSort,
} = useListPage<'name' | 'phone' | 'department'>({
defaultSortKey: 'name',
});
// Filter state
const [selectedDepartments, setSelectedDepartments] = useState<string[]>([]);
const [filterModalOpen, setFilterModalOpen] = useState(false);
@@ -36,13 +48,6 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
// 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');
// Debounce search term
const debouncedSearchTerm = useDebounce(searchTerm, 300);
// Fetch unread message counts
const { data: unreadCounts } = useUnreadCounts();
@@ -184,15 +189,6 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
setSelectedDepartments([]);
};
const handleSort = (column: typeof sortColumn) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
};
const handleRemoveDepartmentFilter = (dept: string) => {
setSelectedDepartments((prev) => prev.filter((d) => d !== dept));
};
@@ -359,36 +355,24 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/30">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent transition-colors"
onClick={() => handleSort('name')}
>
<div className="flex items-center gap-2">
Name
<ArrowUpDown className="h-4 w-4" />
{sortColumn === 'name' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent transition-colors"
onClick={() => handleSort('phone')}
>
<div className="flex items-center gap-2">
Phone
<ArrowUpDown className="h-4 w-4" />
{sortColumn === 'phone' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent transition-colors"
onClick={() => handleSort('department')}
>
<div className="flex items-center gap-2">
Department
<ArrowUpDown className="h-4 w-4" />
{sortColumn === 'department' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
</div>
</th>
<SortableHeader
column="name"
label="Name"
currentSort={{ key: sortColumn, direction: sortDirection }}
onSort={handleSort}
/>
<SortableHeader
column="phone"
label="Phone"
currentSort={{ key: sortColumn, direction: sortDirection }}
onSort={handleSort}
/>
<SortableHeader
column="department"
label="Department"
currentSort={{ key: sortColumn, direction: sortDirection }}
onSort={handleSort}
/>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
Assigned Events
</th>

View File

@@ -4,11 +4,12 @@ import { useLocation, useNavigate } from 'react-router-dom';
import toast from 'react-hot-toast';
import { api } from '@/lib/api';
import { ScheduleEvent, EventType } from '@/types';
import { Plus, Edit, Trash2, Search, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import { Plus, Edit, Trash2, Search } from 'lucide-react';
import { EventForm, EventFormData } from '@/components/EventForm';
import { Loading } from '@/components/Loading';
import { InlineDriverSelector } from '@/components/InlineDriverSelector';
import { ConfirmModal } from '@/components/ConfirmModal';
import { SortableHeader } from '@/components/SortableHeader';
import { useFormattedDate } from '@/hooks/useFormattedDate';
import { queryKeys } from '@/lib/query-keys';
@@ -26,12 +27,14 @@ export function EventList() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeFilter, setActiveFilter] = useState<ActivityFilter>('ALL');
const [searchQuery, setSearchQuery] = useState('');
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);
// Sort state (inline instead of useListPage since search is not debounced here)
const [sortField, setSortField] = useState<SortField>('startTime');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const { data: events, isLoading } = useQuery<ScheduleEvent[]>({
queryKey: queryKeys.events.all,
queryFn: async () => {
@@ -322,77 +325,47 @@ export function EventList() {
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/30">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
onClick={() => handleSort('title')}
>
<div className="flex items-center gap-1">
Title
{sortField === 'title' ? (
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
) : (
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
onClick={() => handleSort('type')}
>
<div className="flex items-center gap-1">
Type
{sortField === 'type' ? (
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
) : (
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
onClick={() => handleSort('vips')}
>
<div className="flex items-center gap-1">
VIPs
{sortField === 'vips' ? (
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
) : (
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
)}
</div>
</th>
<SortableHeader
column="title"
label="Title"
currentSort={{ key: sortField, direction: sortDirection }}
onSort={handleSort}
className="gap-1"
/>
<SortableHeader
column="type"
label="Type"
currentSort={{ key: sortField, direction: sortDirection }}
onSort={handleSort}
className="gap-1"
/>
<SortableHeader
column="vips"
label="VIPs"
currentSort={{ key: sortField, direction: sortDirection }}
onSort={handleSort}
className="gap-1"
/>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
Vehicle
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
Driver
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
onClick={() => handleSort('startTime')}
>
<div className="flex items-center gap-1">
Start Time
{sortField === 'startTime' ? (
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
) : (
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
onClick={() => handleSort('status')}
>
<div className="flex items-center gap-1">
Status
{sortField === 'status' ? (
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
) : (
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
)}
</div>
</th>
<SortableHeader
column="startTime"
label="Start Time"
currentSort={{ key: sortField, direction: sortDirection }}
onSort={handleSort}
className="gap-1"
/>
<SortableHeader
column="status"
label="Status"
currentSort={{ key: sortField, direction: sortDirection }}
onSort={handleSort}
className="gap-1"
/>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
Actions
</th>

View File

@@ -4,13 +4,14 @@ import { useNavigate } from 'react-router-dom';
import toast from 'react-hot-toast';
import { api } from '@/lib/api';
import { VIP } from '@/types';
import { Plus, Edit, Trash2, Search, X, Calendar, Filter, ArrowUpDown, ClipboardList } from 'lucide-react';
import { Plus, Edit, Trash2, Search, X, Calendar, Filter, ClipboardList } from 'lucide-react';
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 { SortableHeader } from '@/components/SortableHeader';
import { useListPage } from '@/hooks/useListPage';
import { DEPARTMENT_LABELS, ARRIVAL_MODE_LABELS } from '@/lib/enum-labels';
export function VIPList() {
@@ -20,25 +21,29 @@ export function VIPList() {
const [editingVIP, setEditingVIP] = useState<VIP | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Search and filter state
const [searchTerm, setSearchTerm] = useState('');
// List page state management
const {
search: searchTerm,
setSearch: setSearchTerm,
debouncedSearch: debouncedSearchTerm,
sortKey: sortColumn,
sortDirection,
handleSort,
} = useListPage<'name' | 'organization' | 'department' | 'arrivalMode'>({
defaultSortKey: 'name',
});
// Filter state
const [selectedDepartments, setSelectedDepartments] = useState<string[]>([]);
const [selectedArrivalModes, setSelectedArrivalModes] = useState<string[]>([]);
const [filterModalOpen, setFilterModalOpen] = useState(false);
// Sort state
const [sortColumn, setSortColumn] = useState<'name' | 'organization' | 'department' | 'arrivalMode'>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
// 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);
const { data: vips, isLoading } = useQuery<VIP[]>({
queryKey: ['vips'],
queryFn: async () => {
@@ -175,15 +180,6 @@ export function VIPList() {
setSelectedArrivalModes([]);
};
const handleSort = (column: typeof sortColumn) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
};
const handleRemoveDepartmentFilter = (dept: string) => {
setSelectedDepartments((prev) => prev.filter((d) => d !== dept));
};
@@ -363,46 +359,30 @@ export function VIPList() {
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/30">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-accent transition-colors"
onClick={() => handleSort('name')}
>
<div className="flex items-center gap-2">
Name
<ArrowUpDown className="h-4 w-4" />
{sortColumn === 'name' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-accent transition-colors"
onClick={() => handleSort('organization')}
>
<div className="flex items-center gap-2">
Organization
<ArrowUpDown className="h-4 w-4" />
{sortColumn === 'organization' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-accent transition-colors"
onClick={() => handleSort('department')}
>
<div className="flex items-center gap-2">
Department
<ArrowUpDown className="h-4 w-4" />
{sortColumn === 'department' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-accent transition-colors"
onClick={() => handleSort('arrivalMode')}
>
<div className="flex items-center gap-2">
Arrival Mode
<ArrowUpDown className="h-4 w-4" />
{sortColumn === 'arrivalMode' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
</div>
</th>
<SortableHeader
column="name"
label="Name"
currentSort={{ key: sortColumn, direction: sortDirection }}
onSort={handleSort}
/>
<SortableHeader
column="organization"
label="Organization"
currentSort={{ key: sortColumn, direction: sortDirection }}
onSort={handleSort}
/>
<SortableHeader
column="department"
label="Department"
currentSort={{ key: sortColumn, direction: sortDirection }}
onSort={handleSort}
/>
<SortableHeader
column="arrivalMode"
label="Arrival Mode"
currentSort={{ key: sortColumn, direction: sortDirection }}
onSort={handleSort}
/>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Actions
</th>