forked from baron/baron-sso
worksmobile 연동 & ory stack 26.2.0으로 업그레이드
This commit is contained in:
@@ -18,8 +18,8 @@ import { createTenant, fetchTenants } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { DomainTagInput } from "../components/DomainTagInput";
|
||||
import {
|
||||
formatDomainConflictMessage,
|
||||
type ServerDomainConflict,
|
||||
formatDomainConflictMessage,
|
||||
} from "../utils/domainTags";
|
||||
|
||||
function TenantCreatePage() {
|
||||
@@ -151,6 +151,12 @@ function TenantCreatePage() {
|
||||
"COMPANY_GROUP (그룹사/지주사)",
|
||||
)}
|
||||
</option>
|
||||
<option value="ORGANIZATION">
|
||||
{t(
|
||||
"domain.tenant_type.organization",
|
||||
"ORGANIZATION (정규 조직)",
|
||||
)}
|
||||
</option>
|
||||
<option value="USER_GROUP">
|
||||
{t(
|
||||
"domain.tenant_type.user_group",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canShowWorksmobileEntry } from "./TenantDetailPage";
|
||||
|
||||
describe("TenantDetailPage Worksmobile entry visibility", () => {
|
||||
it("shows Worksmobile entry only for hanmac-family root tenant", () => {
|
||||
expect(
|
||||
canShowWorksmobileEntry({
|
||||
id: "hanmac-family-id",
|
||||
slug: "hanmac-family",
|
||||
parentId: undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canShowWorksmobileEntry({
|
||||
id: "hanmac-child-id",
|
||||
slug: "hanmac-family",
|
||||
parentId: "root-id",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
canShowWorksmobileEntry({
|
||||
id: "other-id",
|
||||
slug: "other",
|
||||
parentId: undefined,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,17 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
export function canShowWorksmobileEntry(tenant?: {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
parentId?: string | null;
|
||||
}) {
|
||||
return tenant?.slug === "hanmac-family" && !tenant.parentId;
|
||||
}
|
||||
|
||||
function TenantDetailPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
const tenantId = params.tenantId ?? "";
|
||||
@@ -23,9 +30,11 @@ function TenantDetailPage() {
|
||||
|
||||
const canAccessSchema =
|
||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||
const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data);
|
||||
|
||||
const isPermissionsTab = location.pathname.includes("/permissions");
|
||||
const isOrganizationTab = location.pathname.includes("/organization");
|
||||
const isWorksmobileTab = location.pathname.includes("/worksmobile");
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -51,6 +60,7 @@ function TenantDetailPage() {
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
!isPermissionsTab &&
|
||||
!location.pathname.includes("/schema") &&
|
||||
!isWorksmobileTab &&
|
||||
!isOrganizationTab
|
||||
? "text-primary border-b-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -90,6 +100,18 @@ function TenantDetailPage() {
|
||||
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
|
||||
</Link>
|
||||
)}
|
||||
{showWorksmobileEntry && (
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/worksmobile`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
isWorksmobileTab
|
||||
? "text-primary border-b-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("ui.admin.tenants.detail.tab_worksmobile", "Worksmobile")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Outlet for nested routes */}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import TenantDetailPage from "./TenantDetailPage";
|
||||
|
||||
vi.mock("../../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({ role: "super_admin" })),
|
||||
fetchTenant: vi.fn(async () => ({
|
||||
id: "hanmac-family-id",
|
||||
name: "한맥 가족",
|
||||
slug: "hanmac-family",
|
||||
parentId: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderTenantDetailPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={["/tenants/hanmac-family-id"]}>
|
||||
<Routes>
|
||||
<Route path="/tenants/:tenantId/*" element={<TenantDetailPage />}>
|
||||
<Route index element={<div>profile</div>} />
|
||||
<Route path="worksmobile" element={<div>worksmobile</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TenantDetailPage Worksmobile navigation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("opens Worksmobile management in the current admin route", async () => {
|
||||
renderTenantDetailPage();
|
||||
|
||||
const link = await screen.findByRole("link", { name: /Worksmobile/i });
|
||||
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"/tenants/hanmac-family-id/worksmobile",
|
||||
);
|
||||
expect(link).not.toHaveAttribute("target");
|
||||
expect(link).not.toHaveAttribute("rel");
|
||||
});
|
||||
});
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { DomainTagInput } from "../components/DomainTagInput";
|
||||
import {
|
||||
formatDomainConflictMessage,
|
||||
type ServerDomainConflict,
|
||||
formatDomainConflictMessage,
|
||||
} from "../utils/domainTags";
|
||||
import { isSeedTenant } from "../utils/protectedTenants";
|
||||
|
||||
@@ -230,6 +230,12 @@ export function TenantProfilePage() {
|
||||
"COMPANY_GROUP (그룹사/지주사)",
|
||||
)}
|
||||
</option>
|
||||
<option value="ORGANIZATION">
|
||||
{t(
|
||||
"domain.tenant_type.organization",
|
||||
"ORGANIZATION (정규 조직)",
|
||||
)}
|
||||
</option>
|
||||
<option value="USER_GROUP">
|
||||
{t(
|
||||
"domain.tenant_type.user_group",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canCreateWorksmobileRow,
|
||||
filterWorksmobileComparisonRows,
|
||||
formatWorksmobileOrgDetails,
|
||||
formatWorksmobilePersonName,
|
||||
getWorksmobileComparisonStatusLabel,
|
||||
summarizeWorksmobileComparison,
|
||||
userFilterOptions,
|
||||
} from "./TenantWorksmobilePage";
|
||||
|
||||
describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
it("summarizes comparison rows by status", () => {
|
||||
const summary = summarizeWorksmobileComparison([
|
||||
{ resourceType: "USER", status: "matched" },
|
||||
{ resourceType: "USER", status: "missing_in_worksmobile" },
|
||||
{ resourceType: "USER", status: "missing_in_baron" },
|
||||
{ resourceType: "USER", status: "missing_external_key" },
|
||||
{ resourceType: "USER", status: "missing_in_baron" },
|
||||
]);
|
||||
|
||||
expect(summary).toEqual({
|
||||
total: 5,
|
||||
matched: 1,
|
||||
missingInWorksmobile: 1,
|
||||
missingInBaron: 2,
|
||||
missingExternalKey: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns Korean labels for known comparison statuses", () => {
|
||||
expect(getWorksmobileComparisonStatusLabel("matched")).toBe("일치");
|
||||
expect(getWorksmobileComparisonStatusLabel("missing_in_worksmobile")).toBe(
|
||||
"WORKS 없음",
|
||||
);
|
||||
expect(getWorksmobileComparisonStatusLabel("missing_in_baron")).toBe(
|
||||
"Baron 없음",
|
||||
);
|
||||
expect(getWorksmobileComparisonStatusLabel("missing_external_key")).toBe(
|
||||
"ex_key 없음",
|
||||
);
|
||||
expect(getWorksmobileComparisonStatusLabel("unknown_status")).toBe(
|
||||
"unknown_status",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows WORKS creation only for Baron rows missing in WORKS", () => {
|
||||
expect(
|
||||
canCreateWorksmobileRow({
|
||||
resourceType: "USER",
|
||||
status: "missing_in_worksmobile",
|
||||
baronId: "user-1",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canCreateWorksmobileRow({
|
||||
resourceType: "USER",
|
||||
status: "missing_in_worksmobile",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
canCreateWorksmobileRow({
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileId: "works-user-1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("filters user comparison rows by selected relationship", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_in_worksmobile",
|
||||
baronId: "baron-only",
|
||||
baronName: "Baron only",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileId: "works-only",
|
||||
worksmobileName: "WORKS only",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "matched",
|
||||
baronId: "matched",
|
||||
worksmobileId: "works-matched",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "missing-external-key",
|
||||
},
|
||||
];
|
||||
|
||||
expect(filterWorksmobileComparisonRows(rows, ["baron_only"])).toEqual([
|
||||
rows[0],
|
||||
]);
|
||||
expect(filterWorksmobileComparisonRows(rows, ["works_only"])).toEqual([
|
||||
rows[1],
|
||||
rows[3],
|
||||
]);
|
||||
expect(filterWorksmobileComparisonRows(rows, ["matched"])).toEqual([
|
||||
rows[2],
|
||||
]);
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, ["baron_only", "works_only"]),
|
||||
).toEqual([rows[0], rows[1], rows[3]]);
|
||||
expect(filterWorksmobileComparisonRows(rows, [])).toEqual(rows);
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, [
|
||||
"baron_only",
|
||||
"works_only",
|
||||
"matched",
|
||||
]),
|
||||
).toEqual(rows);
|
||||
});
|
||||
|
||||
it("orders user comparison filter options from Baron-only first", () => {
|
||||
expect(userFilterOptions.map((option) => option.value)).toEqual([
|
||||
"baron_only",
|
||||
"works_only",
|
||||
"matched",
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats WORKS account name with level on one line", () => {
|
||||
expect(
|
||||
formatWorksmobilePersonName({
|
||||
resourceType: "USER",
|
||||
status: "matched",
|
||||
worksmobileName: "홍길동",
|
||||
worksmobileLevelName: "책임",
|
||||
}),
|
||||
).toBe("홍길동 책임");
|
||||
});
|
||||
|
||||
it("formats WORKS organization details with task and manager status", () => {
|
||||
expect(
|
||||
formatWorksmobileOrgDetails({
|
||||
resourceType: "USER",
|
||||
status: "matched",
|
||||
worksmobileTask: "기술검토",
|
||||
worksmobilePrimaryOrgPositionName: "팀장",
|
||||
worksmobilePrimaryOrgIsManager: true,
|
||||
}),
|
||||
).toEqual(["직책 팀장", "직무 기술검토", "조직장"]);
|
||||
});
|
||||
});
|
||||
854
adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx
Normal file
854
adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx
Normal file
@@ -0,0 +1,854 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Download, RefreshCw, RotateCcw } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Checkbox } from "../../../components/ui/checkbox";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type WorksmobileComparisonItem,
|
||||
downloadWorksmobileInitialPasswordsCSV,
|
||||
enqueueWorksmobileBackfillDryRun,
|
||||
enqueueWorksmobileOrgUnitSync,
|
||||
enqueueWorksmobileUserSync,
|
||||
fetchWorksmobileComparison,
|
||||
fetchWorksmobileOverview,
|
||||
retryWorksmobileJob,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
export type WorksmobileComparisonFilter =
|
||||
| "works_only"
|
||||
| "baron_only"
|
||||
| "matched";
|
||||
|
||||
export function TenantWorksmobilePage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
const tenantId = params.tenantId ?? "";
|
||||
const [orgUnitId, setOrgUnitId] = React.useState("");
|
||||
const [userId, setUserId] = React.useState("");
|
||||
const [userFilters, setUserFilters] = React.useState<
|
||||
WorksmobileComparisonFilter[]
|
||||
>(["baron_only", "works_only"]);
|
||||
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
||||
const [selectedGroupIds, setSelectedGroupIds] = React.useState<string[]>([]);
|
||||
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: ["worksmobile", tenantId],
|
||||
queryFn: () => fetchWorksmobileOverview(tenantId),
|
||||
enabled: tenantId.length > 0,
|
||||
});
|
||||
|
||||
const comparisonQuery = useQuery({
|
||||
queryKey: ["worksmobile-comparison", tenantId],
|
||||
queryFn: () => fetchWorksmobileComparison(tenantId, true),
|
||||
enabled: tenantId.length > 0,
|
||||
});
|
||||
|
||||
const dryRunMutation = useMutation({
|
||||
mutationFn: () => enqueueWorksmobileBackfillDryRun(tenantId),
|
||||
onSuccess: () => {
|
||||
toast.success("Backfill Dry-run 작업을 등록했습니다.");
|
||||
overviewQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Backfill Dry-run 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: (jobId: string) => retryWorksmobileJob(tenantId, jobId),
|
||||
onSuccess: () => {
|
||||
toast.success("재시도 작업을 등록했습니다.");
|
||||
overviewQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("재시도 작업 등록 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const initialPasswordDownloadMutation = useMutation({
|
||||
mutationFn: () => downloadWorksmobileInitialPasswordsCSV(tenantId),
|
||||
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 orgUnitSyncMutation = useMutation({
|
||||
mutationFn: () => enqueueWorksmobileOrgUnitSync(tenantId, orgUnitId.trim()),
|
||||
onSuccess: () => {
|
||||
toast.success("조직 Sync 작업을 등록했습니다.");
|
||||
overviewQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("조직 Sync 작업 등록 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const userSyncMutation = useMutation({
|
||||
mutationFn: () => enqueueWorksmobileUserSync(tenantId, userId.trim()),
|
||||
onSuccess: () => {
|
||||
toast.success("구성원 Sync 작업을 등록했습니다.");
|
||||
overviewQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("구성원 Sync 작업 등록 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const createSelectedMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
resourceKind,
|
||||
ids,
|
||||
}: {
|
||||
resourceKind: "users" | "groups";
|
||||
ids: string[];
|
||||
}) => {
|
||||
for (const id of ids) {
|
||||
if (resourceKind === "users") {
|
||||
await enqueueWorksmobileUserSync(tenantId, id);
|
||||
} else {
|
||||
await enqueueWorksmobileOrgUnitSync(tenantId, id);
|
||||
}
|
||||
}
|
||||
return { resourceKind, count: ids.length };
|
||||
},
|
||||
onSuccess: ({ resourceKind, count }) => {
|
||||
if (resourceKind === "users") {
|
||||
setSelectedUserIds([]);
|
||||
} else {
|
||||
setSelectedGroupIds([]);
|
||||
}
|
||||
toast.success("WORKS 생성 작업을 등록했습니다.", {
|
||||
description: `${count}건`,
|
||||
});
|
||||
overviewQuery.refetch();
|
||||
comparisonQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("WORKS 생성 작업 등록 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (overviewQuery.isError) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{t(
|
||||
"ui.admin.tenants.worksmobile.forbidden",
|
||||
"한맥가족 테넌트에서만 Worksmobile 연동을 관리할 수 있습니다.",
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const overview = overviewQuery.data;
|
||||
const comparisonUsers = comparisonQuery.data?.users ?? [];
|
||||
const comparisonGroups = comparisonQuery.data?.groups ?? [];
|
||||
const filteredComparisonUsers = filterWorksmobileComparisonRows(
|
||||
comparisonUsers,
|
||||
userFilters,
|
||||
);
|
||||
const userSummary = summarizeWorksmobileComparison(comparisonUsers);
|
||||
const groupSummary = summarizeWorksmobileComparison(comparisonGroups);
|
||||
const isCreatingUsers =
|
||||
createSelectedMutation.isPending &&
|
||||
createSelectedMutation.variables?.resourceKind === "users";
|
||||
const isCreatingGroups =
|
||||
createSelectedMutation.isPending &&
|
||||
createSelectedMutation.variables?.resourceKind === "groups";
|
||||
const isRefreshing = overviewQuery.isFetching || comparisonQuery.isFetching;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">
|
||||
{t("ui.admin.tenants.worksmobile.title", "Worksmobile 연동")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.tenants.worksmobile.subtitle",
|
||||
"한맥가족 Directory 조직/구성원 동기화 상태를 확인하고 실패 작업을 재시도합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => initialPasswordDownloadMutation.mutate()}
|
||||
disabled={initialPasswordDownloadMutation.isPending}
|
||||
>
|
||||
<Download size={16} />
|
||||
{t(
|
||||
"ui.admin.tenants.worksmobile.initial_password_csv",
|
||||
"초기 비밀번호 CSV",
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
overviewQuery.refetch();
|
||||
comparisonQuery.refetch();
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.admin.tenants.worksmobile.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => dryRunMutation.mutate()}
|
||||
disabled={dryRunMutation.isPending}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.admin.tenants.worksmobile.dry_run", "Backfill Dry-run")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">
|
||||
{t("ui.admin.tenants.worksmobile.compare", "Baron / Works 비교")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"ui.admin.tenants.worksmobile.compare_description",
|
||||
"구성원은 기본적으로 Baron 또는 WORKS 한쪽에만 있는 항목을 보여줍니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<ComparisonSummary
|
||||
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
|
||||
summary={userSummary}
|
||||
/>
|
||||
<ComparisonSummary
|
||||
title={t(
|
||||
"ui.admin.tenants.worksmobile.compare_groups",
|
||||
"조직/그룹",
|
||||
)}
|
||||
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}
|
||||
actionLabel="선택 구성원 WORKS에 생성"
|
||||
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
|
||||
onCreateSelected={() =>
|
||||
createSelectedMutation.mutate({
|
||||
resourceKind: "users",
|
||||
ids: selectedUserIds,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ComparisonTable
|
||||
title={t(
|
||||
"ui.admin.tenants.worksmobile.compare_groups",
|
||||
"조직/그룹",
|
||||
)}
|
||||
rows={comparisonGroups}
|
||||
loading={comparisonQuery.isLoading}
|
||||
selectedIds={selectedGroupIds}
|
||||
onSelectedIdsChange={setSelectedGroupIds}
|
||||
actionLabel="선택 조직 WORKS에 생성"
|
||||
actionDisabled={
|
||||
isCreatingGroups || createSelectedMutation.isPending
|
||||
}
|
||||
onCreateSelected={() =>
|
||||
createSelectedMutation.mutate({
|
||||
resourceKind: "groups",
|
||||
ids: selectedGroupIds,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("ui.admin.tenants.worksmobile.single_sync", "단건 동기화")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"ui.admin.tenants.worksmobile.single_sync_description",
|
||||
"Baron UUID 기준으로 조직 또는 구성원 sync 작업을 생성합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={orgUnitId}
|
||||
onChange={(event) => setOrgUnitId(event.target.value)}
|
||||
placeholder="orgUnit tenant UUID"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => orgUnitSyncMutation.mutate()}
|
||||
disabled={!orgUnitId.trim() || orgUnitSyncMutation.isPending}
|
||||
>
|
||||
{t("ui.admin.tenants.worksmobile.sync_orgunit", "조직 Sync")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={userId}
|
||||
onChange={(event) => setUserId(event.target.value)}
|
||||
placeholder="Kratos user UUID"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => userSyncMutation.mutate()}
|
||||
disabled={!userId.trim() || userSyncMutation.isPending}
|
||||
>
|
||||
{t("ui.admin.tenants.worksmobile.sync_user", "구성원 Sync")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("ui.admin.tenants.worksmobile.recent_jobs", "최근 작업")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>resource</TableHead>
|
||||
<TableHead>action</TableHead>
|
||||
<TableHead>status</TableHead>
|
||||
<TableHead>retry</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(overview?.recentJobs ?? []).map((job) => (
|
||||
<TableRow key={job.id}>
|
||||
<TableCell>
|
||||
{job.resourceType}:{job.resourceId}
|
||||
</TableCell>
|
||||
<TableCell>{job.action}</TableCell>
|
||||
<TableCell>{job.status}</TableCell>
|
||||
<TableCell>{job.retryCount}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => retryMutation.mutate(job.id)}
|
||||
disabled={retryMutation.isPending}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type WorksmobileComparisonSummary = {
|
||||
total: number;
|
||||
matched: number;
|
||||
missingInWorksmobile: number;
|
||||
missingInBaron: number;
|
||||
missingExternalKey: number;
|
||||
};
|
||||
|
||||
export function summarizeWorksmobileComparison(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
): WorksmobileComparisonSummary {
|
||||
return rows.reduce<WorksmobileComparisonSummary>(
|
||||
(summary, row) => {
|
||||
if (row.status === "matched") {
|
||||
summary.matched += 1;
|
||||
} else if (row.status === "missing_in_worksmobile") {
|
||||
summary.missingInWorksmobile += 1;
|
||||
} else if (row.status === "missing_in_baron") {
|
||||
summary.missingInBaron += 1;
|
||||
} else if (row.status === "missing_external_key") {
|
||||
summary.missingExternalKey += 1;
|
||||
}
|
||||
return summary;
|
||||
},
|
||||
{
|
||||
total: rows.length,
|
||||
matched: 0,
|
||||
missingInWorksmobile: 0,
|
||||
missingInBaron: 0,
|
||||
missingExternalKey: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorksmobileComparisonStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case "matched":
|
||||
return "일치";
|
||||
case "missing_in_worksmobile":
|
||||
return "WORKS 없음";
|
||||
case "missing_in_baron":
|
||||
return "Baron 없음";
|
||||
case "missing_external_key":
|
||||
return "ex_key 없음";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
export function canCreateWorksmobileRow(row: WorksmobileComparisonItem) {
|
||||
return row.status === "missing_in_worksmobile" && Boolean(row.baronId);
|
||||
}
|
||||
|
||||
export function filterWorksmobileComparisonRows(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
filters: WorksmobileComparisonFilter[],
|
||||
) {
|
||||
if (filters.length === 0 || filters.length === userFilterOptions.length) {
|
||||
return rows;
|
||||
}
|
||||
const allowedStatuses = new Set(
|
||||
filters.flatMap((filter) => worksmobileFilterStatuses[filter]),
|
||||
);
|
||||
return rows.filter((row) => allowedStatuses.has(row.status));
|
||||
}
|
||||
|
||||
export function formatWorksmobilePersonName(row: WorksmobileComparisonItem) {
|
||||
return [
|
||||
row.worksmobileName,
|
||||
row.worksmobileLevelName ?? row.worksmobileLevelId,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
|
||||
const details: string[] = [];
|
||||
const position =
|
||||
row.worksmobilePrimaryOrgPositionName ??
|
||||
row.worksmobilePrimaryOrgPositionId;
|
||||
if (position) {
|
||||
details.push(`직책 ${position}`);
|
||||
}
|
||||
if (row.worksmobileTask) {
|
||||
details.push(`직무 ${row.worksmobileTask}`);
|
||||
}
|
||||
if (typeof row.worksmobilePrimaryOrgIsManager === "boolean") {
|
||||
details.push(row.worksmobilePrimaryOrgIsManager ? "조직장" : "조직장 아님");
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
export const userFilterOptions: Array<{
|
||||
value: WorksmobileComparisonFilter;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "baron_only", label: "Baron에만 있음" },
|
||||
{ value: "works_only", label: "WORKS에만 있음" },
|
||||
{ value: "matched", label: "양쪽에 다 있음" },
|
||||
];
|
||||
|
||||
const worksmobileFilterStatuses: Record<WorksmobileComparisonFilter, string[]> =
|
||||
{
|
||||
baron_only: ["missing_in_worksmobile"],
|
||||
works_only: ["missing_in_baron", "missing_external_key"],
|
||||
matched: ["matched"],
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
const responseData = (error as { response?: { data?: unknown } })?.response
|
||||
?.data;
|
||||
if (typeof responseData === "string") {
|
||||
return responseData;
|
||||
}
|
||||
if (responseData && typeof responseData === "object") {
|
||||
const data = responseData as { error?: unknown; message?: unknown };
|
||||
if (typeof data.error === "string") {
|
||||
return data.error;
|
||||
}
|
||||
if (typeof data.message === "string") {
|
||||
return data.message;
|
||||
}
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function getWorksmobileComparisonStatusVariant(status: string) {
|
||||
if (status === "matched") {
|
||||
return "success";
|
||||
}
|
||||
if (status === "missing_external_key") {
|
||||
return "warning";
|
||||
}
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
function ComparisonSummary({
|
||||
title,
|
||||
summary,
|
||||
}: {
|
||||
title: string;
|
||||
summary: WorksmobileComparisonSummary;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
<Badge variant="outline">{summary.total}</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">WORKS 없음</span>
|
||||
<span className="font-mono">{summary.missingInWorksmobile}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Baron 없음</span>
|
||||
<span className="font-mono">{summary.missingInBaron}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">ex_key 없음</span>
|
||||
<span className="font-mono">{summary.missingExternalKey}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">일치</span>
|
||||
<span className="font-mono">{summary.matched}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonTable({
|
||||
title,
|
||||
rows,
|
||||
loading,
|
||||
selectedIds,
|
||||
onSelectedIdsChange,
|
||||
actionLabel,
|
||||
actionDisabled,
|
||||
onCreateSelected,
|
||||
}: {
|
||||
title: string;
|
||||
rows: WorksmobileComparisonItem[];
|
||||
loading: boolean;
|
||||
selectedIds: string[];
|
||||
onSelectedIdsChange: (ids: string[]) => void;
|
||||
actionLabel: string;
|
||||
actionDisabled: boolean;
|
||||
onCreateSelected: () => 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 toggleAll = (checked: boolean | "indeterminate") => {
|
||||
onSelectedIdsChange(checked === true ? creatableIds : []);
|
||||
};
|
||||
|
||||
const toggleRow = (
|
||||
id: string | undefined,
|
||||
checked: boolean | "indeterminate",
|
||||
) => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
if (checked === true) {
|
||||
onSelectedIdsChange([...new Set([...selectedIds, id])]);
|
||||
return;
|
||||
}
|
||||
onSelectedIdsChange(selectedIds.filter((selectedId) => selectedId !== id));
|
||||
};
|
||||
|
||||
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>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10 whitespace-nowrap">
|
||||
<Checkbox
|
||||
aria-label={`${title} 전체 선택`}
|
||||
checked={allCreatableSelected}
|
||||
disabled={creatableIds.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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-muted-foreground">
|
||||
불러오는 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!loading && rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-muted-foreground">
|
||||
표시할 차이가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{rows.map((row) => (
|
||||
<TableRow
|
||||
key={`${row.status}:${row.baronId ?? row.worksmobileId ?? row.externalKey}`}
|
||||
>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Checkbox
|
||||
aria-label={`${row.baronName ?? row.baronId ?? row.worksmobileName ?? row.worksmobileId ?? "row"} 선택`}
|
||||
checked={Boolean(
|
||||
row.baronId && selectedIds.includes(row.baronId),
|
||||
)}
|
||||
disabled={!canCreateWorksmobileRow(row)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleRow(row.baronId, 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 ?? ""}
|
||||
</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 ?? ""}
|
||||
</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>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonDomainCell({
|
||||
name,
|
||||
id,
|
||||
}: {
|
||||
name?: string;
|
||||
id?: number;
|
||||
}) {
|
||||
if (!name && !id) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div>{name ?? "-"}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">{id ?? ""}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonOrgCell({
|
||||
name,
|
||||
id,
|
||||
details = [],
|
||||
}: {
|
||||
name?: string;
|
||||
id?: string;
|
||||
details?: string[];
|
||||
}) {
|
||||
if (!name && !id && details.length === 0) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div>{name ?? "-"}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">{id ?? ""}</div>
|
||||
{details.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{details.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -120,7 +120,7 @@ describe("tenantCsvImport", () => {
|
||||
[
|
||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain",
|
||||
"local-parent-id,Parent Tenant,COMPANY,,parent-local,,",
|
||||
"local-child-id,Child Tenant,USER_GROUP,local-parent-id,child-local,,",
|
||||
"local-child-id,Child Tenant,ORGANIZATION,local-parent-id,child-local,,",
|
||||
].join("\n"),
|
||||
);
|
||||
const preview = buildTenantImportPreview(rows, tenants);
|
||||
@@ -141,7 +141,7 @@ describe("tenantCsvImport", () => {
|
||||
"staging-parent-id,Parent Tenant,COMPANY,,parent-staging,,",
|
||||
);
|
||||
expect(csv).toContain(
|
||||
"staging-child-id,Child Tenant,USER_GROUP,staging-parent-id,child-staging,,",
|
||||
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,child-staging,,",
|
||||
);
|
||||
expect(csv).not.toContain("local-parent-id");
|
||||
expect(csv).not.toContain("local-child-id");
|
||||
@@ -152,7 +152,7 @@ describe("tenantCsvImport", () => {
|
||||
[
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain",
|
||||
"Parent Tenant,COMPANY,,parent-slug,,",
|
||||
"Child Tenant,USER_GROUP,parent-slug,child-slug,,",
|
||||
"Child Tenant,ORGANIZATION,parent-slug,child-slug,,",
|
||||
].join("\n"),
|
||||
);
|
||||
const preview = buildTenantImportPreview(rows, tenants);
|
||||
@@ -171,7 +171,7 @@ describe("tenantCsvImport", () => {
|
||||
|
||||
expect(rows[1].parentTenantSlug).toBe("parent-slug");
|
||||
expect(csv).toContain(
|
||||
"staging-child-id,Child Tenant,USER_GROUP,staging-parent-id,child-slug,,",
|
||||
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,child-slug,,",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user