1
0
forked from baron/baron-sso

worksmobile 관리화면 보완.

This commit is contained in:
2026-05-06 10:37:34 +09:00
parent 3169dd958a
commit 6cdd0fd81e
10 changed files with 943 additions and 219 deletions

View File

@@ -534,7 +534,7 @@ function AppLayout() {
</nav>
</aside>
<div className="relative">
<div className="relative min-w-0">
<header className="sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:px-8">
<div className="flex flex-col gap-1">
@@ -730,7 +730,7 @@ function AppLayout() {
</div>
</div>
</header>
<main className="px-5 py-6 md:px-10 md:py-10">
<main className="min-w-0 px-5 py-6 md:px-10 md:py-10">
<Outlet />
</main>
<RoleSwitcher />

View File

@@ -483,10 +483,10 @@ function TenantListPage() {
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<Table className="min-w-[1180px]">
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead className="w-[40px]">
<TableHead className="w-[48px] whitespace-nowrap">
<Checkbox
checked={
tenants.length > 0 &&
@@ -498,28 +498,28 @@ function TenantListPage() {
}
/>
</TableHead>
<TableHead className="min-w-[220px]">
<TableHead className="w-[280px] whitespace-nowrap">
{t("ui.admin.tenants.table.id", "ID")}
</TableHead>
<TableHead>
<TableHead className="w-[220px] whitespace-nowrap">
{t("ui.admin.tenants.table.name", "NAME")}
</TableHead>
<TableHead>
<TableHead className="w-[140px] whitespace-nowrap">
{t("ui.admin.tenants.table.type", "TYPE")}
</TableHead>
<TableHead>
<TableHead className="w-[180px] whitespace-nowrap">
{t("ui.admin.tenants.table.slug", "SLUG")}
</TableHead>
<TableHead>
<TableHead className="w-[120px] whitespace-nowrap">
{t("ui.admin.tenants.table.status", "STATUS")}
</TableHead>
<TableHead>
<TableHead className="w-[120px] whitespace-nowrap">
{t("ui.admin.tenants.table.members", "MEMBERS")}
</TableHead>
<TableHead>
<TableHead className="w-[180px] whitespace-nowrap">
{t("ui.admin.tenants.table.updated", "UPDATED")}
</TableHead>
<TableHead className="text-right">
<TableHead className="w-[160px] whitespace-nowrap text-right">
{t("ui.admin.tenants.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
@@ -575,21 +575,18 @@ function TenantListPage() {
)}
</div>
</TableCell>
<TableCell>
<TableCell className="whitespace-nowrap">
<Badge
variant="outline"
className="text-[10px] font-mono"
>
{t(
`domain.tenant_type.${tenant.type?.toLowerCase()}`,
tenant.type,
)}
{tenant.type}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{tenant.slug}
</TableCell>
<TableCell>
<TableCell className="whitespace-nowrap">
<Badge
variant={
tenant.status === "active"
@@ -605,15 +602,15 @@ function TenantListPage() {
)}
</Badge>
</TableCell>
<TableCell className="font-medium">
<TableCell className="whitespace-nowrap font-medium">
{tenant.memberCount}
</TableCell>
<TableCell className="text-xs">
<TableCell className="whitespace-nowrap text-xs">
{tenant.updatedAt
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
: "-"}
</TableCell>
<TableCell className="text-right">
<TableCell className="whitespace-nowrap text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"

View File

@@ -1,10 +1,17 @@
import { describe, expect, it } from "vitest";
import {
buildWorksmobilePasswordManageUrl,
canOpenWorksmobilePasswordManage,
canCreateWorksmobileRow,
canSelectWorksmobileRow,
filterWorksmobileComparisonRows,
formatWorksmobileOrgDetails,
formatWorksmobilePersonName,
getDefaultWorksmobileComparisonColumns,
getWorksmobileRowSelectionKey,
getWorksmobileSelectedActionIds,
getWorksmobileComparisonStatusLabel,
isImmutableWorksmobileAccount,
summarizeWorksmobileComparison,
userFilterOptions,
} from "./TenantWorksmobilePage";
@@ -69,6 +76,121 @@ describe("TenantWorksmobilePage comparison helpers", () => {
).toBe(false);
});
it("allows selection for Baron-only, WORKS-only, and matched rows", () => {
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_worksmobile",
baronId: "user-1",
}),
).toBe(true);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_baron",
worksmobileId: "works-user-1",
}),
).toBe(true);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "matched",
baronId: "user-2",
worksmobileId: "works-user-2",
}),
).toBe(true);
});
it("does not allow selection for immutable WORKS accounts", () => {
expect(
isImmutableWorksmobileAccount({
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "cyhan@samaneng.com",
worksmobileId: "works-cyhan",
}),
).toBe(true);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "CYHAN1@HANMACENG.CO.KR",
worksmobileId: "works-cyhan1",
}),
).toBe(false);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "normal@samaneng.com",
worksmobileId: "works-normal",
}),
).toBe(true);
});
it("does not allow password management for immutable WORKS accounts", () => {
expect(
canOpenWorksmobilePasswordManage(
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "su-@samaneng.com",
worksmobileDomainId: 300285955,
worksmobileId: "works-su",
},
"works-tenant-1",
),
).toBe(false);
});
it("keeps row selection keys separate from Baron action ids", () => {
const rows = [
{
resourceType: "USER",
status: "missing_in_worksmobile",
baronId: "baron-only",
},
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileId: "works-only",
},
{
resourceType: "USER",
status: "matched",
baronId: "matched-baron",
worksmobileId: "matched-works",
},
];
const selectedKeys = rows.map(getWorksmobileRowSelectionKey);
expect(selectedKeys).toEqual([
"USER:baron:baron-only",
"USER:works:works-only",
"USER:baron:matched-baron",
]);
expect(getWorksmobileSelectedActionIds(rows, selectedKeys)).toEqual([
"baron-only",
"matched-baron",
]);
});
it("uses compact comparison columns by default", () => {
expect(getDefaultWorksmobileComparisonColumns()).toEqual({
status: true,
baronId: false,
baron: true,
baronOrg: true,
worksmobileId: false,
externalKey: false,
worksmobileDomain: true,
worksmobile: true,
worksmobileOrg: true,
manage: true,
});
});
it("filters user comparison rows by selected relationship", () => {
const rows = [
{
@@ -149,4 +271,77 @@ describe("TenantWorksmobilePage comparison helpers", () => {
}),
).toEqual(["직책 팀장", "직무 기술검토", "조직장"]);
});
it("builds the WORKS admin password management URL from remote user identifiers", () => {
const url = buildWorksmobilePasswordManageUrl({
tenantId: " works-tenant-1 ",
domainId: 300285955,
userIdNo: " works-user-1 ",
});
const parsed = new URL(url);
expect(parsed.origin + parsed.pathname).toBe(
"https://auth.worksmobile.com/integrate/password/manage",
);
expect(parsed.searchParams.get("usage")).toBe("admin");
expect(parsed.searchParams.get("targetUserTenantId")).toBe(
"works-tenant-1",
);
expect(parsed.searchParams.get("targetUserDomainId")).toBe("300285955");
expect(parsed.searchParams.get("targetUserIdNo")).toBe("works-user-1");
expect(parsed.searchParams.get("accessUrl")).toBe(
"https://admin.worksmobile.com/assets/self-close.html",
);
});
it("does not open WORKS password management without required identifiers", () => {
const row = {
resourceType: "USER",
status: "matched",
worksmobileDomainId: 300285955,
worksmobileId: "works-user-1",
};
expect(canOpenWorksmobilePasswordManage(row, "works-tenant-1")).toBe(true);
expect(canOpenWorksmobilePasswordManage(row, undefined)).toBe(false);
expect(
canOpenWorksmobilePasswordManage(
{ ...row, worksmobileDomainId: undefined },
"works-tenant-1",
),
).toBe(false);
expect(
canOpenWorksmobilePasswordManage(
{ ...row, worksmobileId: undefined },
"works-tenant-1",
),
).toBe(false);
expect(
canOpenWorksmobilePasswordManage(
{ ...row, resourceType: "GROUP" },
"works-tenant-1",
),
).toBe(false);
expect(
buildWorksmobilePasswordManageUrl({
tenantId: "works-tenant-1",
domainId: 0,
userIdNo: "works-user-1",
}),
).toBe("");
});
it("allows WORKS password management for WORKS-only user rows", () => {
expect(
canOpenWorksmobilePasswordManage(
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileDomainId: 300285955,
worksmobileId: "works-user-1",
},
"works-tenant-1",
),
).toBe(true);
});
});

