1
0
forked from baron/baron-sso
Files
baron-sso/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx

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>
);
}