forked from baron/baron-sso
common 정렬 헬퍼 공통화 및 devfront 목록 정렬 추가
This commit is contained in:
@@ -21,6 +21,12 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
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 { RoleGuard } from "../../../components/auth/RoleGuard";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
@@ -82,10 +88,7 @@ import {
|
|||||||
const tenantCSVTemplate =
|
const tenantCSVTemplate =
|
||||||
"name,type,parent_tenant_slug,slug,memo,email_domain\n";
|
"name,type,parent_tenant_slug,slug,memo,email_domain\n";
|
||||||
|
|
||||||
type SortConfig = {
|
type TenantSortKey = keyof TenantSummary | "recursiveMemberCount";
|
||||||
key: keyof TenantSummary | "recursiveMemberCount";
|
|
||||||
direction: "asc" | "desc";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTenantIcon = (type?: string) => {
|
const getTenantIcon = (type?: string) => {
|
||||||
switch (type?.toUpperCase()) {
|
switch (type?.toUpperCase()) {
|
||||||
@@ -225,7 +228,8 @@ function TenantListPage() {
|
|||||||
const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list");
|
const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list");
|
||||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||||
const [search, setSearch] = React.useState("");
|
const [search, setSearch] = React.useState("");
|
||||||
const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null);
|
const [sortConfig, setSortConfig] =
|
||||||
|
React.useState<SortConfig<TenantSortKey> | null>(null);
|
||||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
const [importMessage, setImportMessage] = React.useState("");
|
const [importMessage, setImportMessage] = React.useState("");
|
||||||
const [previewRows, setPreviewRows] = React.useState<
|
const [previewRows, setPreviewRows] = React.useState<
|
||||||
@@ -363,6 +367,14 @@ function TenantListPage() {
|
|||||||
const allTenants = query.data?.items ?? [];
|
const allTenants = query.data?.items ?? [];
|
||||||
const importParentOptionGroups =
|
const importParentOptionGroups =
|
||||||
buildTenantImportParentOptionGroups(allTenants);
|
buildTenantImportParentOptionGroups(allTenants);
|
||||||
|
const tenantSortResolvers = React.useMemo<
|
||||||
|
SortResolverMap<TenantSummary & { recursiveMemberCount: number }, TenantSortKey>
|
||||||
|
>(
|
||||||
|
() => ({
|
||||||
|
recursiveMemberCount: (tenant) => tenant.recursiveMemberCount,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
const tenants = React.useMemo(() => {
|
const tenants = React.useMemo(() => {
|
||||||
// 1. Calculate recursive counts
|
// 1. Calculate recursive counts
|
||||||
// buildTenantFullTree returns subTree which represents roots, but it also mutates the mapped nodes internally.
|
// buildTenantFullTree returns subTree which represents roots, but it also mutates the mapped nodes internally.
|
||||||
@@ -396,38 +408,14 @@ function TenantListPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sortConfig) {
|
return sortItems(enriched, sortConfig, tenantSortResolvers);
|
||||||
enriched.sort((a, b) => {
|
}, [allTenants, search, sortConfig, tenantSortResolvers]);
|
||||||
const aValue = a[sortConfig.key as keyof typeof a];
|
|
||||||
const bValue = b[sortConfig.key as keyof typeof b];
|
|
||||||
|
|
||||||
if (aValue === bValue) return 0;
|
const requestSort = (key: TenantSortKey) => {
|
||||||
if (aValue === null || aValue === undefined) return 1;
|
setSortConfig((current) => toggleSort(current, key));
|
||||||
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 getSortIcon = (key: SortConfig["key"]) => {
|
const getSortIcon = (key: TenantSortKey) => {
|
||||||
if (!sortConfig || sortConfig.key !== key) {
|
if (!sortConfig || sortConfig.key !== key) {
|
||||||
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
|
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
import { toast } from "../../components/ui/use-toast";
|
import { toast } from "../../components/ui/use-toast";
|
||||||
|
import {
|
||||||
|
sortItems,
|
||||||
|
toggleSort,
|
||||||
|
type SortConfig,
|
||||||
|
type SortResolverMap,
|
||||||
|
} from "../../../../common/core/utils";
|
||||||
import {
|
import {
|
||||||
type UserSummary,
|
type UserSummary,
|
||||||
bulkDeleteUsers,
|
bulkDeleteUsers,
|
||||||
@@ -71,10 +77,7 @@ type UserSchemaField = {
|
|||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SortConfig = {
|
type UserSortKey = string;
|
||||||
key: string;
|
|
||||||
direction: "asc" | "desc";
|
|
||||||
};
|
|
||||||
|
|
||||||
function UserListPage() {
|
function UserListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -86,7 +89,9 @@ function UserListPage() {
|
|||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({});
|
>({});
|
||||||
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
||||||
const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null);
|
const [sortConfig, setSortConfig] = React.useState<SortConfig<UserSortKey> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const limit = 1000;
|
const limit = 1000;
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
@@ -219,60 +224,38 @@ function UserListPage() {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const rawItems = query.data?.items ?? [];
|
const rawItems = query.data?.items ?? [];
|
||||||
|
const userSortResolvers = React.useMemo<SortResolverMap<UserSummary, UserSortKey>>(
|
||||||
|
() =>
|
||||||
|
userSchema.reduce<SortResolverMap<UserSummary, UserSortKey>>(
|
||||||
|
(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 items = React.useMemo(() => {
|
||||||
const sorted = [...rawItems];
|
return sortItems(rawItems, sortConfig, userSortResolvers);
|
||||||
if (sortConfig) {
|
}, [rawItems, sortConfig, userSortResolvers]);
|
||||||
sorted.sort((a, b) => {
|
|
||||||
let aValue: string | number | boolean | null | undefined;
|
|
||||||
let bValue: string | number | boolean | null | undefined;
|
|
||||||
|
|
||||||
if (sortConfig.key === "name_email") {
|
const requestSort = (key: UserSortKey) => {
|
||||||
aValue = a.name?.toLowerCase() || "";
|
setSortConfig((current) => toggleSort(current, key));
|
||||||
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<string, unknown>)[sortConfig.key] as
|
|
||||||
| string
|
|
||||||
| number
|
|
||||||
| boolean;
|
|
||||||
bValue = (b as Record<string, unknown>)[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 getSortIcon = (key: SortConfig["key"]) => {
|
const getSortIcon = (key: UserSortKey) => {
|
||||||
if (!sortConfig || sortConfig.key !== key) {
|
if (!sortConfig || sortConfig.key !== key) {
|
||||||
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
|
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
|
||||||
}
|
}
|
||||||
|
|||||||
72
adminfront/src/lib/sort.test.ts
Normal file
72
adminfront/src/lib/sort.test.ts
Normal file
@@ -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<string>(null, "name")).toEqual({
|
||||||
|
key: "name",
|
||||||
|
direction: "asc",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
toggleSort<string>({ key: "name", direction: "asc" }, "name"),
|
||||||
|
).toEqual({
|
||||||
|
key: "name",
|
||||||
|
direction: "desc",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
toggleSort<string>({ 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,3 +4,5 @@ export function mergeClassNames(
|
|||||||
) {
|
) {
|
||||||
return mergeFn(...classNames);
|
return mergeFn(...classNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export * from "./sort";
|
||||||
|
|||||||
97
common/core/utils/sort.ts
Normal file
97
common/core/utils/sort.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
export type SortDirection = "asc" | "desc";
|
||||||
|
|
||||||
|
export type SortConfig<Key extends string = string> = {
|
||||||
|
key: Key;
|
||||||
|
direction: SortDirection;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SortableValue = string | number | boolean | Date | null | undefined;
|
||||||
|
|
||||||
|
export type SortResolver<T> = (item: T) => SortableValue;
|
||||||
|
|
||||||
|
export type SortResolverMap<T, Key extends string = string> = Partial<
|
||||||
|
Record<Key, SortResolver<T>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
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<Key extends string>(
|
||||||
|
current: SortConfig<Key> | null,
|
||||||
|
key: Key,
|
||||||
|
): SortConfig<Key> {
|
||||||
|
if (current?.key === key && current.direction === "asc") {
|
||||||
|
return { key, direction: "desc" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { key, direction: "asc" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortItems<T, Key extends string = string>(
|
||||||
|
items: T[],
|
||||||
|
sortConfig: SortConfig<Key> | null,
|
||||||
|
resolverMap: SortResolverMap<T, Key> = {},
|
||||||
|
) {
|
||||||
|
if (!sortConfig) {
|
||||||
|
return [...items];
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveValue =
|
||||||
|
resolverMap[sortConfig.key] ??
|
||||||
|
((item: T) =>
|
||||||
|
(item as Record<string, SortableValue>)[sortConfig.key] ?? null);
|
||||||
|
|
||||||
|
return [...items].sort((left, right) =>
|
||||||
|
compareNullableValues(
|
||||||
|
resolveValue(left),
|
||||||
|
resolveValue(right),
|
||||||
|
sortConfig.direction,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,24 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import { BookOpenText, Filter, Plus, Search, X } from "lucide-react";
|
import {
|
||||||
import { useEffect, useState } from "react";
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowUpDown,
|
||||||
|
BookOpenText,
|
||||||
|
Filter,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
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 { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -37,6 +52,7 @@ import {
|
|||||||
fetchDeveloperRequestStatus,
|
fetchDeveloperRequestStatus,
|
||||||
fetchMyTenants,
|
fetchMyTenants,
|
||||||
requestDeveloperAccess,
|
requestDeveloperAccess,
|
||||||
|
type ClientSummary,
|
||||||
} from "../../lib/devApi";
|
} from "../../lib/devApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { resolveProfileRole } from "../../lib/role";
|
import { resolveProfileRole } from "../../lib/role";
|
||||||
@@ -44,6 +60,8 @@ import { cn } from "../../lib/utils";
|
|||||||
import { fetchMe } from "../auth/authApi";
|
import { fetchMe } from "../auth/authApi";
|
||||||
import { ClientLogo } from "./components/ClientLogo";
|
import { ClientLogo } from "./components/ClientLogo";
|
||||||
|
|
||||||
|
type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt";
|
||||||
|
|
||||||
function ClientsPage() {
|
function ClientsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
@@ -104,19 +122,48 @@ function ClientsPage() {
|
|||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
||||||
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
|
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
|
||||||
|
const [sortConfig, setSortConfig] =
|
||||||
|
useState<SortConfig<ClientSortKey> | null>(null);
|
||||||
|
|
||||||
const clients = data?.items || [];
|
const clients = data?.items || [];
|
||||||
|
const clientSortResolvers = useMemo<
|
||||||
|
SortResolverMap<ClientSummary, ClientSortKey>
|
||||||
|
>(
|
||||||
|
() => ({
|
||||||
|
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 filteredClients = useMemo(() => {
|
||||||
const matchesSearch =
|
const nextClients = clients.filter((client) => {
|
||||||
!searchQuery ||
|
const matchesSearch =
|
||||||
client.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
!searchQuery ||
|
||||||
client.id.toLowerCase().includes(searchQuery.toLowerCase());
|
client.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
const matchesType = typeFilter === "all" || client.type === typeFilter;
|
client.id.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
const matchesStatus =
|
const matchesType = typeFilter === "all" || client.type === typeFilter;
|
||||||
statusFilter === "all" || client.status === statusFilter;
|
const matchesStatus =
|
||||||
return matchesSearch && matchesType && 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 totalClients = statsData?.total_clients ?? clients.length;
|
||||||
const activeSessions = statsData?.active_sessions ?? 0;
|
const activeSessions = statsData?.active_sessions ?? 0;
|
||||||
@@ -179,6 +226,22 @@ function ClientsPage() {
|
|||||||
|
|
||||||
const isLoading = isLoadingClients || isLoadingStats || isLoadingRequest;
|
const isLoading = isLoadingClients || isLoadingStats || isLoadingRequest;
|
||||||
|
|
||||||
|
const requestSort = (key: ClientSortKey) => {
|
||||||
|
setSortConfig((current) => toggleSort(current, key));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortIcon = (key: ClientSortKey) => {
|
||||||
|
if (!sortConfig || sortConfig.key !== key) {
|
||||||
|
return <ArrowUpDown className="ml-1 h-4 w-4 opacity-50" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortConfig.direction === "asc" ? (
|
||||||
|
<ArrowUp className="ml-1 h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="ml-1 h-4 w-4" />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (auth.isLoading || !hasAccessToken || isLoading) {
|
if (auth.isLoading || !hasAccessToken || isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
@@ -389,18 +452,50 @@ function ClientsPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>
|
<TableHead
|
||||||
{t("ui.dev.clients.table.application", "애플리케이션")}
|
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => requestSort("application")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{t("ui.dev.clients.table.application", "애플리케이션")}
|
||||||
|
{getSortIcon("application")}
|
||||||
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead
|
||||||
{t("ui.dev.clients.table.client_id", "Client ID")}
|
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => requestSort("id")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{t("ui.dev.clients.table.client_id", "Client ID")}
|
||||||
|
{getSortIcon("id")}
|
||||||
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>{t("ui.dev.clients.table.type", "유형")}</TableHead>
|
<TableHead
|
||||||
<TableHead>
|
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
{t("ui.dev.clients.table.status", "상태")}
|
onClick={() => requestSort("type")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{t("ui.dev.clients.table.type", "유형")}
|
||||||
|
{getSortIcon("type")}
|
||||||
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead
|
||||||
{t("ui.dev.clients.table.created_at", "생성일")}
|
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => requestSort("status")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{t("ui.dev.clients.table.status", "상태")}
|
||||||
|
{getSortIcon("status")}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => requestSort("createdAt")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{t("ui.dev.clients.table.created_at", "생성일")}
|
||||||
|
{getSortIcon("createdAt")}
|
||||||
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right">
|
<TableHead className="text-right">
|
||||||
{t("ui.dev.clients.table.actions", "액션")}
|
{t("ui.dev.clients.table.actions", "액션")}
|
||||||
|
|||||||
Reference in New Issue
Block a user