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, formatWorksmobileSelectionFailureDescription, formatWorksmobileUpdateDetails, formatWorksmobileUserMembershipDetails, getDefaultGroupComparisonFilters, getDefaultUserComparisonFilters, getDefaultWorksmobileComparisonColumns, getWorksmobileComparisonStatusLabel, getWorksmobileRowSelectionKey, getWorksmobileSelectedActionIds, getWorksmobileSelectedCreateUserIds, getWorksmobileSelectedUpdateUserIds, getWorksmobileSelectedWorksOnlyOrgUnitIds, summarizeWorksmobileComparison, type WorksmobileAccountStatusFilter, type WorksmobileComparisonColumnKey, type WorksmobileComparisonColumnVisibility, type WorksmobileComparisonFilter, type WorksmobileComparisonSummary, worksmobileAccountStatusFilterOptions, } 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; } function worksmobileSummaryString( summary: Record, 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 [userAccountStatusFilter, setUserAccountStatusFilter] = React.useState("all"); 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, failures, failureCount: failures.length, }; }, onSuccess: ({ resourceKind, count, failureCount, failures }) => { if (resourceKind === "users") { setSelectedUserRowKeys([]); } else { setSelectedGroupRowKeys([]); } if (failureCount > 0) { toast.error("일부 WORKS 생성 작업 등록 실패", { description: formatWorksmobileSelectionFailureDescription( count, failures, ), }); } 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 (
Worksmobile 연동은 super admin 또는 한맥가족 admin/owner 이상만 사용할 수 있습니다.
); } if (overviewQuery.isError) { return (
{t( "ui.admin.tenants.worksmobile.forbidden", "한맥가족 테넌트에서만 Worksmobile 연동을 관리할 수 있습니다.", )}
); } 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, userAccountStatusFilter, ), 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 (

{t("ui.admin.tenants.worksmobile.title", "Worksmobile 연동")}

{t( "ui.admin.tenants.worksmobile.subtitle", "한맥가족 Directory 조직/구성원 동기화 상태를 확인하고 실패 작업을 재시도합니다.", )}

{activeTab === "history" ? (
{t("ui.admin.tenants.worksmobile.recent_jobs", "최근 작업")} pending payload {pendingJobCount}건
대상 작업 변경 요약 상태 retry {(overview?.recentJobs ?? []).map((job) => (
{formatWorksmobileJobTarget(job)}
{job.resourceType}: {formatWorksmobileJobTargetSubtext(job)}
{job.action}
{formatWorksmobileJobSummaryParts(job).map((part) => ( {part} ))} {formatWorksmobileJobSummaryParts(job).length === 0 && ( {job.resourceId} )}
{formatWorksmobileJobPayload(job) && (
payload
                              {formatWorksmobileJobPayload(job)}
                            
)}
{job.status} {job.retryCount}
))}
) : null} {activeTab === "users" ? (
{ setUserSearch(nextSearch); setSelectedUserRowKeys([]); }} searchPlaceholder="구성원 이름 또는 UUID 검색" filters={userFilters} onFiltersChange={(nextFilters) => { setUserFilters(nextFilters); setSelectedUserRowKeys([]); }} accountStatusFilter={userAccountStatusFilter} onAccountStatusFilterChange={(nextStatus) => { setUserAccountStatusFilter(nextStatus); 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.mutateAsync({ resourceKind: "users", ids, initialPassword, }) } onUpdateSelected={(ids) => createSelectedMutation.mutate({ resourceKind: "users", ids, }) } requireInitialPassword />
사용자 단건 동기화 Baron 사용자 UUID 기준으로 구성원 sync 작업을 생성합니다.
setUserId(event.target.value)} placeholder="Kratos user UUID" />
) : null} {activeTab === "groups" ? (
{ 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, }) } />
조직 단건 동기화 Baron 조직 UUID 기준으로 조직 sync 작업을 생성합니다.
setOrgUnitId(event.target.value)} placeholder="orgUnit tenant UUID" />
) : null}
); } 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: 320, 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 (
{title} {summary.total}
WORKS 없음 {summary.missingInWorksmobile}
Baron 없음 {summary.missingInBaron}
업데이트 필요 {summary.needsUpdate}
ex_key 없음 {summary.missingExternalKey}
일치 {summary.matched}
); } function ComparisonFilterButtons({ 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(null); const rootRef = React.useRef(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 (
{options.map((option) => { const isSelected = selected.includes(option.value); const hasDetail = detailFor === option.value && Boolean(detail); return (
{hasDetail && isSelected && openDetailFor === option.value && (
{detail}
)}
); })}
); } function ComparisonTable({ title, rows, totalRows, loading, selectedKeys, onSelectedKeysChange, search, onSearchChange, searchPlaceholder = "이름 또는 UUID 검색", filters, onFiltersChange, accountStatusFilter, onAccountStatusFilterChange, 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; accountStatusFilter?: WorksmobileAccountStatusFilter; onAccountStatusFilterChange?: ( status: WorksmobileAccountStatusFilter, ) => void; baronOrgColumnLabel?: string; includeMissingExternalKey?: boolean; onIncludeMissingExternalKeyChange?: (checked: boolean) => void; visibleColumns: WorksmobileComparisonColumnVisibility; onVisibleColumnsChange: React.Dispatch< React.SetStateAction >; passwordManageTenantId?: string; showBaronIdColumn?: boolean; showManageColumn?: boolean; actionLabel: string; updateActionLabel?: string; actionDisabled: boolean; onCreateSelected: (ids: string[], initialPassword?: string) => unknown; 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([]); const tableViewportRef = React.useRef(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 = async () => { const password = initialPassword.trim(); if (!password) { toast.error("WORKS 초기 비밀번호를 입력해 주세요."); return; } try { await onCreateSelected(pendingInitialPasswordIds, password); } catch { return; } setInitialPasswordOpen(false); setInitialPassword(""); setPendingInitialPasswordIds([]); }; return (

{title}

표시 {rows.length} / 전체 {totalRows} onSearchChange(event.target.value)} placeholder={searchPlaceholder} aria-label={`${title} 검색`} className="h-9 w-56" /> onIncludeMissingExternalKeyChange?.(checked === true) } /> ex_key 없음만 보기 ) : null } /> {accountStatusFilter && onAccountStatusFilterChange ? (
{worksmobileAccountStatusFilterOptions.map((option) => ( ))}
) : null}
{title} 컬럼 설정 이 테이블에 표시할 비교 컬럼을 선택하세요.
{columnOptions.map((column) => ( ))}
{canRunUserUpdateAction && ( )} { setInitialPasswordOpen(open); if (!open) { setInitialPassword(""); setPendingInitialPasswordIds([]); } }} > WORKS 초기 비밀번호 선택한 구성원을 WORKS에 신규 생성할 때 사용할 공통 초기 비밀번호를 입력하세요.
setInitialPassword(event.target.value)} autoComplete="new-password" />
{isColumnVisible("status") && (
상태
)} {showBaronIdColumn && isColumnVisible("baronId") && (
Baron ID
)} {isColumnVisible("baron") && (
Baron
)} {isColumnVisible("baronOrg") && (
{baronOrgColumnLabel}
)} {isColumnVisible("externalKey") && (
external_key
)} {isColumnVisible("worksmobileDomain") && (
WORKS 도메인
)} {isColumnVisible("worksmobile") && (
WORKS
)} {isColumnVisible("worksmobileOrg") && (
WORKS 조직 매칭
)} {showManageColumn && isColumnVisible("manage") && (
관리
)}
{loading && ( 불러오는 중... )} {!loading && rows.length === 0 && ( 표시할 차이가 없습니다. )} {shouldVirtualizeRows && virtualRows.map((virtualRow) => { const row = rows[virtualRow.index]; if (!row) { return null; } const rowKey = `${row.status}:${row.baronId ?? row.worksmobileId ?? row.externalKey}`; return ( toggleRow(row, checked)} /> {isColumnVisible("status") && ( {getWorksmobileComparisonStatusLabel(row.status)} {row.worksmobileAccountStatus && (
WORKS {row.worksmobileAccountStatus}
)} {formatWorksmobileUpdateDetails(row).map((detail) => (
{detail}
))}
)} {showBaronIdColumn && isColumnVisible("baronId") && ( {row.baronId ?? "-"} )} {isColumnVisible("baron") && ( )} {isColumnVisible("baronOrg") && ( )} {isColumnVisible("externalKey") && ( {row.externalKey ?? "-"} )} {isColumnVisible("worksmobileDomain") && ( )} {isColumnVisible("worksmobile") && ( )} {isColumnVisible("worksmobileOrg") && ( {row.resourceType === "USER" ? ( ) : ( )} )} {showManageColumn && isColumnVisible("manage") && ( {row.resourceType === "USER" && (
)}
)}
); })}
); } function ComparisonDomainCell({ name, id }: { name?: string; id?: number }) { if (!name && !id) { return -; } return (
{name ?? "-"}
{id ?? ""}
); } function ComparisonBaronCell({ name, email, slug, id, }: { name?: string; email?: string; slug?: string; id?: string; }) { if (!name && !email && !slug && !id) { return -; } return (
{name ?? "-"}
{email &&
{email}
} {slug && (
{slug}
)} {id && (
{id}
)}
); } function ComparisonWorksmobileCell({ name, email, id, }: { name?: string; email?: string; id?: string; }) { if (!name && !email && !id) { return -; } return (
{name ?? "-"}
{email &&
{email}
} {id && (
{id}
)}
); } 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 ComparisonUserMembershipCell({ row, }: { row: WorksmobileComparisonItem; }) { const membershipDetails = formatWorksmobileUserMembershipDetails(row); if (membershipDetails.length > 0) { return (
{membershipDetails.map((detail) => (
{detail}
))}
); } return ( ); } 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 {missingLabel}; } return (
{name ?? "-"}
{email &&
{email}
} {slug && (
{slug}
)}
{id ?? ""}
{details.length > 0 && (
{details.join(" · ")}
)}
); }