diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index d892b6a5..afa440e1 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -21,6 +21,12 @@ import { } from "lucide-react"; import * as React from "react"; import { Link, useNavigate } from "react-router-dom"; +import { + sortItems, + toggleSort, + type SortConfig, + type SortResolverMap, +} from "../../../../../common/core/utils"; import { RoleGuard } from "../../../components/auth/RoleGuard"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; @@ -82,10 +88,7 @@ import { const tenantCSVTemplate = "name,type,parent_tenant_slug,slug,memo,email_domain\n"; -type SortConfig = { - key: keyof TenantSummary | "recursiveMemberCount"; - direction: "asc" | "desc"; -}; +type TenantSortKey = keyof TenantSummary | "recursiveMemberCount"; const getTenantIcon = (type?: string) => { switch (type?.toUpperCase()) { @@ -225,7 +228,8 @@ function TenantListPage() { const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list"); const [selectedIds, setSelectedIds] = React.useState([]); const [search, setSearch] = React.useState(""); - const [sortConfig, setSortConfig] = React.useState(null); + const [sortConfig, setSortConfig] = + React.useState | null>(null); const fileInputRef = React.useRef(null); const [importMessage, setImportMessage] = React.useState(""); const [previewRows, setPreviewRows] = React.useState< @@ -363,6 +367,14 @@ function TenantListPage() { const allTenants = query.data?.items ?? []; const importParentOptionGroups = buildTenantImportParentOptionGroups(allTenants); + const tenantSortResolvers = React.useMemo< + SortResolverMap + >( + () => ({ + recursiveMemberCount: (tenant) => tenant.recursiveMemberCount, + }), + [], + ); const tenants = React.useMemo(() => { // 1. Calculate recursive counts // buildTenantFullTree returns subTree which represents roots, but it also mutates the mapped nodes internally. @@ -396,38 +408,14 @@ function TenantListPage() { ); } - if (sortConfig) { - enriched.sort((a, b) => { - const aValue = a[sortConfig.key as keyof typeof a]; - const bValue = b[sortConfig.key as keyof typeof b]; + return sortItems(enriched, sortConfig, tenantSortResolvers); + }, [allTenants, search, sortConfig, tenantSortResolvers]); - if (aValue === bValue) return 0; - if (aValue === null || aValue === undefined) return 1; - if (bValue === null || bValue === undefined) return -1; - - if (sortConfig.direction === "asc") { - return aValue < bValue ? -1 : 1; - } - return aValue > bValue ? -1 : 1; - }); - } - - return enriched; - }, [allTenants, search, sortConfig]); - - const requestSort = (key: SortConfig["key"]) => { - let direction: "asc" | "desc" = "asc"; - if ( - sortConfig && - sortConfig.key === key && - sortConfig.direction === "asc" - ) { - direction = "desc"; - } - setSortConfig({ key, direction }); + const requestSort = (key: TenantSortKey) => { + setSortConfig((current) => toggleSort(current, key)); }; - const getSortIcon = (key: SortConfig["key"]) => { + const getSortIcon = (key: TenantSortKey) => { if (!sortConfig || sortConfig.key !== key) { return ; } diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index ad8fa582..cfc59a30 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -50,6 +50,12 @@ import { TableRow, } from "../../components/ui/table"; import { toast } from "../../components/ui/use-toast"; +import { + sortItems, + toggleSort, + type SortConfig, + type SortResolverMap, +} from "../../../../common/core/utils"; import { type UserSummary, bulkDeleteUsers, @@ -71,10 +77,7 @@ type UserSchemaField = { type: string; }; -type SortConfig = { - key: string; - direction: "asc" | "desc"; -}; +type UserSortKey = string; function UserListPage() { const navigate = useNavigate(); @@ -86,7 +89,9 @@ function UserListPage() { Record >({}); const [selectedUserIds, setSelectedUserIds] = React.useState([]); - const [sortConfig, setSortConfig] = React.useState(null); + const [sortConfig, setSortConfig] = React.useState | null>( + null, + ); const limit = 1000; const offset = (page - 1) * limit; @@ -219,60 +224,38 @@ function UserListPage() { : null; const rawItems = query.data?.items ?? []; + const userSortResolvers = React.useMemo>( + () => + userSchema.reduce>( + (accumulator, field) => ({ + ...accumulator, + [field.key]: (user) => { + const value = user.metadata?.[field.key]; + return typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ? value + : null; + }, + }), + { + name_email: (user) => + `${user.name ?? ""} ${user.email ?? ""} ${user.phone ?? ""}`, + tenant_dept: (user) => + `${user.tenant?.name ?? user.tenantSlug ?? ""} ${user.department ?? ""}`, + }, + ), + [userSchema], + ); const items = React.useMemo(() => { - const sorted = [...rawItems]; - if (sortConfig) { - sorted.sort((a, b) => { - let aValue: string | number | boolean | null | undefined; - let bValue: string | number | boolean | null | undefined; + return sortItems(rawItems, sortConfig, userSortResolvers); + }, [rawItems, sortConfig, userSortResolvers]); - if (sortConfig.key === "name_email") { - aValue = a.name?.toLowerCase() || ""; - bValue = b.name?.toLowerCase() || ""; - } else if (sortConfig.key === "tenant_dept") { - aValue = - (a.tenant?.name || a.tenantSlug || "").toLowerCase() + - (a.department || "").toLowerCase(); - bValue = - (b.tenant?.name || b.tenantSlug || "").toLowerCase() + - (b.department || "").toLowerCase(); - } else { - aValue = (a as Record)[sortConfig.key] as - | string - | number - | boolean; - bValue = (b as Record)[sortConfig.key] as - | string - | number - | boolean; - } - - if (aValue === bValue) return 0; - if (aValue === null || aValue === undefined) return 1; - if (bValue === null || bValue === undefined) return -1; - - if (sortConfig.direction === "asc") { - return aValue < bValue ? -1 : 1; - } - return aValue > bValue ? -1 : 1; - }); - } - return sorted; - }, [rawItems, sortConfig]); - - const requestSort = (key: SortConfig["key"]) => { - let direction: "asc" | "desc" = "asc"; - if ( - sortConfig && - sortConfig.key === key && - sortConfig.direction === "asc" - ) { - direction = "desc"; - } - setSortConfig({ key, direction }); + const requestSort = (key: UserSortKey) => { + setSortConfig((current) => toggleSort(current, key)); }; - const getSortIcon = (key: SortConfig["key"]) => { + const getSortIcon = (key: UserSortKey) => { if (!sortConfig || sortConfig.key !== key) { return ; } diff --git a/adminfront/src/lib/sort.test.ts b/adminfront/src/lib/sort.test.ts new file mode 100644 index 00000000..31394203 --- /dev/null +++ b/adminfront/src/lib/sort.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + compareNullableValues, + sortItems, + toggleSort, + type SortConfig, +} from "../../../common/core/utils"; + +describe("shared sort helpers", () => { + it("toggles sort direction for the same key", () => { + expect(toggleSort(null, "name")).toEqual({ + key: "name", + direction: "asc", + }); + + expect( + toggleSort({ key: "name", direction: "asc" }, "name"), + ).toEqual({ + key: "name", + direction: "desc", + }); + + expect( + toggleSort({ key: "name", direction: "desc" }, "status"), + ).toEqual({ + key: "status", + direction: "asc", + }); + }); + + it("compares nullable values with nulls last", () => { + expect(compareNullableValues("a", "b", "asc")).toBeLessThan(0); + expect(compareNullableValues("a", "b", "desc")).toBeGreaterThan(0); + expect(compareNullableValues(null, "b", "asc")).toBeGreaterThan(0); + expect(compareNullableValues("b", null, "asc")).toBeLessThan(0); + }); + + it("sorts items with resolver maps", () => { + const items = [ + { + id: "2", + name: "Beta", + metadata: { score: 2 }, + }, + { + id: "3", + name: "gamma", + metadata: {}, + }, + { + id: "1", + name: "alpha", + metadata: { score: 1 }, + }, + ]; + + const nameSort: SortConfig<"name"> = { key: "name", direction: "asc" }; + expect(sortItems(items, nameSort).map((item) => item.id)).toEqual([ + "1", + "2", + "3", + ]); + + const scoreSort: SortConfig<"score"> = { key: "score", direction: "asc" }; + expect( + sortItems(items, scoreSort, { + score: (item) => + typeof item.metadata.score === "number" ? item.metadata.score : null, + }).map((item) => item.id), + ).toEqual(["1", "2", "3"]); + }); +}); diff --git a/common/core/utils/index.ts b/common/core/utils/index.ts index 010050f4..174fa92a 100644 --- a/common/core/utils/index.ts +++ b/common/core/utils/index.ts @@ -4,3 +4,5 @@ export function mergeClassNames( ) { return mergeFn(...classNames); } + +export * from "./sort"; diff --git a/common/core/utils/sort.ts b/common/core/utils/sort.ts new file mode 100644 index 00000000..b75ee57d --- /dev/null +++ b/common/core/utils/sort.ts @@ -0,0 +1,97 @@ +export type SortDirection = "asc" | "desc"; + +export type SortConfig = { + key: Key; + direction: SortDirection; +}; + +export type SortableValue = string | number | boolean | Date | null | undefined; + +export type SortResolver = (item: T) => SortableValue; + +export type SortResolverMap = Partial< + Record> +>; + +function normalizeSortableValue(value: SortableValue) { + if (value instanceof Date) { + return value.getTime(); + } + if (typeof value === "string") { + return value.toLocaleLowerCase(); + } + if (typeof value === "boolean") { + return value ? 1 : 0; + } + return value; +} + +export function compareNullableValues( + left: SortableValue, + right: SortableValue, + direction: SortDirection, +) { + if (left === right) { + return 0; + } + + if (left === null || left === undefined) { + return 1; + } + + if (right === null || right === undefined) { + return -1; + } + + const normalizedLeft = normalizeSortableValue(left); + const normalizedRight = normalizeSortableValue(right); + + if (normalizedLeft === normalizedRight) { + return 0; + } + + if (normalizedLeft === null || normalizedLeft === undefined) { + return 1; + } + + if (normalizedRight === null || normalizedRight === undefined) { + return -1; + } + + const comparison = normalizedLeft < normalizedRight ? -1 : 1; + return direction === "asc" ? comparison : -comparison; +} + +export function toggleSort( + current: SortConfig | null, + key: Key, +): SortConfig { + if (current?.key === key && current.direction === "asc") { + return { key, direction: "desc" }; + } + + return { key, direction: "asc" }; +} + +export function sortItems( + items: T[], + sortConfig: SortConfig | null, + resolverMap: SortResolverMap = {}, +) { + if (!sortConfig) { + return [...items]; + } + + const resolveValue = + resolverMap[sortConfig.key] ?? + ((item: T) => + (item as Record)[sortConfig.key] ?? null); + + return [...items].sort((left, right) => + compareNullableValues( + resolveValue(left), + resolveValue(right), + sortConfig.direction, + ), + ); +} diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 5657fb42..78f670b0 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -1,9 +1,24 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { BookOpenText, Filter, Plus, Search, X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { + ArrowDown, + ArrowUp, + ArrowUpDown, + BookOpenText, + Filter, + Plus, + Search, + X, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; import { useAuth } from "react-oidc-context"; import { Link, useNavigate } from "react-router-dom"; +import { + sortItems, + toggleSort, + type SortConfig, + type SortResolverMap, +} from "../../../../common/core/utils"; import { ForbiddenMessage } from "../../components/common/ForbiddenMessage"; import { Avatar, @@ -37,6 +52,7 @@ import { fetchDeveloperRequestStatus, fetchMyTenants, requestDeveloperAccess, + type ClientSummary, } from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; @@ -44,6 +60,8 @@ import { cn } from "../../lib/utils"; import { fetchMe } from "../auth/authApi"; import { ClientLogo } from "./components/ClientLogo"; +type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt"; + function ClientsPage() { const navigate = useNavigate(); const auth = useAuth(); @@ -104,19 +122,48 @@ function ClientsPage() { const [statusFilter, setStatusFilter] = useState("all"); const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false); const [isRequestModalOpen, setIsRequestModalOpen] = useState(false); + const [sortConfig, setSortConfig] = + useState | null>(null); const clients = data?.items || []; + const clientSortResolvers = useMemo< + SortResolverMap + >( + () => ({ + application: (client) => client.name || client.id, + id: (client) => client.id, + type: (client) => + client.metadata?.headless_login_enabled + ? "private-headless" + : client.type, + status: (client) => client.status, + createdAt: (client) => + client.createdAt ? new Date(client.createdAt) : null, + }), + [], + ); - const filteredClients = clients.filter((client) => { - const matchesSearch = - !searchQuery || - client.name?.toLowerCase().includes(searchQuery.toLowerCase()) || - client.id.toLowerCase().includes(searchQuery.toLowerCase()); - const matchesType = typeFilter === "all" || client.type === typeFilter; - const matchesStatus = - statusFilter === "all" || client.status === statusFilter; - return matchesSearch && matchesType && matchesStatus; - }); + const filteredClients = useMemo(() => { + const nextClients = clients.filter((client) => { + const matchesSearch = + !searchQuery || + client.name?.toLowerCase().includes(searchQuery.toLowerCase()) || + client.id.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesType = typeFilter === "all" || client.type === typeFilter; + const matchesStatus = + statusFilter === "all" || client.status === statusFilter; + return matchesSearch && matchesType && matchesStatus; + }); + + return sortItems(nextClients, sortConfig, clientSortResolvers); + }, [ + clientSortResolvers, + clients, + searchQuery, + sortConfig, + statusFilter, + typeFilter, + ]); const totalClients = statsData?.total_clients ?? clients.length; const activeSessions = statsData?.active_sessions ?? 0; @@ -179,6 +226,22 @@ function ClientsPage() { const isLoading = isLoadingClients || isLoadingStats || isLoadingRequest; + const requestSort = (key: ClientSortKey) => { + setSortConfig((current) => toggleSort(current, key)); + }; + + const getSortIcon = (key: ClientSortKey) => { + if (!sortConfig || sortConfig.key !== key) { + return ; + } + + return sortConfig.direction === "asc" ? ( + + ) : ( + + ); + }; + if (auth.isLoading || !hasAccessToken || isLoading) { return (
@@ -389,18 +452,50 @@ function ClientsPage() { - - {t("ui.dev.clients.table.application", "애플리케이션")} + requestSort("application")} + > +
+ {t("ui.dev.clients.table.application", "애플리케이션")} + {getSortIcon("application")} +
- - {t("ui.dev.clients.table.client_id", "Client ID")} + requestSort("id")} + > +
+ {t("ui.dev.clients.table.client_id", "Client ID")} + {getSortIcon("id")} +
- {t("ui.dev.clients.table.type", "유형")} - - {t("ui.dev.clients.table.status", "상태")} + requestSort("type")} + > +
+ {t("ui.dev.clients.table.type", "유형")} + {getSortIcon("type")} +
- - {t("ui.dev.clients.table.created_at", "생성일")} + requestSort("status")} + > +
+ {t("ui.dev.clients.table.status", "상태")} + {getSortIcon("status")} +
+
+ requestSort("createdAt")} + > +
+ {t("ui.dev.clients.table.created_at", "생성일")} + {getSortIcon("createdAt")} +
{t("ui.dev.clients.table.actions", "액션")}