forked from baron/baron-sso
1871 lines
63 KiB
TypeScript
1871 lines
63 KiB
TypeScript
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
import {
|
|
KeyRound,
|
|
RefreshCw,
|
|
RotateCcw,
|
|
Settings2,
|
|
Trash2,
|
|
} 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 {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "../../../components/ui/dialog";
|
|
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 {
|
|
deleteWorksmobilePendingJobs,
|
|
enqueueWorksmobileBackfillDryRun,
|
|
enqueueWorksmobileOrgUnitDelete,
|
|
enqueueWorksmobileOrgUnitSync,
|
|
enqueueWorksmobileUserSync,
|
|
fetchMe,
|
|
fetchWorksmobileComparison,
|
|
fetchWorksmobileOverview,
|
|
retryWorksmobileJob,
|
|
type WorksmobileComparisonItem,
|
|
type WorksmobileOutboxItem,
|
|
} from "../../../lib/adminApi";
|
|
import { t } from "../../../lib/i18n";
|
|
import {
|
|
canAccessWorksmobile,
|
|
HANMAC_FAMILY_TENANT_ID,
|
|
} from "./worksmobileAccess";
|
|
import {
|
|
buildWorksmobilePasswordManageUrl,
|
|
canOpenWorksmobilePasswordManage,
|
|
canSelectWorksmobileRow,
|
|
comparisonFilterOptions,
|
|
filterVisibleWorksmobileComparisonRows,
|
|
filterWorksmobileComparisonRows,
|
|
filterWorksmobileComparisonRowsBySearch,
|
|
formatWorksmobileOrgDetails,
|
|
formatWorksmobilePersonName,
|
|
formatWorksmobileUpdateDetails,
|
|
getDefaultGroupComparisonFilters,
|
|
getDefaultUserComparisonFilters,
|
|
getDefaultWorksmobileComparisonColumns,
|
|
getWorksmobileComparisonStatusLabel,
|
|
getWorksmobileRowSelectionKey,
|
|
getWorksmobileSelectedActionIds,
|
|
getWorksmobileSelectedCreateUserIds,
|
|
getWorksmobileSelectedUpdateUserIds,
|
|
getWorksmobileSelectedWorksOnlyOrgUnitIds,
|
|
summarizeWorksmobileComparison,
|
|
type WorksmobileComparisonColumnKey,
|
|
type WorksmobileComparisonColumnVisibility,
|
|
type WorksmobileComparisonFilter,
|
|
type WorksmobileComparisonSummary,
|
|
} from "./worksmobileComparison";
|
|
|
|
function worksmobileJobPayloadString(job: WorksmobileOutboxItem, key: string) {
|
|
const value = job.payload?.[key];
|
|
return typeof value === "string" ? value.trim() : "";
|
|
}
|
|
|
|
function worksmobileJobRequestSummary(job: WorksmobileOutboxItem) {
|
|
const summary = job.payload?.requestSummary;
|
|
if (!summary || typeof summary !== "object" || Array.isArray(summary)) {
|
|
return {};
|
|
}
|
|
return summary as Record<string, unknown>;
|
|
}
|
|
|
|
function worksmobileSummaryString(
|
|
summary: Record<string, unknown>,
|
|
key: string,
|
|
) {
|
|
const value = summary[key];
|
|
if (typeof value === "string") {
|
|
return value.trim();
|
|
}
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return String(value);
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function formatWorksmobileJobTarget(job: WorksmobileOutboxItem) {
|
|
const summary = worksmobileJobRequestSummary(job);
|
|
return (
|
|
worksmobileJobPayloadString(job, "displayName") ||
|
|
worksmobileSummaryString(summary, "displayName") ||
|
|
worksmobileSummaryString(summary, "orgUnitName") ||
|
|
worksmobileJobPayloadString(job, "name") ||
|
|
worksmobileJobPayloadString(job, "loginEmail") ||
|
|
worksmobileJobPayloadString(job, "email") ||
|
|
job.resourceId
|
|
);
|
|
}
|
|
|
|
function formatWorksmobileJobTargetSubtext(job: WorksmobileOutboxItem) {
|
|
const summary = worksmobileJobRequestSummary(job);
|
|
return (
|
|
worksmobileJobPayloadString(job, "loginEmail") ||
|
|
worksmobileSummaryString(summary, "email") ||
|
|
worksmobileJobPayloadString(job, "email") ||
|
|
worksmobileJobPayloadString(job, "externalKey") ||
|
|
worksmobileSummaryString(summary, "orgUnitExternalKey") ||
|
|
job.resourceId
|
|
);
|
|
}
|
|
|
|
function formatWorksmobileJobSummaryParts(job: WorksmobileOutboxItem) {
|
|
const summary = worksmobileJobRequestSummary(job);
|
|
const parts = [
|
|
worksmobileJobPayloadString(job, "primaryLeafOrgName"),
|
|
worksmobileJobPayloadString(job, "matchLocalPart"),
|
|
worksmobileSummaryString(summary, "parentOrgUnitId"),
|
|
worksmobileSummaryString(summary, "employeeNumber"),
|
|
worksmobileSummaryString(summary, "task"),
|
|
].filter(Boolean);
|
|
return Array.from(new Set(parts));
|
|
}
|
|
|
|
function formatWorksmobileJobPayload(job: WorksmobileOutboxItem) {
|
|
if (!job.payload || Object.keys(job.payload).length === 0) {
|
|
return "";
|
|
}
|
|
return JSON.stringify(
|
|
job.payload,
|
|
(key, value) => {
|
|
if (typeof key === "string" && key.toLowerCase().includes("password")) {
|
|
return "[redacted]";
|
|
}
|
|
return value;
|
|
},
|
|
2,
|
|
);
|
|
}
|
|
|
|
function pageTabClassName(active: boolean) {
|
|
return `relative px-6 py-3 text-sm font-medium transition-colors ${
|
|
active
|
|
? "border-b-2 border-primary text-primary"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`;
|
|
}
|
|
|
|
export function TenantWorksmobilePage() {
|
|
const params = useParams<{ tenantId: string }>();
|
|
const tenantId = params.tenantId ?? HANMAC_FAMILY_TENANT_ID;
|
|
const [orgUnitId, setOrgUnitId] = React.useState("");
|
|
const [userId, setUserId] = React.useState("");
|
|
const [activeTab, setActiveTab] = React.useState("users");
|
|
const [userFilters, setUserFilters] = React.useState<
|
|
WorksmobileComparisonFilter[]
|
|
>(getDefaultUserComparisonFilters);
|
|
const [groupFilters, setGroupFilters] = React.useState<
|
|
WorksmobileComparisonFilter[]
|
|
>(getDefaultGroupComparisonFilters);
|
|
const [includeUserMissingExternalKey, setIncludeUserMissingExternalKey] =
|
|
React.useState(false);
|
|
const [includeGroupMissingExternalKey, setIncludeGroupMissingExternalKey] =
|
|
React.useState(false);
|
|
const [userSearch, setUserSearch] = React.useState("");
|
|
const [groupSearch, setGroupSearch] = React.useState("");
|
|
const [selectedUserRowKeys, setSelectedUserRowKeys] = React.useState<
|
|
string[]
|
|
>([]);
|
|
const [selectedGroupRowKeys, setSelectedGroupRowKeys] = React.useState<
|
|
string[]
|
|
>([]);
|
|
const [userVisibleColumns, setUserVisibleColumns] = React.useState(
|
|
getDefaultWorksmobileComparisonColumns,
|
|
);
|
|
const [groupVisibleColumns, setGroupVisibleColumns] = React.useState(
|
|
getDefaultGroupWorksmobileComparisonColumns,
|
|
);
|
|
|
|
const profileQuery = useQuery({
|
|
queryKey: ["me"],
|
|
queryFn: fetchMe,
|
|
});
|
|
const hasWorksmobileAccess = canAccessWorksmobile(profileQuery.data);
|
|
|
|
const overviewQuery = useQuery({
|
|
queryKey: ["worksmobile", tenantId],
|
|
queryFn: () => fetchWorksmobileOverview(tenantId),
|
|
enabled: tenantId.length > 0 && hasWorksmobileAccess,
|
|
});
|
|
|
|
const comparisonQuery = useQuery({
|
|
queryKey: ["worksmobile-comparison", tenantId],
|
|
queryFn: () => fetchWorksmobileComparison(tenantId, true),
|
|
enabled: tenantId.length > 0 && hasWorksmobileAccess,
|
|
});
|
|
|
|
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 deletePendingJobsMutation = useMutation({
|
|
mutationFn: () => deleteWorksmobilePendingJobs(tenantId),
|
|
onSuccess: (result) => {
|
|
toast.success(`대기중 payload ${result.deletedCount}건을 삭제했습니다.`);
|
|
overviewQuery.refetch();
|
|
},
|
|
onError: (error) => {
|
|
toast.error("대기중 payload 삭제 실패", {
|
|
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,
|
|
initialPassword,
|
|
}: {
|
|
resourceKind: "users" | "groups";
|
|
ids: string[];
|
|
initialPassword?: string;
|
|
}) => {
|
|
const trimmedInitialPassword = initialPassword?.trim();
|
|
const failures: string[] = [];
|
|
let successCount = 0;
|
|
for (const id of ids) {
|
|
try {
|
|
if (resourceKind === "users") {
|
|
await enqueueWorksmobileUserSync(
|
|
tenantId,
|
|
id,
|
|
undefined,
|
|
trimmedInitialPassword,
|
|
);
|
|
} else {
|
|
await enqueueWorksmobileOrgUnitSync(tenantId, id);
|
|
}
|
|
successCount += 1;
|
|
} catch (error) {
|
|
failures.push(`${id}: ${getErrorMessage(error)}`);
|
|
}
|
|
}
|
|
|
|
if (successCount === 0 && failures.length > 0) {
|
|
throw new Error(failures.slice(0, 3).join("\n"));
|
|
}
|
|
|
|
return {
|
|
resourceKind,
|
|
count: successCount,
|
|
failureCount: failures.length,
|
|
};
|
|
},
|
|
onSuccess: ({ resourceKind, count, failureCount }) => {
|
|
if (resourceKind === "users") {
|
|
setSelectedUserRowKeys([]);
|
|
} else {
|
|
setSelectedGroupRowKeys([]);
|
|
}
|
|
if (failureCount > 0) {
|
|
toast.error("일부 WORKS 생성 작업 등록 실패", {
|
|
description: `성공 ${count}건, 실패 ${failureCount}건`,
|
|
});
|
|
} else {
|
|
toast.success("WORKS 생성 작업을 등록했습니다.", {
|
|
description: `${count}건`,
|
|
});
|
|
}
|
|
overviewQuery.refetch();
|
|
comparisonQuery.refetch();
|
|
},
|
|
onError: (error) => {
|
|
toast.error("WORKS 생성 작업 등록 실패", {
|
|
description: getErrorMessage(error),
|
|
});
|
|
},
|
|
});
|
|
|
|
const syncSelectedOrgUnitsMutation = useMutation({
|
|
mutationFn: async ({
|
|
baronIds,
|
|
worksmobileIds,
|
|
}: {
|
|
baronIds: string[];
|
|
worksmobileIds: string[];
|
|
}) => {
|
|
for (const id of baronIds) {
|
|
await enqueueWorksmobileOrgUnitSync(tenantId, id);
|
|
}
|
|
for (const id of worksmobileIds) {
|
|
await enqueueWorksmobileOrgUnitDelete(tenantId, id);
|
|
}
|
|
return {
|
|
upsertCount: baronIds.length,
|
|
deleteOrReconcileCount: worksmobileIds.length,
|
|
};
|
|
},
|
|
onSuccess: ({ upsertCount, deleteOrReconcileCount }) => {
|
|
setSelectedGroupRowKeys([]);
|
|
toast.success("선택 조직 동기화 작업을 등록했습니다.", {
|
|
description: `upsert ${upsertCount}건, WORKS-only 정리 ${deleteOrReconcileCount}건`,
|
|
});
|
|
overviewQuery.refetch();
|
|
comparisonQuery.refetch();
|
|
},
|
|
onError: (error) => {
|
|
toast.error("선택 조직 동기화 작업 등록 실패", {
|
|
description: getErrorMessage(error),
|
|
});
|
|
},
|
|
});
|
|
|
|
if (!profileQuery.isLoading && !hasWorksmobileAccess) {
|
|
return (
|
|
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
|
Worksmobile 연동은 super admin 또는 한맥가족 admin/owner 이상만 사용할
|
|
수 있습니다.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 pendingJobCount = (overview?.recentJobs ?? []).filter(
|
|
(job) => job.status === "pending",
|
|
).length;
|
|
const comparisonUsers = filterVisibleWorksmobileComparisonRows(
|
|
comparisonQuery.data?.users ?? [],
|
|
);
|
|
const comparisonGroups = comparisonQuery.data?.groups ?? [];
|
|
const filteredComparisonUsers = filterWorksmobileComparisonRowsBySearch(
|
|
filterWorksmobileComparisonRows(
|
|
comparisonUsers,
|
|
userFilters,
|
|
includeUserMissingExternalKey,
|
|
),
|
|
userSearch,
|
|
);
|
|
const filteredComparisonGroups = filterWorksmobileComparisonRowsBySearch(
|
|
filterWorksmobileComparisonRows(
|
|
comparisonGroups,
|
|
groupFilters,
|
|
includeGroupMissingExternalKey,
|
|
),
|
|
groupSearch,
|
|
);
|
|
const userSummary = summarizeWorksmobileComparison(comparisonUsers);
|
|
const groupSummary = summarizeWorksmobileComparison(comparisonGroups);
|
|
const isCreatingUsers =
|
|
createSelectedMutation.isPending &&
|
|
createSelectedMutation.variables?.resourceKind === "users";
|
|
const isSyncingGroups = syncSelectedOrgUnitsMutation.isPending;
|
|
const isRefreshing = overviewQuery.isFetching || comparisonQuery.isFetching;
|
|
|
|
return (
|
|
<div className="min-w-0 max-w-full space-y-6">
|
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h3 className="text-xl font-semibold">
|
|
{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={() => {
|
|
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>
|
|
|
|
<div
|
|
className="flex border-b border-border"
|
|
role="tablist"
|
|
aria-label="Worksmobile 탭"
|
|
>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeTab === "history"}
|
|
className={pageTabClassName(activeTab === "history")}
|
|
onClick={() => setActiveTab("history")}
|
|
>
|
|
이력
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeTab === "users"}
|
|
className={pageTabClassName(activeTab === "users")}
|
|
onClick={() => setActiveTab("users")}
|
|
>
|
|
사용자
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeTab === "groups"}
|
|
className={pageTabClassName(activeTab === "groups")}
|
|
onClick={() => setActiveTab("groups")}
|
|
>
|
|
조직
|
|
</button>
|
|
</div>
|
|
|
|
{activeTab === "history" ? (
|
|
<div className="space-y-4 animate-in fade-in duration-500">
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
|
<div>
|
|
<CardTitle className="text-base">
|
|
{t("ui.admin.tenants.worksmobile.recent_jobs", "최근 작업")}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
pending payload {pendingJobCount}건
|
|
</CardDescription>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
if (
|
|
window.confirm(
|
|
`아직 실행되지 않은 pending payload ${pendingJobCount}건을 삭제할까요?`,
|
|
)
|
|
) {
|
|
deletePendingJobsMutation.mutate();
|
|
}
|
|
}}
|
|
disabled={
|
|
pendingJobCount === 0 || deletePendingJobsMutation.isPending
|
|
}
|
|
>
|
|
<Trash2 size={16} />
|
|
대기중 payload 삭제
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>대상</TableHead>
|
|
<TableHead>작업</TableHead>
|
|
<TableHead>변경 요약</TableHead>
|
|
<TableHead>상태</TableHead>
|
|
<TableHead>retry</TableHead>
|
|
<TableHead className="w-24" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{(overview?.recentJobs ?? []).map((job) => (
|
|
<TableRow key={job.id}>
|
|
<TableCell>
|
|
<div className="font-medium">
|
|
{formatWorksmobileJobTarget(job)}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{job.resourceType}:
|
|
{formatWorksmobileJobTargetSubtext(job)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline">{job.action}</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex max-w-md flex-wrap gap-1">
|
|
{formatWorksmobileJobSummaryParts(job).map((part) => (
|
|
<Badge key={part} variant="secondary">
|
|
{part}
|
|
</Badge>
|
|
))}
|
|
{formatWorksmobileJobSummaryParts(job).length ===
|
|
0 && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{job.resourceId}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{formatWorksmobileJobPayload(job) && (
|
|
<details className="mt-2 max-w-xl text-xs">
|
|
<summary className="cursor-pointer text-muted-foreground">
|
|
payload
|
|
</summary>
|
|
<pre className="mt-2 max-h-64 overflow-auto rounded-md border bg-muted/30 p-2 text-[11px] leading-relaxed">
|
|
{formatWorksmobileJobPayload(job)}
|
|
</pre>
|
|
</details>
|
|
)}
|
|
</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>
|
|
) : null}
|
|
|
|
{activeTab === "users" ? (
|
|
<div className="space-y-4 animate-in fade-in duration-500">
|
|
<ComparisonSummary
|
|
title={t(
|
|
"ui.admin.tenants.worksmobile.compare",
|
|
"Baron / Works 비교",
|
|
)}
|
|
summary={userSummary}
|
|
/>
|
|
<ComparisonTable
|
|
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
|
|
rows={filteredComparisonUsers}
|
|
totalRows={comparisonQuery.data?.users.length ?? 0}
|
|
loading={comparisonQuery.isLoading}
|
|
selectedKeys={selectedUserRowKeys}
|
|
onSelectedKeysChange={setSelectedUserRowKeys}
|
|
search={userSearch}
|
|
onSearchChange={(nextSearch) => {
|
|
setUserSearch(nextSearch);
|
|
setSelectedUserRowKeys([]);
|
|
}}
|
|
searchPlaceholder="구성원 이름 또는 UUID 검색"
|
|
filters={userFilters}
|
|
onFiltersChange={(nextFilters) => {
|
|
setUserFilters(nextFilters);
|
|
setSelectedUserRowKeys([]);
|
|
}}
|
|
baronOrgColumnLabel="대표 Baron 조직"
|
|
includeMissingExternalKey={includeUserMissingExternalKey}
|
|
onIncludeMissingExternalKeyChange={(checked) => {
|
|
setIncludeUserMissingExternalKey(checked);
|
|
setSelectedUserRowKeys([]);
|
|
}}
|
|
visibleColumns={userVisibleColumns}
|
|
onVisibleColumnsChange={setUserVisibleColumns}
|
|
passwordManageTenantId={overview?.config.adminTenantId}
|
|
actionLabel="선택 구성원 WORKS에 생성"
|
|
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
|
|
updateActionLabel="선택 구성원 업데이트 적용"
|
|
onCreateSelected={(ids, initialPassword) =>
|
|
createSelectedMutation.mutate({
|
|
resourceKind: "users",
|
|
ids,
|
|
initialPassword,
|
|
})
|
|
}
|
|
onUpdateSelected={(ids) =>
|
|
createSelectedMutation.mutate({
|
|
resourceKind: "users",
|
|
ids,
|
|
})
|
|
}
|
|
requireInitialPassword
|
|
/>
|
|
<Card data-testid="worksmobile-users-single-sync">
|
|
<CardContent className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
|
|
<div className="min-w-0">
|
|
<CardTitle className="text-base">사용자 단건 동기화</CardTitle>
|
|
<CardDescription className="mt-1">
|
|
Baron 사용자 UUID 기준으로 구성원 sync 작업을 생성합니다.
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex w-full gap-2 md:w-auto">
|
|
<Input
|
|
className="md:w-80"
|
|
value={userId}
|
|
onChange={(event) => setUserId(event.target.value)}
|
|
placeholder="Kratos user UUID"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
className="shrink-0"
|
|
onClick={() => userSyncMutation.mutate()}
|
|
disabled={!userId.trim() || userSyncMutation.isPending}
|
|
>
|
|
{t("ui.admin.tenants.worksmobile.sync_user", "구성원 Sync")}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
) : null}
|
|
|
|
{activeTab === "groups" ? (
|
|
<div className="space-y-4 animate-in fade-in duration-500">
|
|
<ComparisonSummary
|
|
title={t(
|
|
"ui.admin.tenants.worksmobile.compare_groups",
|
|
"조직/그룹",
|
|
)}
|
|
summary={groupSummary}
|
|
/>
|
|
<ComparisonTable
|
|
title={t(
|
|
"ui.admin.tenants.worksmobile.compare_groups",
|
|
"조직/그룹",
|
|
)}
|
|
rows={filteredComparisonGroups}
|
|
totalRows={comparisonQuery.data?.groups.length ?? 0}
|
|
loading={comparisonQuery.isLoading}
|
|
selectedKeys={selectedGroupRowKeys}
|
|
onSelectedKeysChange={setSelectedGroupRowKeys}
|
|
search={groupSearch}
|
|
onSearchChange={(nextSearch) => {
|
|
setGroupSearch(nextSearch);
|
|
setSelectedGroupRowKeys([]);
|
|
}}
|
|
searchPlaceholder="조직 이름 또는 UUID 검색"
|
|
filters={groupFilters}
|
|
onFiltersChange={(nextFilters) => {
|
|
setGroupFilters(nextFilters);
|
|
setSelectedGroupRowKeys([]);
|
|
}}
|
|
baronOrgColumnLabel="상위 Baron 조직"
|
|
includeMissingExternalKey={includeGroupMissingExternalKey}
|
|
onIncludeMissingExternalKeyChange={(checked) => {
|
|
setIncludeGroupMissingExternalKey(checked);
|
|
setSelectedGroupRowKeys([]);
|
|
}}
|
|
visibleColumns={groupVisibleColumns}
|
|
onVisibleColumnsChange={setGroupVisibleColumns}
|
|
passwordManageTenantId={undefined}
|
|
showBaronIdColumn={false}
|
|
showManageColumn={false}
|
|
actionLabel="선택 조직 동기화"
|
|
actionDisabled={isSyncingGroups}
|
|
onCreateSelected={(ids) =>
|
|
syncSelectedOrgUnitsMutation.mutate({
|
|
baronIds: ids,
|
|
worksmobileIds: [],
|
|
})
|
|
}
|
|
onRunSelected={(baronIds, worksmobileIds) =>
|
|
syncSelectedOrgUnitsMutation.mutate({
|
|
baronIds,
|
|
worksmobileIds,
|
|
})
|
|
}
|
|
/>
|
|
<Card data-testid="worksmobile-groups-single-sync">
|
|
<CardContent className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
|
|
<div className="min-w-0">
|
|
<CardTitle className="text-base">조직 단건 동기화</CardTitle>
|
|
<CardDescription className="mt-1">
|
|
Baron 조직 UUID 기준으로 조직 sync 작업을 생성합니다.
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex w-full gap-2 md:w-auto">
|
|
<Input
|
|
className="md:w-80"
|
|
value={orgUnitId}
|
|
onChange={(event) => setOrgUnitId(event.target.value)}
|
|
placeholder="orgUnit tenant UUID"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
className="shrink-0"
|
|
onClick={() => orgUnitSyncMutation.mutate()}
|
|
disabled={!orgUnitId.trim() || orgUnitSyncMutation.isPending}
|
|
>
|
|
{t("ui.admin.tenants.worksmobile.sync_orgunit", "조직 Sync")}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const worksmobileComparisonColumnOptions: Array<{
|
|
key: WorksmobileComparisonColumnKey;
|
|
label: string;
|
|
}> = [
|
|
{ key: "status", label: "상태" },
|
|
{ key: "baronId", label: "Baron ID" },
|
|
{ key: "baron", label: "Baron" },
|
|
{ key: "baronOrg", label: "Baron 조직" },
|
|
{ key: "externalKey", label: "external_key" },
|
|
{ key: "worksmobileDomain", label: "WORKS 도메인" },
|
|
{ key: "worksmobile", label: "WORKS" },
|
|
{ key: "worksmobileOrg", label: "상위 Works 조직" },
|
|
{ key: "manage", label: "관리" },
|
|
];
|
|
|
|
const WORKSMOBILE_ROW_ESTIMATED_HEIGHT = 88;
|
|
const WORKSMOBILE_ROW_OVERSCAN = 8;
|
|
const WORKSMOBILE_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 520;
|
|
const worksmobileComparisonColumnWidths: Record<
|
|
WorksmobileComparisonColumnKey,
|
|
number
|
|
> = {
|
|
status: 160,
|
|
baronId: 176,
|
|
baron: 220,
|
|
baronOrg: 220,
|
|
externalKey: 180,
|
|
worksmobileDomain: 160,
|
|
worksmobileId: 176,
|
|
worksmobile: 220,
|
|
worksmobileOrg: 260,
|
|
manage: 112,
|
|
};
|
|
const worksmobileComparisonTableHeadClassName =
|
|
"h-12 whitespace-nowrap px-0 align-middle";
|
|
const worksmobileComparisonTableHeadContentClassName =
|
|
"flex h-full items-center px-4";
|
|
const worksmobileComparisonTableHeadCenterContentClassName = `${worksmobileComparisonTableHeadContentClassName} justify-center`;
|
|
|
|
function getDefaultGroupWorksmobileComparisonColumns(): WorksmobileComparisonColumnVisibility {
|
|
return {
|
|
...getDefaultWorksmobileComparisonColumns(),
|
|
manage: false,
|
|
};
|
|
}
|
|
|
|
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 === "needs_update") {
|
|
return "warning";
|
|
}
|
|
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">업데이트 필요</span>
|
|
<span className="font-mono">{summary.needsUpdate}</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 ComparisonFilterButtons<T extends string>({
|
|
options,
|
|
selected,
|
|
onToggle,
|
|
detailFor,
|
|
detail,
|
|
}: {
|
|
options: Array<{ value: T; label: string }>;
|
|
selected?: T[];
|
|
onToggle: (value: T) => void;
|
|
detailFor?: T;
|
|
detail?: React.ReactNode;
|
|
}) {
|
|
const [openDetailFor, setOpenDetailFor] = React.useState<T | null>(null);
|
|
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
if (!openDetailFor) {
|
|
return;
|
|
}
|
|
const onPointerDown = (event: PointerEvent) => {
|
|
if (!rootRef.current?.contains(event.target as Node)) {
|
|
setOpenDetailFor(null);
|
|
}
|
|
};
|
|
document.addEventListener("pointerdown", onPointerDown);
|
|
return () => document.removeEventListener("pointerdown", onPointerDown);
|
|
}, [openDetailFor]);
|
|
|
|
if (!selected) {
|
|
return null;
|
|
}
|
|
return (
|
|
<div ref={rootRef} className="flex flex-wrap items-center gap-2">
|
|
{options.map((option) => {
|
|
const isSelected = selected.includes(option.value);
|
|
const hasDetail = detailFor === option.value && Boolean(detail);
|
|
return (
|
|
<div key={option.value} className="relative">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={isSelected ? "default" : "outline"}
|
|
aria-pressed={isSelected}
|
|
aria-expanded={hasDetail && openDetailFor === option.value}
|
|
onClick={() => {
|
|
if (hasDetail && isSelected && openDetailFor !== option.value) {
|
|
setOpenDetailFor(option.value);
|
|
return;
|
|
}
|
|
onToggle(option.value);
|
|
if (hasDetail) {
|
|
setOpenDetailFor(isSelected ? null : option.value);
|
|
}
|
|
}}
|
|
>
|
|
{option.label}
|
|
</Button>
|
|
{hasDetail && isSelected && openDetailFor === option.value && (
|
|
<div className="absolute left-0 top-[calc(100%+0.25rem)] z-30 w-52 rounded-md border bg-background p-3 shadow-lg">
|
|
{detail}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ComparisonTable({
|
|
title,
|
|
rows,
|
|
totalRows,
|
|
loading,
|
|
selectedKeys,
|
|
onSelectedKeysChange,
|
|
search,
|
|
onSearchChange,
|
|
searchPlaceholder = "이름 또는 UUID 검색",
|
|
filters,
|
|
onFiltersChange,
|
|
baronOrgColumnLabel = "Baron 조직",
|
|
includeMissingExternalKey,
|
|
onIncludeMissingExternalKeyChange,
|
|
visibleColumns,
|
|
onVisibleColumnsChange,
|
|
passwordManageTenantId,
|
|
showBaronIdColumn = true,
|
|
showManageColumn = true,
|
|
actionLabel,
|
|
updateActionLabel,
|
|
actionDisabled,
|
|
onCreateSelected,
|
|
onUpdateSelected,
|
|
onRunSelected,
|
|
deleteActionLabel,
|
|
deleteActionDisabled = false,
|
|
onDeleteSelected,
|
|
requireInitialPassword = false,
|
|
}: {
|
|
title: string;
|
|
rows: WorksmobileComparisonItem[];
|
|
totalRows: number;
|
|
loading: boolean;
|
|
selectedKeys: string[];
|
|
onSelectedKeysChange: (ids: string[]) => void;
|
|
search: string;
|
|
onSearchChange: (value: string) => void;
|
|
searchPlaceholder?: string;
|
|
filters?: WorksmobileComparisonFilter[];
|
|
onFiltersChange?: (filters: WorksmobileComparisonFilter[]) => void;
|
|
baronOrgColumnLabel?: string;
|
|
includeMissingExternalKey?: boolean;
|
|
onIncludeMissingExternalKeyChange?: (checked: boolean) => void;
|
|
visibleColumns: WorksmobileComparisonColumnVisibility;
|
|
onVisibleColumnsChange: React.Dispatch<
|
|
React.SetStateAction<WorksmobileComparisonColumnVisibility>
|
|
>;
|
|
passwordManageTenantId?: string;
|
|
showBaronIdColumn?: boolean;
|
|
showManageColumn?: boolean;
|
|
actionLabel: string;
|
|
updateActionLabel?: string;
|
|
actionDisabled: boolean;
|
|
onCreateSelected: (ids: string[], initialPassword?: string) => void;
|
|
onUpdateSelected?: (ids: string[]) => void;
|
|
onRunSelected?: (actionIds: string[], deleteIds: string[]) => void;
|
|
deleteActionLabel?: string;
|
|
deleteActionDisabled?: boolean;
|
|
onDeleteSelected?: (ids: string[]) => void;
|
|
requireInitialPassword?: boolean;
|
|
}) {
|
|
const [columnSettingsOpen, setColumnSettingsOpen] = React.useState(false);
|
|
const [initialPasswordOpen, setInitialPasswordOpen] = React.useState(false);
|
|
const [initialPassword, setInitialPassword] = React.useState("");
|
|
const [pendingInitialPasswordIds, setPendingInitialPasswordIds] =
|
|
React.useState<string[]>([]);
|
|
const tableViewportRef = React.useRef<HTMLDivElement>(null);
|
|
const selectableKeys = rows
|
|
.filter(canSelectWorksmobileRow)
|
|
.map(getWorksmobileRowSelectionKey)
|
|
.filter(Boolean);
|
|
const selectedActionIds = getWorksmobileSelectedActionIds(rows, selectedKeys);
|
|
const selectedCreateUserIds = getWorksmobileSelectedCreateUserIds(
|
|
rows,
|
|
selectedKeys,
|
|
);
|
|
const selectedUpdateUserIds = getWorksmobileSelectedUpdateUserIds(
|
|
rows,
|
|
selectedKeys,
|
|
);
|
|
const selectedDeleteIds = getWorksmobileSelectedWorksOnlyOrgUnitIds(
|
|
rows,
|
|
selectedKeys,
|
|
);
|
|
const canRunDeleteAction = Boolean(onDeleteSelected && deleteActionLabel);
|
|
const canRunCombinedAction = Boolean(onRunSelected);
|
|
const shouldRunDeleteAction =
|
|
selectedActionIds.length === 0 &&
|
|
selectedDeleteIds.length > 0 &&
|
|
canRunDeleteAction;
|
|
const canRunUserUpdateAction = Boolean(onUpdateSelected);
|
|
const selectedActionLabel = shouldRunDeleteAction
|
|
? deleteActionLabel
|
|
: actionLabel;
|
|
const selectedActionVariant = shouldRunDeleteAction
|
|
? "destructive"
|
|
: "default";
|
|
const selectedActionDisabled =
|
|
(canRunCombinedAction
|
|
? selectedActionIds.length === 0 && selectedDeleteIds.length === 0
|
|
: shouldRunDeleteAction
|
|
? selectedDeleteIds.length === 0 || deleteActionDisabled
|
|
: requireInitialPassword
|
|
? selectedCreateUserIds.length === 0
|
|
: selectedActionIds.length === 0) || actionDisabled;
|
|
const updateActionDisabled =
|
|
selectedUpdateUserIds.length === 0 || actionDisabled;
|
|
const allSelectableSelected =
|
|
selectableKeys.length > 0 &&
|
|
selectableKeys.every((key) => selectedKeys.includes(key));
|
|
const columnOptions = worksmobileComparisonColumnOptions.filter(
|
|
(column) =>
|
|
(showManageColumn || column.key !== "manage") &&
|
|
(showBaronIdColumn || column.key !== "baronId"),
|
|
);
|
|
const visibleColumnCount = columnOptions.filter(
|
|
(column) => visibleColumns[column.key] !== false,
|
|
).length;
|
|
const tableColSpan = visibleColumnCount + 1;
|
|
const tableColumnWidths = React.useMemo(() => {
|
|
const widths = [40];
|
|
for (const column of columnOptions) {
|
|
if (visibleColumns[column.key] !== false) {
|
|
widths.push(worksmobileComparisonColumnWidths[column.key]);
|
|
}
|
|
}
|
|
return widths;
|
|
}, [columnOptions, visibleColumns]);
|
|
const tableGridTemplateColumns = React.useMemo(
|
|
() => tableColumnWidths.map((width) => `${width}px`).join(" "),
|
|
[tableColumnWidths],
|
|
);
|
|
const tableMinWidth = React.useMemo(
|
|
() => tableColumnWidths.reduce((sum, width) => sum + width, 0),
|
|
[tableColumnWidths],
|
|
);
|
|
const rowVirtualizer = useVirtualizer({
|
|
count: rows.length,
|
|
getScrollElement: () => tableViewportRef.current,
|
|
estimateSize: () => WORKSMOBILE_ROW_ESTIMATED_HEIGHT,
|
|
measureElement: (element) =>
|
|
element.getBoundingClientRect().height ||
|
|
WORKSMOBILE_ROW_ESTIMATED_HEIGHT,
|
|
overscan: WORKSMOBILE_ROW_OVERSCAN,
|
|
initialRect: {
|
|
width: tableMinWidth,
|
|
height: WORKSMOBILE_TABLE_VIEWPORT_ESTIMATED_HEIGHT,
|
|
},
|
|
});
|
|
const isTestEnv =
|
|
typeof process !== "undefined" && process.env.NODE_ENV === "test";
|
|
|
|
const virtualRows = isTestEnv
|
|
? rows.map((_, index) => ({
|
|
index,
|
|
start: index * WORKSMOBILE_ROW_ESTIMATED_HEIGHT,
|
|
size: WORKSMOBILE_ROW_ESTIMATED_HEIGHT,
|
|
key: index,
|
|
lanes: 0,
|
|
}))
|
|
: rowVirtualizer.getVirtualItems();
|
|
const shouldVirtualizeRows = !loading && rows.length > 0;
|
|
|
|
const toggleAll = (checked: boolean | "indeterminate") => {
|
|
onSelectedKeysChange(checked === true ? selectableKeys : []);
|
|
};
|
|
|
|
const toggleRow = (
|
|
row: WorksmobileComparisonItem,
|
|
checked: boolean | "indeterminate",
|
|
) => {
|
|
const key = getWorksmobileRowSelectionKey(row);
|
|
if (!key) {
|
|
return;
|
|
}
|
|
if (checked === true) {
|
|
onSelectedKeysChange([...new Set([...selectedKeys, key])]);
|
|
return;
|
|
}
|
|
onSelectedKeysChange(
|
|
selectedKeys.filter((selectedKey) => selectedKey !== key),
|
|
);
|
|
};
|
|
|
|
const openPasswordManage = (row: WorksmobileComparisonItem) => {
|
|
const url = buildWorksmobilePasswordManageUrl({
|
|
tenantId: passwordManageTenantId,
|
|
domainId: row.worksmobileDomainId,
|
|
userIdNo: row.worksmobileId,
|
|
});
|
|
if (!url) return;
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
};
|
|
|
|
const toggleColumn = (key: WorksmobileComparisonColumnKey) => {
|
|
onVisibleColumnsChange((current) => ({
|
|
...current,
|
|
[key]: current[key] === false,
|
|
}));
|
|
};
|
|
|
|
const isColumnVisible = (key: WorksmobileComparisonColumnKey) =>
|
|
visibleColumns[key] !== false;
|
|
const columnLabel = (column: {
|
|
key: WorksmobileComparisonColumnKey;
|
|
label: string;
|
|
}) => (column.key === "baronOrg" ? baronOrgColumnLabel : column.label);
|
|
|
|
const toggleFilter = (filter: WorksmobileComparisonFilter) => {
|
|
if (!filters || !onFiltersChange) {
|
|
return;
|
|
}
|
|
onFiltersChange(
|
|
filters.includes(filter)
|
|
? filters.filter((value) => value !== filter)
|
|
: [...filters, filter],
|
|
);
|
|
};
|
|
|
|
const runSelectedAction = () => {
|
|
if (onRunSelected) {
|
|
onRunSelected(selectedActionIds, selectedDeleteIds);
|
|
return;
|
|
}
|
|
if (shouldRunDeleteAction && onDeleteSelected) {
|
|
onDeleteSelected(selectedDeleteIds);
|
|
return;
|
|
}
|
|
if (requireInitialPassword) {
|
|
setPendingInitialPasswordIds(selectedCreateUserIds);
|
|
setInitialPassword("");
|
|
setInitialPasswordOpen(true);
|
|
return;
|
|
}
|
|
onCreateSelected(selectedActionIds);
|
|
};
|
|
|
|
const runUpdateAction = () => {
|
|
if (!onUpdateSelected || selectedUpdateUserIds.length === 0) {
|
|
return;
|
|
}
|
|
onUpdateSelected(selectedUpdateUserIds);
|
|
};
|
|
|
|
const confirmInitialPassword = () => {
|
|
const password = initialPassword.trim();
|
|
if (!password) {
|
|
toast.error("WORKS 초기 비밀번호를 입력해 주세요.");
|
|
return;
|
|
}
|
|
onCreateSelected(pendingInitialPasswordIds, password);
|
|
setInitialPasswordOpen(false);
|
|
setInitialPassword("");
|
|
setPendingInitialPasswordIds([]);
|
|
};
|
|
|
|
return (
|
|
<div className="min-w-0 space-y-2">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3">
|
|
<h4 className="text-lg font-semibold leading-none">{title}</h4>
|
|
<Badge
|
|
variant="outline"
|
|
data-testid={`worksmobile-${title}-row-count`}
|
|
className="font-mono"
|
|
>
|
|
표시 {rows.length} / 전체 {totalRows}
|
|
</Badge>
|
|
<Input
|
|
type="search"
|
|
value={search}
|
|
onChange={(event) => onSearchChange(event.target.value)}
|
|
placeholder={searchPlaceholder}
|
|
aria-label={`${title} 검색`}
|
|
className="h-9 w-56"
|
|
/>
|
|
<ComparisonFilterButtons
|
|
options={comparisonFilterOptions}
|
|
selected={filters}
|
|
onToggle={toggleFilter}
|
|
detailFor={"works_only"}
|
|
detail={
|
|
onIncludeMissingExternalKeyChange ? (
|
|
<label className="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
|
|
<Checkbox
|
|
checked={includeMissingExternalKey === true}
|
|
onCheckedChange={(checked) =>
|
|
onIncludeMissingExternalKeyChange?.(checked === true)
|
|
}
|
|
/>
|
|
<span>ex_key 없음만 보기</span>
|
|
</label>
|
|
) : null
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
|
<Dialog
|
|
open={columnSettingsOpen}
|
|
onOpenChange={setColumnSettingsOpen}
|
|
>
|
|
<DialogTrigger asChild>
|
|
<Button type="button" variant="outline" size="sm">
|
|
<Settings2 size={16} />
|
|
컬럼 설정
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{title} 컬럼 설정</DialogTitle>
|
|
<DialogDescription>
|
|
이 테이블에 표시할 비교 컬럼을 선택하세요.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid gap-2 py-2">
|
|
{columnOptions.map((column) => (
|
|
<label
|
|
key={column.key}
|
|
className="flex cursor-pointer items-center gap-3 rounded-md p-2 hover:bg-muted/50"
|
|
>
|
|
<input
|
|
name={`worksmobile-column-${column.key}`}
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
|
|
checked={isColumnVisible(column.key)}
|
|
onChange={() => toggleColumn(column.key)}
|
|
/>
|
|
<span className="text-sm font-medium">
|
|
{columnLabel(column)}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => setColumnSettingsOpen(false)}
|
|
>
|
|
닫기
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={selectedActionVariant}
|
|
onClick={runSelectedAction}
|
|
disabled={selectedActionDisabled}
|
|
>
|
|
{selectedActionLabel}
|
|
</Button>
|
|
{canRunUserUpdateAction && (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={runUpdateAction}
|
|
disabled={updateActionDisabled}
|
|
>
|
|
{updateActionLabel || "선택 구성원 업데이트 적용"}
|
|
</Button>
|
|
)}
|
|
<Dialog
|
|
open={initialPasswordOpen}
|
|
onOpenChange={(open) => {
|
|
setInitialPasswordOpen(open);
|
|
if (!open) {
|
|
setInitialPassword("");
|
|
setPendingInitialPasswordIds([]);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>WORKS 초기 비밀번호</DialogTitle>
|
|
<DialogDescription>
|
|
선택한 구성원을 WORKS에 신규 생성할 때 사용할 공통 초기
|
|
비밀번호를 입력하세요.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-2 py-2">
|
|
<label
|
|
className="text-sm font-medium"
|
|
htmlFor="worksmobile-initial-password"
|
|
>
|
|
초기 비밀번호
|
|
</label>
|
|
<Input
|
|
id="worksmobile-initial-password"
|
|
type="password"
|
|
value={initialPassword}
|
|
onChange={(event) => setInitialPassword(event.target.value)}
|
|
autoComplete="new-password"
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => setInitialPasswordOpen(false)}
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button type="button" onClick={confirmInitialPassword}>
|
|
생성 작업 등록
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref={tableViewportRef}
|
|
className="max-h-[560px] w-full max-w-full overflow-auto rounded-md border"
|
|
data-testid={`worksmobile-${title}-virtual-viewport`}
|
|
>
|
|
<Table className="min-w-max" style={{ minWidth: tableMinWidth }}>
|
|
<TableHeader>
|
|
<TableRow
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: tableGridTemplateColumns,
|
|
minWidth: tableMinWidth,
|
|
}}
|
|
>
|
|
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
|
<div
|
|
className={
|
|
worksmobileComparisonTableHeadCenterContentClassName
|
|
}
|
|
>
|
|
<Checkbox
|
|
aria-label={`${title} 전체 선택`}
|
|
checked={allSelectableSelected}
|
|
disabled={selectableKeys.length === 0}
|
|
onCheckedChange={toggleAll}
|
|
/>
|
|
</div>
|
|
</TableHead>
|
|
{isColumnVisible("status") && (
|
|
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
|
<div
|
|
className={worksmobileComparisonTableHeadContentClassName}
|
|
>
|
|
상태
|
|
</div>
|
|
</TableHead>
|
|
)}
|
|
{showBaronIdColumn && isColumnVisible("baronId") && (
|
|
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
|
<div
|
|
className={worksmobileComparisonTableHeadContentClassName}
|
|
>
|
|
Baron ID
|
|
</div>
|
|
</TableHead>
|
|
)}
|
|
{isColumnVisible("baron") && (
|
|
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
|
<div
|
|
className={worksmobileComparisonTableHeadContentClassName}
|
|
>
|
|
Baron
|
|
</div>
|
|
</TableHead>
|
|
)}
|
|
{isColumnVisible("baronOrg") && (
|
|
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
|
<div
|
|
className={worksmobileComparisonTableHeadContentClassName}
|
|
>
|
|
{baronOrgColumnLabel}
|
|
</div>
|
|
</TableHead>
|
|
)}
|
|
{isColumnVisible("externalKey") && (
|
|
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
|
<div
|
|
className={worksmobileComparisonTableHeadContentClassName}
|
|
>
|
|
external_key
|
|
</div>
|
|
</TableHead>
|
|
)}
|
|
{isColumnVisible("worksmobileDomain") && (
|
|
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
|
<div
|
|
className={worksmobileComparisonTableHeadContentClassName}
|
|
>
|
|
WORKS 도메인
|
|
</div>
|
|
</TableHead>
|
|
)}
|
|
{isColumnVisible("worksmobile") && (
|
|
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
|
<div
|
|
className={worksmobileComparisonTableHeadContentClassName}
|
|
>
|
|
WORKS
|
|
</div>
|
|
</TableHead>
|
|
)}
|
|
{isColumnVisible("worksmobileOrg") && (
|
|
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
|
<div
|
|
className={worksmobileComparisonTableHeadContentClassName}
|
|
>
|
|
상위 Works 조직
|
|
</div>
|
|
</TableHead>
|
|
)}
|
|
{showManageColumn && isColumnVisible("manage") && (
|
|
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
|
<div
|
|
className={worksmobileComparisonTableHeadContentClassName}
|
|
>
|
|
관리
|
|
</div>
|
|
</TableHead>
|
|
)}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody
|
|
data-testid={`worksmobile-${title}-virtual-body`}
|
|
style={
|
|
shouldVirtualizeRows
|
|
? {
|
|
display: "grid",
|
|
height: `${
|
|
isTestEnv
|
|
? rows.length * WORKSMOBILE_ROW_ESTIMATED_HEIGHT
|
|
: rowVirtualizer.getTotalSize()
|
|
}px`,
|
|
minWidth: tableMinWidth,
|
|
position: "relative",
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{loading && (
|
|
<TableRow
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: tableGridTemplateColumns,
|
|
minWidth: tableMinWidth,
|
|
}}
|
|
>
|
|
<TableCell
|
|
colSpan={tableColSpan}
|
|
className="text-muted-foreground"
|
|
style={{ gridColumn: "1 / -1" }}
|
|
>
|
|
불러오는 중...
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{!loading && rows.length === 0 && (
|
|
<TableRow
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: tableGridTemplateColumns,
|
|
minWidth: tableMinWidth,
|
|
}}
|
|
>
|
|
<TableCell
|
|
colSpan={tableColSpan}
|
|
className="text-muted-foreground"
|
|
style={{ gridColumn: "1 / -1" }}
|
|
>
|
|
표시할 차이가 없습니다.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{shouldVirtualizeRows &&
|
|
virtualRows.map((virtualRow) => {
|
|
const row = rows[virtualRow.index];
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
const rowKey = `${row.status}:${row.baronId ?? row.worksmobileId ?? row.externalKey}`;
|
|
|
|
return (
|
|
<TableRow
|
|
key={rowKey}
|
|
data-index={virtualRow.index}
|
|
ref={rowVirtualizer.measureElement}
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: tableGridTemplateColumns,
|
|
minWidth: tableMinWidth,
|
|
position: "absolute",
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
width: "100%",
|
|
}}
|
|
>
|
|
<TableCell className="whitespace-nowrap">
|
|
<Checkbox
|
|
aria-label={`${row.baronName ?? row.baronId ?? row.worksmobileName ?? row.worksmobileId ?? "row"} 선택`}
|
|
checked={selectedKeys.includes(
|
|
getWorksmobileRowSelectionKey(row),
|
|
)}
|
|
disabled={!canSelectWorksmobileRow(row)}
|
|
onCheckedChange={(checked) => toggleRow(row, checked)}
|
|
/>
|
|
</TableCell>
|
|
{isColumnVisible("status") && (
|
|
<TableCell className="whitespace-nowrap">
|
|
<Badge
|
|
className="whitespace-nowrap"
|
|
variant={getWorksmobileComparisonStatusVariant(
|
|
row.status,
|
|
)}
|
|
>
|
|
{getWorksmobileComparisonStatusLabel(row.status)}
|
|
</Badge>
|
|
{formatWorksmobileUpdateDetails(row).map((detail) => (
|
|
<div
|
|
key={detail}
|
|
className="mt-1 max-w-56 whitespace-normal text-xs text-muted-foreground"
|
|
>
|
|
{detail}
|
|
</div>
|
|
))}
|
|
</TableCell>
|
|
)}
|
|
{showBaronIdColumn && isColumnVisible("baronId") && (
|
|
<TableCell className="font-mono text-xs">
|
|
{row.baronId ?? "-"}
|
|
</TableCell>
|
|
)}
|
|
{isColumnVisible("baron") && (
|
|
<TableCell>
|
|
<ComparisonBaronCell
|
|
name={row.baronName}
|
|
email={row.baronEmail}
|
|
slug={row.baronSlug}
|
|
id={row.baronId}
|
|
/>
|
|
</TableCell>
|
|
)}
|
|
{isColumnVisible("baronOrg") && (
|
|
<TableCell>
|
|
<ComparisonOrgCell
|
|
name={
|
|
row.resourceType === "GROUP"
|
|
? row.baronParentName
|
|
: row.baronPrimaryOrgName
|
|
}
|
|
id={
|
|
row.resourceType === "GROUP"
|
|
? row.baronParentId
|
|
: row.baronPrimaryOrgId
|
|
}
|
|
slug={
|
|
row.resourceType === "GROUP"
|
|
? row.baronParentSlug
|
|
: row.baronPrimaryOrgSlug
|
|
}
|
|
/>
|
|
</TableCell>
|
|
)}
|
|
{isColumnVisible("externalKey") && (
|
|
<TableCell className="font-mono text-xs">
|
|
{row.externalKey ?? "-"}
|
|
</TableCell>
|
|
)}
|
|
{isColumnVisible("worksmobileDomain") && (
|
|
<TableCell>
|
|
<ComparisonDomainCell
|
|
name={row.worksmobileDomainName}
|
|
id={row.worksmobileDomainId}
|
|
/>
|
|
</TableCell>
|
|
)}
|
|
{isColumnVisible("worksmobile") && (
|
|
<TableCell>
|
|
<ComparisonWorksmobileCell
|
|
name={formatWorksmobilePersonName(row)}
|
|
email={row.worksmobileEmail}
|
|
id={row.worksmobileId}
|
|
/>
|
|
</TableCell>
|
|
)}
|
|
{isColumnVisible("worksmobileOrg") && (
|
|
<TableCell>
|
|
<ComparisonOrgCell
|
|
name={
|
|
row.resourceType === "GROUP"
|
|
? getWorksmobileParentName(row)
|
|
: row.worksmobilePrimaryOrgName
|
|
}
|
|
email={
|
|
row.resourceType === "GROUP"
|
|
? getWorksmobileParentEmail(row)
|
|
: undefined
|
|
}
|
|
id={
|
|
row.resourceType === "GROUP"
|
|
? row.worksmobileParentId
|
|
: row.worksmobilePrimaryOrgId
|
|
}
|
|
details={
|
|
row.resourceType === "GROUP"
|
|
? formatWorksmobileParentOrgDetails(row)
|
|
: formatWorksmobileOrgDetails(row)
|
|
}
|
|
missingLabel={
|
|
row.resourceType === "GROUP"
|
|
? "상위 Works 조직 정보 없음"
|
|
: undefined
|
|
}
|
|
/>
|
|
</TableCell>
|
|
)}
|
|
{showManageColumn && isColumnVisible("manage") && (
|
|
<TableCell className="whitespace-nowrap">
|
|
{row.resourceType === "USER" && (
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
aria-label={`${row.worksmobileName ?? row.baronName ?? row.worksmobileId ?? "WORKS user"} 비밀번호 관리`}
|
|
disabled={
|
|
!canOpenWorksmobilePasswordManage(
|
|
row,
|
|
passwordManageTenantId,
|
|
)
|
|
}
|
|
onClick={() => openPasswordManage(row)}
|
|
>
|
|
<KeyRound size={16} />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</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 ComparisonBaronCell({
|
|
name,
|
|
email,
|
|
slug,
|
|
id,
|
|
}: {
|
|
name?: string;
|
|
email?: string;
|
|
slug?: string;
|
|
id?: string;
|
|
}) {
|
|
if (!name && !email && !slug && !id) {
|
|
return <span className="text-muted-foreground">-</span>;
|
|
}
|
|
return (
|
|
<div className="space-y-1">
|
|
<div>{name ?? "-"}</div>
|
|
{email && <div className="text-xs text-muted-foreground">{email}</div>}
|
|
{slug && (
|
|
<div className="font-mono text-xs text-muted-foreground">{slug}</div>
|
|
)}
|
|
{id && (
|
|
<div className="font-mono text-xs text-muted-foreground">{id}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ComparisonWorksmobileCell({
|
|
name,
|
|
email,
|
|
id,
|
|
}: {
|
|
name?: string;
|
|
email?: string;
|
|
id?: string;
|
|
}) {
|
|
if (!name && !email && !id) {
|
|
return <span className="text-muted-foreground">-</span>;
|
|
}
|
|
return (
|
|
<div className="space-y-1">
|
|
<div>{name ?? "-"}</div>
|
|
{email && <div className="text-xs text-muted-foreground">{email}</div>}
|
|
{id && (
|
|
<div className="font-mono text-xs text-muted-foreground">{id}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getWorksmobileParentName(row: WorksmobileComparisonItem) {
|
|
if (row.worksmobileParentName) {
|
|
return row.worksmobileParentName;
|
|
}
|
|
if (
|
|
row.worksmobileParentId &&
|
|
row.worksmobileParentId === row.baronParentWorksmobileId
|
|
) {
|
|
return row.baronParentWorksmobileName;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function getWorksmobileParentEmail(row: WorksmobileComparisonItem) {
|
|
if (row.worksmobileParentEmail) {
|
|
return row.worksmobileParentEmail;
|
|
}
|
|
if (
|
|
row.worksmobileParentId &&
|
|
row.worksmobileParentId === row.baronParentWorksmobileId
|
|
) {
|
|
return row.baronParentWorksmobileEmail;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function formatWorksmobileParentOrgDetails(row: WorksmobileComparisonItem) {
|
|
const details: string[] = [];
|
|
if (
|
|
row.status === "matched" &&
|
|
row.baronParentId &&
|
|
row.baronParentWorksmobileId
|
|
) {
|
|
if (!row.worksmobileParentId) {
|
|
details.push("Baron 상위는 Works에 연결됨, 현재 Works 상위 없음");
|
|
} else if (row.worksmobileParentId !== row.baronParentWorksmobileId) {
|
|
details.push(
|
|
`Baron 상위와 연결 불일치: ${row.baronParentWorksmobileName ?? row.baronParentWorksmobileId}`,
|
|
);
|
|
}
|
|
}
|
|
return details;
|
|
}
|
|
|
|
function ComparisonOrgCell({
|
|
name,
|
|
email,
|
|
id,
|
|
slug,
|
|
details = [],
|
|
missingLabel = "-",
|
|
}: {
|
|
name?: string;
|
|
email?: string;
|
|
id?: string;
|
|
slug?: string;
|
|
details?: string[];
|
|
missingLabel?: string;
|
|
}) {
|
|
if (!name && !email && !id && !slug && details.length === 0) {
|
|
return <span className="text-muted-foreground">{missingLabel}</span>;
|
|
}
|
|
return (
|
|
<div className="space-y-1">
|
|
<div>{name ?? "-"}</div>
|
|
{email && <div className="text-xs text-muted-foreground">{email}</div>}
|
|
{slug && (
|
|
<div className="font-mono text-xs text-muted-foreground">{slug}</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>
|
|
);
|
|
}
|