From 72c9b592d904bbf7fb65b0ebf1e102c933e902a6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kov=C3=A1cs=20Zsombor?=
<125221225+KoZsombat@users.noreply.github.com>
Date: Tue, 10 Mar 2026 21:10:51 +0100
Subject: [PATCH] added sorting
---
.../routes/_private/admin/doorlock/cards.tsx | 197 ++++++++++++++---
.../_private/admin/doorlock/devices.tsx | 149 +++++++++++--
.../routes/_private/admin/doorlock/logs.tsx | 203 ++++++++++++++++--
3 files changed, 498 insertions(+), 51 deletions(-)
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 '';
+ }
+}