1
0
forked from baron/baron-sso

chore: consolidate local integration changes

This commit is contained in:
2026-06-09 21:03:05 +09:00
parent aa2848c3b6
commit 1341f07ef9
158 changed files with 10995 additions and 1490 deletions

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
filterTenantsByScope,
getTenantSearchMatchIds,
getTenantViewRows,
resolveTenantSelectionIds,
tenantMatchesListSearch,
@@ -69,6 +70,7 @@ describe("TenantListPage tenant list helpers", () => {
expect(tenantMatchesListSearch(tenants[2], "team-1")).toBe(true);
expect(tenantMatchesListSearch(tenants[2], "platform")).toBe(true);
expect(tenantMatchesListSearch(tenants[2], "플랫폼")).toBe(true);
expect(tenantMatchesListSearch(tenants[2], "삼안")).toBe(false);
});
it("can return tree rows or same-level table rows", () => {
@@ -79,4 +81,20 @@ describe("TenantListPage tenant list helpers", () => {
[0, 0, 0, 0],
);
});
it("marks only direct search matches when tree search includes ancestors", () => {
const treeRows = getTenantViewRows(
tenants.filter((item) => item.id !== "company-2"),
"tree",
"",
true,
);
expect(treeRows.map((row) => row.id)).toEqual([
"company-1",
"dept-1",
"team-1",
]);
expect(getTenantSearchMatchIds(treeRows, "platform")).toEqual(["team-1"]);
});
});

View File