View File

@@ -1,5 +1,11 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Download, RefreshCw, RotateCcw } from "lucide-react";
import {
Download,
KeyRound,
RefreshCw,
RotateCcw,
Settings2,
} from "lucide-react";
import * as React from "react";
import { useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
@@ -12,6 +18,15 @@ import {
CardTitle,
} from "../../../components/ui/card";
import { Checkbox } from "../../../components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import {
Table,
@@ -47,8 +62,18 @@ export function TenantWorksmobilePage() {
const [userFilters, setUserFilters] = React.useState<
WorksmobileComparisonFilter[]
>(["baron_only", "works_only"]);
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
const [selectedGroupIds, setSelectedGroupIds] = React.useState<string[]>([]);
const [selectedUserRowKeys, setSelectedUserRowKeys] = React.useState<
string[]
>([]);
const [selectedGroupRowKeys, setSelectedGroupRowKeys] = React.useState<
string[]
>([]);
const [userVisibleColumns, setUserVisibleColumns] = React.useState(
getDefaultWorksmobileComparisonColumns,
);
const [groupVisibleColumns, setGroupVisibleColumns] = React.useState(
getDefaultWorksmobileComparisonColumns,
);
const overviewQuery = useQuery({
queryKey: ["worksmobile", tenantId],
@@ -152,9 +177,9 @@ export function TenantWorksmobilePage() {
},
onSuccess: ({ resourceKind, count }) => {
if (resourceKind === "users") {
setSelectedUserIds([]);
setSelectedUserRowKeys([]);
} else {
setSelectedGroupIds([]);
setSelectedGroupRowKeys([]);
}
toast.success("WORKS 생성 작업을 등록했습니다.", {
description: `${count}`,
@@ -198,7 +223,7 @@ export function TenantWorksmobilePage() {
const isRefreshing = overviewQuery.isFetching || comparisonQuery.isFetching;
return (
<div className="space-y-6">
<div className="min-w-0 max-w-full space-y-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-xl font-semibold">
@@ -247,7 +272,7 @@ export function TenantWorksmobilePage() {
</div>
</header>
<Card>
<Card className="min-w-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between gap-3">
<div>
<CardTitle className="text-base">
@@ -261,7 +286,7 @@ export function TenantWorksmobilePage() {
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="min-w-0 space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<ComparisonSummary
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
@@ -275,41 +300,26 @@ export function TenantWorksmobilePage() {
summary={groupSummary}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{userFilterOptions.map((option) => (
<Button
key={option.value}
type="button"
size="sm"
variant={
userFilters.includes(option.value) ? "default" : "outline"
}
aria-pressed={userFilters.includes(option.value)}
onClick={() => {
setUserFilters((current) =>
current.includes(option.value)
? current.filter((value) => value !== option.value)
: [...current, option.value],
);
setSelectedUserIds([]);
}}
>
{option.label}
</Button>
))}
</div>
<ComparisonTable
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
rows={filteredComparisonUsers}
loading={comparisonQuery.isLoading}
selectedIds={selectedUserIds}
onSelectedIdsChange={setSelectedUserIds}
selectedKeys={selectedUserRowKeys}
onSelectedKeysChange={setSelectedUserRowKeys}
filters={userFilters}
onFiltersChange={(nextFilters) => {
setUserFilters(nextFilters);
setSelectedUserRowKeys([]);
}}
visibleColumns={userVisibleColumns}
onVisibleColumnsChange={setUserVisibleColumns}
passwordManageTenantId={overview?.config.adminTenantId}
actionLabel="선택 구성원 WORKS에 생성"
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
onCreateSelected={() =>
onCreateSelected={(ids) =>
createSelectedMutation.mutate({
resourceKind: "users",
ids: selectedUserIds,
ids,
})
}
/>
@@ -320,16 +330,21 @@ export function TenantWorksmobilePage() {
)}
rows={comparisonGroups}
loading={comparisonQuery.isLoading}
selectedIds={selectedGroupIds}
onSelectedIdsChange={setSelectedGroupIds}
selectedKeys={selectedGroupRowKeys}
onSelectedKeysChange={setSelectedGroupRowKeys}
filters={undefined}
onFiltersChange={undefined}
visibleColumns={groupVisibleColumns}
onVisibleColumnsChange={setGroupVisibleColumns}
passwordManageTenantId={undefined}
actionLabel="선택 조직 WORKS에 생성"
actionDisabled={
isCreatingGroups || createSelectedMutation.isPending
}
onCreateSelected={() =>
onCreateSelected={(ids) =>
createSelectedMutation.mutate({
resourceKind: "groups",
ids: selectedGroupIds,
ids,
})
}
/>
@@ -435,6 +450,54 @@ export type WorksmobileComparisonSummary = {
missingExternalKey: number;
};
export type WorksmobileComparisonColumnKey =
| "status"
| "baronId"
| "baron"
| "baronOrg"
| "worksmobileId"
| "externalKey"
| "worksmobileDomain"
| "worksmobile"
| "worksmobileOrg"
| "manage";
export type WorksmobileComparisonColumnVisibility = Record<
WorksmobileComparisonColumnKey,
boolean
>;
const worksmobileComparisonColumnOptions: Array<{
key: WorksmobileComparisonColumnKey;
label: string;
}> = [
{ key: "status", label: "상태" },
{ key: "baronId", label: "Baron ID" },
{ key: "baron", label: "Baron" },
{ key: "baronOrg", label: "Baron 조직" },
{ key: "worksmobileId", label: "WORKS ID" },
{ key: "externalKey", label: "external_key" },
{ key: "worksmobileDomain", label: "WORKS 도메인" },
{ key: "worksmobile", label: "WORKS" },
{ key: "worksmobileOrg", label: "WORKS 조직" },
{ key: "manage", label: "관리" },
];
export function getDefaultWorksmobileComparisonColumns(): WorksmobileComparisonColumnVisibility {
return {
status: true,
baronId: false,
baron: true,
baronOrg: true,
worksmobileId: false,
externalKey: false,
worksmobileDomain: true,
worksmobile: true,
worksmobileOrg: true,
manage: true,
};
}
export function summarizeWorksmobileComparison(
rows: WorksmobileComparisonItem[],
): WorksmobileComparisonSummary {
@@ -480,6 +543,54 @@ export function canCreateWorksmobileRow(row: WorksmobileComparisonItem) {
return row.status === "missing_in_worksmobile" && Boolean(row.baronId);
}
const immutableWorksmobileAccountEmails = new Set([
"cyhan@samaneng.com",
"cyhan1@hanmaceng.co.kr",
"cyhan2@baroncs.co.kr",
"cyhan3@brsw.kr",
"su-@samaneng.com",
]);
export function isImmutableWorksmobileAccount(row: WorksmobileComparisonItem) {
return (
row.resourceType === "USER" &&
immutableWorksmobileAccountEmails.has(
row.worksmobileEmail?.trim().toLowerCase() ?? "",
)
);
}
export function getWorksmobileRowSelectionKey(row: WorksmobileComparisonItem) {
if (row.baronId) {
return `${row.resourceType}:baron:${row.baronId}`;
}
if (row.worksmobileId) {
return `${row.resourceType}:works:${row.worksmobileId}`;
}
if (row.externalKey) {
return `${row.resourceType}:external:${row.externalKey}`;
}
return "";
}
export function canSelectWorksmobileRow(row: WorksmobileComparisonItem) {
return (
Boolean(getWorksmobileRowSelectionKey(row)) &&
!isImmutableWorksmobileAccount(row)
);
}
export function getWorksmobileSelectedActionIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
) {
const selected = new Set(selectedKeys);
return rows
.filter((row) => selected.has(getWorksmobileRowSelectionKey(row)))
.map((row) => row.baronId)
.filter((id): id is string => Boolean(id));
}
export function filterWorksmobileComparisonRows(
rows: WorksmobileComparisonItem[],
filters: WorksmobileComparisonFilter[],
@@ -519,13 +630,56 @@ export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
return details;
}
export function buildWorksmobilePasswordManageUrl({
tenantId,
domainId,
userIdNo,
}: {
tenantId?: string;
domainId?: number;
userIdNo?: string;
}) {
const normalizedTenantId = tenantId?.trim();
const normalizedUserIdNo = userIdNo?.trim();
if (!normalizedTenantId || !domainId || domainId <= 0 || !normalizedUserIdNo) {
return "";
}
const url = new URL("https://auth.worksmobile.com/integrate/password/manage");
url.searchParams.set("usage", "admin");
url.searchParams.set("targetUserTenantId", normalizedTenantId);
url.searchParams.set("targetUserDomainId", String(domainId));
url.searchParams.set("targetUserIdNo", normalizedUserIdNo);
url.searchParams.set(
"accessUrl",
"https://admin.worksmobile.com/assets/self-close.html",
);
return url.toString();
}
export function canOpenWorksmobilePasswordManage(
row: WorksmobileComparisonItem,
tenantId?: string,
) {
return (
row.resourceType === "USER" &&
!isImmutableWorksmobileAccount(row) &&
Boolean(
buildWorksmobilePasswordManageUrl({
tenantId,
domainId: row.worksmobileDomainId,
userIdNo: row.worksmobileId,
}),
)
);
}
export const userFilterOptions: Array<{
value: WorksmobileComparisonFilter;
label: string;
}> = [
{ value: "baron_only", label: "Baron에만 있음" },
{ value: "works_only", label: "WORKS에만 있음" },
{ value: "matched", label: "양쪽 다 있음" },
{ value: "baron_only", label: "바론에만 있음" },
{ value: "works_only", label: "웍스에만 있음" },
{ value: "matched", label: "양쪽 다 있음" },
];
const worksmobileFilterStatuses: Record<WorksmobileComparisonFilter, string[]> =
@@ -605,8 +759,13 @@ function ComparisonTable({
title,
rows,
loading,
selectedIds,
onSelectedIdsChange,
selectedKeys,
onSelectedKeysChange,
filters,
onFiltersChange,
visibleColumns,
onVisibleColumnsChange,
passwordManageTenantId,
actionLabel,
actionDisabled,
onCreateSelected,
@@ -614,101 +773,238 @@ function ComparisonTable({
title: string;
rows: WorksmobileComparisonItem[];
loading: boolean;
selectedIds: string[];
onSelectedIdsChange: (ids: string[]) => void;
selectedKeys: string[];
onSelectedKeysChange: (ids: string[]) => void;
filters?: WorksmobileComparisonFilter[];
onFiltersChange?: (filters: WorksmobileComparisonFilter[]) => void;
visibleColumns: WorksmobileComparisonColumnVisibility;
onVisibleColumnsChange: React.Dispatch<
React.SetStateAction<WorksmobileComparisonColumnVisibility>
>;
passwordManageTenantId?: string;
actionLabel: string;
actionDisabled: boolean;
onCreateSelected: () => void;
onCreateSelected: (ids: string[]) => void;
}) {
const creatableIds = rows
.filter(canCreateWorksmobileRow)
.map((row) => row.baronId)
.filter((id): id is string => Boolean(id));
const allCreatableSelected =
creatableIds.length > 0 &&
creatableIds.every((id) => selectedIds.includes(id));
const selectableKeys = rows
.filter(canSelectWorksmobileRow)
.map(getWorksmobileRowSelectionKey)
.filter(Boolean);
const selectedActionIds = getWorksmobileSelectedActionIds(
rows,
selectedKeys,
);
const allSelectableSelected =
selectableKeys.length > 0 &&
selectableKeys.every((key) => selectedKeys.includes(key));
const visibleColumnCount = worksmobileComparisonColumnOptions.filter(
(column) => visibleColumns[column.key] !== false,
).length;
const tableColSpan = visibleColumnCount + 1;
const toggleAll = (checked: boolean | "indeterminate") => {
onSelectedIdsChange(checked === true ? creatableIds : []);
onSelectedKeysChange(checked === true ? selectableKeys : []);
};
const toggleRow = (
id: string | undefined,
row: WorksmobileComparisonItem,
checked: boolean | "indeterminate",
) => {
if (!id) {
const key = getWorksmobileRowSelectionKey(row);
if (!key) {
return;
}
if (checked === true) {
onSelectedIdsChange([...new Set([...selectedIds, id])]);
onSelectedKeysChange([...new Set([...selectedKeys, key])]);
return;
}
onSelectedIdsChange(selectedIds.filter((selectedId) => selectedId !== id));
onSelectedKeysChange(
selectedKeys.filter((selectedKey) => selectedKey !== key),
);
};
const openPasswordManage = (row: WorksmobileComparisonItem) => {
const url = buildWorksmobilePasswordManageUrl({
tenantId: passwordManageTenantId,
domainId: row.worksmobileDomainId,
userIdNo: row.worksmobileId,
});
if (!url) return;
window.open(url, "_blank", "noopener,noreferrer");
};
const toggleColumn = (key: WorksmobileComparisonColumnKey) => {
onVisibleColumnsChange((current) => ({
...current,
[key]: current[key] === false,
}));
};
const isColumnVisible = (key: WorksmobileComparisonColumnKey) =>
visibleColumns[key] !== false;
const toggleFilter = (filter: WorksmobileComparisonFilter) => {
if (!filters || !onFiltersChange) {
return;
}
onFiltersChange(
filters.includes(filter)
? filters.filter((value) => value !== filter)
: [...filters, filter],
);
};
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="text-sm font-medium">{title}</h4>
<Button
type="button"
size="sm"
onClick={onCreateSelected}
disabled={selectedIds.length === 0 || actionDisabled}
>
{actionLabel}
</Button>
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-3">
<h4 className="text-lg font-semibold leading-none">{title}</h4>
{filters && onFiltersChange && (
<div className="flex flex-wrap items-center gap-2">
{userFilterOptions.map((option) => (
<Button
key={option.value}
type="button"
size="sm"
variant={
filters.includes(option.value) ? "default" : "outline"
}
aria-pressed={filters.includes(option.value)}
onClick={() => toggleFilter(option.value)}
>
{option.label}
</Button>
))}
</div>
)}
</div>
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<Settings2 size={16} />
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{title} </DialogTitle>
<DialogDescription>
.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-2">
{worksmobileComparisonColumnOptions.map((column) => (
<label
key={column.key}
className="flex cursor-pointer items-center gap-3 rounded-md p-2 hover:bg-muted/50"
>
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
checked={isColumnVisible(column.key)}
onChange={() => toggleColumn(column.key)}
/>
<span className="text-sm font-medium">{column.label}</span>
</label>
))}
</div>
<DialogFooter>
<DialogTrigger asChild>
<Button type="button" variant="secondary">
</Button>
</DialogTrigger>
</DialogFooter>
</DialogContent>
</Dialog>
<Button
type="button"
size="sm"
onClick={() => onCreateSelected(selectedActionIds)}
disabled={selectedActionIds.length === 0 || actionDisabled}
>
{actionLabel}
</Button>
</div>
</div>
<div className="overflow-x-auto">
<Table>
<div className="w-full max-w-full overflow-x-auto rounded-md border">
<Table className="min-w-max">
<TableHeader>
<TableRow>
<TableHead className="w-10 whitespace-nowrap">
<Checkbox
aria-label={`${title} 전체 선택`}
checked={allCreatableSelected}
disabled={creatableIds.length === 0}
checked={allSelectableSelected}
disabled={selectableKeys.length === 0}
onCheckedChange={toggleAll}
/>
</TableHead>
<TableHead className="w-24 whitespace-nowrap"></TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
Baron ID
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
Baron
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
Baron
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
WORKS ID
</TableHead>
<TableHead className="min-w-40 whitespace-nowrap">
external_key
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
WORKS
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
WORKS
</TableHead>
<TableHead className="min-w-52 whitespace-nowrap">
WORKS
</TableHead>
{isColumnVisible("status") && (
<TableHead className="w-24 whitespace-nowrap"></TableHead>
)}
{isColumnVisible("baronId") && (
<TableHead className="min-w-44 whitespace-nowrap">
Baron ID
</TableHead>
)}
{isColumnVisible("baron") && (
<TableHead className="min-w-44 whitespace-nowrap">
Baron
</TableHead>
)}
{isColumnVisible("baronOrg") && (
<TableHead className="min-w-44 whitespace-nowrap">
Baron
</TableHead>
)}
{isColumnVisible("worksmobileId") && (
<TableHead className="min-w-44 whitespace-nowrap">
WORKS ID
</TableHead>
)}
{isColumnVisible("externalKey") && (
<TableHead className="min-w-40 whitespace-nowrap">
external_key
</TableHead>
)}
{isColumnVisible("worksmobileDomain") && (
<TableHead className="min-w-44 whitespace-nowrap">
WORKS
</TableHead>
)}
{isColumnVisible("worksmobile") && (
<TableHead className="min-w-44 whitespace-nowrap">
WORKS
</TableHead>
)}
{isColumnVisible("worksmobileOrg") && (
<TableHead className="min-w-52 whitespace-nowrap">
WORKS
</TableHead>
)}
{isColumnVisible("manage") && (
<TableHead className="w-14 whitespace-nowrap"></TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{loading && (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground">
<TableCell
colSpan={tableColSpan}
className="text-muted-foreground"
>
...
</TableCell>
</TableRow>
)}
{!loading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground">
<TableCell
colSpan={tableColSpan}
className="text-muted-foreground"
>
.
</TableCell>
</TableRow>
@@ -720,87 +1016,126 @@ function ComparisonTable({
<TableCell className="whitespace-nowrap">
<Checkbox
aria-label={`${row.baronName ?? row.baronId ?? row.worksmobileName ?? row.worksmobileId ?? "row"} 선택`}
checked={Boolean(
row.baronId && selectedIds.includes(row.baronId),
checked={selectedKeys.includes(
getWorksmobileRowSelectionKey(row),
)}
disabled={!canCreateWorksmobileRow(row)}
onCheckedChange={(checked) =>
toggleRow(row.baronId, checked)
}
disabled={!canSelectWorksmobileRow(row)}
onCheckedChange={(checked) => toggleRow(row, checked)}
/>
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge
className="whitespace-nowrap"
variant={getWorksmobileComparisonStatusVariant(row.status)}
>
{getWorksmobileComparisonStatusLabel(row.status)}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{row.baronId ?? "-"}
</TableCell>
<TableCell>
<div className="space-y-1">
<div>{row.baronName ?? "-"}</div>
<div className="text-xs text-muted-foreground">
{row.baronEmail ?? ""}
{isColumnVisible("status") && (
<TableCell className="whitespace-nowrap">
<Badge
className="whitespace-nowrap"
variant={getWorksmobileComparisonStatusVariant(
row.status,
)}
>
{getWorksmobileComparisonStatusLabel(row.status)}
</Badge>
</TableCell>
)}
{isColumnVisible("baronId") && (
<TableCell className="font-mono text-xs">
{row.baronId ?? "-"}
</TableCell>
)}
{isColumnVisible("baron") && (
<TableCell>
<div className="space-y-1">
<div>{row.baronName ?? "-"}</div>
<div className="text-xs text-muted-foreground">
{row.baronEmail ?? ""}
</div>
</div>
</div>
</TableCell>
<TableCell>
<ComparisonOrgCell
name={
row.resourceType === "GROUP"
? row.baronParentName
: row.baronPrimaryOrgName
}
id={
row.resourceType === "GROUP"
? row.baronParentId
: row.baronPrimaryOrgId
}
/>
</TableCell>
<TableCell className="font-mono text-xs">
{row.worksmobileId ?? "-"}
</TableCell>
<TableCell className="font-mono text-xs">
{row.externalKey ?? "-"}
</TableCell>
<TableCell>
<ComparisonDomainCell
name={row.worksmobileDomainName}
id={row.worksmobileDomainId}
/>
</TableCell>
<TableCell>
<div className="space-y-1">
<div>{formatWorksmobilePersonName(row) || "-"}</div>
<div className="text-xs text-muted-foreground">
{row.worksmobileEmail ?? ""}
</TableCell>
)}
{isColumnVisible("baronOrg") && (
<TableCell>
<ComparisonOrgCell
name={
row.resourceType === "GROUP"
? row.baronParentName
: row.baronPrimaryOrgName
}
id={
row.resourceType === "GROUP"
? row.baronParentId
: row.baronPrimaryOrgId
}
/>
</TableCell>
)}
{isColumnVisible("worksmobileId") && (
<TableCell className="font-mono text-xs">
{row.worksmobileId ?? "-"}
</TableCell>
)}
{isColumnVisible("externalKey") && (
<TableCell className="font-mono text-xs">
{row.externalKey ?? "-"}
</TableCell>
)}
{isColumnVisible("worksmobileDomain") && (
<TableCell>
<ComparisonDomainCell
name={row.worksmobileDomainName}
id={row.worksmobileDomainId}
/>
</TableCell>
)}
{isColumnVisible("worksmobile") && (
<TableCell>
<div className="space-y-1">
<div>{formatWorksmobilePersonName(row) || "-"}</div>
<div className="text-xs text-muted-foreground">
{row.worksmobileEmail ?? ""}
</div>
</div>
</div>
</TableCell>
<TableCell>
<ComparisonOrgCell
name={
row.resourceType === "GROUP"
? row.worksmobileParentName
: row.worksmobilePrimaryOrgName
}
id={
row.resourceType === "GROUP"
? row.worksmobileParentId
: row.worksmobilePrimaryOrgId
}
details={
row.resourceType === "GROUP"
? []
: formatWorksmobileOrgDetails(row)
}
/>
</TableCell>
</TableCell>
)}
{isColumnVisible("worksmobileOrg") && (
<TableCell>
<ComparisonOrgCell
name={
row.resourceType === "GROUP"
? row.worksmobileParentName
: row.worksmobilePrimaryOrgName
}
id={
row.resourceType === "GROUP"
? row.worksmobileParentId
: row.worksmobilePrimaryOrgId
}
details={
row.resourceType === "GROUP"
? []
: formatWorksmobileOrgDetails(row)
}
/>
</TableCell>
)}
{isColumnVisible("manage") && (
<TableCell className="whitespace-nowrap">
{row.resourceType === "USER" && (
<Button
type="button"
variant="ghost"
size="sm"
aria-label={`${row.worksmobileName ?? row.baronName ?? row.worksmobileId ?? "WORKS user"} 비밀번호 관리`}
disabled={
!canOpenWorksmobilePasswordManage(
row,
passwordManageTenantId,
)
}
onClick={() => openPasswordManage(row)}
>
<KeyRound size={16} />
</Button>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>

View File

@@ -508,7 +508,9 @@ export type WorksmobileOverview = {
tenant: TenantSummary;
config: {
enabled: boolean;
domainMappings?: Record<string, number>;
tokenConfigured: boolean;
adminTenantId?: string;
};
recentJobs: WorksmobileOutboxItem[];
};