diff --git a/apps/iris/src/routes/_private/admin/doorlock/cards.tsx b/apps/iris/src/routes/_private/admin/doorlock/cards.tsx index 54e7f5f..6d9c27f 100644 --- a/apps/iris/src/routes/_private/admin/doorlock/cards.tsx +++ b/apps/iris/src/routes/_private/admin/doorlock/cards.tsx @@ -6,7 +6,17 @@ import { type InferResponseType, parseResponse, } from 'hono/client'; -import { Ban, CreditCard, Lock, Pen, Plus, Trash } from 'lucide-react'; +import { + ArrowDown, + ArrowUp, + ArrowUpDown, + Ban, + CreditCard, + Lock, + Pen, + Plus, + Trash, +} from 'lucide-react'; import type { ReactNode } from 'react'; import { useMemo, useState } from 'react'; import { toast } from 'sonner'; @@ -46,10 +56,35 @@ export const Route = createFileRoute('/_private/admin/doorlock/cards')({ ), }); +type CardSortColumn = 'name' | 'owner' | 'status' | 'devices' | 'updated'; + +function SortIcon({ + column, + currentColumn, + direction, +}: { + column: CardSortColumn; + currentColumn: CardSortColumn | null; + direction: 'asc' | 'desc' | null; +}) { + if (currentColumn !== column) { + return ; + } + return direction === 'asc' ? ( + + ) : ( + + ); +} + function CardsPage() { const queryClient = useQueryClient(); const { data: session } = authClient.useSession(); const [search, setSearch] = useState(''); + const [sortColumn, setSortColumn] = useState(null); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>( + null + ); const [dialogOpen, setDialogOpen] = useState(false); const [selectedCard, setSelectedCard] = useState(null); @@ -142,25 +177,42 @@ function CardsPage() { const filteredCards = useMemo(() => { const list = cardsQuery.data ?? []; const term = search.trim().toLowerCase(); - if (!term) { - return list; + let filtered = list; + + if (term) { + filtered = filtered.filter((card) => { + const ownerLabel = ( + card.owner?.nickname || + card.owner?.name || + card.owner?.email || + '' + ).toLowerCase(); + return ( + card.name.toLowerCase().includes(term) || + ownerLabel.includes(term) || + card.authorizedDevices.some((device) => + device.name.toLowerCase().includes(term) + ) + ); + }); } - return list.filter((card) => { - const ownerLabel = ( - card.owner?.nickname || - card.owner?.name || - card.owner?.email || - '' - ).toLowerCase(); - return ( - card.name.toLowerCase().includes(term) || - ownerLabel.includes(term) || - card.authorizedDevices.some((device) => - device.name.toLowerCase().includes(term) - ) - ); - }); - }, [cardsQuery.data, search]); + + if (sortColumn && sortDirection) { + filtered = [...filtered].sort((a, b) => { + const aValue = getCardSortValue(a, sortColumn); + const bValue = getCardSortValue(b, sortColumn); + + if (typeof aValue === 'number' && typeof bValue === 'number') { + return sortDirection === 'asc' ? aValue - bValue : bValue - aValue; + } + + const comparison = String(aValue).localeCompare(String(bValue)); + return sortDirection === 'asc' ? comparison : -comparison; + }); + } + + return filtered; + }, [cardsQuery.data, search, sortColumn, sortDirection]); const totals = useMemo(() => { const cards = cardsQuery.data ?? []; @@ -193,6 +245,21 @@ function CardsPage() { await deleteMutation.mutateAsync(card.id); }; + const handleSort = (column: CardSortColumn) => { + if (sortColumn === column) { + if (sortDirection === 'asc') { + setSortDirection('desc'); + } else if (sortDirection === 'desc') { + setSortColumn(null); + setSortDirection(null); + } + return; + } + + setSortColumn(column); + setSortDirection('asc'); + }; + const isLoading = cardsQuery.isLoading; const hasError = cardsQuery.isError; @@ -255,11 +322,71 @@ function CardsPage() { - Name - Owner - Status - Authorized devices - Updated + handleSort('name')} + > +
+ Name + +
+
+ handleSort('owner')} + > +
+ Owner + +
+
+ handleSort('status')} + > +
+ Status + +
+
+ handleSort('devices')} + > +
+ Authorized devices + +
+
+ handleSort('updated')} + > +
+ Updated + +
+
{hasWritePermission && Actions}
@@ -385,3 +512,25 @@ function useHasPermission(permission: string, permissions?: string[] | null) { } return permissions.includes(permission); } + +function getCardSortValue(card: DoorlockCard, column: CardSortColumn) { + switch (column) { + case 'name': + return card.name; + case 'owner': + return ( + card.owner?.nickname || card.owner?.name || card.owner?.email || '' + ); + case 'status': + if (!card.enabled) { + return 2; + } + return card.frozen ? 1 : 0; + case 'devices': + return card.authorizedDevices.map((device) => device.name).join(', '); + case 'updated': + return new Date(card.updatedAt).getTime(); + default: + return ''; + } +} diff --git a/apps/iris/src/routes/_private/admin/doorlock/devices.tsx b/apps/iris/src/routes/_private/admin/doorlock/devices.tsx index 5bfd399..a5887a5 100644 --- a/apps/iris/src/routes/_private/admin/doorlock/devices.tsx +++ b/apps/iris/src/routes/_private/admin/doorlock/devices.tsx @@ -7,6 +7,9 @@ import { parseResponse, } from 'hono/client'; import { + ArrowDown, + ArrowUp, + ArrowUpDown, ChartArea, DoorOpen, Download, @@ -51,10 +54,35 @@ export const Route = createFileRoute('/_private/admin/doorlock/devices')({ ), }); +type DeviceSortColumn = 'name' | 'location' | 'apiToken' | 'updated'; + +function SortIcon({ + column, + currentColumn, + direction, +}: { + column: DeviceSortColumn; + currentColumn: DeviceSortColumn | null; + direction: 'asc' | 'desc' | null; +}) { + if (currentColumn !== column) { + return ; + } + return direction === 'asc' ? ( + + ) : ( + + ); +} + function DevicesPage() { const queryClient = useQueryClient(); const { data: session } = authClient.useSession(); const [search, setSearch] = useState(''); + const [sortColumn, setSortColumn] = useState(null); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>( + null + ); const [dialogOpen, setDialogOpen] = useState(false); const [selectedDevice, setSelectedDevice] = useState( null @@ -160,16 +188,33 @@ function DevicesPage() { const filteredDevices = useMemo(() => { const items = devicesQuery.data ?? []; const term = search.trim().toLowerCase(); - if (!term) { - return items; + let filtered = items; + + if (term) { + filtered = filtered.filter( + (device) => + device.name.toLowerCase().includes(term) || + device.apiToken.toLowerCase().includes(term) || + (device.location ?? '').toLowerCase().includes(term) + ); + } + + if (sortColumn && sortDirection) { + filtered = [...filtered].sort((a, b) => { + const aValue = getDeviceSortValue(a, sortColumn); + const bValue = getDeviceSortValue(b, sortColumn); + + if (typeof aValue === 'number' && typeof bValue === 'number') { + return sortDirection === 'asc' ? aValue - bValue : bValue - aValue; + } + + const comparison = String(aValue).localeCompare(String(bValue)); + return sortDirection === 'asc' ? comparison : -comparison; + }); } - return items.filter( - (device) => - device.name.toLowerCase().includes(term) || - device.apiToken.toLowerCase().includes(term) || - (device.location ?? '').toLowerCase().includes(term) - ); - }, [devicesQuery.data, search]); + + return filtered; + }, [devicesQuery.data, search, sortColumn, sortDirection]); const totalDevices = devicesQuery.data?.length ?? 0; const activeDevices = useMemo(() => { @@ -204,6 +249,21 @@ function DevicesPage() { await deleteMutation.mutateAsync(device.id); }; + const handleSort = (column: DeviceSortColumn) => { + if (sortColumn === column) { + if (sortDirection === 'asc') { + setSortDirection('desc'); + } else if (sortDirection === 'desc') { + setSortColumn(null); + setSortDirection(null); + } + return; + } + + setSortColumn(column); + setSortDirection('asc'); + }; + const isLoading = devicesQuery.isLoading; const hasError = devicesQuery.isError; @@ -278,10 +338,58 @@ function DevicesPage() {
- Name - Location - API token - Last updated + handleSort('name')} + > +
+ Name + +
+
+ handleSort('location')} + > +
+ Location + +
+
+ handleSort('apiToken')} + > +
+ API token + +
+
+ handleSort('updated')} + > +
+ Last updated + +
+
{hasWritePermission && Actions}
@@ -423,3 +531,18 @@ function StatCard({ icon, label, value }: StatCardProps) { ); } + +function getDeviceSortValue(device: DoorlockDevice, column: DeviceSortColumn) { + switch (column) { + case 'name': + return device.name; + case 'location': + return device.location ?? ''; + case 'apiToken': + return device.apiToken; + case 'updated': + return new Date(device.updatedAt).getTime(); + default: + return ''; + } +} diff --git a/apps/iris/src/routes/_private/admin/doorlock/logs.tsx b/apps/iris/src/routes/_private/admin/doorlock/logs.tsx index c6f6fc3..a50b41b 100644 --- a/apps/iris/src/routes/_private/admin/doorlock/logs.tsx +++ b/apps/iris/src/routes/_private/admin/doorlock/logs.tsx @@ -2,7 +2,15 @@ import { useQuery } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; import dayjs from 'dayjs'; import { type InferResponseType, parseResponse } from 'hono/client'; -import { Calendar as CalendarIcon, Check, DoorOpen, User } from 'lucide-react'; +import { + ArrowDown, + ArrowUp, + ArrowUpDown, + Calendar as CalendarIcon, + Check, + DoorOpen, + User, +} from 'lucide-react'; import type { Dispatch, ReactNode, SetStateAction } from 'react'; import { useDeferredValue, useMemo, useState } from 'react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; @@ -47,6 +55,14 @@ type DoorlockCard = NonNullable['cards'][number]; type DoorlockLogEntry = NonNullable['logs'][number]; type EventFilter = 'all' | 'virtual' | 'physical'; +type LogSortColumn = + | 'timestamp' + | 'device' + | 'user' + | 'card' + | 'cardData' + | 'triggeredBy' + | 'result'; const isVirtualLog = (log: DoorlockLogEntry) => Boolean(log.buttonPressed && log.cardId); @@ -79,6 +95,25 @@ const buildButtonMeta = (log: DoorlockLogEntry): ButtonMeta => { }; }; +function SortIcon({ + column, + currentColumn, + direction, +}: { + column: LogSortColumn; + currentColumn: LogSortColumn | null; + direction: 'asc' | 'desc' | null; +}) { + if (currentColumn !== column) { + return ; + } + return direction === 'asc' ? ( + + ) : ( + + ); +} + export const Route = createFileRoute('/_private/admin/doorlock/logs')({ component: () => ( @@ -148,6 +183,10 @@ const buildLogsQuery = ({ function LogsPage() { const { data: session } = authClient.useSession(); const [search, setSearch] = useState(''); + const [sortColumn, setSortColumn] = useState(null); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>( + null + ); const [deviceFilter, setDeviceFilter] = useState<'all' | string>('all'); const [cardFilter, setCardFilter] = useState<'all' | string>('all'); const [userFilter, setUserFilter] = useState<'all' | string>('all'); @@ -275,13 +314,44 @@ function LogsPage() { const filteredLogs = useMemo(() => { const logs = logsQuery.data ?? []; - if (eventFilter === 'all') { - return logs; + let filtered = + eventFilter === 'all' + ? logs + : logs.filter((log) => + eventFilter === 'virtual' ? isVirtualLog(log) : !isVirtualLog(log) + ); + + if (sortColumn && sortDirection) { + filtered = [...filtered].sort((a, b) => { + const aValue = getLogSortValue(a, sortColumn); + const bValue = getLogSortValue(b, sortColumn); + + if (typeof aValue === 'number' && typeof bValue === 'number') { + return sortDirection === 'asc' ? aValue - bValue : bValue - aValue; + } + + const comparison = String(aValue).localeCompare(String(bValue)); + return sortDirection === 'asc' ? comparison : -comparison; + }); } - return logs.filter((log) => - eventFilter === 'virtual' ? isVirtualLog(log) : !isVirtualLog(log) - ); - }, [eventFilter, logsQuery.data]); + + return filtered; + }, [eventFilter, logsQuery.data, sortColumn, sortDirection]); + + const handleSort = (column: LogSortColumn) => { + if (sortColumn === column) { + if (sortDirection === 'asc') { + setSortDirection('desc'); + } else if (sortDirection === 'desc') { + setSortColumn(null); + setSortDirection(null); + } + return; + } + + setSortColumn(column); + setSortDirection('asc'); + }; const hasError = logsQuery.isError; const isLoading = logsQuery.isLoading; @@ -371,13 +441,97 @@ function LogsPage() {
- Timestamp - Device - User - Card - Card UID - Triggered by - Result + handleSort('timestamp')} + > +
+ Timestamp + +
+
+ handleSort('device')} + > +
+ Device + +
+
+ handleSort('user')} + > +
+ User + +
+
+ handleSort('card')} + > +
+ Card + +
+
+ handleSort('cardData')} + > +
+ Card UID + +
+
+ handleSort('triggeredBy')} + > +
+ Triggered by + +
+
+ handleSort('result')} + > +
+ Result + +
+
@@ -593,3 +747,24 @@ function useOptions( } return Array.from(seen.entries()).map(([id, label]) => ({ id, label })); } + +function getLogSortValue(log: DoorlockLogEntry, column: LogSortColumn) { + switch (column) { + case 'timestamp': + return new Date(log.timestamp).getTime(); + case 'device': + return log.device?.name ?? ''; + case 'user': + return log.owner?.nickname || log.owner?.name || log.owner?.email || ''; + case 'card': + return log.card?.name ?? ''; + case 'cardData': + return log.cardData ?? ''; + case 'triggeredBy': + return buildButtonMeta(log).label; + case 'result': + return log.result ? 1 : 0; + default: + return ''; + } +}