forked from baron/baron-sso
chore: consolidate local integration changes
This commit is contained in:
@@ -1,9 +1,6 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
KeyRound,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
@@ -42,21 +39,16 @@ import {
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
deleteWorksmobileCredentialBatchPasswords,
|
||||
deleteWorksmobilePendingJobs,
|
||||
downloadWorksmobileInitialPasswordsCSV,
|
||||
enqueueWorksmobileBackfillDryRun,
|
||||
enqueueWorksmobileOrgUnitDelete,
|
||||
enqueueWorksmobileOrgUnitSync,
|
||||
enqueueWorksmobileUserSync,
|
||||
fetchMe,
|
||||
fetchWorksmobileComparison,
|
||||
fetchWorksmobileCredentialBatches,
|
||||
fetchWorksmobileOverview,
|
||||
resetWorksmobileUserPassword,
|
||||
retryWorksmobileJob,
|
||||
type WorksmobileComparisonItem,
|
||||
type WorksmobileCredentialBatch,
|
||||
type WorksmobileOutboxItem,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
@@ -81,8 +73,9 @@ import {
|
||||
getWorksmobileComparisonStatusLabel,
|
||||
getWorksmobileRowSelectionKey,
|
||||
getWorksmobileSelectedActionIds,
|
||||
getWorksmobileSelectedCreateUserIds,
|
||||
getWorksmobileSelectedUpdateUserIds,
|
||||
getWorksmobileSelectedWorksOnlyOrgUnitIds,
|
||||
isImmutableWorksmobileAccount,
|
||||
summarizeWorksmobileComparison,
|
||||
type WorksmobileComparisonColumnKey,
|
||||
type WorksmobileComparisonColumnVisibility,
|
||||
@@ -90,17 +83,6 @@ import {
|
||||
type WorksmobileComparisonSummary,
|
||||
} from "./worksmobileComparison";
|
||||
|
||||
type InitialPasswordDownloadVariables = {
|
||||
batchId?: string;
|
||||
};
|
||||
|
||||
export function createWorksmobileCredentialBatchId() {
|
||||
if (globalThis.crypto?.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `worksmobile-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function worksmobileJobPayloadString(job: WorksmobileOutboxItem, key: string) {
|
||||
const value = job.payload?.[key];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
@@ -238,12 +220,6 @@ export function TenantWorksmobilePage() {
|
||||
enabled: tenantId.length > 0 && hasWorksmobileAccess,
|
||||
});
|
||||
|
||||
const credentialBatchesQuery = useQuery({
|
||||
queryKey: ["worksmobile-credential-batches", tenantId],
|
||||
queryFn: () => fetchWorksmobileCredentialBatches(tenantId),
|
||||
enabled: tenantId.length > 0 && hasWorksmobileAccess,
|
||||
});
|
||||
|
||||
const dryRunMutation = useMutation({
|
||||
mutationFn: () => enqueueWorksmobileBackfillDryRun(tenantId),
|
||||
onSuccess: () => {
|
||||
@@ -275,7 +251,6 @@ export function TenantWorksmobilePage() {
|
||||
onSuccess: (result) => {
|
||||
toast.success(`대기중 payload ${result.deletedCount}건을 삭제했습니다.`);
|
||||
overviewQuery.refetch();
|
||||
credentialBatchesQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("대기중 payload 삭제 실패", {
|
||||
@@ -284,40 +259,6 @@ export function TenantWorksmobilePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const initialPasswordDownloadMutation = useMutation({
|
||||
mutationFn: (variables?: InitialPasswordDownloadVariables) =>
|
||||
downloadWorksmobileInitialPasswordsCSV(tenantId, variables?.batchId),
|
||||
onSuccess: ({ blob, filename }) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("초기 비밀번호 CSV 다운로드 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteCredentialBatchPasswordsMutation = useMutation({
|
||||
mutationFn: (batchId: string) =>
|
||||
deleteWorksmobileCredentialBatchPasswords(tenantId, batchId),
|
||||
onSuccess: () => {
|
||||
toast.success("비밀번호 값을 삭제했습니다.");
|
||||
credentialBatchesQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("비밀번호 값 삭제 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const orgUnitSyncMutation = useMutation({
|
||||
mutationFn: () => enqueueWorksmobileOrgUnitSync(tenantId, orgUnitId.trim()),
|
||||
onSuccess: () => {
|
||||
@@ -348,20 +289,24 @@ export function TenantWorksmobilePage() {
|
||||
mutationFn: async ({
|
||||
resourceKind,
|
||||
ids,
|
||||
initialPassword,
|
||||
}: {
|
||||
resourceKind: "users" | "groups";
|
||||
ids: string[];
|
||||
initialPassword?: string;
|
||||
}) => {
|
||||
const credentialBatchId =
|
||||
resourceKind === "users"
|
||||
? createWorksmobileCredentialBatchId()
|
||||
: undefined;
|
||||
const trimmedInitialPassword = initialPassword?.trim();
|
||||
const failures: string[] = [];
|
||||
let successCount = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
if (resourceKind === "users") {
|
||||
await enqueueWorksmobileUserSync(tenantId, id, credentialBatchId);
|
||||
await enqueueWorksmobileUserSync(
|
||||
tenantId,
|
||||
id,
|
||||
undefined,
|
||||
trimmedInitialPassword,
|
||||
);
|
||||
} else {
|
||||
await enqueueWorksmobileOrgUnitSync(tenantId, id);
|
||||
}
|
||||
@@ -379,10 +324,6 @@ export function TenantWorksmobilePage() {
|
||||
resourceKind,
|
||||
count: successCount,
|
||||
failureCount: failures.length,
|
||||
credentialBatchId:
|
||||
resourceKind === "users" && successCount > 0
|
||||
? credentialBatchId
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ resourceKind, count, failureCount }) => {
|
||||
@@ -397,15 +338,11 @@ export function TenantWorksmobilePage() {
|
||||
});
|
||||
} else {
|
||||
toast.success("WORKS 생성 작업을 등록했습니다.", {
|
||||
description:
|
||||
resourceKind === "users"
|
||||
? `${count}건, 비밀번호 CSV는 배치 처리 완료 후 히스토리에서 다운로드할 수 있습니다.`
|
||||
: `${count}건`,
|
||||
description: `${count}건`,
|
||||
});
|
||||
}
|
||||
overviewQuery.refetch();
|
||||
comparisonQuery.refetch();
|
||||
credentialBatchesQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("WORKS 생성 작업 등록 실패", {
|
||||
@@ -414,30 +351,6 @@ export function TenantWorksmobilePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const resetWorksmobilePasswordMutation = useMutation({
|
||||
mutationFn: ({
|
||||
userId,
|
||||
credentialBatchId,
|
||||
}: {
|
||||
userId: string;
|
||||
credentialBatchId: string;
|
||||
}) => resetWorksmobileUserPassword(tenantId, userId, credentialBatchId),
|
||||
onSuccess: () => {
|
||||
toast.success("WORKS 비밀번호 재설정 작업을 등록했습니다.", {
|
||||
description:
|
||||
"비밀번호 CSV는 배치 처리 완료 후 히스토리에서 다운로드할 수 있습니다.",
|
||||
});
|
||||
overviewQuery.refetch();
|
||||
comparisonQuery.refetch();
|
||||
credentialBatchesQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("WORKS 비밀번호 재설정 등록 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const syncSelectedOrgUnitsMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
baronIds,
|
||||
@@ -522,10 +435,7 @@ export function TenantWorksmobilePage() {
|
||||
createSelectedMutation.isPending &&
|
||||
createSelectedMutation.variables?.resourceKind === "users";
|
||||
const isSyncingGroups = syncSelectedOrgUnitsMutation.isPending;
|
||||
const isRefreshing =
|
||||
overviewQuery.isFetching ||
|
||||
comparisonQuery.isFetching ||
|
||||
credentialBatchesQuery.isFetching;
|
||||
const isRefreshing = overviewQuery.isFetching || comparisonQuery.isFetching;
|
||||
|
||||
return (
|
||||
<div className="min-w-0 max-w-full space-y-6">
|
||||
@@ -548,7 +458,6 @@ export function TenantWorksmobilePage() {
|
||||
onClick={() => {
|
||||
overviewQuery.refetch();
|
||||
comparisonQuery.refetch();
|
||||
credentialBatchesQuery.refetch();
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
@@ -602,29 +511,6 @@ export function TenantWorksmobilePage() {
|
||||
|
||||
{activeTab === "history" ? (
|
||||
<div className="space-y-4 animate-in fade-in duration-500">
|
||||
<CredentialBatchHistory
|
||||
batches={credentialBatchesQuery.data ?? []}
|
||||
loading={credentialBatchesQuery.isLoading}
|
||||
downloadingBatchId={
|
||||
initialPasswordDownloadMutation.isPending
|
||||
? initialPasswordDownloadMutation.variables?.batchId
|
||||
: undefined
|
||||
}
|
||||
deletingBatchId={deleteCredentialBatchPasswordsMutation.variables}
|
||||
onDownload={(batchId) =>
|
||||
initialPasswordDownloadMutation.mutate({ batchId })
|
||||
}
|
||||
onDelete={(batchId) => {
|
||||
if (
|
||||
window.confirm(
|
||||
"이 배치의 실제 비밀번호 값을 삭제할까요? 생성 이력은 유지됩니다.",
|
||||
)
|
||||
) {
|
||||
deleteCredentialBatchPasswordsMutation.mutate(batchId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -742,6 +628,7 @@ export function TenantWorksmobilePage() {
|
||||
<ComparisonTable
|
||||
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
|
||||
rows={filteredComparisonUsers}
|
||||
totalRows={comparisonQuery.data?.users.length ?? 0}
|
||||
loading={comparisonQuery.isLoading}
|
||||
selectedKeys={selectedUserRowKeys}
|
||||
onSelectedKeysChange={setSelectedUserRowKeys}
|
||||
@@ -767,29 +654,21 @@ export function TenantWorksmobilePage() {
|
||||
passwordManageTenantId={overview?.config.adminTenantId}
|
||||
actionLabel="선택 구성원 WORKS에 생성"
|
||||
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
|
||||
onCreateSelected={(ids) =>
|
||||
updateActionLabel="선택 구성원 업데이트 적용"
|
||||
onCreateSelected={(ids, initialPassword) =>
|
||||
createSelectedMutation.mutate({
|
||||
resourceKind: "users",
|
||||
ids,
|
||||
initialPassword,
|
||||
})
|
||||
}
|
||||
onUpdateSelected={(ids) =>
|
||||
createSelectedMutation.mutate({
|
||||
resourceKind: "users",
|
||||
ids,
|
||||
})
|
||||
}
|
||||
resettingPasswordUserId={
|
||||
resetWorksmobilePasswordMutation.isPending
|
||||
? resetWorksmobilePasswordMutation.variables?.userId
|
||||
: undefined
|
||||
}
|
||||
onResetUserPassword={(userId) => {
|
||||
if (
|
||||
window.confirm(
|
||||
"선택한 WORKS 계정의 비밀번호를 재설정할까요? 새 비밀번호는 배치 처리 완료 후 히스토리에서 CSV로 다운로드할 수 있습니다.",
|
||||
)
|
||||
) {
|
||||
resetWorksmobilePasswordMutation.mutate({
|
||||
userId,
|
||||
credentialBatchId: createWorksmobileCredentialBatchId(),
|
||||
});
|
||||
}
|
||||
}}
|
||||
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">
|
||||
@@ -835,6 +714,7 @@ export function TenantWorksmobilePage() {
|
||||
"조직/그룹",
|
||||
)}
|
||||
rows={filteredComparisonGroups}
|
||||
totalRows={comparisonQuery.data?.groups.length ?? 0}
|
||||
loading={comparisonQuery.isLoading}
|
||||
selectedKeys={selectedGroupRowKeys}
|
||||
onSelectedKeysChange={setSelectedGroupRowKeys}
|
||||
@@ -940,6 +820,11 @@ const worksmobileComparisonColumnWidths: Record<
|
||||
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 {
|
||||
@@ -982,216 +867,6 @@ function getWorksmobileComparisonStatusVariant(status: string) {
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
function formatCredentialBatchDate(value?: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "-";
|
||||
return date.toLocaleString("ko-KR", {
|
||||
timeZone: "Asia/Seoul",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function CredentialBatchHistory({
|
||||
batches,
|
||||
loading,
|
||||
downloadingBatchId,
|
||||
deletingBatchId,
|
||||
onDownload,
|
||||
onDelete,
|
||||
}: {
|
||||
batches: WorksmobileCredentialBatch[];
|
||||
loading: boolean;
|
||||
downloadingBatchId?: string;
|
||||
deletingBatchId?: string;
|
||||
onDownload: (batchId: string) => void;
|
||||
onDelete: (batchId: string) => void;
|
||||
}) {
|
||||
const [expandedBatchIds, setExpandedBatchIds] = React.useState<string[]>([]);
|
||||
const toggleExpanded = (batchId: string) => {
|
||||
setExpandedBatchIds((current) =>
|
||||
current.includes(batchId)
|
||||
? current.filter((id) => id !== batchId)
|
||||
: [...current, batchId],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">비밀번호 파일 히스토리</CardTitle>
|
||||
<CardDescription>
|
||||
생성 배치별 CSV를 다시 받거나 전달 완료된 배치의 실제 비밀번호 값을
|
||||
삭제합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="w-full max-w-full overflow-x-auto rounded-md border">
|
||||
<Table className="min-w-max">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-56 whitespace-nowrap">
|
||||
배치
|
||||
</TableHead>
|
||||
<TableHead className="w-24 whitespace-nowrap">사용자</TableHead>
|
||||
<TableHead className="min-w-36 whitespace-nowrap">
|
||||
상태
|
||||
</TableHead>
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
생성
|
||||
</TableHead>
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
삭제
|
||||
</TableHead>
|
||||
<TableHead className="w-24 whitespace-nowrap">관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
불러오는 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!loading && batches.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
생성된 비밀번호 배치가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{batches.map((batch) => {
|
||||
const isComplete =
|
||||
(batch.pendingCount ?? 0) === 0 &&
|
||||
(batch.processingCount ?? 0) === 0;
|
||||
const isExpanded = expandedBatchIds.includes(batch.batchId);
|
||||
const failures = batch.failures ?? [];
|
||||
return (
|
||||
<React.Fragment key={batch.batchId}>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
{failures.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`${batch.batchId} 실패 사유 보기`}
|
||||
onClick={() => toggleExpanded(batch.batchId)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={16} />
|
||||
) : (
|
||||
<ChevronRight size={16} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<span>{batch.batchId}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono">
|
||||
{batch.userCount}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
<span className="mr-2">
|
||||
성공 {batch.processedCount ?? 0}
|
||||
</span>
|
||||
<span className="mr-2">
|
||||
대기 {batch.pendingCount ?? 0}
|
||||
</span>
|
||||
<span className="mr-2">
|
||||
처리 {batch.processingCount ?? 0}
|
||||
</span>
|
||||
<span>실패 {batch.failedCount ?? 0}</span>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
{formatCredentialBatchDate(batch.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
{batch.hasPasswords
|
||||
? "보관 중"
|
||||
: formatCredentialBatchDate(batch.deletedAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`${batch.batchId} 비밀번호 CSV 다운로드`}
|
||||
disabled={
|
||||
!batch.hasPasswords ||
|
||||
!isComplete ||
|
||||
downloadingBatchId === batch.batchId
|
||||
}
|
||||
onClick={() => onDownload(batch.batchId)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`${batch.batchId} 비밀번호 값 삭제`}
|
||||
disabled={
|
||||
!batch.hasPasswords ||
|
||||
deletingBatchId === batch.batchId
|
||||
}
|
||||
onClick={() => onDelete(batch.batchId)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && failures.length > 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="bg-muted/30">
|
||||
<div className="space-y-2 text-xs">
|
||||
{failures.map((failure) => (
|
||||
<div
|
||||
key={`${failure.userId ?? failure.email}:${failure.lastError}`}
|
||||
className="grid gap-1 md:grid-cols-[minmax(12rem,1fr)_5rem_minmax(18rem,2fr)]"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{failure.email ?? failure.userId ?? "-"}
|
||||
</div>
|
||||
{failure.userId && (
|
||||
<div className="font-mono text-muted-foreground">
|
||||
{failure.userId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{failure.status} / retry{" "}
|
||||
{failure.retryCount ?? 0}
|
||||
</div>
|
||||
<div className="break-words">
|
||||
{failure.lastError ?? "-"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonSummary({
|
||||
title,
|
||||
summary,
|
||||
@@ -1304,6 +979,7 @@ function ComparisonFilterButtons<T extends string>({
|
||||
function ComparisonTable({
|
||||
title,
|
||||
rows,
|
||||
totalRows,
|
||||
loading,
|
||||
selectedKeys,
|
||||
onSelectedKeysChange,
|
||||
@@ -1321,17 +997,19 @@ function ComparisonTable({
|
||||
showBaronIdColumn = true,
|
||||
showManageColumn = true,
|
||||
actionLabel,
|
||||
updateActionLabel,
|
||||
actionDisabled,
|
||||
onCreateSelected,
|
||||
onUpdateSelected,
|
||||
onRunSelected,
|
||||
deleteActionLabel,
|
||||
deleteActionDisabled = false,
|
||||
onDeleteSelected,
|
||||
resettingPasswordUserId,
|
||||
onResetUserPassword,
|
||||
requireInitialPassword = false,
|
||||
}: {
|
||||
title: string;
|
||||
rows: WorksmobileComparisonItem[];
|
||||
totalRows: number;
|
||||
loading: boolean;
|
||||
selectedKeys: string[];
|
||||
onSelectedKeysChange: (ids: string[]) => void;
|
||||
@@ -1351,22 +1029,35 @@ function ComparisonTable({
|
||||
showBaronIdColumn?: boolean;
|
||||
showManageColumn?: boolean;
|
||||
actionLabel: string;
|
||||
updateActionLabel?: string;
|
||||
actionDisabled: boolean;
|
||||
onCreateSelected: (ids: string[]) => void;
|
||||
onCreateSelected: (ids: string[], initialPassword?: string) => void;
|
||||
onUpdateSelected?: (ids: string[]) => void;
|
||||
onRunSelected?: (actionIds: string[], deleteIds: string[]) => void;
|
||||
deleteActionLabel?: string;
|
||||
deleteActionDisabled?: boolean;
|
||||
onDeleteSelected?: (ids: string[]) => void;
|
||||
resettingPasswordUserId?: string;
|
||||
onResetUserPassword?: (userId: 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,
|
||||
@@ -1377,6 +1068,7 @@ function ComparisonTable({
|
||||
selectedActionIds.length === 0 &&
|
||||
selectedDeleteIds.length > 0 &&
|
||||
canRunDeleteAction;
|
||||
const canRunUserUpdateAction = Boolean(onUpdateSelected);
|
||||
const selectedActionLabel = shouldRunDeleteAction
|
||||
? deleteActionLabel
|
||||
: actionLabel;
|
||||
@@ -1388,7 +1080,11 @@ function ComparisonTable({
|
||||
? selectedActionIds.length === 0 && selectedDeleteIds.length === 0
|
||||
: shouldRunDeleteAction
|
||||
? selectedDeleteIds.length === 0 || deleteActionDisabled
|
||||
: selectedActionIds.length === 0) || actionDisabled;
|
||||
: 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));
|
||||
@@ -1476,15 +1172,6 @@ function ComparisonTable({
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const canResetPassword = (row: WorksmobileComparisonItem) =>
|
||||
Boolean(
|
||||
onResetUserPassword &&
|
||||
row.resourceType === "USER" &&
|
||||
row.baronId &&
|
||||
row.status !== "missing_in_worksmobile" &&
|
||||
!isImmutableWorksmobileAccount(row),
|
||||
);
|
||||
|
||||
const toggleColumn = (key: WorksmobileComparisonColumnKey) => {
|
||||
onVisibleColumnsChange((current) => ({
|
||||
...current,
|
||||
@@ -1510,11 +1197,55 @@ function ComparisonTable({
|
||||
);
|
||||
};
|
||||
|
||||
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}
|
||||
@@ -1568,6 +1299,7 @@ function ComparisonTable({
|
||||
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)}
|
||||
@@ -1594,21 +1326,69 @@ function ComparisonTable({
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selectedActionVariant}
|
||||
onClick={() => {
|
||||
if (onRunSelected) {
|
||||
onRunSelected(selectedActionIds, selectedDeleteIds);
|
||||
return;
|
||||
}
|
||||
if (shouldRunDeleteAction && onDeleteSelected) {
|
||||
onDeleteSelected(selectedDeleteIds);
|
||||
return;
|
||||
}
|
||||
onCreateSelected(selectedActionIds);
|
||||
}}
|
||||
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
|
||||
@@ -1625,54 +1405,100 @@ function ComparisonTable({
|
||||
minWidth: tableMinWidth,
|
||||
}}
|
||||
>
|
||||
<TableHead className="w-10 whitespace-nowrap">
|
||||
<Checkbox
|
||||
aria-label={`${title} 전체 선택`}
|
||||
checked={allSelectableSelected}
|
||||
disabled={selectableKeys.length === 0}
|
||||
onCheckedChange={toggleAll}
|
||||
/>
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={
|
||||
worksmobileComparisonTableHeadCenterContentClassName
|
||||
}
|
||||
>
|
||||
<Checkbox
|
||||
aria-label={`${title} 전체 선택`}
|
||||
checked={allSelectableSelected}
|
||||
disabled={selectableKeys.length === 0}
|
||||
onCheckedChange={toggleAll}
|
||||
/>
|
||||
</div>
|
||||
</TableHead>
|
||||
{isColumnVisible("status") && (
|
||||
<TableHead className="w-24 whitespace-nowrap">상태</TableHead>
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
상태
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{showBaronIdColumn && isColumnVisible("baronId") && (
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
Baron ID
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
Baron ID
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("baron") && (
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
Baron
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
Baron
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("baronOrg") && (
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
{baronOrgColumnLabel}
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
{baronOrgColumnLabel}
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("externalKey") && (
|
||||
<TableHead className="min-w-40 whitespace-nowrap">
|
||||
external_key
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
external_key
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("worksmobileDomain") && (
|
||||
<TableHead className="min-w-28 whitespace-nowrap">
|
||||
WORKS 도메인
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
WORKS 도메인
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("worksmobile") && (
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
WORKS
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
WORKS
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("worksmobileOrg") && (
|
||||
<TableHead className="min-w-52 whitespace-nowrap">
|
||||
상위 Works 조직
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
상위 Works 조직
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{showManageColumn && isColumnVisible("manage") && (
|
||||
<TableHead className="w-14 whitespace-nowrap">관리</TableHead>
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
관리
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -1887,23 +1713,6 @@ function ComparisonTable({
|
||||
>
|
||||
<KeyRound size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`${row.worksmobileName ?? row.baronName ?? row.worksmobileId ?? "WORKS user"} 비밀번호 재설정`}
|
||||
disabled={
|
||||
!canResetPassword(row) ||
|
||||
resettingPasswordUserId === row.baronId
|
||||
}
|
||||
onClick={() => {
|
||||
if (row.baronId) {
|
||||
onResetUserPassword?.(row.baronId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
Reference in New Issue
Block a user