@@ -107,6 +107,7 @@ import {
} from "../utils/tenantCsvImport";
import {
filterTenantsByScope,
getTenantSearchMatchIds,
getTenantViewRows,
resolveTenantSelectionIds,
type TenantViewMode,
@@ -842,6 +843,7 @@ function TenantListPage() {
<RoleGuard roles={["super_admin"]}>
<input
ref={fileInputRef}
name="tenant-import-file"
type="file"
accept=".csv,text/csv"
className="hidden"
@@ -1368,6 +1370,8 @@ function TenantListPage() {
</TableCell>
<TableCell>
<select
id={`tenant-import-parent-select-${preview.row.rowNumber}`}
name={`tenant-import-parent-select-${preview.row.rowNumber}`}
className="h-9 w-full min-w-[220px] rounded-md border border-input bg-background px-3 text-sm"
value={
selectedParentRefs[preview.row.rowNumber] ??
@@ -1457,6 +1461,8 @@ function TenantListPage() {
<TableCell>
<div className="space-y-2">
<select
id={`tenant-import-match-select-${preview.row.rowNumber}`}
name={`tenant-import-match-select-${preview.row.rowNumber}`}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={
selectedMatches[preview.row.rowNumber] ??
@@ -1705,6 +1711,10 @@ const TenantHierarchyView: React.FC<{
const virtualRows = rowVirtualizer.getVirtualItems();
const shouldVirtualizeRows = !(isTest && flattenedRows.length < 100);
const searchMatchIds = React.useMemo(
() => new Set(getTenantSearchMatchIds(flattenedRows, search)),
[flattenedRows, search],
);
React.useEffect(() => {
if (isTest) return;
@@ -1757,6 +1767,7 @@ const TenantHierarchyView: React.FC<{
virtualRow?: { start: number; end: number },
) => {
const isSelected = selectedIds.includes(node.id);
const isSearchMatch = searchMatchIds.has(node.id);
const hasChildren =
viewMode === "tree" && node.children && node.children.length > 0;
const isExpanded =
@@ -1770,6 +1781,9 @@ const TenantHierarchyView: React.FC<{
ref={virtualRow ? rowVirtualizer.measureElement : undefined}
className={cn(
isSelected ? "bg-primary/5" : "",
isSearchMatch
? "bg-amber-50/80 ring-1 ring-inset ring-amber-300"
: "",
"h-[73px]",
virtualRow ? "absolute left-0 w-full" : "",
)}
@@ -1851,6 +1865,15 @@ const TenantHierarchyView: React.FC<{
{t("ui.admin.tenants.seed_badge", "초기 설정")}
</Badge>
)}
{isSearchMatch && (
<Badge
variant="outline"
className="flex-shrink-0 border-amber-300 bg-amber-100 text-[10px] font-semibold text-amber-900"
data-testid={`tenant-search-match-${node.id}`}
>
{t("ui.admin.tenants.search_match_badge", "검색 일치")}
</Badge>
)}
</div>
{(() => {
const parentPath = tenantParentPathMap.get(node.id) ?? [];

View File

@@ -343,6 +343,8 @@ export function TenantProfilePage() {
)}
</Label>
<select
id="tenant-org-unit-type"
name="tenant-org-unit-type"
data-testid="tenant-org-unit-type-select"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={orgUnitType}
@@ -361,6 +363,8 @@ export function TenantProfilePage() {
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
</Label>
<select
id="tenant-visibility"
name="tenant-visibility"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={tenantVisibility}
onChange={(event) =>

View File

@@ -205,6 +205,8 @@ export function TenantSchemaPage() {
{t("ui.admin.tenants.schema.field.type", "유형")}
</Label>
<select
id={`tenant-schema-field-type-${field.key || index}`}
name={`tenant-schema-field-type-${field.key || index}`}
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary"
value={field.type}
onChange={(e) => {
@@ -266,6 +268,7 @@ export function TenantSchemaPage() {
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-required-${field.key || index}`}
type="checkbox"
checked={field.required}
onChange={(e) =>
@@ -279,6 +282,7 @@ export function TenantSchemaPage() {
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-admin-only-${field.key || index}`}
type="checkbox"
checked={field.adminOnly}
onChange={(e) =>
@@ -295,6 +299,7 @@ export function TenantSchemaPage() {
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-login-id-${field.key || index}`}
type="checkbox"
checked={field.isLoginId || false}
onChange={(e) =>
@@ -315,6 +320,7 @@ export function TenantSchemaPage() {
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-indexed-${field.key || index}`}
type="checkbox"
checked={field.indexed || field.isLoginId || false}
disabled={field.isLoginId}
@@ -333,6 +339,7 @@ export function TenantSchemaPage() {
{(field.type === "number" || field.type === "float") && (
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-unsigned-${field.key || index}`}
type="checkbox"
checked={field.unsigned}
onChange={(e) =>

View File

@@ -0,0 +1,148 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../../test/i18nMock";
import TenantUsersPage from "./TenantUsersPage";
const exportUsersCSVMock = vi.hoisted(() => vi.fn());
const updateUserMock = vi.hoisted(() => vi.fn());
const fetchUsersMock = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/i18n", () => createI18nMock());
vi.mock("../../../lib/adminApi", () => ({
fetchTenant: vi.fn(async () => ({
id: "tenant-team-id",
name: "기술기획팀",
slug: "tech-planning",
})),
fetchUsers: fetchUsersMock,
exportUsersCSV: exportUsersCSVMock,
updateUser: updateUserMock,
}));
function renderTenantUsersPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/tenants/tenant-team-id/users"]}>
<Routes>
<Route
path="/tenants/:tenantId/users"
element={<TenantUsersPage />}
/>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("TenantUsersPage export", () => {
beforeEach(() => {
exportUsersCSVMock.mockReset();
updateUserMock.mockReset();
fetchUsersMock.mockReset();
fetchUsersMock.mockResolvedValue({
items: [
{
id: "user-1",
name: "Alice",
email: "alice@example.com",
role: "user",
status: "active",
},
],
total: 1,
});
exportUsersCSVMock.mockResolvedValue({
blob: new Blob(["email,name\nalice@example.com,Alice\n"], {
type: "text/csv",
}),
filename: "users_export_20260609.csv",
});
vi.spyOn(window.URL, "createObjectURL").mockReturnValue(
"blob:tenant-users-export",
);
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
});
it("exports only the currently opened tenant users by tenant slug", async () => {
renderTenantUsersPage();
await screen.findByText("Alice");
fireEvent.click(screen.getByTestId("tenant-users-export-menu-item"));
await waitFor(() => {
expect(exportUsersCSVMock).toHaveBeenCalledWith(
"",
"tech-planning",
false,
);
});
});
it("queues searched users and adds all queued users to the tenant at once", async () => {
fetchUsersMock
.mockResolvedValueOnce({ items: [], total: 0 })
.mockResolvedValueOnce({
items: [
{
id: "user-2",
name: "Bob",
email: "bob@example.com",
role: "user",
status: "active",
},
{
id: "user-3",
name: "Carol",
email: "carol@example.com",
role: "user",
status: "active",
},
],
total: 2,
})
.mockResolvedValue({ items: [], total: 0 });
updateUserMock.mockResolvedValue({});
renderTenantUsersPage();
const addButton = await screen.findByTestId(
"tenant-member-add-existing-btn",
);
await waitFor(() => expect(addButton).not.toBeDisabled());
fireEvent.click(addButton);
fireEvent.change(screen.getByTestId("tenant-member-search-input"), {
target: { value: "bo" },
});
fireEvent.click(await screen.findByText("Bob"));
fireEvent.click(await screen.findByText("Carol"));
expect(screen.getByTestId("tenant-member-add-queue")).toHaveTextContent(
"Bob",
);
expect(screen.getByTestId("tenant-member-add-queue")).toHaveTextContent(
"Carol",
);
fireEvent.click(screen.getByTestId("tenant-member-add-submit-btn"));
await waitFor(() => {
expect(updateUserMock).toHaveBeenCalledWith("user-2", {
tenantSlug: "tech-planning",
isAddTenant: true,
});
expect(updateUserMock).toHaveBeenCalledWith("user-3", {
tenantSlug: "tech-planning",
isAddTenant: true,
});
});
});
});

View File

@@ -1,6 +1,16 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Loader2, Mail, Plus, User, UserPlus } from "lucide-react";
import {
FileDown,
Loader2,
Mail,
Plus,
Search,
User,
UserPlus,
X,
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
@@ -11,6 +21,15 @@ import {
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import {
Table,
TableBody,
@@ -20,7 +39,13 @@ import {
TableRow,
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import { fetchTenant, fetchUsers, updateUser } from "../../../lib/adminApi";
import {
exportUsersCSV,
fetchTenant,
fetchUsers,
type UserSummary,
updateUser,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantUsersPage() {
@@ -28,6 +53,9 @@ function TenantUsersPage() {
const navigate = useNavigate();
const tenantId = params.tenantId ?? "";
const queryClient = useQueryClient();
const [addMembersOpen, setAddMembersOpen] = React.useState(false);
const [memberSearch, setMemberSearch] = React.useState("");
const [queuedMembers, setQueuedMembers] = React.useState<UserSummary[]>([]);
// 테넌트의 슬러그(tenantSlug)를 먼저 가져옴
const tenantQuery = useQuery({
@@ -45,6 +73,33 @@ function TenantUsersPage() {
enabled: !!tenantSlug,
});
const memberSearchTerm = memberSearch.trim();
const memberSearchQuery = useQuery({
queryKey: ["tenant-member-search", tenantSlug, memberSearchTerm],
queryFn: () => fetchUsers(20, 0, memberSearchTerm),
enabled: addMembersOpen && memberSearchTerm.length >= 2,
});
const exportMutation = useMutation({
mutationFn: (includeIds: boolean) =>
exportUsersCSV("", tenantSlug ?? "", includeIds),
onSuccess: ({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onError: () => {
toast.error(
t("msg.admin.users.export_error", "사용자 내보내기에 실패했습니다."),
);
},
});
const removeTenantMutation = useMutation({
mutationFn: ({ userId, slug }: { userId: string; slug: string }) =>
updateUser(userId, { tenantSlug: slug, isRemoveTenant: true }),
@@ -66,6 +121,38 @@ function TenantUsersPage() {
},
});
const addMembersMutation = useMutation({
mutationFn: async (members: UserSummary[]) => {
if (!tenantSlug || members.length === 0) return;
await Promise.all(
members.map((member) =>
updateUser(member.id, { tenantSlug, isAddTenant: true }),
),
);
},
onSuccess: () => {
const count = queuedMembers.length;
toast.success(
t(
"msg.admin.tenants.members.add_success",
"{{count}}명의 구성원이 추가되었습니다.",
{ count },
),
);
setQueuedMembers([]);
setMemberSearch("");
setAddMembersOpen(false);
usersQuery.refetch();
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("msg.admin.tenants.members.add_error", "구성원 추가 실패"),
);
},
});
const _handleRemoveMember = (userId: string, userName: string) => {
if (!tenantSlug) return;
if (
@@ -82,6 +169,28 @@ function TenantUsersPage() {
};
const users = usersQuery.data?.items ?? [];
const existingUserIds = React.useMemo(
() => new Set(users.map((user) => user.id)),
[users],
);
const queuedUserIds = React.useMemo(
() => new Set(queuedMembers.map((user) => user.id)),
[queuedMembers],
);
const searchResults = memberSearchQuery.data?.items ?? [];
const queueMember = (member: UserSummary) => {
if (existingUserIds.has(member.id) || queuedUserIds.has(member.id)) {
return;
}
setQueuedMembers((current) => [...current, member]);
};
const removeQueuedMember = (memberId: string) => {
setQueuedMembers((current) =>
current.filter((member) => member.id !== memberId),
);
};
return (
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
@@ -92,12 +201,39 @@ function TenantUsersPage() {
count: users.length,
})}
</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild className="gap-2">
<Link to={`/users?addTenant=${tenantSlug}`}>
<UserPlus size={16} />
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
</Link>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
className="gap-2"
disabled={!tenantSlug || exportMutation.isPending}
data-testid="tenant-users-export-menu-item"
onClick={() => exportMutation.mutate(false)}
>
<FileDown size={16} />
{t("ui.common.export_without_ids", "UUID 제외 내보내기")}
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
disabled={!tenantSlug || exportMutation.isPending}
data-testid="tenant-users-export-with-ids-menu-item"
onClick={() => exportMutation.mutate(true)}
>
<FileDown size={16} />
{t("ui.common.export_with_ids", "UUID 포함 내보내기")}
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
disabled={!tenantSlug}
data-testid="tenant-member-add-existing-btn"
onClick={() => setAddMembersOpen(true)}
>
<UserPlus size={16} />
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
</Button>
<Button size="sm" asChild className="gap-2">
<Link to={`/users/new?tenantSlug=${tenantSlug}`}>
@@ -107,6 +243,143 @@ function TenantUsersPage() {
</Button>
</div>
</CardHeader>
<Dialog open={addMembersOpen} onOpenChange={setAddMembersOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
</DialogTitle>
<DialogDescription>
{t(
"ui.admin.tenants.members.add_existing_description",
"검색 결과를 선택해 추가 명단에 담은 뒤 한 번에 배정합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={memberSearch}
onChange={(event) => setMemberSearch(event.target.value)}
className="h-9 pl-9"
placeholder={t(
"ui.admin.tenants.members.search_placeholder",
"이름 또는 이메일 검색",
)}
data-testid="tenant-member-search-input"
/>
</div>
<div className="rounded-md border">
<div className="max-h-56 overflow-auto">
{memberSearchTerm.length < 2 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{t(
"ui.admin.tenants.members.search_min_length",
"두 글자 이상 입력하세요.",
)}
</div>
) : memberSearchQuery.isFetching ? (
<div className="flex items-center justify-center gap-2 px-3 py-6 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
{t("ui.common.searching", "검색 중...")}
</div>
) : searchResults.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{t("ui.common.no_results", "검색 결과가 없습니다.")}
</div>
) : (
<div className="divide-y">
{searchResults.map((user) => {
const disabled =
existingUserIds.has(user.id) ||
queuedUserIds.has(user.id);
return (
<button
key={user.id}
type="button"
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled}
onClick={() => queueMember(user)}
>
<span className="min-w-0">
<span className="block truncate font-medium">
{user.name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{user.email}
</span>
</span>
<Plus size={16} className="flex-shrink-0" />
</button>
);
})}
</div>
)}
</div>
</div>
<div
className="min-h-20 rounded-md border bg-muted/20 p-3"
data-testid="tenant-member-add-queue"
>
{queuedMembers.length === 0 ? (
<div className="flex h-14 items-center justify-center text-sm text-muted-foreground">
{t(
"ui.admin.tenants.members.queue_empty",
"추가할 구성원을 선택하세요.",
)}
</div>
) : (
<div className="flex flex-wrap gap-2">
{queuedMembers.map((user) => (
<span
key={user.id}
className="inline-flex max-w-full items-center gap-2 rounded-md border bg-background px-2 py-1 text-sm"
>
<span className="max-w-52 truncate">{user.name}</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() => removeQueuedMember(user.id)}
aria-label={t(
"ui.admin.tenants.members.queue_remove",
"추가 명단에서 제거",
)}
>
<X size={14} />
</button>
</span>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setAddMembersOpen(false)}
disabled={addMembersMutation.isPending}
>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => addMembersMutation.mutate(queuedMembers)}
disabled={
queuedMembers.length === 0 || addMembersMutation.isPending
}
data-testid="tenant-member-add-submit-btn"
>
{addMembersMutation.isPending && (
<Loader2 size={16} className="animate-spin" />
)}
{t("ui.admin.tenants.members.add_queued", "선택 구성원 추가")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">

View File

@@ -17,7 +17,9 @@ import {
getWorksmobileComparisonStatusLabel,
getWorksmobileRowSelectionKey,
getWorksmobileSelectedActionIds,
getWorksmobileSelectedCreateUserIds,
getWorksmobileSelectedMissingExternalKeyOrgUnitIds,
getWorksmobileSelectedUpdateUserIds,
getWorksmobileSelectedWorksOnlyOrgUnitIds,
isImmutableWorksmobileAccount,
summarizeWorksmobileComparison,
@@ -225,6 +227,41 @@ describe("TenantWorksmobilePage comparison helpers", () => {
]);
});
it("separates selected WORKS user creation ids from update-needed user ids", () => {
const rows = [
{
resourceType: "USER",
status: "missing_in_worksmobile",
baronId: "baron-only",
},
{
resourceType: "USER",
status: "needs_update",
baronId: "needs-update",
worksmobileId: "works-needs-update",
},
{
resourceType: "USER",
status: "matched",
baronId: "matched",
worksmobileId: "works-matched",
},
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileId: "works-only",
},
];
const selectedKeys = rows.map(getWorksmobileRowSelectionKey);
expect(getWorksmobileSelectedCreateUserIds(rows, selectedKeys)).toEqual([
"baron-only",
]);
expect(getWorksmobileSelectedUpdateUserIds(rows, selectedKeys)).toEqual([
"needs-update",
]);
});
it("uses compact comparison columns by default", () => {
expect(getDefaultWorksmobileComparisonColumns()).toEqual({
status: true,

View File

@@ -1,9 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
ChevronDown,
ChevronRight,
Download,
KeyRound,
RefreshCw,
RotateCcw,
@@ -42,21 +39,16 @@ import {
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
deleteWorksmobileCredentialBatchPasswords,
deleteWorksmobilePendingJobs,
downloadWorksmobileInitialPasswordsCSV,
enqueueWorksmobileBackfillDryRun,
enqueueWorksmobileOrgUnitDelete,
enqueueWorksmobileOrgUnitSync,
enqueueWorksmobileUserSync,
fetchMe,
fetchWorksmobileComparison,
fetchWorksmobileCredentialBatches,
fetchWorksmobileOverview,
resetWorksmobileUserPassword,
retryWorksmobileJob,
type WorksmobileComparisonItem,
type WorksmobileCredentialBatch,
type WorksmobileOutboxItem,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
@@ -81,8 +73,9 @@ import {
getWorksmobileComparisonStatusLabel,
getWorksmobileRowSelectionKey,
getWorksmobileSelectedActionIds,
getWorksmobileSelectedCreateUserIds,
getWorksmobileSelectedUpdateUserIds,
getWorksmobileSelectedWorksOnlyOrgUnitIds,
isImmutableWorksmobileAccount,
summarizeWorksmobileComparison,
type WorksmobileComparisonColumnKey,
type WorksmobileComparisonColumnVisibility,
@@ -90,17 +83,6 @@ import {
type WorksmobileComparisonSummary,
} from "./worksmobileComparison";
type InitialPasswordDownloadVariables = {
batchId?: string;
};
export function createWorksmobileCredentialBatchId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return `worksmobile-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function worksmobileJobPayloadString(job: WorksmobileOutboxItem, key: string) {
const value = job.payload?.[key];
return typeof value === "string" ? value.trim() : "";
@@ -238,12 +220,6 @@ export function TenantWorksmobilePage() {
enabled: tenantId.length > 0 && hasWorksmobileAccess,
});
const credentialBatchesQuery = useQuery({
queryKey: ["worksmobile-credential-batches", tenantId],
queryFn: () => fetchWorksmobileCredentialBatches(tenantId),
enabled: tenantId.length > 0 && hasWorksmobileAccess,
});
const dryRunMutation = useMutation({
mutationFn: () => enqueueWorksmobileBackfillDryRun(tenantId),
onSuccess: () => {
@@ -275,7 +251,6 @@ export function TenantWorksmobilePage() {
onSuccess: (result) => {
toast.success(`대기중 payload ${result.deletedCount}건을 삭제했습니다.`);
overviewQuery.refetch();
credentialBatchesQuery.refetch();
},
onError: (error) => {
toast.error("대기중 payload 삭제 실패", {
@@ -284,40 +259,6 @@ export function TenantWorksmobilePage() {
},
});
const initialPasswordDownloadMutation = useMutation({
mutationFn: (variables?: InitialPasswordDownloadVariables) =>
downloadWorksmobileInitialPasswordsCSV(tenantId, variables?.batchId),
onSuccess: ({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onError: (error) => {
toast.error("초기 비밀번호 CSV 다운로드 실패", {
description: getErrorMessage(error),
});
},
});
const deleteCredentialBatchPasswordsMutation = useMutation({
mutationFn: (batchId: string) =>
deleteWorksmobileCredentialBatchPasswords(tenantId, batchId),
onSuccess: () => {
toast.success("비밀번호 값을 삭제했습니다.");
credentialBatchesQuery.refetch();
},
onError: (error) => {
toast.error("비밀번호 값 삭제 실패", {
description: getErrorMessage(error),
});
},
});
const orgUnitSyncMutation = useMutation({
mutationFn: () => enqueueWorksmobileOrgUnitSync(tenantId, orgUnitId.trim()),
onSuccess: () => {
@@ -348,20 +289,24 @@ export function TenantWorksmobilePage() {
mutationFn: async ({
resourceKind,
ids,
initialPassword,
}: {
resourceKind: "users" | "groups";
ids: string[];
initialPassword?: string;
}) => {
const credentialBatchId =
resourceKind === "users"
? createWorksmobileCredentialBatchId()
: undefined;
const trimmedInitialPassword = initialPassword?.trim();
const failures: string[] = [];
let successCount = 0;
for (const id of ids) {
try {
if (resourceKind === "users") {
await enqueueWorksmobileUserSync(tenantId, id, credentialBatchId);
await enqueueWorksmobileUserSync(
tenantId,
id,
undefined,
trimmedInitialPassword,
);
} else {
await enqueueWorksmobileOrgUnitSync(tenantId, id);
}
@@ -379,10 +324,6 @@ export function TenantWorksmobilePage() {
resourceKind,
count: successCount,
failureCount: failures.length,
credentialBatchId:
resourceKind === "users" && successCount > 0
? credentialBatchId
: undefined,
};
},
onSuccess: ({ resourceKind, count, failureCount }) => {
@@ -397,15 +338,11 @@ export function TenantWorksmobilePage() {
});
} else {
toast.success("WORKS 생성 작업을 등록했습니다.", {
description:
resourceKind === "users"
? `${count}건, 비밀번호 CSV는 배치 처리 완료 후 히스토리에서 다운로드할 수 있습니다.`
: `${count}`,
description: `${count}`,
});
}
overviewQuery.refetch();
comparisonQuery.refetch();
credentialBatchesQuery.refetch();
},
onError: (error) => {
toast.error("WORKS 생성 작업 등록 실패", {
@@ -414,30 +351,6 @@ export function TenantWorksmobilePage() {
},
});
const resetWorksmobilePasswordMutation = useMutation({
mutationFn: ({
userId,
credentialBatchId,
}: {
userId: string;
credentialBatchId: string;
}) => resetWorksmobileUserPassword(tenantId, userId, credentialBatchId),
onSuccess: () => {
toast.success("WORKS 비밀번호 재설정 작업을 등록했습니다.", {
description:
"비밀번호 CSV는 배치 처리 완료 후 히스토리에서 다운로드할 수 있습니다.",
});
overviewQuery.refetch();
comparisonQuery.refetch();
credentialBatchesQuery.refetch();
},
onError: (error) => {
toast.error("WORKS 비밀번호 재설정 등록 실패", {
description: getErrorMessage(error),
});
},
});
const syncSelectedOrgUnitsMutation = useMutation({
mutationFn: async ({
baronIds,
@@ -522,10 +435,7 @@ export function TenantWorksmobilePage() {
createSelectedMutation.isPending &&
createSelectedMutation.variables?.resourceKind === "users";
const isSyncingGroups = syncSelectedOrgUnitsMutation.isPending;
const isRefreshing =
overviewQuery.isFetching ||
comparisonQuery.isFetching ||
credentialBatchesQuery.isFetching;
const isRefreshing = overviewQuery.isFetching || comparisonQuery.isFetching;
return (
<div className="min-w-0 max-w-full space-y-6">
@@ -548,7 +458,6 @@ export function TenantWorksmobilePage() {
onClick={() => {
overviewQuery.refetch();
comparisonQuery.refetch();
credentialBatchesQuery.refetch();
}}
disabled={isRefreshing}
>
@@ -602,29 +511,6 @@ export function TenantWorksmobilePage() {
{activeTab === "history" ? (
<div className="space-y-4 animate-in fade-in duration-500">
<CredentialBatchHistory
batches={credentialBatchesQuery.data ?? []}
loading={credentialBatchesQuery.isLoading}
downloadingBatchId={
initialPasswordDownloadMutation.isPending
? initialPasswordDownloadMutation.variables?.batchId
: undefined
}
deletingBatchId={deleteCredentialBatchPasswordsMutation.variables}
onDownload={(batchId) =>
initialPasswordDownloadMutation.mutate({ batchId })
}
onDelete={(batchId) => {
if (
window.confirm(
"이 배치의 실제 비밀번호 값을 삭제할까요? 생성 이력은 유지됩니다.",
)
) {
deleteCredentialBatchPasswordsMutation.mutate(batchId);
}
}}
/>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3">
<div>
@@ -742,6 +628,7 @@ export function TenantWorksmobilePage() {
<ComparisonTable
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
rows={filteredComparisonUsers}
totalRows={comparisonQuery.data?.users.length ?? 0}
loading={comparisonQuery.isLoading}
selectedKeys={selectedUserRowKeys}
onSelectedKeysChange={setSelectedUserRowKeys}
@@ -767,29 +654,21 @@ export function TenantWorksmobilePage() {
passwordManageTenantId={overview?.config.adminTenantId}
actionLabel="선택 구성원 WORKS에 생성"
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
onCreateSelected={(ids) =>
updateActionLabel="선택 구성원 업데이트 적용"
onCreateSelected={(ids, initialPassword) =>
createSelectedMutation.mutate({
resourceKind: "users",
ids,
initialPassword,
})
}
onUpdateSelected={(ids) =>
createSelectedMutation.mutate({
resourceKind: "users",
ids,
})
}
resettingPasswordUserId={
resetWorksmobilePasswordMutation.isPending
? resetWorksmobilePasswordMutation.variables?.userId
: undefined
}
onResetUserPassword={(userId) => {
if (
window.confirm(
"선택한 WORKS 계정의 비밀번호를 재설정할까요? 새 비밀번호는 배치 처리 완료 후 히스토리에서 CSV로 다운로드할 수 있습니다.",
)
) {
resetWorksmobilePasswordMutation.mutate({
userId,
credentialBatchId: createWorksmobileCredentialBatchId(),
});
}
}}
requireInitialPassword
/>
<Card data-testid="worksmobile-users-single-sync">
<CardContent className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
@@ -835,6 +714,7 @@ export function TenantWorksmobilePage() {
"조직/그룹",
)}
rows={filteredComparisonGroups}
totalRows={comparisonQuery.data?.groups.length ?? 0}
loading={comparisonQuery.isLoading}
selectedKeys={selectedGroupRowKeys}
onSelectedKeysChange={setSelectedGroupRowKeys}
@@ -940,6 +820,11 @@ const worksmobileComparisonColumnWidths: Record<
worksmobileOrg: 260,
manage: 112,
};
const worksmobileComparisonTableHeadClassName =
"h-12 whitespace-nowrap px-0 align-middle";
const worksmobileComparisonTableHeadContentClassName =
"flex h-full items-center px-4";
const worksmobileComparisonTableHeadCenterContentClassName = `${worksmobileComparisonTableHeadContentClassName} justify-center`;
function getDefaultGroupWorksmobileComparisonColumns(): WorksmobileComparisonColumnVisibility {
return {
@@ -982,216 +867,6 @@ function getWorksmobileComparisonStatusVariant(status: string) {
return "secondary";
}
function formatCredentialBatchDate(value?: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("ko-KR", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function CredentialBatchHistory({
batches,
loading,
downloadingBatchId,
deletingBatchId,
onDownload,
onDelete,
}: {
batches: WorksmobileCredentialBatch[];
loading: boolean;
downloadingBatchId?: string;
deletingBatchId?: string;
onDownload: (batchId: string) => void;
onDelete: (batchId: string) => void;
}) {
const [expandedBatchIds, setExpandedBatchIds] = React.useState<string[]>([]);
const toggleExpanded = (batchId: string) => {
setExpandedBatchIds((current) =>
current.includes(batchId)
? current.filter((id) => id !== batchId)
: [...current, batchId],
);
};
return (
<Card className="min-w-0 overflow-hidden">
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<CardDescription>
CSV를
.
</CardDescription>
</CardHeader>
<CardContent>
<div className="w-full max-w-full overflow-x-auto rounded-md border">
<Table className="min-w-max">
<TableHeader>
<TableRow>
<TableHead className="min-w-56 whitespace-nowrap">
</TableHead>
<TableHead className="w-24 whitespace-nowrap"></TableHead>
<TableHead className="min-w-36 whitespace-nowrap">
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
</TableHead>
<TableHead className="w-24 whitespace-nowrap"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
...
</TableCell>
</TableRow>
)}
{!loading && batches.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{batches.map((batch) => {
const isComplete =
(batch.pendingCount ?? 0) === 0 &&
(batch.processingCount ?? 0) === 0;
const isExpanded = expandedBatchIds.includes(batch.batchId);
const failures = batch.failures ?? [];
return (
<React.Fragment key={batch.batchId}>
<TableRow>
<TableCell className="font-mono text-xs">
<div className="flex items-center gap-1">
{failures.length > 0 && (
<Button
type="button"
size="icon"
variant="ghost"
aria-label={`${batch.batchId} 실패 사유 보기`}
onClick={() => toggleExpanded(batch.batchId)}
>
{isExpanded ? (
<ChevronDown size={16} />
) : (
<ChevronRight size={16} />
)}
</Button>
)}
<span>{batch.batchId}</span>
</div>
</TableCell>
<TableCell className="font-mono">
{batch.userCount}
</TableCell>
<TableCell className="whitespace-nowrap text-xs">
<span className="mr-2">
{batch.processedCount ?? 0}
</span>
<span className="mr-2">
{batch.pendingCount ?? 0}
</span>
<span className="mr-2">
{batch.processingCount ?? 0}
</span>
<span> {batch.failedCount ?? 0}</span>
</TableCell>
<TableCell className="whitespace-nowrap text-xs">
{formatCredentialBatchDate(batch.createdAt)}
</TableCell>
<TableCell className="whitespace-nowrap text-xs">
{batch.hasPasswords
? "보관 중"
: formatCredentialBatchDate(batch.deletedAt)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
type="button"
size="icon"
variant="ghost"
aria-label={`${batch.batchId} 비밀번호 CSV 다운로드`}
disabled={
!batch.hasPasswords ||
!isComplete ||
downloadingBatchId === batch.batchId
}
onClick={() => onDownload(batch.batchId)}
>
<Download size={16} />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
aria-label={`${batch.batchId} 비밀번호 값 삭제`}
disabled={
!batch.hasPasswords ||
deletingBatchId === batch.batchId
}
onClick={() => onDelete(batch.batchId)}
>
<Trash2 size={16} />
</Button>
</div>
</TableCell>
</TableRow>
{isExpanded && failures.length > 0 && (
<TableRow>
<TableCell colSpan={6} className="bg-muted/30">
<div className="space-y-2 text-xs">
{failures.map((failure) => (
<div
key={`${failure.userId ?? failure.email}:${failure.lastError}`}
className="grid gap-1 md:grid-cols-[minmax(12rem,1fr)_5rem_minmax(18rem,2fr)]"
>
<div>
<div className="font-medium">
{failure.email ?? failure.userId ?? "-"}
</div>
{failure.userId && (
<div className="font-mono text-muted-foreground">
{failure.userId}
</div>
)}
</div>
<div className="text-muted-foreground">
{failure.status} / retry{" "}
{failure.retryCount ?? 0}
</div>
<div className="break-words">
{failure.lastError ?? "-"}
</div>
</div>
))}
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}
function ComparisonSummary({
title,
summary,
@@ -1304,6 +979,7 @@ function ComparisonFilterButtons<T extends string>({
function ComparisonTable({
title,
rows,
totalRows,
loading,
selectedKeys,
onSelectedKeysChange,
@@ -1321,17 +997,19 @@ function ComparisonTable({
showBaronIdColumn = true,
showManageColumn = true,
actionLabel,
updateActionLabel,
actionDisabled,
onCreateSelected,
onUpdateSelected,
onRunSelected,
deleteActionLabel,
deleteActionDisabled = false,
onDeleteSelected,
resettingPasswordUserId,
onResetUserPassword,
requireInitialPassword = false,
}: {
title: string;
rows: WorksmobileComparisonItem[];
totalRows: number;
loading: boolean;
selectedKeys: string[];
onSelectedKeysChange: (ids: string[]) => void;
@@ -1351,22 +1029,35 @@ function ComparisonTable({
showBaronIdColumn?: boolean;
showManageColumn?: boolean;
actionLabel: string;
updateActionLabel?: string;
actionDisabled: boolean;
onCreateSelected: (ids: string[]) => void;
onCreateSelected: (ids: string[], initialPassword?: string) => void;
onUpdateSelected?: (ids: string[]) => void;
onRunSelected?: (actionIds: string[], deleteIds: string[]) => void;
deleteActionLabel?: string;
deleteActionDisabled?: boolean;
onDeleteSelected?: (ids: string[]) => void;
resettingPasswordUserId?: string;
onResetUserPassword?: (userId: string) => void;
requireInitialPassword?: boolean;
}) {
const [columnSettingsOpen, setColumnSettingsOpen] = React.useState(false);
const [initialPasswordOpen, setInitialPasswordOpen] = React.useState(false);
const [initialPassword, setInitialPassword] = React.useState("");
const [pendingInitialPasswordIds, setPendingInitialPasswordIds] =
React.useState<string[]>([]);
const tableViewportRef = React.useRef<HTMLDivElement>(null);
const selectableKeys = rows
.filter(canSelectWorksmobileRow)
.map(getWorksmobileRowSelectionKey)
.filter(Boolean);
const selectedActionIds = getWorksmobileSelectedActionIds(rows, selectedKeys);
const selectedCreateUserIds = getWorksmobileSelectedCreateUserIds(
rows,
selectedKeys,
);
const selectedUpdateUserIds = getWorksmobileSelectedUpdateUserIds(
rows,
selectedKeys,
);
const selectedDeleteIds = getWorksmobileSelectedWorksOnlyOrgUnitIds(
rows,
selectedKeys,
@@ -1377,6 +1068,7 @@ function ComparisonTable({
selectedActionIds.length === 0 &&
selectedDeleteIds.length > 0 &&
canRunDeleteAction;
const canRunUserUpdateAction = Boolean(onUpdateSelected);
const selectedActionLabel = shouldRunDeleteAction
? deleteActionLabel
: actionLabel;
@@ -1388,7 +1080,11 @@ function ComparisonTable({
? selectedActionIds.length === 0 && selectedDeleteIds.length === 0
: shouldRunDeleteAction
? selectedDeleteIds.length === 0 || deleteActionDisabled
: selectedActionIds.length === 0) || actionDisabled;
: requireInitialPassword
? selectedCreateUserIds.length === 0
: selectedActionIds.length === 0) || actionDisabled;
const updateActionDisabled =
selectedUpdateUserIds.length === 0 || actionDisabled;
const allSelectableSelected =
selectableKeys.length > 0 &&
selectableKeys.every((key) => selectedKeys.includes(key));
@@ -1476,15 +1172,6 @@ function ComparisonTable({
window.open(url, "_blank", "noopener,noreferrer");
};
const canResetPassword = (row: WorksmobileComparisonItem) =>
Boolean(
onResetUserPassword &&
row.resourceType === "USER" &&
row.baronId &&
row.status !== "missing_in_worksmobile" &&
!isImmutableWorksmobileAccount(row),
);
const toggleColumn = (key: WorksmobileComparisonColumnKey) => {
onVisibleColumnsChange((current) => ({
...current,
@@ -1510,11 +1197,55 @@ function ComparisonTable({
);
};
const runSelectedAction = () => {
if (onRunSelected) {
onRunSelected(selectedActionIds, selectedDeleteIds);
return;
}
if (shouldRunDeleteAction && onDeleteSelected) {
onDeleteSelected(selectedDeleteIds);
return;
}
if (requireInitialPassword) {
setPendingInitialPasswordIds(selectedCreateUserIds);
setInitialPassword("");
setInitialPasswordOpen(true);
return;
}
onCreateSelected(selectedActionIds);
};
const runUpdateAction = () => {
if (!onUpdateSelected || selectedUpdateUserIds.length === 0) {
return;
}
onUpdateSelected(selectedUpdateUserIds);
};
const confirmInitialPassword = () => {
const password = initialPassword.trim();
if (!password) {
toast.error("WORKS 초기 비밀번호를 입력해 주세요.");
return;
}
onCreateSelected(pendingInitialPasswordIds, password);
setInitialPasswordOpen(false);
setInitialPassword("");
setPendingInitialPasswordIds([]);
};
return (
<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-1 flex-wrap items-center gap-3">
<h4 className="text-lg font-semibold leading-none">{title}</h4>
<Badge
variant="outline"
data-testid={`worksmobile-${title}-row-count`}
className="font-mono"
>
{rows.length} / {totalRows}
</Badge>
<Input
type="search"
value={search}
@@ -1568,6 +1299,7 @@ function ComparisonTable({
className="flex cursor-pointer items-center gap-3 rounded-md p-2 hover:bg-muted/50"
>
<input
name={`worksmobile-column-${column.key}`}
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
checked={isColumnVisible(column.key)}
@@ -1594,21 +1326,69 @@ function ComparisonTable({
type="button"
size="sm"
variant={selectedActionVariant}
onClick={() => {
if (onRunSelected) {
onRunSelected(selectedActionIds, selectedDeleteIds);
return;
}
if (shouldRunDeleteAction && onDeleteSelected) {
onDeleteSelected(selectedDeleteIds);
return;
}
onCreateSelected(selectedActionIds);
}}
onClick={runSelectedAction}
disabled={selectedActionDisabled}
>
{selectedActionLabel}
</Button>
{canRunUserUpdateAction && (
<Button
type="button"
size="sm"
variant="outline"
onClick={runUpdateAction}
disabled={updateActionDisabled}
>
{updateActionLabel || "선택 구성원 업데이트 적용"}
</Button>
)}
<Dialog
open={initialPasswordOpen}
onOpenChange={(open) => {
setInitialPasswordOpen(open);
if (!open) {
setInitialPassword("");
setPendingInitialPasswordIds([]);
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>WORKS </DialogTitle>
<DialogDescription>
WORKS에
.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 py-2">
<label
className="text-sm font-medium"
htmlFor="worksmobile-initial-password"
>
</label>
<Input
id="worksmobile-initial-password"
type="password"
value={initialPassword}
onChange={(event) => setInitialPassword(event.target.value)}
autoComplete="new-password"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setInitialPasswordOpen(false)}
>
</Button>
<Button type="button" onClick={confirmInitialPassword}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
<div
@@ -1625,54 +1405,100 @@ function ComparisonTable({
minWidth: tableMinWidth,
}}
>
<TableHead className="w-10 whitespace-nowrap">
<Checkbox
aria-label={`${title} 전체 선택`}
checked={allSelectableSelected}
disabled={selectableKeys.length === 0}
onCheckedChange={toggleAll}
/>
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={
worksmobileComparisonTableHeadCenterContentClassName
}
>
<Checkbox
aria-label={`${title} 전체 선택`}
checked={allSelectableSelected}
disabled={selectableKeys.length === 0}
onCheckedChange={toggleAll}
/>
</div>
</TableHead>
{isColumnVisible("status") && (
<TableHead className="w-24 whitespace-nowrap"></TableHead>
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={worksmobileComparisonTableHeadContentClassName}
>
</div>
</TableHead>
)}
{showBaronIdColumn && isColumnVisible("baronId") && (
<TableHead className="min-w-44 whitespace-nowrap">
Baron ID
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={worksmobileComparisonTableHeadContentClassName}
>
Baron ID
</div>
</TableHead>
)}
{isColumnVisible("baron") && (
<TableHead className="min-w-44 whitespace-nowrap">
Baron
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={worksmobileComparisonTableHeadContentClassName}
>
Baron
</div>
</TableHead>
)}
{isColumnVisible("baronOrg") && (
<TableHead className="min-w-44 whitespace-nowrap">
{baronOrgColumnLabel}
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={worksmobileComparisonTableHeadContentClassName}
>
{baronOrgColumnLabel}
</div>
</TableHead>
)}
{isColumnVisible("externalKey") && (
<TableHead className="min-w-40 whitespace-nowrap">
external_key
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={worksmobileComparisonTableHeadContentClassName}
>
external_key
</div>
</TableHead>
)}
{isColumnVisible("worksmobileDomain") && (
<TableHead className="min-w-28 whitespace-nowrap">
WORKS
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={worksmobileComparisonTableHeadContentClassName}
>
WORKS
</div>
</TableHead>
)}
{isColumnVisible("worksmobile") && (
<TableHead className="min-w-44 whitespace-nowrap">
WORKS
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={worksmobileComparisonTableHeadContentClassName}
>
WORKS
</div>
</TableHead>
)}
{isColumnVisible("worksmobileOrg") && (
<TableHead className="min-w-52 whitespace-nowrap">
Works
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={worksmobileComparisonTableHeadContentClassName}
>
Works
</div>
</TableHead>
)}
{showManageColumn && isColumnVisible("manage") && (
<TableHead className="w-14 whitespace-nowrap"></TableHead>
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={worksmobileComparisonTableHeadContentClassName}
>
</div>
</TableHead>
)}
</TableRow>
</TableHeader>
@@ -1887,23 +1713,6 @@ function ComparisonTable({
>
<KeyRound size={16} />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
aria-label={`${row.worksmobileName ?? row.baronName ?? row.worksmobileId ?? "WORKS user"} 비밀번호 재설정`}
disabled={
!canResetPassword(row) ||
resettingPasswordUserId === row.baronId
}
onClick={() => {
if (row.baronId) {
onResetUserPassword?.(row.baronId);
}
}}
>
<RotateCcw size={16} />
</Button>
</div>
)}
</TableCell>

View File

@@ -16,6 +16,16 @@ export function tenantMatchesListSearch(
.some((value) => value.toLowerCase().includes(normalizedSearch));
}
export function getTenantSearchMatchIds(
rows: Array<Pick<TenantSummary, "id" | "name" | "slug" | "type">>,
search: string,
) {
if (!search.trim()) return [];
return rows
.filter((row) => tenantMatchesListSearch(row, search))
.map((row) => row.id);
}
function collectTenantTreeRows(
nodes: TenantNode[],
depth: number,
@@ -91,7 +101,8 @@ export function getTenantViewRows(
...(rowsById.get(tenant.id) ?? {
...tenant,
children: [],
recursiveMemberCount: Number(tenant.memberCount) || 0,
recursiveMemberCount:
Number(tenant.totalMemberCount ?? tenant.memberCount) || 0,
}),
depth: 0,
}));

View File

@@ -172,6 +172,38 @@ export function getWorksmobileSelectedActionIds(
.filter((id): id is string => Boolean(id));
}
export function getWorksmobileSelectedCreateUserIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
) {
const selected = new Set(selectedKeys);
return rows
.filter(
(row) =>
row.resourceType === "USER" &&
row.status === "missing_in_worksmobile" &&
selected.has(getWorksmobileRowSelectionKey(row)),
)
.map((row) => row.baronId)
.filter((id): id is string => Boolean(id));
}
export function getWorksmobileSelectedUpdateUserIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
) {
const selected = new Set(selectedKeys);
return rows
.filter(
(row) =>
row.resourceType === "USER" &&
row.status === "needs_update" &&
selected.has(getWorksmobileRowSelectionKey(row)),
)
.map((row) => row.baronId)
.filter((id): id is string => Boolean(id));
}
export function getWorksmobileSelectedMissingExternalKeyOrgUnitIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],