forked from baron/baron-sso
worksmobile 연동 & ory stack 26.2.0으로 업그레이드
This commit is contained in:
@@ -13,6 +13,7 @@ import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
||||
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
||||
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
||||
import { TenantWorksmobilePage } from "../features/tenants/routes/TenantWorksmobilePage";
|
||||
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
|
||||
import { UserGroupDetailPage } from "../features/user-groups/routes/UserGroupDetailPage";
|
||||
import UserCreatePage from "../features/users/UserCreatePage";
|
||||
@@ -49,6 +50,7 @@ export const router = createBrowserRouter(
|
||||
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
||||
{ path: "organization", element: <TenantUserGroupsTab /> },
|
||||
{ path: "schema", element: <TenantSchemaPage /> },
|
||||
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,,",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,6 +87,7 @@ const getTenantIcon = (type?: string) => {
|
||||
return Briefcase;
|
||||
case "PERSONAL":
|
||||
return UserCircle;
|
||||
case "ORGANIZATION":
|
||||
case "USER_GROUP":
|
||||
return Network;
|
||||
default:
|
||||
|
||||
@@ -44,6 +44,13 @@ import {
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../components/ui/select";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import {
|
||||
Tabs,
|
||||
@@ -78,6 +85,7 @@ import {
|
||||
parseOrgChartTenantSelection,
|
||||
} from "./orgChartPicker";
|
||||
import type { UserSchemaField } from "./userSchemaFields";
|
||||
import { userStatusLabel, userStatusValues } from "./userStatus";
|
||||
|
||||
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
||||
metadata: Record<string, Record<string, string | number | boolean>>;
|
||||
@@ -123,12 +131,40 @@ function createEmptyAppointment(): AppointmentDraft {
|
||||
tenantId: "",
|
||||
tenantName: "",
|
||||
tenantSlug: "",
|
||||
isPrimary: false,
|
||||
isOwner: false,
|
||||
jobTitle: "",
|
||||
position: "",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePrimaryAppointments(
|
||||
appointments: AppointmentDraft[],
|
||||
): AppointmentDraft[] {
|
||||
const leafIndexes = appointments
|
||||
.map((appointment, index) =>
|
||||
appointment.tenantId.trim().length > 0 ? index : -1,
|
||||
)
|
||||
.filter((index) => index >= 0);
|
||||
if (leafIndexes.length === 1) {
|
||||
const primaryIndex = leafIndexes[0];
|
||||
return appointments.map((appointment, index) => ({
|
||||
...appointment,
|
||||
isPrimary: index === primaryIndex,
|
||||
}));
|
||||
}
|
||||
const selectedIndex = appointments.findIndex(
|
||||
(appointment) => appointment.isPrimary === true,
|
||||
);
|
||||
return appointments.map((appointment, index) => ({
|
||||
...appointment,
|
||||
isPrimary:
|
||||
selectedIndex >= 0 &&
|
||||
index === selectedIndex &&
|
||||
appointment.tenantId.trim().length > 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function validateManualPassword(
|
||||
password: string,
|
||||
policy?: PasswordPolicyResponse,
|
||||
@@ -485,15 +521,17 @@ function UserDetailPage() {
|
||||
try {
|
||||
const tenant = await resolveTenantSelection(selection, tenants);
|
||||
setAdditionalAppointments((current) =>
|
||||
current.map((appointment, index) =>
|
||||
index === target.index
|
||||
? {
|
||||
...appointment,
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
tenantSlug: tenant.slug,
|
||||
}
|
||||
: appointment,
|
||||
normalizePrimaryAppointments(
|
||||
current.map((appointment, index) =>
|
||||
index === target.index
|
||||
? {
|
||||
...appointment,
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
tenantSlug: tenant.slug,
|
||||
}
|
||||
: appointment,
|
||||
),
|
||||
),
|
||||
);
|
||||
setPickerTarget(null);
|
||||
@@ -536,15 +574,30 @@ function UserDetailPage() {
|
||||
patch: Partial<UserAppointment>,
|
||||
) => {
|
||||
setAdditionalAppointments((current) =>
|
||||
current.map((appointment, currentIndex) =>
|
||||
currentIndex === index ? { ...appointment, ...patch } : appointment,
|
||||
normalizePrimaryAppointments(
|
||||
current.map((appointment, currentIndex) =>
|
||||
currentIndex === index ? { ...appointment, ...patch } : appointment,
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const setPrimaryAppointment = (index: number, checked: boolean) => {
|
||||
setAdditionalAppointments((current) =>
|
||||
normalizePrimaryAppointments(
|
||||
current.map((appointment, currentIndex) => ({
|
||||
...appointment,
|
||||
isPrimary: checked && currentIndex === index,
|
||||
})),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const removeAppointment = (index: number) => {
|
||||
setAdditionalAppointments((current) =>
|
||||
current.filter((_, currentIndex) => currentIndex !== index),
|
||||
normalizePrimaryAppointments(
|
||||
current.filter((_, currentIndex) => currentIndex !== index),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -602,7 +655,10 @@ function UserDetailPage() {
|
||||
tenantSlug:
|
||||
user.companyCode ||
|
||||
user.joinedTenants?.find(
|
||||
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
|
||||
(t) =>
|
||||
t.type === "COMPANY" ||
|
||||
t.type === "COMPANY_GROUP" ||
|
||||
t.type === "ORGANIZATION",
|
||||
)?.slug ||
|
||||
"",
|
||||
department: user.department || "",
|
||||
@@ -636,38 +692,45 @@ function UserDetailPage() {
|
||||
isHanmacFamilyTenant(tenant, tenants, hanmacFamilyTenantId),
|
||||
);
|
||||
setAdditionalAppointments(
|
||||
Array.isArray(rawAppointments)
|
||||
? (rawAppointments as UserAppointment[]).map((appointment) => ({
|
||||
...appointment,
|
||||
draftId: createDraftId(),
|
||||
}))
|
||||
: isUserHanmacFamily
|
||||
? familyFallbackTenants.length > 0
|
||||
? familyFallbackTenants.map((tenant) => ({
|
||||
draftId: createDraftId(),
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
tenantSlug: tenant.slug,
|
||||
isOwner:
|
||||
metadata.primaryTenantIsOwner === true &&
|
||||
tenant.id === fallbackAppointment?.id,
|
||||
jobTitle: user.jobTitle,
|
||||
position: user.position,
|
||||
}))
|
||||
: fallbackAppointment
|
||||
? [
|
||||
{
|
||||
draftId: createDraftId(),
|
||||
tenantId: fallbackAppointment.id,
|
||||
tenantName: fallbackAppointment.name,
|
||||
tenantSlug: fallbackAppointment.slug,
|
||||
isOwner: metadata.primaryTenantIsOwner === true,
|
||||
jobTitle: user.jobTitle,
|
||||
position: user.position,
|
||||
},
|
||||
]
|
||||
: []
|
||||
: [],
|
||||
normalizePrimaryAppointments(
|
||||
Array.isArray(rawAppointments)
|
||||
? (rawAppointments as UserAppointment[]).map((appointment) => ({
|
||||
...appointment,
|
||||
isPrimary:
|
||||
appointment.isPrimary === true ||
|
||||
appointment.tenantId === metadata.primaryTenantId,
|
||||
draftId: createDraftId(),
|
||||
}))
|
||||
: isUserHanmacFamily
|
||||
? familyFallbackTenants.length > 0
|
||||
? familyFallbackTenants.map((tenant) => ({
|
||||
draftId: createDraftId(),
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
tenantSlug: tenant.slug,
|
||||
isPrimary: tenant.id === fallbackAppointment?.id,
|
||||
isOwner:
|
||||
metadata.primaryTenantIsOwner === true &&
|
||||
tenant.id === fallbackAppointment?.id,
|
||||
jobTitle: user.jobTitle,
|
||||
position: user.position,
|
||||
}))
|
||||
: fallbackAppointment
|
||||
? [
|
||||
{
|
||||
draftId: createDraftId(),
|
||||
tenantId: fallbackAppointment.id,
|
||||
tenantName: fallbackAppointment.name,
|
||||
tenantSlug: fallbackAppointment.slug,
|
||||
isPrimary: true,
|
||||
isOwner: metadata.primaryTenantIsOwner === true,
|
||||
jobTitle: user.jobTitle,
|
||||
position: user.position,
|
||||
},
|
||||
]
|
||||
: []
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [hanmacFamilyTenantId, tenants, user, reset]);
|
||||
@@ -748,19 +811,37 @@ function UserDetailPage() {
|
||||
tenantId: appointment.tenantId,
|
||||
tenantSlug: appointment.tenantSlug,
|
||||
tenantName: appointment.tenantName,
|
||||
isPrimary: appointment.isPrimary === true,
|
||||
isOwner: appointment.isOwner,
|
||||
jobTitle: appointment.jobTitle,
|
||||
position: appointment.position,
|
||||
}));
|
||||
const primaryAppointment = appointments.find(
|
||||
(appointment) => appointment.isPrimary,
|
||||
);
|
||||
|
||||
payload.tenantSlug = undefined;
|
||||
payload.department = undefined;
|
||||
payload.position = undefined;
|
||||
payload.jobTitle = undefined;
|
||||
payload.additionalAppointments = appointments;
|
||||
if (primaryAppointment) {
|
||||
payload.tenantSlug = primaryAppointment.tenantSlug;
|
||||
payload.primaryTenantId = primaryAppointment.tenantId;
|
||||
payload.primaryTenantName = primaryAppointment.tenantName;
|
||||
payload.primaryTenantIsOwner = primaryAppointment.isOwner;
|
||||
}
|
||||
payload.metadata = {
|
||||
...metadata,
|
||||
additionalAppointments: appointments,
|
||||
...(primaryAppointment
|
||||
? {
|
||||
primaryTenantId: primaryAppointment.tenantId,
|
||||
primaryTenantName: primaryAppointment.tenantName,
|
||||
primaryTenantSlug: primaryAppointment.tenantSlug,
|
||||
primaryTenantIsOwner: primaryAppointment.isOwner,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -791,6 +872,9 @@ function UserDetailPage() {
|
||||
filterNonHanmacFamilyTenants(userAffiliatedTenants, hanmacFamilyTenantId),
|
||||
[userAffiliatedTenants, hanmacFamilyTenantId],
|
||||
);
|
||||
const primaryAppointmentLeafCount = additionalAppointments.filter(
|
||||
(appointment) => appointment.tenantId.trim().length > 0,
|
||||
).length;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -857,7 +941,10 @@ function UserDetailPage() {
|
||||
{user.tenant?.name ||
|
||||
user.companyCode ||
|
||||
user.joinedTenants?.find(
|
||||
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
|
||||
(t) =>
|
||||
t.type === "COMPANY" ||
|
||||
t.type === "COMPANY_GROUP" ||
|
||||
t.type === "ORGANIZATION",
|
||||
)?.name ||
|
||||
t("ui.admin.users.detail.form.tenant_global", "시스템 전역")}
|
||||
</Badge>
|
||||
@@ -1001,21 +1088,23 @@ function UserDetailPage() {
|
||||
>
|
||||
{t("ui.admin.users.detail.form.status", "상태")}
|
||||
</Label>
|
||||
<div className="flex h-11 items-center gap-3 rounded-md border border-input bg-background px-3">
|
||||
<Switch
|
||||
id="status"
|
||||
checked={watchedStatus === "active"}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue("status", checked ? "active" : "inactive")
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
`ui.common.status.${watchedStatus}`,
|
||||
watchedStatus || "inactive",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Select
|
||||
value={watchedStatus || "inactive"}
|
||||
onValueChange={(status) => setValue("status", status)}
|
||||
>
|
||||
<SelectTrigger id="status" className="h-11">
|
||||
<SelectValue>
|
||||
{userStatusLabel(watchedStatus || "inactive")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userStatusValues.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{userStatusLabel(status)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1160,6 +1249,26 @@ function UserDetailPage() {
|
||||
{appointment.tenantSlug}
|
||||
</span>
|
||||
)}
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<Switch
|
||||
aria-label={t(
|
||||
"ui.admin.users.detail.form.appointment_primary",
|
||||
"대표 조직",
|
||||
)}
|
||||
checked={appointment.isPrimary === true}
|
||||
disabled={
|
||||
!appointment.tenantId ||
|
||||
primaryAppointmentLeafCount <= 1
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
setPrimaryAppointment(index, checked)
|
||||
}
|
||||
/>
|
||||
{t(
|
||||
"ui.admin.users.detail.form.appointment_primary",
|
||||
"대표 조직",
|
||||
)}
|
||||
</label>
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<Checkbox
|
||||
checked={appointment.isOwner}
|
||||
|
||||
@@ -32,7 +32,13 @@ import {
|
||||
DialogTrigger,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -55,6 +61,7 @@ import {
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
|
||||
import { userStatusLabel, userStatusValues } from "./userStatus";
|
||||
|
||||
type UserSchemaField = {
|
||||
key: string;
|
||||
@@ -579,28 +586,40 @@ function UserListPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={user.status === "active"}
|
||||
onCheckedChange={(checked) =>
|
||||
<Select
|
||||
value={user.status}
|
||||
onValueChange={(status) =>
|
||||
statusMutation.mutate({
|
||||
userId: user.id,
|
||||
status: checked ? "active" : "inactive",
|
||||
status,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
statusMutation.isPending ||
|
||||
user.id === profile?.id
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.admin.users.list.toggle_status",
|
||||
"{{name}} 활성 상태",
|
||||
{ name: user.name },
|
||||
)}
|
||||
data-testid={`user-status-toggle-${user.id}`}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</span>
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-36"
|
||||
aria-label={t(
|
||||
"ui.admin.users.list.status_select",
|
||||
"{{name}} 상태",
|
||||
{ name: user.name },
|
||||
)}
|
||||
data-testid={`user-status-select-${user.id}`}
|
||||
>
|
||||
<SelectValue>
|
||||
{userStatusLabel(user.status)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userStatusValues.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{userStatusLabel(status)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Dynamic Metadata Cells */}
|
||||
@@ -683,6 +702,24 @@ function UserListPage() {
|
||||
>
|
||||
{t("ui.common.status.inactive", "비활성화")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-background hover:bg-background/10 h-8"
|
||||
onClick={() => handleBulkStatusChange("suspended")}
|
||||
data-testid="bulk-suspended-btn"
|
||||
>
|
||||
{t("ui.common.status.suspended", "정지")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-background hover:bg-background/10 h-8"
|
||||
onClick={() => handleBulkStatusChange("leave_of_absence")}
|
||||
data-testid="bulk-leave-of-absence-btn"
|
||||
>
|
||||
{t("ui.common.status.leave_of_absence", "휴직")}
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
14
adminfront/src/features/users/userStatus.ts
Normal file
14
adminfront/src/features/users/userStatus.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
export const userStatusValues = [
|
||||
"active",
|
||||
"inactive",
|
||||
"suspended",
|
||||
"leave_of_absence",
|
||||
] as const;
|
||||
|
||||
export type UserStatusValue = (typeof userStatusValues)[number];
|
||||
|
||||
export function userStatusLabel(status: string) {
|
||||
return t(`ui.common.status.${status}`, status);
|
||||
}
|
||||
@@ -44,6 +44,36 @@ test@test.com,Test,baron`;
|
||||
expect(result[0].tenantSlug).toBe("baron");
|
||||
});
|
||||
|
||||
it("should parse NAVERWORKS member CSV sample into Baron bulk user fields", () => {
|
||||
const csv = `"LastName","FirstName","ID","Personal email","Sub email","Nickname","User type","Level","Organization","Position","CompanyMainPhone","Mobile/Country code","Mobile/Numbers","Language","Responsibilities","Workplace","SNS","SNS_ID","Birthday (solar, lunar)","Birthday","Entry Date","Employee number","Account activation time"
|
||||
"Doe","John","john.doe","john@naver.com","john1@company.com; john2@company.com","John","Permanent Employee","Manager","org.1|org.2|org.3|myteam","Manager","02-0000-0000","+1","9144812222","English","Sales management","New York","Facebook","john","solar","19830415","20230415","AB001","20230415 08:00"`;
|
||||
|
||||
const result = parseUserCSV(csv);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
email: "john1@company.com",
|
||||
loginId: "john.doe",
|
||||
name: "John Doe",
|
||||
phone: "+19144812222",
|
||||
department: "myteam",
|
||||
position: "Manager",
|
||||
jobTitle: "Sales management",
|
||||
tenantImport: {
|
||||
name: "myteam",
|
||||
parentTenantName: "org.3",
|
||||
},
|
||||
metadata: {
|
||||
personal_email: "john@naver.com",
|
||||
employee_id: "AB001",
|
||||
naverworks_user_type: "Permanent Employee",
|
||||
naverworks_level: "Manager",
|
||||
naverworks_organization_path: "org.1|org.2|org.3|myteam",
|
||||
naverworks_workplace: "New York",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse tenant conflict metadata for import resolution", () => {
|
||||
const csv = `email,name,tenant_id,tenant_slug,tenant_name,tenant_type,parent_tenant_slug,tenant_memo,email_domain
|
||||
test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-slug,Imported memo,missing.example.com`;
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import type { BulkUserItem } from "../../../lib/adminApi";
|
||||
|
||||
export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
const lines = text.split(/\r?\n/);
|
||||
if (lines.length < 2) {
|
||||
const records = parseCSVRecords(text.replace(/^\uFEFF/, ""));
|
||||
if (records.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
const headers = records[0].map(normalizeHeader);
|
||||
const data: BulkUserItem[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (!lines[i].trim()) continue;
|
||||
|
||||
const values = lines[i].split(",").map((v) => v.trim());
|
||||
for (let i = 1; i < records.length; i++) {
|
||||
const values = records[i].map((v) => v.trim());
|
||||
if (values.every((value) => value === "")) continue;
|
||||
const item: Partial<BulkUserItem> & { metadata: Record<string, string> } = {
|
||||
metadata: {},
|
||||
};
|
||||
@@ -84,11 +83,70 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
item.position = value;
|
||||
} else if (header === "jobtitle") {
|
||||
item.jobTitle = value;
|
||||
} else if (header === "lastname") {
|
||||
item.metadata.naverworks_last_name = value;
|
||||
} else if (header === "firstname") {
|
||||
item.metadata.naverworks_first_name = value;
|
||||
} else if (header === "id") {
|
||||
item.loginId = value;
|
||||
item.metadata.naverworks_id = value;
|
||||
} else if (header === "personalemail") {
|
||||
item.metadata.personal_email = value;
|
||||
} else if (header === "subemail") {
|
||||
item.metadata.naverworks_sub_email = value;
|
||||
item.email = firstEmailToken(value) || item.email;
|
||||
} else if (header === "nickname") {
|
||||
item.metadata.naverworks_nickname = value;
|
||||
} else if (header === "usertype") {
|
||||
item.metadata.naverworks_user_type = value;
|
||||
} else if (header === "level") {
|
||||
item.metadata.naverworks_level = value;
|
||||
} else if (header === "organization") {
|
||||
item.metadata.naverworks_organization_path = value;
|
||||
const parts = splitOrganizationPath(value);
|
||||
const leaf = parts.at(-1) ?? "";
|
||||
const parent = parts.at(-2) ?? "";
|
||||
if (leaf) {
|
||||
item.department = leaf;
|
||||
item.tenantImport = {
|
||||
...(item.tenantImport ?? {}),
|
||||
name: leaf,
|
||||
parentTenantName: parent,
|
||||
};
|
||||
}
|
||||
} else if (header === "companymainphone") {
|
||||
item.metadata.naverworks_company_main_phone = value;
|
||||
} else if (header === "mobilecountrycode") {
|
||||
item.metadata.naverworks_mobile_country_code = value;
|
||||
} else if (header === "mobilenumbers") {
|
||||
item.metadata.naverworks_mobile_numbers = value;
|
||||
} else if (header === "language") {
|
||||
item.metadata.naverworks_language = value;
|
||||
} else if (header === "responsibilities") {
|
||||
item.jobTitle = value;
|
||||
} else if (header === "workplace") {
|
||||
item.metadata.naverworks_workplace = value;
|
||||
} else if (header === "sns") {
|
||||
item.metadata.naverworks_sns = value;
|
||||
} else if (header === "snsid") {
|
||||
item.metadata.naverworks_sns_id = value;
|
||||
} else if (header === "birthdaysolarlunar") {
|
||||
item.metadata.naverworks_birthday_calendar = value;
|
||||
} else if (header === "birthday") {
|
||||
item.metadata.naverworks_birthday = value;
|
||||
} else if (header === "entrydate") {
|
||||
item.metadata.naverworks_entry_date = value;
|
||||
} else if (header === "employeenumber") {
|
||||
item.metadata.employee_id = value;
|
||||
} else if (header === "accountactivationtime") {
|
||||
item.metadata.naverworks_account_activation_time = value;
|
||||
} else {
|
||||
item.metadata[header] = value;
|
||||
}
|
||||
}
|
||||
|
||||
applyNaverWorksFallbacks(item);
|
||||
|
||||
if (item.email && item.name) {
|
||||
data.push(item as BulkUserItem);
|
||||
}
|
||||
@@ -96,3 +154,100 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function normalizeHeader(header: string) {
|
||||
return header
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\uFEFF/, "")
|
||||
.replace(/[^a-z0-9_]/g, "");
|
||||
}
|
||||
|
||||
function parseCSVRecords(text: string) {
|
||||
const records: string[][] = [];
|
||||
let field = "";
|
||||
let row: string[] = [];
|
||||
let quoted = false;
|
||||
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const char = text[index];
|
||||
const next = text[index + 1];
|
||||
|
||||
if (char === '"') {
|
||||
if (quoted && next === '"') {
|
||||
field += '"';
|
||||
index++;
|
||||
} else {
|
||||
quoted = !quoted;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "," && !quoted) {
|
||||
row.push(field);
|
||||
field = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((char === "\n" || char === "\r") && !quoted) {
|
||||
if (char === "\r" && next === "\n") index++;
|
||||
row.push(field);
|
||||
records.push(row);
|
||||
field = "";
|
||||
row = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
field += char;
|
||||
}
|
||||
|
||||
if (field !== "" || row.length > 0) {
|
||||
row.push(field);
|
||||
records.push(row);
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
function firstEmailToken(value: string) {
|
||||
return (
|
||||
value
|
||||
.split(/[;,]/)
|
||||
.map((token) => token.trim())
|
||||
.find((token) => token.includes("@")) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function splitOrganizationPath(value: string) {
|
||||
return value
|
||||
.split("|")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function applyNaverWorksFallbacks(
|
||||
item: Partial<BulkUserItem> & { metadata: Record<string, string> },
|
||||
) {
|
||||
if (!item.name) {
|
||||
const firstName = item.metadata.naverworks_first_name ?? "";
|
||||
const lastName = item.metadata.naverworks_last_name ?? "";
|
||||
item.name = [firstName, lastName].filter(Boolean).join(" ").trim();
|
||||
if (!item.name && item.metadata.naverworks_nickname) {
|
||||
item.name = item.metadata.naverworks_nickname;
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.email) {
|
||||
item.email = item.metadata.personal_email;
|
||||
}
|
||||
|
||||
if (!item.phone) {
|
||||
const countryCode = item.metadata.naverworks_mobile_country_code ?? "";
|
||||
const number = item.metadata.naverworks_mobile_numbers ?? "";
|
||||
item.phone = `${countryCode}${number}`.replace(/\s/g, "");
|
||||
}
|
||||
|
||||
if (!item.position && item.metadata.naverworks_level) {
|
||||
item.position = item.metadata.naverworks_level;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export type AuditLogListResponse = {
|
||||
|
||||
export type TenantSummary = {
|
||||
id: string;
|
||||
type: string; // PERSONAL, COMPANY, COMPANY_GROUP, USER_GROUP
|
||||
type: string; // 허용 타입: PERSONAL, COMPANY, COMPANY_GROUP, ORGANIZATION, USER_GROUP
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
@@ -447,6 +447,7 @@ export type UserAppointment = {
|
||||
tenantId: string;
|
||||
tenantSlug?: string;
|
||||
tenantName: string;
|
||||
isPrimary?: boolean;
|
||||
isOwner: boolean;
|
||||
jobTitle?: string;
|
||||
position?: string;
|
||||
@@ -491,6 +492,60 @@ export type BulkUserResponse = {
|
||||
results: BulkUserResult[];
|
||||
};
|
||||
|
||||
export type WorksmobileOutboxItem = {
|
||||
id: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
action: string;
|
||||
status: string;
|
||||
retryCount: number;
|
||||
lastError?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type WorksmobileOverview = {
|
||||
tenant: TenantSummary;
|
||||
config: {
|
||||
enabled: boolean;
|
||||
tokenConfigured: boolean;
|
||||
};
|
||||
recentJobs: WorksmobileOutboxItem[];
|
||||
};
|
||||
|
||||
export type WorksmobileComparisonItem = {
|
||||
resourceType: string;
|
||||
baronId?: string;
|
||||
baronName?: string;
|
||||
baronEmail?: string;
|
||||
baronPrimaryOrgId?: string;
|
||||
baronPrimaryOrgName?: string;
|
||||
baronParentId?: string;
|
||||
baronParentName?: string;
|
||||
worksmobileId?: string;
|
||||
externalKey?: string;
|
||||
worksmobileName?: string;
|
||||
worksmobileEmail?: string;
|
||||
worksmobileLevelId?: string;
|
||||
worksmobileLevelName?: string;
|
||||
worksmobileTask?: string;
|
||||
worksmobileDomainId?: number;
|
||||
worksmobileDomainName?: string;
|
||||
worksmobilePrimaryOrgId?: string;
|
||||
worksmobilePrimaryOrgName?: string;
|
||||
worksmobilePrimaryOrgPositionId?: string;
|
||||
worksmobilePrimaryOrgPositionName?: string;
|
||||
worksmobilePrimaryOrgIsManager?: boolean;
|
||||
worksmobileParentId?: string;
|
||||
worksmobileParentName?: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type WorksmobileComparison = {
|
||||
users: WorksmobileComparisonItem[];
|
||||
groups: WorksmobileComparisonItem[];
|
||||
};
|
||||
|
||||
export async function fetchUsers(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
@@ -561,12 +616,86 @@ export async function bulkCreateUsers(users: BulkUserItem[]) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchWorksmobileOverview(tenantId: string) {
|
||||
const { data } = await apiClient.get<WorksmobileOverview>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchWorksmobileComparison(
|
||||
tenantId: string,
|
||||
includeMatched = false,
|
||||
) {
|
||||
const { data } = await apiClient.get<WorksmobileComparison>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/comparison`,
|
||||
{
|
||||
params: { includeMatched },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function downloadWorksmobileInitialPasswordsCSV(tenantId: string) {
|
||||
const response = await apiClient.get<Blob>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/initial-passwords.csv`,
|
||||
{
|
||||
responseType: "blob",
|
||||
},
|
||||
);
|
||||
const dispositionHeader = response.headers["content-disposition"];
|
||||
const disposition = Array.isArray(dispositionHeader)
|
||||
? dispositionHeader[0]
|
||||
: String(dispositionHeader ?? "");
|
||||
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/i);
|
||||
return {
|
||||
blob: response.data,
|
||||
filename: filenameMatch?.[1] ?? "worksmobile_initial_passwords.csv",
|
||||
};
|
||||
}
|
||||
|
||||
export async function enqueueWorksmobileBackfillDryRun(tenantId: string) {
|
||||
const { data } = await apiClient.post(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/backfill/dry-run`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function enqueueWorksmobileOrgUnitSync(
|
||||
tenantId: string,
|
||||
orgUnitId: string,
|
||||
) {
|
||||
const { data } = await apiClient.post<WorksmobileOutboxItem>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${orgUnitId}/sync`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function enqueueWorksmobileUserSync(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const { data } = await apiClient.post<WorksmobileOutboxItem>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/sync`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function retryWorksmobileJob(tenantId: string, jobId: string) {
|
||||
const { data } = await apiClient.post(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/jobs/${jobId}/retry`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function bulkUpdateUsers(payload: {
|
||||
userIds: string[];
|
||||
status?: string;
|
||||
role?: string;
|
||||
tenantSlug?: string;
|
||||
department?: string;
|
||||
position?: string;
|
||||
jobTitle?: string;
|
||||
}) {
|
||||
const requestPayload: typeof payload & { companyCode?: string } = {
|
||||
...payload,
|
||||
|
||||
@@ -16,6 +16,7 @@ saman = "Saman"
|
||||
[domain.tenant_type]
|
||||
company = "Company"
|
||||
company_group = "Company Group"
|
||||
organization = "Organization"
|
||||
personal = "Personal"
|
||||
user_group = "User Group"
|
||||
|
||||
@@ -1282,9 +1283,12 @@ active = "Active"
|
||||
blocked = "Blocked"
|
||||
failure = "Failure"
|
||||
inactive = "Inactive"
|
||||
leave_of_absence = "Leave of absence"
|
||||
ok = "Ok"
|
||||
pending = "Pending"
|
||||
status = "Status"
|
||||
success = "Success"
|
||||
suspended = "Suspended"
|
||||
|
||||
[test]
|
||||
key = "Test"
|
||||
|
||||
@@ -16,6 +16,7 @@ saman = "삼안"
|
||||
[domain.tenant_type]
|
||||
company = "COMPANY (일반 기업)"
|
||||
company_group = "COMPANY_GROUP (그룹사/지주사)"
|
||||
organization = "ORGANIZATION (정규 조직)"
|
||||
personal = "PERSONAL (개인 워크스페이스)"
|
||||
user_group = "USER_GROUP (내부 부서/팀)"
|
||||
|
||||
@@ -1284,9 +1285,12 @@ active = "활성"
|
||||
blocked = "차단됨"
|
||||
failure = "실패"
|
||||
inactive = "비활성"
|
||||
leave_of_absence = "휴직"
|
||||
ok = "정상"
|
||||
pending = "준비 중"
|
||||
status = "상태"
|
||||
success = "성공"
|
||||
suspended = "정지"
|
||||
|
||||
[test]
|
||||
key = "테스트"
|
||||
|
||||
@@ -16,6 +16,7 @@ saman = ""
|
||||
[domain.tenant_type]
|
||||
company = ""
|
||||
company_group = ""
|
||||
organization = ""
|
||||
personal = ""
|
||||
user_group = ""
|
||||
|
||||
@@ -1284,9 +1285,12 @@ active = ""
|
||||
blocked = ""
|
||||
failure = ""
|
||||
inactive = ""
|
||||
leave_of_absence = ""
|
||||
ok = ""
|
||||
pending = ""
|
||||
status = ""
|
||||
success = ""
|
||||
suspended = ""
|
||||
|
||||
[test]
|
||||
key = ""
|
||||
|
||||
Reference in New Issue
Block a user