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:
@@ -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';
|
||||
|
||||
36
frontend/src/components/SortableHeader.tsx
Normal file
36
frontend/src/components/SortableHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
frontend/src/hooks/useListPage.ts
Normal file
55
frontend/src/hooks/useListPage.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user