forked from baron/baron-sso
Merge branch 'dev' into feature/1058-adminfront-tab-rebac-permissions
This commit is contained in:
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import {
|
||||
filterTenantsByScope,
|
||||
filterTenantViewRowsBySearch,
|
||||
getTenantSearchMatchIds,
|
||||
getTenantViewRows,
|
||||
resolveTenantSelectionIds,
|
||||
@@ -97,4 +98,17 @@ describe("TenantListPage tenant list helpers", () => {
|
||||
]);
|
||||
expect(getTenantSearchMatchIds(treeRows, "platform")).toEqual(["team-1"]);
|
||||
});
|
||||
|
||||
it("filters displayed tenant rows to direct matches only", () => {
|
||||
const treeRows = getTenantViewRows(
|
||||
tenants.filter((item) => item.id !== "company-2"),
|
||||
"tree",
|
||||
"",
|
||||
true,
|
||||
);
|
||||
|
||||
expect(
|
||||
filterTenantViewRowsBySearch(treeRows, "team-1").map((row) => row.id),
|
||||
).toEqual(["team-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@ import {
|
||||
} from "../utils/tenantCsvImport";
|
||||
import {
|
||||
filterTenantsByScope,
|
||||
filterTenantViewRowsBySearch,
|
||||
getTenantSearchMatchIds,
|
||||
getTenantViewRows,
|
||||
resolveTenantSelectionIds,
|
||||
@@ -1667,11 +1668,12 @@ const TenantHierarchyView: React.FC<{
|
||||
|
||||
const flattenedRows = React.useMemo(() => {
|
||||
if (viewMode === "table") {
|
||||
return sortItems(
|
||||
const rows = sortItems(
|
||||
getTenantViewRows(tenants, "table", scopeTenantId, !!search),
|
||||
sortConfig,
|
||||
tenantSortResolvers,
|
||||
);
|
||||
return filterTenantViewRowsBySearch(rows, search);
|
||||
}
|
||||
|
||||
const result: TenantViewRow[] = [];
|
||||
@@ -1692,7 +1694,7 @@ const TenantHierarchyView: React.FC<{
|
||||
}
|
||||
};
|
||||
collect(subTree, 0);
|
||||
return result;
|
||||
return filterTenantViewRowsBySearch(result, search);
|
||||
}, [
|
||||
expandedIds,
|
||||
scopeTenantId,
|
||||
|
||||
@@ -7,6 +7,7 @@ import TenantUsersPage from "./TenantUsersPage";
|
||||
|
||||
const exportUsersCSVMock = vi.hoisted(() => vi.fn());
|
||||
const updateUserMock = vi.hoisted(() => vi.fn());
|
||||
const bulkUpdateUsersMock = vi.hoisted(() => vi.fn());
|
||||
const fetchUsersMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../../lib/i18n", () => createI18nMock());
|
||||
@@ -18,6 +19,7 @@ vi.mock("../../../lib/adminApi", () => ({
|
||||
slug: "tech-planning",
|
||||
})),
|
||||
fetchUsers: fetchUsersMock,
|
||||
bulkUpdateUsers: bulkUpdateUsersMock,
|
||||
exportUsersCSV: exportUsersCSVMock,
|
||||
updateUser: updateUserMock,
|
||||
}));
|
||||
@@ -26,8 +28,7 @@ function renderTenantUsersPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
return render(
|
||||
const result = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={["/tenants/tenant-team-id/users"]}>
|
||||
<Routes>
|
||||
@@ -39,12 +40,15 @@ function renderTenantUsersPage() {
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
return { ...result, queryClient };
|
||||
}
|
||||
|
||||
describe("TenantUsersPage export", () => {
|
||||
beforeEach(() => {
|
||||
exportUsersCSVMock.mockReset();
|
||||
updateUserMock.mockReset();
|
||||
bulkUpdateUsersMock.mockReset();
|
||||
fetchUsersMock.mockReset();
|
||||
fetchUsersMock.mockResolvedValue({
|
||||
items: [
|
||||
@@ -64,10 +68,12 @@ describe("TenantUsersPage export", () => {
|
||||
}),
|
||||
filename: "users_export_20260609.csv",
|
||||
});
|
||||
updateUserMock.mockResolvedValue({});
|
||||
vi.spyOn(window.URL, "createObjectURL").mockReturnValue(
|
||||
"blob:tenant-users-export",
|
||||
);
|
||||
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
bulkUpdateUsersMock.mockResolvedValue({ results: [] });
|
||||
});
|
||||
|
||||
it("exports only the currently opened tenant users by tenant slug", async () => {
|
||||
@@ -135,14 +141,121 @@ describe("TenantUsersPage export", () => {
|
||||
fireEvent.click(screen.getByTestId("tenant-member-add-submit-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateUserMock).toHaveBeenCalledWith("user-2", {
|
||||
expect(bulkUpdateUsersMock).toHaveBeenCalledWith({
|
||||
userIds: ["user-2", "user-3"],
|
||||
tenantSlug: "tech-planning",
|
||||
isAddTenant: true,
|
||||
});
|
||||
expect(updateUserMock).toHaveBeenCalledWith("user-3", {
|
||||
});
|
||||
expect(updateUserMock).not.toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ isAddTenant: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("queues orgfront multi picker users and adds them with one bulk request", async () => {
|
||||
fetchUsersMock
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
id: "existing-user",
|
||||
name: "Existing",
|
||||
email: "existing@example.com",
|
||||
role: "user",
|
||||
status: "active",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})
|
||||
.mockResolvedValue({ items: [], total: 0 });
|
||||
|
||||
renderTenantUsersPage();
|
||||
|
||||
const addButton = await screen.findByTestId(
|
||||
"tenant-member-add-existing-btn",
|
||||
);
|
||||
await waitFor(() => expect(addButton).not.toBeDisabled());
|
||||
fireEvent.click(addButton);
|
||||
|
||||
const picker = await screen.findByTitle("조직도에서 구성원 선택");
|
||||
expect(decodeURIComponent(picker.getAttribute("src") ?? "")).toContain(
|
||||
"/embed/picker?mode=multiple&select=user",
|
||||
);
|
||||
|
||||
fireEvent(
|
||||
window,
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "orgfront:picker:confirm",
|
||||
payload: {
|
||||
mode: "multiple",
|
||||
selections: [
|
||||
{ type: "tenant", id: "team-1", name: "플랫폼팀" },
|
||||
{
|
||||
type: "user",
|
||||
id: "picked-user-1",
|
||||
name: "Picked One",
|
||||
email: "picked1@example.com",
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
id: "picked-user-2",
|
||||
name: "Picked Two",
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
id: "existing-user",
|
||||
name: "Existing",
|
||||
email: "existing@example.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("tenant-member-add-queue")).toHaveTextContent(
|
||||
"Picked One",
|
||||
);
|
||||
expect(screen.getByTestId("tenant-member-add-queue")).toHaveTextContent(
|
||||
"Picked Two",
|
||||
);
|
||||
expect(screen.getByTestId("tenant-member-add-queue")).not.toHaveTextContent(
|
||||
"Existing",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("tenant-member-add-submit-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(bulkUpdateUsersMock).toHaveBeenCalledWith({
|
||||
userIds: ["picked-user-1", "picked-user-2"],
|
||||
tenantSlug: "tech-planning",
|
||||
isAddTenant: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("removes a member from the tenant and invalidates the user detail cache", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const { queryClient } = renderTenantUsersPage();
|
||||
queryClient.setQueryData(["user", "user-1"], {
|
||||
id: "user-1",
|
||||
name: "Alice",
|
||||
});
|
||||
|
||||
await screen.findByText("Alice");
|
||||
|
||||
fireEvent.click(screen.getByTestId("tenant-member-remove-user-1"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateUserMock).toHaveBeenCalledWith("user-1", {
|
||||
tenantSlug: "tech-planning",
|
||||
isRemoveTenant: true,
|
||||
});
|
||||
});
|
||||
expect(queryClient.getQueryState(["user", "user-1"])?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
bulkUpdateUsers,
|
||||
exportUsersCSV,
|
||||
fetchTenant,
|
||||
fetchUsers,
|
||||
@@ -47,6 +48,10 @@ import {
|
||||
updateUser,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import {
|
||||
buildAuthenticatedOrgChartUserMultiPickerUrl,
|
||||
parseOrgChartUserSelections,
|
||||
} from "../../users/orgChartPicker";
|
||||
|
||||
function TenantUsersPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
@@ -56,6 +61,13 @@ function TenantUsersPage() {
|
||||
const [addMembersOpen, setAddMembersOpen] = React.useState(false);
|
||||
const [memberSearch, setMemberSearch] = React.useState("");
|
||||
const [queuedMembers, setQueuedMembers] = React.useState<UserSummary[]>([]);
|
||||
const orgChartMemberPickerUrl = React.useMemo(
|
||||
() =>
|
||||
buildAuthenticatedOrgChartUserMultiPickerUrl(
|
||||
import.meta.env.ORGFRONT_URL,
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
// 테넌트의 슬러그(tenantSlug)를 먼저 가져옴
|
||||
const tenantQuery = useQuery({
|
||||
@@ -103,7 +115,7 @@ function TenantUsersPage() {
|
||||
const removeTenantMutation = useMutation({
|
||||
mutationFn: ({ userId, slug }: { userId: string; slug: string }) =>
|
||||
updateUser(userId, { tenantSlug: slug, isRemoveTenant: true }),
|
||||
onSuccess: () => {
|
||||
onSuccess: (_result, variables) => {
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.tenants.members.remove_success",
|
||||
@@ -111,6 +123,8 @@ function TenantUsersPage() {
|
||||
),
|
||||
);
|
||||
usersQuery.refetch();
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["user", variables.userId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
@@ -124,11 +138,11 @@ function TenantUsersPage() {
|
||||
const addMembersMutation = useMutation({
|
||||
mutationFn: async (members: UserSummary[]) => {
|
||||
if (!tenantSlug || members.length === 0) return;
|
||||
await Promise.all(
|
||||
members.map((member) =>
|
||||
updateUser(member.id, { tenantSlug, isAddTenant: true }),
|
||||
),
|
||||
);
|
||||
await bulkUpdateUsers({
|
||||
userIds: members.map((member) => member.id),
|
||||
tenantSlug,
|
||||
isAddTenant: true,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
const count = queuedMembers.length;
|
||||
@@ -179,11 +193,27 @@ function TenantUsersPage() {
|
||||
);
|
||||
const searchResults = memberSearchQuery.data?.items ?? [];
|
||||
|
||||
const queueMembers = React.useCallback(
|
||||
(members: UserSummary[]) => {
|
||||
setQueuedMembers((current) => {
|
||||
const blockedIds = new Set([
|
||||
...existingUserIds,
|
||||
...current.map((member) => member.id),
|
||||
]);
|
||||
const next = [...current];
|
||||
for (const member of members) {
|
||||
if (blockedIds.has(member.id)) continue;
|
||||
blockedIds.add(member.id);
|
||||
next.push(member);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[existingUserIds],
|
||||
);
|
||||
|
||||
const queueMember = (member: UserSummary) => {
|
||||
if (existingUserIds.has(member.id) || queuedUserIds.has(member.id)) {
|
||||
return;
|
||||
}
|
||||
setQueuedMembers((current) => [...current, member]);
|
||||
queueMembers([member]);
|
||||
};
|
||||
|
||||
const removeQueuedMember = (memberId: string) => {
|
||||
@@ -192,6 +222,30 @@ function TenantUsersPage() {
|
||||
);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!addMembersOpen) return;
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const selections = parseOrgChartUserSelections(event.data);
|
||||
if (selections.length === 0) return;
|
||||
|
||||
queueMembers(
|
||||
selections.map((selection) => ({
|
||||
id: selection.id,
|
||||
name: selection.name,
|
||||
email: selection.email,
|
||||
role: "user",
|
||||
status: "active",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [addMembersOpen, queueMembers]);
|
||||
|
||||
return (
|
||||
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex-shrink-0 flex flex-row items-center justify-between">
|
||||
@@ -244,7 +298,7 @@ function TenantUsersPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Dialog open={addMembersOpen} onOpenChange={setAddMembersOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className="max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
|
||||
@@ -256,73 +310,86 @@ function TenantUsersPage() {
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={memberSearch}
|
||||
onChange={(event) => setMemberSearch(event.target.value)}
|
||||
className="h-9 pl-9"
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.members.search_placeholder",
|
||||
"이름 또는 이메일 검색",
|
||||
)}
|
||||
data-testid="tenant-member-search-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<div className="max-h-56 overflow-auto">
|
||||
{memberSearchTerm.length < 2 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.tenants.members.search_min_length",
|
||||
"두 글자 이상 입력하세요.",
|
||||
)}
|
||||
</div>
|
||||
) : memberSearchQuery.isFetching ? (
|
||||
<div className="flex items-center justify-center gap-2 px-3 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{t("ui.common.searching", "검색 중...")}
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{t("ui.common.no_results", "검색 결과가 없습니다.")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{searchResults.map((user) => {
|
||||
const disabled =
|
||||
existingUserIds.has(user.id) ||
|
||||
queuedUserIds.has(user.id);
|
||||
return (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
onClick={() => queueMember(user)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium">
|
||||
{user.name}
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(360px,1.2fr)]">
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={memberSearch}
|
||||
onChange={(event) => setMemberSearch(event.target.value)}
|
||||
className="h-9 pl-9"
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.members.search_placeholder",
|
||||
"이름 또는 이메일 검색",
|
||||
)}
|
||||
data-testid="tenant-member-search-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<div className="max-h-56 overflow-auto">
|
||||
{memberSearchTerm.length < 2 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.tenants.members.search_min_length",
|
||||
"두 글자 이상 입력하세요.",
|
||||
)}
|
||||
</div>
|
||||
) : memberSearchQuery.isFetching ? (
|
||||
<div className="flex items-center justify-center gap-2 px-3 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{t("ui.common.searching", "검색 중...")}
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{t("ui.common.no_results", "검색 결과가 없습니다.")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{searchResults.map((user) => {
|
||||
const disabled =
|
||||
existingUserIds.has(user.id) ||
|
||||
queuedUserIds.has(user.id);
|
||||
return (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
onClick={() => queueMember(user)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</span>
|
||||
<Plus size={16} className="flex-shrink-0" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Plus size={16} className="flex-shrink-0" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-[360px] overflow-hidden rounded-md border">
|
||||
<iframe
|
||||
title={t(
|
||||
"ui.admin.tenants.members.org_picker_title",
|
||||
"조직도에서 구성원 선택",
|
||||
)}
|
||||
src={orgChartMemberPickerUrl}
|
||||
className="h-[420px] w-full"
|
||||
data-testid="tenant-member-org-picker-frame"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="min-h-20 rounded-md border bg-muted/20 p-3"
|
||||
className="min-h-20 rounded-md border bg-muted/20 p-3 lg:col-span-2"
|
||||
data-testid="tenant-member-add-queue"
|
||||
>
|
||||
{queuedMembers.length === 0 ? (
|
||||
@@ -398,12 +465,15 @@ function TenantUsersPage() {
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.common.actions", "작업")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{usersQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-20">
|
||||
<TableCell colSpan={5} className="text-center py-20">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2
|
||||
className="animate-spin text-muted-foreground"
|
||||
@@ -418,7 +488,7 @@ function TenantUsersPage() {
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
colSpan={5}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
@@ -460,6 +530,23 @@ function TenantUsersPage() {
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t(
|
||||
"ui.admin.tenants.members.remove",
|
||||
"구성원 제외",
|
||||
)}
|
||||
data-testid={`tenant-member-remove-${user.id}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
_handleRemoveMember(user.id, user.name);
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -656,7 +656,7 @@ export function TenantWorksmobilePage() {
|
||||
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
|
||||
updateActionLabel="선택 구성원 업데이트 적용"
|
||||
onCreateSelected={(ids, initialPassword) =>
|
||||
createSelectedMutation.mutate({
|
||||
createSelectedMutation.mutateAsync({
|
||||
resourceKind: "users",
|
||||
ids,
|
||||
initialPassword,
|
||||
@@ -1031,7 +1031,7 @@ function ComparisonTable({
|
||||
actionLabel: string;
|
||||
updateActionLabel?: string;
|
||||
actionDisabled: boolean;
|
||||
onCreateSelected: (ids: string[], initialPassword?: string) => void;
|
||||
onCreateSelected: (ids: string[], initialPassword?: string) => unknown;
|
||||
onUpdateSelected?: (ids: string[]) => void;
|
||||
onRunSelected?: (actionIds: string[], deleteIds: string[]) => void;
|
||||
deleteActionLabel?: string;
|
||||
@@ -1222,13 +1222,17 @@ function ComparisonTable({
|
||||
onUpdateSelected(selectedUpdateUserIds);
|
||||
};
|
||||
|
||||
const confirmInitialPassword = () => {
|
||||
const confirmInitialPassword = async () => {
|
||||
const password = initialPassword.trim();
|
||||
if (!password) {
|
||||
toast.error("WORKS 초기 비밀번호를 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
onCreateSelected(pendingInitialPasswordIds, password);
|
||||
try {
|
||||
await onCreateSelected(pendingInitialPasswordIds, password);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setInitialPasswordOpen(false);
|
||||
setInitialPassword("");
|
||||
setPendingInitialPasswordIds([]);
|
||||
@@ -1383,7 +1387,11 @@ function ComparisonTable({
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="button" onClick={confirmInitialPassword}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={confirmInitialPassword}
|
||||
disabled={actionDisabled}
|
||||
>
|
||||
생성 작업 등록
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -26,6 +26,14 @@ export function getTenantSearchMatchIds(
|
||||
.map((row) => row.id);
|
||||
}
|
||||
|
||||
export function filterTenantViewRowsBySearch<T extends TenantViewRow>(
|
||||
rows: T[],
|
||||
search: string,
|
||||
) {
|
||||
if (!search.trim()) return rows;
|
||||
return rows.filter((row) => tenantMatchesListSearch(row, search));
|
||||
}
|
||||
|
||||
function collectTenantTreeRows(
|
||||
nodes: TenantNode[],
|
||||
depth: number,
|
||||
|
||||
Reference in New Issue
Block a user