forked from baron/baron-sso
네이버 웍스 연동기능 개선
This commit is contained in:
23
adminfront/src/components/ui/dialog.focus-scope.test.tsx
Normal file
23
adminfront/src/components/ui/dialog.focus-scope.test.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "./dialog";
|
||||
|
||||
describe("Dialog FocusScope integration", () => {
|
||||
it("mounts an open dialog without a ref update loop", () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogContent>
|
||||
<DialogTitle>Focus scope check</DialogTitle>
|
||||
<DialogDescription>Dialog content is mounted.</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Focus scope check")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,55 +1,221 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
type DialogContextValue = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogContext = React.createContext<DialogContextValue | null>(null);
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
function useDialogContext(componentName: string) {
|
||||
const context = React.useContext(DialogContext);
|
||||
if (!context) {
|
||||
throw new Error(`${componentName} must be used within Dialog`);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
function composeEventHandlers<E extends React.SyntheticEvent>(
|
||||
theirs: ((event: E) => void) | undefined,
|
||||
ours: (event: E) => void,
|
||||
) {
|
||||
return (event: E) => {
|
||||
theirs?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
ours(event);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type DialogProps = {
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
function Dialog({
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: DialogProps) {
|
||||
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
|
||||
const isControlled = open !== undefined;
|
||||
const currentOpen = isControlled ? open : internalOpen;
|
||||
const setOpen = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalOpen(nextOpen);
|
||||
}
|
||||
onOpenChange?.(nextOpen);
|
||||
},
|
||||
[isControlled, onOpenChange],
|
||||
);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ open: currentOpen, setOpen }),
|
||||
[currentOpen, setOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={value}>{children}</DialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogTriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
|
||||
({ asChild = false, children, onClick, ...props }, ref) => {
|
||||
const { setOpen } = useDialogContext("DialogTrigger");
|
||||
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
const child = children as React.ReactElement<{
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
}>;
|
||||
return React.cloneElement(child, {
|
||||
...props,
|
||||
onClick: composeEventHandlers(
|
||||
child.props.onClick as React.MouseEventHandler<HTMLButtonElement>,
|
||||
() => setOpen(true),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" ref={ref} onClick={handleOpen} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
DialogTrigger.displayName = "DialogTrigger";
|
||||
|
||||
const DialogPortal = ({ children }: { children?: React.ReactNode }) => {
|
||||
if (typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return createPortal(children, document.body);
|
||||
};
|
||||
DialogPortal.displayName = "DialogPortal";
|
||||
|
||||
const DialogClose = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
DialogTriggerProps
|
||||
>(({ asChild = false, children, onClick, ...props }, ref) => {
|
||||
const { setOpen } = useDialogContext("DialogClose");
|
||||
const handleClose = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
const child = children as React.ReactElement<{
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
}>;
|
||||
return React.cloneElement(child, {
|
||||
...props,
|
||||
onClick: composeEventHandlers(
|
||||
child.props.onClick as React.MouseEventHandler<HTMLButtonElement>,
|
||||
() => setOpen(false),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" ref={ref} onClick={handleClose} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
DialogClose.displayName = "DialogClose";
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, onMouseDown, ...props }, ref) => {
|
||||
const { setOpen } = useDialogContext("DialogOverlay");
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
data-state="open"
|
||||
onMouseDown={composeEventHandlers(onMouseDown, (event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
setOpen(false);
|
||||
}
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
/>
|
||||
);
|
||||
});
|
||||
DialogOverlay.displayName = "DialogOverlay";
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, children, onKeyDown, ...props }, ref) => {
|
||||
const { open, setOpen } = useDialogContext("DialogContent");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const onDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onDocumentKeyDown);
|
||||
return () => document.removeEventListener("keydown", onDocumentKeyDown);
|
||||
}, [open, setOpen]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<div
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-state="open"
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
onKeyDown={onKeyDown}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogClose className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
@@ -80,10 +246,10 @@ const DialogFooter = ({
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
<h2
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
@@ -92,19 +258,19 @@ const DialogTitle = React.forwardRef<
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
DialogTitle.displayName = "DialogTitle";
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
DialogDescription.displayName = "DialogDescription";
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
|
||||
@@ -1,26 +1,68 @@
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
|
||||
interface SwitchProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
checked,
|
||||
defaultChecked = false,
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
onClick,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const isControlled = checked !== undefined;
|
||||
const [internalChecked, setInternalChecked] =
|
||||
React.useState(defaultChecked);
|
||||
const currentChecked = isControlled ? checked : internalChecked;
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
if (event.defaultPrevented || disabled) {
|
||||
return;
|
||||
}
|
||||
const nextChecked = !currentChecked;
|
||||
if (!isControlled) {
|
||||
setInternalChecked(nextChecked);
|
||||
}
|
||||
onCheckedChange?.(nextChecked);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={currentChecked}
|
||||
data-state={currentChecked ? "checked" : "unchecked"}
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-state={currentChecked ? "checked" : "unchecked"}
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
Switch.displayName = "Switch";
|
||||
|
||||
export { Switch };
|
||||
|
||||
@@ -22,6 +22,17 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
})),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "group-1",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "한맥그룹",
|
||||
slug: "hanmac-group",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-06T00:00:00Z",
|
||||
updatedAt: "2026-05-06T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "company-1",
|
||||
type: "COMPANY",
|
||||
@@ -58,7 +69,7 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
],
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
total: 3,
|
||||
total: 4,
|
||||
})),
|
||||
fetchAdminRPUsageDaily: vi.fn(async () => ({
|
||||
days: 14,
|
||||
@@ -150,7 +161,7 @@ describe("admin overview and auth guard pages", () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황"),
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByLabelText("일 단위 RP 요청 현황"),
|
||||
@@ -168,7 +179,7 @@ describe("admin overview and auth guard pages", () => {
|
||||
|
||||
expect(
|
||||
(await screen.findByText("전체 테넌트 수")).parentElement,
|
||||
).toHaveTextContent("3");
|
||||
).toHaveTextContent("4");
|
||||
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
|
||||
"3",
|
||||
);
|
||||
@@ -180,17 +191,30 @@ describe("admin overview and auth guard pages", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("changes the RP usage perspective and targets a permitted organization", async () => {
|
||||
it("limits the overview graph choices to company tenants", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황");
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황");
|
||||
|
||||
expect(
|
||||
await screen.findByRole("checkbox", { name: "한맥 (hanmac)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("한맥그룹 (hanmac-group)"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("changes the RP usage perspective and targets a permitted company", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황");
|
||||
fireEvent.click(screen.getByRole("button", { name: "주" }));
|
||||
expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0);
|
||||
expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0);
|
||||
fireEvent.click(screen.getByRole("button", { name: "월" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("checkbox", { name: "개발팀 (dev-team)" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "한맥 (hanmac)" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
|
||||
@@ -198,6 +222,10 @@ describe("admin overview and auth guard pages", () => {
|
||||
period: "month",
|
||||
});
|
||||
});
|
||||
expect(
|
||||
screen.queryByText("한맥그룹 (hanmac-group)"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
|
||||
expect(await screen.findAllByText("05월")).not.toHaveLength(0);
|
||||
});
|
||||
@@ -217,7 +245,7 @@ describe("admin overview and auth guard pages", () => {
|
||||
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황");
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황");
|
||||
expect(screen.queryByText("정합성 최종 검증")).not.toBeInTheDocument();
|
||||
expect(fetchDataIntegrityReport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import {
|
||||
OverviewAxisNotes,
|
||||
OverviewMetric,
|
||||
OverviewSelectionChips,
|
||||
} from "../../../../common/core/components/overview";
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import {
|
||||
type DataIntegrityStatus,
|
||||
@@ -21,11 +26,6 @@ import {
|
||||
fetchDataIntegrityReport,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import {
|
||||
OverviewAxisNotes,
|
||||
OverviewMetric,
|
||||
OverviewSelectionChips,
|
||||
} from "../../../../common/core/components/overview";
|
||||
|
||||
type DailyPoint = {
|
||||
date: string;
|
||||
@@ -72,7 +72,10 @@ function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
|
||||
uniqueSubjects: 0,
|
||||
} satisfies SeriesSummary);
|
||||
current.loginRequests += row.loginRequests;
|
||||
current.uniqueSubjects = Math.max(current.uniqueSubjects, row.uniqueSubjects);
|
||||
current.uniqueSubjects = Math.max(
|
||||
current.uniqueSubjects,
|
||||
row.uniqueSubjects,
|
||||
);
|
||||
bySeries.set(key, current);
|
||||
}
|
||||
return Array.from(bySeries.values())
|
||||
@@ -200,10 +203,7 @@ function IntegrityOverviewSummary() {
|
||||
<AlertTriangle size={18} className="text-amber-600" />
|
||||
)}
|
||||
<h3 className="text-base font-semibold">
|
||||
{t(
|
||||
"ui.admin.integrity.summary.title",
|
||||
"정합성 최종 검증",
|
||||
)}
|
||||
{t("ui.admin.integrity.summary.title", "정합성 최종 검증")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
@@ -213,11 +213,9 @@ function IntegrityOverviewSummary() {
|
||||
{integrityStatusText(data.status)}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{t(
|
||||
"ui.admin.integrity.summary.failures_text",
|
||||
"실패 {{count}}건",
|
||||
{ count: data.summary.failures },
|
||||
)}
|
||||
{t("ui.admin.integrity.summary.failures_text", "실패 {{count}}건", {
|
||||
count: data.summary.failures,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatOverviewDateTime(data.checkedAt)}
|
||||
@@ -303,7 +301,7 @@ function RPUsageMixedChart({
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.overview.chart.description",
|
||||
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
|
||||
"전체 또는 선택한 회사 기준으로 그래프를 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -397,17 +395,20 @@ function RPUsageMixedChart({
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<OverviewAxisNotes
|
||||
xAxisLabel={t("ui.common.chart.axis.x", "X축: 기간")}
|
||||
yAxisLabel={t("ui.common.chart.axis.y", "Y축: 로그인 요청 수")}
|
||||
/>
|
||||
<OverviewAxisNotes
|
||||
xAxisLabel={t("ui.common.chart.axis.x", "X축: 기간")}
|
||||
yAxisLabel={t("ui.common.chart.axis.y", "Y축: 로그인 요청 수")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{series.length > 0 && (
|
||||
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
|
||||
{series.map((item) => (
|
||||
<div key={item.key} className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
|
||||
>
|
||||
<span className="font-medium">{item.clientLabel}</span>
|
||||
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
|
||||
{t(
|
||||
@@ -423,7 +424,6 @@ function RPUsageMixedChart({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -444,7 +444,7 @@ function GlobalOverviewPage() {
|
||||
});
|
||||
const tenantOptions = useMemo(() => {
|
||||
return (tenantsQuery.data?.items ?? []).filter(
|
||||
(tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION",
|
||||
(tenant) => tenant.type === "COMPANY",
|
||||
);
|
||||
}, [tenantsQuery.data?.items]);
|
||||
const usageQuery = useQuery({
|
||||
@@ -582,7 +582,7 @@ function GlobalOverviewPage() {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.overview.chart.description",
|
||||
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
|
||||
"전체 또는 선택한 회사 기준으로 그래프를 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
|
||||
const companyParentTypes = new Set(["COMPANY", "COMPANY_GROUP"]);
|
||||
|
||||
export function filterParentTenants(
|
||||
tenants: TenantSummary[],
|
||||
search: string,
|
||||
companyOnly: boolean,
|
||||
excludeTenantId = "",
|
||||
) {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
return tenants.filter((tenant) => {
|
||||
if (excludeTenantId && tenant.id === excludeTenantId) return false;
|
||||
if (companyOnly && !companyParentTypes.has(tenant.type)) return false;
|
||||
if (!normalizedSearch) return true;
|
||||
|
||||
return [tenant.name, tenant.slug, tenant.type]
|
||||
.filter(Boolean)
|
||||
.some((value) => value.toLowerCase().includes(normalizedSearch));
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { filterParentTenants } from "./ParentTenantSelector";
|
||||
import { filterParentTenants } from "./ParentTenantSelector.helpers";
|
||||
|
||||
const tenants: TenantSummary[] = [
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
parseOrgChartTenantSelection,
|
||||
} from "../../users/orgChartPicker";
|
||||
import { filterParentTenants } from "./ParentTenantSelector.helpers";
|
||||
|
||||
type ParentTenantSelectorProps = {
|
||||
id: string;
|
||||
@@ -33,26 +34,6 @@ type ParentTenantSelectorProps = {
|
||||
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
||||
};
|
||||
|
||||
const companyParentTypes = new Set(["COMPANY", "COMPANY_GROUP"]);
|
||||
|
||||
export function filterParentTenants(
|
||||
tenants: TenantSummary[],
|
||||
search: string,
|
||||
companyOnly: boolean,
|
||||
excludeTenantId = "",
|
||||
) {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
return tenants.filter((tenant) => {
|
||||
if (excludeTenantId && tenant.id === excludeTenantId) return false;
|
||||
if (companyOnly && !companyParentTypes.has(tenant.type)) return false;
|
||||
if (!normalizedSearch) return true;
|
||||
|
||||
return [tenant.name, tenant.slug, tenant.type]
|
||||
.filter(Boolean)
|
||||
.some((value) => value.toLowerCase().includes(normalizedSearch));
|
||||
});
|
||||
}
|
||||
|
||||
export function ParentTenantSelector({
|
||||
id,
|
||||
label,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function canShowWorksmobileEntry(tenant?: {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
parentId?: string | null;
|
||||
}) {
|
||||
return tenant?.slug === "hanmac-family" && !tenant.parentId;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canShowWorksmobileEntry } from "./TenantDetailPage";
|
||||
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
|
||||
|
||||
describe("TenantDetailPage Worksmobile entry visibility", () => {
|
||||
it("shows Worksmobile entry only for hanmac-family root tenant", () => {
|
||||
|
||||
@@ -6,14 +6,7 @@ import { Button } from "../../../components/ui/button";
|
||||
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
|
||||
export function canShowWorksmobileEntry(tenant?: {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
parentId?: string | null;
|
||||
}) {
|
||||
return tenant?.slug === "hanmac-family" && !tenant.parentId;
|
||||
}
|
||||
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
|
||||
|
||||
function TenantDetailPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import {
|
||||
filterTenantsByScope,
|
||||
getTenantViewRows,
|
||||
resolveTenantSelectionIds,
|
||||
tenantMatchesListSearch,
|
||||
} from "./tenantListView";
|
||||
|
||||
function tenant(
|
||||
id: string,
|
||||
name: string,
|
||||
slug: string,
|
||||
parentId?: string,
|
||||
): TenantSummary {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
parentId,
|
||||
type: parentId ? "ORGANIZATION" : "COMPANY",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
}
|
||||
|
||||
const tenants = [
|
||||
tenant("company-1", "한맥기술", "hanmac"),
|
||||
tenant("dept-1", "기술기획", "planning", "company-1"),
|
||||
tenant("team-1", "플랫폼팀", "platform", "dept-1"),
|
||||
tenant("company-2", "삼안", "saman"),
|
||||
];
|
||||
|
||||
describe("TenantListPage tenant list helpers", () => {
|
||||
it("selects a parent tenant together with every descendant", () => {
|
||||
expect(
|
||||
resolveTenantSelectionIds({
|
||||
currentIds: [],
|
||||
tenant: tenants[0],
|
||||
checked: true,
|
||||
tenants,
|
||||
deletableTenants: tenants,
|
||||
}),
|
||||
).toEqual(["company-1", "dept-1", "team-1"]);
|
||||
});
|
||||
|
||||
it("removes a parent tenant together with every descendant", () => {
|
||||
expect(
|
||||
resolveTenantSelectionIds({
|
||||
currentIds: ["company-1", "dept-1", "team-1", "company-2"],
|
||||
tenant: tenants[0],
|
||||
checked: false,
|
||||
tenants,
|
||||
deletableTenants: tenants,
|
||||
}),
|
||||
).toEqual(["company-2"]);
|
||||
});
|
||||
|
||||
it("filters to descendants of the selected scope tenant", () => {
|
||||
expect(
|
||||
filterTenantsByScope(tenants, "company-1").map((item) => item.id),
|
||||
).toEqual(["dept-1", "team-1"]);
|
||||
});
|
||||
|
||||
it("searches tenants by name, slug, and UUID", () => {
|
||||
expect(tenantMatchesListSearch(tenants[2], "team-1")).toBe(true);
|
||||
expect(tenantMatchesListSearch(tenants[2], "platform")).toBe(true);
|
||||
expect(tenantMatchesListSearch(tenants[2], "플랫폼")).toBe(true);
|
||||
});
|
||||
|
||||
it("can return tree rows or same-level table rows", () => {
|
||||
expect(getTenantViewRows(tenants, "tree").map((row) => row.depth)).toEqual([
|
||||
0, 1, 2, 0,
|
||||
]);
|
||||
expect(getTenantViewRows(tenants, "table").map((row) => row.depth)).toEqual(
|
||||
[0, 0, 0, 0],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -104,8 +104,10 @@ import { t } from "../../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||
import {
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
filterNonHanmacFamilyTenants,
|
||||
isHanmacFamilyUser,
|
||||
parseOrgChartTenantSelection,
|
||||
} from "../../users/orgChartPicker";
|
||||
import { isSeedTenant } from "../utils/protectedTenants";
|
||||
import {
|
||||
@@ -117,6 +119,14 @@ import {
|
||||
parseTenantCSV,
|
||||
serializeTenantImportCSV,
|
||||
} from "../utils/tenantCsvImport";
|
||||
import {
|
||||
type TenantViewMode,
|
||||
type TenantViewRow,
|
||||
filterTenantsByScope,
|
||||
getTenantViewRows,
|
||||
resolveTenantSelectionIds,
|
||||
tenantMatchesListSearch,
|
||||
} from "./tenantListView";
|
||||
|
||||
const tenantCSVTemplate =
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n";
|
||||
@@ -266,6 +276,9 @@ function TenantListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [viewMode, setViewMode] = React.useState<TenantViewMode>("tree");
|
||||
const [scopeTenantId, setScopeTenantId] = React.useState("");
|
||||
const [scopePickerOpen, setScopePickerOpen] = React.useState(false);
|
||||
const [sortConfig, setSortConfig] =
|
||||
React.useState<SortConfig<TenantSortKey> | null>({
|
||||
key: "createdAt",
|
||||
@@ -470,8 +483,14 @@ function TenantListPage() {
|
||||
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
|
||||
: null;
|
||||
|
||||
const tenantPages = query.data?.pages ?? [];
|
||||
const rawTenants = tenantPages.flatMap((page) => page.items);
|
||||
const tenantPages = React.useMemo(
|
||||
() => query.data?.pages ?? [],
|
||||
[query.data?.pages],
|
||||
);
|
||||
const rawTenants = React.useMemo(
|
||||
() => tenantPages.flatMap((page) => page.items),
|
||||
[tenantPages],
|
||||
);
|
||||
const tenantTotal = tenantPages[0]?.total ?? 0;
|
||||
const hanmacFamilyTenantId = React.useMemo(() => {
|
||||
const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID;
|
||||
@@ -492,6 +511,18 @@ function TenantListPage() {
|
||||
}
|
||||
return filterNonHanmacFamilyTenants(rawTenants, hanmacFamilyTenantId);
|
||||
}, [hanmacFamilyTenantId, profile, profileRole, rawTenants]);
|
||||
const scopedTenants = React.useMemo(
|
||||
() => filterTenantsByScope(allTenants, scopeTenantId),
|
||||
[allTenants, scopeTenantId],
|
||||
);
|
||||
const selectedScopeTenant = React.useMemo(
|
||||
() => allTenants.find((tenant) => tenant.id === scopeTenantId),
|
||||
[allTenants, scopeTenantId],
|
||||
);
|
||||
const scopePickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
||||
import.meta.env.ORGFRONT_URL,
|
||||
hanmacFamilyTenantId ? { tenantId: hanmacFamilyTenantId } : {},
|
||||
);
|
||||
const importParentOptionGroups =
|
||||
buildTenantImportParentOptionGroups(allTenants);
|
||||
|
||||
@@ -511,10 +542,37 @@ function TenantListPage() {
|
||||
};
|
||||
|
||||
const deletableTenants = React.useMemo(
|
||||
() => allTenants.filter((tenant) => !isSeedTenant(tenant)),
|
||||
[allTenants],
|
||||
() => scopedTenants.filter((tenant) => !isSeedTenant(tenant)),
|
||||
[scopedTenants],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const selectableIds = new Set(deletableTenants.map((tenant) => tenant.id));
|
||||
setSelectedIds((prev) => {
|
||||
const next = prev.filter((id) => selectableIds.has(id));
|
||||
if (next.length === prev.length) {
|
||||
return prev;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [deletableTenants]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!scopePickerOpen) return;
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const selection = parseOrgChartTenantSelection(event.data);
|
||||
if (!selection) return;
|
||||
if (!allTenants.some((tenant) => tenant.id === selection.id)) return;
|
||||
|
||||
setScopeTenantId(selection.id);
|
||||
setScopePickerOpen(false);
|
||||
};
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [allTenants, scopePickerOpen]);
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds(deletableTenants.map((t) => t.id));
|
||||
@@ -527,11 +585,15 @@ function TenantListPage() {
|
||||
if (isSeedTenant(tenant)) {
|
||||
return;
|
||||
}
|
||||
if (checked) {
|
||||
setSelectedIds((prev) => [...prev, tenant.id]);
|
||||
} else {
|
||||
setSelectedIds((prev) => prev.filter((i) => i !== tenant.id));
|
||||
}
|
||||
setSelectedIds((prev) =>
|
||||
resolveTenantSelectionIds({
|
||||
currentIds: prev,
|
||||
tenant,
|
||||
checked,
|
||||
tenants: allTenants,
|
||||
deletableTenants,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteBulk = () => {
|
||||
@@ -701,13 +763,67 @@ function TenantListPage() {
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.list.search_placeholder",
|
||||
"테넌트 이름 또는 슬러그 검색...",
|
||||
"테넌트 이름, 슬러그, UUID 검색...",
|
||||
)}
|
||||
className="pl-9 h-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex rounded-md border bg-background p-0.5"
|
||||
data-testid="tenant-view-mode-toggle"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant={viewMode === "tree" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
onClick={() => setViewMode("tree")}
|
||||
data-testid="tenant-view-tree-btn"
|
||||
>
|
||||
<Network size={14} />
|
||||
{t("ui.admin.tenants.view.tree", "트리")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={viewMode === "table" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
onClick={() => setViewMode("table")}
|
||||
data-testid="tenant-view-table-btn"
|
||||
>
|
||||
<List size={14} />
|
||||
{t("ui.admin.tenants.view.table", "평면")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant={scopeTenantId ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-9 gap-2"
|
||||
onClick={() => setScopePickerOpen(true)}
|
||||
data-testid="tenant-scope-picker-btn"
|
||||
>
|
||||
<Network size={16} />
|
||||
{selectedScopeTenant
|
||||
? t("ui.admin.tenants.scope.active", "{{name}} 하위", {
|
||||
name: selectedScopeTenant.name,
|
||||
})
|
||||
: t("ui.admin.tenants.scope.pick", "상위 범위 선택")}
|
||||
</Button>
|
||||
{scopeTenantId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
onClick={() => setScopeTenantId("")}
|
||||
data-testid="tenant-scope-clear-btn"
|
||||
>
|
||||
{t("ui.common.clear", "초기화")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<input
|
||||
@@ -818,7 +934,7 @@ function TenantListPage() {
|
||||
"msg.admin.tenants.registry.count",
|
||||
"총 {{count}}개 테넌트",
|
||||
{
|
||||
count: tenantTotal,
|
||||
count: scopeTenantId ? scopedTenants.length : tenantTotal,
|
||||
},
|
||||
)}
|
||||
</CardDescription>
|
||||
@@ -846,10 +962,34 @@ function TenantListPage() {
|
||||
sortConfig={sortConfig}
|
||||
requestSort={requestSort}
|
||||
getSortIcon={getSortIcon}
|
||||
viewMode={viewMode}
|
||||
scopeTenantId={scopeTenantId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={scopePickerOpen} onOpenChange={setScopePickerOpen}>
|
||||
<DialogContent className="max-w-[480px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.tenants.scope.pick", "상위 범위 선택")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.tenants.scope.description",
|
||||
"orgfront 조직 선택기에서 상위 테넌트를 선택하면 해당 하위 테넌트만 표시합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<iframe
|
||||
title={t("ui.admin.tenants.scope.pick", "상위 범위 선택")}
|
||||
src={scopePickerUrl}
|
||||
className="h-[600px] w-full rounded-md border"
|
||||
data-testid="tenant-scope-picker-frame"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Bulk Action Bar */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div
|
||||
@@ -1212,6 +1352,8 @@ const TenantHierarchyView: React.FC<{
|
||||
sortConfig: SortConfig<TenantSortKey> | null;
|
||||
requestSort: (key: TenantSortKey) => void;
|
||||
getSortIcon: (key: TenantSortKey) => React.ReactNode;
|
||||
viewMode: TenantViewMode;
|
||||
scopeTenantId: string;
|
||||
}> = ({
|
||||
tenants,
|
||||
selectedIds,
|
||||
@@ -1226,10 +1368,12 @@ const TenantHierarchyView: React.FC<{
|
||||
sortConfig,
|
||||
requestSort,
|
||||
getSortIcon,
|
||||
viewMode,
|
||||
scopeTenantId,
|
||||
}) => {
|
||||
const { subTree } = React.useMemo(
|
||||
() => buildTenantFullTree(tenants),
|
||||
[tenants],
|
||||
() => buildTenantFullTree(tenants, scopeTenantId || undefined),
|
||||
[scopeTenantId, tenants],
|
||||
);
|
||||
|
||||
// Initial expanded state: everything open
|
||||
@@ -1245,6 +1389,18 @@ const TenantHierarchyView: React.FC<{
|
||||
return ids;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const ids = new Set<string>();
|
||||
const collect = (nodes: TenantNode[]) => {
|
||||
for (const n of nodes) {
|
||||
ids.add(n.id);
|
||||
if (n.children) collect(n.children);
|
||||
}
|
||||
};
|
||||
collect(subTree);
|
||||
setExpandedIds((prev) => new Set([...prev, ...ids]));
|
||||
}, [subTree]);
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -1267,7 +1423,17 @@ const TenantHierarchyView: React.FC<{
|
||||
);
|
||||
|
||||
const flattenedRows = React.useMemo(() => {
|
||||
const result: (TenantNode & { depth: number })[] = [];
|
||||
if (viewMode === "table") {
|
||||
return sortItems(
|
||||
getTenantViewRows(tenants, "table", scopeTenantId).filter((tenant) =>
|
||||
tenantMatchesListSearch(tenant, search),
|
||||
),
|
||||
sortConfig,
|
||||
tenantSortResolvers,
|
||||
);
|
||||
}
|
||||
|
||||
const result: TenantViewRow[] = [];
|
||||
const term = search.toLowerCase().trim();
|
||||
|
||||
// When searching, we show matched nodes and all their ancestors.
|
||||
@@ -1275,10 +1441,7 @@ const TenantHierarchyView: React.FC<{
|
||||
if (term) {
|
||||
const findMatches = (nodes: TenantNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (
|
||||
node.name.toLowerCase().includes(term) ||
|
||||
node.slug.toLowerCase().includes(term)
|
||||
) {
|
||||
if (tenantMatchesListSearch(node, term)) {
|
||||
matchedIds.add(node.id);
|
||||
}
|
||||
if (node.children) findMatches(node.children);
|
||||
@@ -1312,7 +1475,24 @@ const TenantHierarchyView: React.FC<{
|
||||
};
|
||||
collect(subTree, 0);
|
||||
return result;
|
||||
}, [subTree, expandedIds, search, sortConfig, tenantSortResolvers]);
|
||||
}, [
|
||||
expandedIds,
|
||||
scopeTenantId,
|
||||
search,
|
||||
sortConfig,
|
||||
subTree,
|
||||
tenantSortResolvers,
|
||||
tenants,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const visibleSelectableIds = React.useMemo(
|
||||
() => new Set(deletableTenants.map((tenant) => tenant.id)),
|
||||
[deletableTenants],
|
||||
);
|
||||
const visibleSelectedCount = selectedIds.filter((id) =>
|
||||
visibleSelectableIds.has(id),
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col mt-4">
|
||||
@@ -1324,7 +1504,7 @@ const TenantHierarchyView: React.FC<{
|
||||
<Checkbox
|
||||
checked={
|
||||
deletableTenants.length > 0 &&
|
||||
selectedIds.length === deletableTenants.length
|
||||
visibleSelectedCount === deletableTenants.length
|
||||
}
|
||||
onCheckedChange={(checked) => onSelectAll(!!checked)}
|
||||
/>
|
||||
@@ -1409,8 +1589,12 @@ const TenantHierarchyView: React.FC<{
|
||||
</TableRow>
|
||||
)}
|
||||
{flattenedRows.map((node) => {
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const isExpanded = expandedIds.has(node.id) || !!search;
|
||||
const hasChildren =
|
||||
viewMode === "tree" &&
|
||||
node.children &&
|
||||
node.children.length > 0;
|
||||
const isExpanded =
|
||||
viewMode === "tree" && (expandedIds.has(node.id) || !!search);
|
||||
const TypeIcon = getTenantIcon(node.type);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSchemaField, normalizeSchemaField } from "./TenantSchemaPage";
|
||||
import { createSchemaField, normalizeSchemaField } from "./tenantSchemaFields";
|
||||
|
||||
describe("TenantSchemaPage schema field helpers", () => {
|
||||
it("creates text fields without varchar maxLength policy", () => {
|
||||
|
||||
@@ -17,81 +17,12 @@ import { toast } from "../../../components/ui/use-toast";
|
||||
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
|
||||
export type SchemaFieldType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "date"
|
||||
| "float"
|
||||
| "datetime";
|
||||
|
||||
export type SchemaField = {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
type: SchemaFieldType;
|
||||
required: boolean;
|
||||
adminOnly: boolean;
|
||||
validation?: string;
|
||||
unsigned?: boolean;
|
||||
isLoginId?: boolean;
|
||||
indexed?: boolean;
|
||||
};
|
||||
|
||||
function createFieldId() {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function isSchemaFieldType(value: unknown): value is SchemaFieldType {
|
||||
return (
|
||||
value === "text" ||
|
||||
value === "number" ||
|
||||
value === "boolean" ||
|
||||
value === "date" ||
|
||||
value === "float" ||
|
||||
value === "datetime"
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeSchemaField(field: unknown): SchemaField {
|
||||
const source =
|
||||
typeof field === "object" && field !== null
|
||||
? (field as Record<string, unknown>)
|
||||
: {};
|
||||
const type = isSchemaFieldType(source.type) ? source.type : "text";
|
||||
const isLoginId = Boolean(source.isLoginId);
|
||||
|
||||
return {
|
||||
id: typeof source.id === "string" ? source.id : createFieldId(),
|
||||
key: typeof source.key === "string" ? source.key : "",
|
||||
label: typeof source.label === "string" ? source.label : "",
|
||||
type,
|
||||
required: Boolean(source.required),
|
||||
adminOnly: Boolean(source.adminOnly),
|
||||
validation: typeof source.validation === "string" ? source.validation : "",
|
||||
unsigned: Boolean(source.unsigned),
|
||||
isLoginId,
|
||||
indexed: isLoginId || Boolean(source.indexed),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSchemaField(): SchemaField {
|
||||
return {
|
||||
id: createFieldId(),
|
||||
key: "",
|
||||
label: "",
|
||||
type: "text",
|
||||
required: false,
|
||||
adminOnly: false,
|
||||
validation: "",
|
||||
unsigned: false,
|
||||
indexed: false,
|
||||
};
|
||||
}
|
||||
import {
|
||||
type SchemaField,
|
||||
createSchemaField,
|
||||
isSchemaFieldType,
|
||||
normalizeSchemaField,
|
||||
} from "./tenantSchemaFields";
|
||||
|
||||
export function TenantSchemaPage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
|
||||
@@ -4,17 +4,21 @@ import {
|
||||
canCreateWorksmobileRow,
|
||||
canOpenWorksmobilePasswordManage,
|
||||
canSelectWorksmobileRow,
|
||||
filterVisibleWorksmobileComparisonRows,
|
||||
filterWorksmobileComparisonRows,
|
||||
filterWorksmobileComparisonRowsBySearch,
|
||||
formatWorksmobileOrgDetails,
|
||||
formatWorksmobilePersonName,
|
||||
getDefaultWorksmobileComparisonColumns,
|
||||
getWorksmobileComparisonStatusLabel,
|
||||
getWorksmobileRowSelectionKey,
|
||||
getWorksmobileSelectedActionIds,
|
||||
getWorksmobileSelectedMissingExternalKeyOrgUnitIds,
|
||||
getWorksmobileSelectedWorksOnlyOrgUnitIds,
|
||||
isImmutableWorksmobileAccount,
|
||||
summarizeWorksmobileComparison,
|
||||
userFilterOptions,
|
||||
} from "./TenantWorksmobilePage";
|
||||
} from "./worksmobileComparison";
|
||||
|
||||
describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
it("summarizes comparison rows by status", () => {
|
||||
@@ -143,6 +147,42 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides protected WORKS member accounts from comparison lists", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileEmail: "su-@samaneng.com",
|
||||
worksmobileId: "works-su",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "matched",
|
||||
baronEmail: "CYHAN1@HANMACENG.CO.KR",
|
||||
baronId: "baron-cyhan1",
|
||||
worksmobileEmail: "cyhan1@hanmaceng.co.kr",
|
||||
worksmobileId: "works-cyhan1",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileEmail: "normal@samaneng.com",
|
||||
worksmobileId: "works-normal",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_baron",
|
||||
worksmobileEmail: "su-@samaneng.com",
|
||||
worksmobileId: "works-group",
|
||||
},
|
||||
];
|
||||
|
||||
expect(filterVisibleWorksmobileComparisonRows(rows)).toEqual([
|
||||
rows[2],
|
||||
rows[3],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps row selection keys separate from Baron action ids", () => {
|
||||
const rows = [
|
||||
{
|
||||
@@ -231,7 +271,8 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, ["baron_only", "works_only"]),
|
||||
).toEqual([rows[0], rows[1], rows[3]]);
|
||||
expect(filterWorksmobileComparisonRows(rows, [])).toEqual(rows);
|
||||
expect(filterWorksmobileComparisonRows(rows, [], true)).toEqual([]);
|
||||
expect(filterWorksmobileComparisonRows(rows, [])).toEqual([]);
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, [
|
||||
"baron_only",
|
||||
@@ -239,6 +280,147 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
"matched",
|
||||
]),
|
||||
).toEqual(rows);
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(
|
||||
rows,
|
||||
["baron_only", "works_only", "matched"],
|
||||
true,
|
||||
),
|
||||
).toEqual([rows[0], rows[2], rows[3]]);
|
||||
});
|
||||
|
||||
it("narrows works-only rows to missing external key rows from the detail filter", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_worksmobile",
|
||||
baronId: "baron-only",
|
||||
baronName: "Baron only",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_baron",
|
||||
worksmobileId: "works-only",
|
||||
worksmobileName: "WORKS only",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "missing-external-key",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "matched",
|
||||
baronId: "matched",
|
||||
worksmobileId: "works-matched",
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, ["works_only"], false),
|
||||
).toEqual([rows[1], rows[2]]);
|
||||
expect(filterWorksmobileComparisonRows(rows, ["works_only"], true)).toEqual(
|
||||
[rows[2]],
|
||||
);
|
||||
expect(filterWorksmobileComparisonRows(rows, [], true)).toEqual([]);
|
||||
expect(filterWorksmobileComparisonRows(rows, ["baron_only"], true)).toEqual(
|
||||
[rows[0]],
|
||||
);
|
||||
});
|
||||
|
||||
it("filters comparison rows by names and identifiers in real time", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "matched",
|
||||
baronId: "baron-user-uuid",
|
||||
baronName: "홍길동",
|
||||
worksmobileName: "Hong Gildong",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "works-org-uuid",
|
||||
worksmobileName: "기술연구소",
|
||||
worksmobileParentName: "한맥가족",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_worksmobile",
|
||||
baronId: "baron-org-uuid",
|
||||
baronSlug: "baron-group-design",
|
||||
baronName: "디자인팀",
|
||||
},
|
||||
];
|
||||
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "")).toEqual(rows);
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "홍길동")).toEqual([
|
||||
rows[0],
|
||||
]);
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "WORKS-ORG")).toEqual([
|
||||
rows[1],
|
||||
]);
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "design")).toEqual([
|
||||
rows[2],
|
||||
]);
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "없음")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns only selected missing-external-key WORKS orgunit ids for delete", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "works-missing-key",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_baron",
|
||||
worksmobileId: "works-only",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "works-user-missing-key",
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
getWorksmobileSelectedMissingExternalKeyOrgUnitIds(rows, [
|
||||
getWorksmobileRowSelectionKey(rows[0]),
|
||||
getWorksmobileRowSelectionKey(rows[1]),
|
||||
getWorksmobileRowSelectionKey(rows[2]),
|
||||
]),
|
||||
).toEqual(["works-missing-key"]);
|
||||
});
|
||||
|
||||
it("returns selected WORKS-only orgunit ids for Baron SSOT cleanup", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "works-missing-key",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_baron",
|
||||
worksmobileId: "works-only",
|
||||
externalKey: "legacy-external-key",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "matched",
|
||||
baronId: "baron-matched",
|
||||
worksmobileId: "works-matched",
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
getWorksmobileSelectedWorksOnlyOrgUnitIds(
|
||||
rows,
|
||||
rows.map(getWorksmobileRowSelectionKey),
|
||||
),
|
||||
).toEqual(["works-missing-key", "works-only"]);
|
||||
});
|
||||
|
||||
it("orders user comparison filter options from Baron-only first", () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
126
adminfront/src/features/tenants/routes/tenantListView.ts
Normal file
126
adminfront/src/features/tenants/routes/tenantListView.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||
|
||||
export type TenantViewMode = "tree" | "table";
|
||||
export type TenantViewRow = TenantNode & { depth: number };
|
||||
|
||||
export function tenantMatchesListSearch(
|
||||
tenant: Pick<TenantSummary, "id" | "name" | "slug" | "type">,
|
||||
search: string,
|
||||
) {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
if (!normalizedSearch) return true;
|
||||
|
||||
return [tenant.name, tenant.slug, tenant.id, tenant.type]
|
||||
.filter(Boolean)
|
||||
.some((value) => value.toLowerCase().includes(normalizedSearch));
|
||||
}
|
||||
|
||||
function collectTenantTreeRows(
|
||||
nodes: TenantNode[],
|
||||
depth: number,
|
||||
rows: TenantViewRow[],
|
||||
) {
|
||||
for (const node of nodes) {
|
||||
rows.push({ ...node, depth });
|
||||
collectTenantTreeRows(node.children, depth + 1, rows);
|
||||
}
|
||||
}
|
||||
|
||||
function collectTenantDescendantIds(
|
||||
tenantId: string,
|
||||
tenants: TenantSummary[],
|
||||
) {
|
||||
const childrenByParent = new Map<string, TenantSummary[]>();
|
||||
for (const tenant of tenants) {
|
||||
if (!tenant.parentId) continue;
|
||||
const children = childrenByParent.get(tenant.parentId) ?? [];
|
||||
children.push(tenant);
|
||||
childrenByParent.set(tenant.parentId, children);
|
||||
}
|
||||
|
||||
const ids: string[] = [];
|
||||
const visitedIds = new Set<string>();
|
||||
const visit = (parentId: string) => {
|
||||
for (const child of childrenByParent.get(parentId) ?? []) {
|
||||
if (visitedIds.has(child.id)) continue;
|
||||
visitedIds.add(child.id);
|
||||
ids.push(child.id);
|
||||
visit(child.id);
|
||||
}
|
||||
};
|
||||
visit(tenantId);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function filterTenantsByScope(
|
||||
tenants: TenantSummary[],
|
||||
scopeTenantId: string,
|
||||
) {
|
||||
if (!scopeTenantId) return tenants;
|
||||
const descendantIds = new Set(
|
||||
collectTenantDescendantIds(scopeTenantId, tenants),
|
||||
);
|
||||
return tenants.filter((tenant) => descendantIds.has(tenant.id));
|
||||
}
|
||||
|
||||
export function getTenantViewRows(
|
||||
tenants: TenantSummary[],
|
||||
viewMode: TenantViewMode,
|
||||
scopeTenantId = "",
|
||||
): TenantViewRow[] {
|
||||
const { subTree } = buildTenantFullTree(tenants, scopeTenantId || undefined);
|
||||
const treeRows: TenantViewRow[] = [];
|
||||
collectTenantTreeRows(subTree, 0, treeRows);
|
||||
|
||||
if (viewMode === "tree") {
|
||||
return treeRows;
|
||||
}
|
||||
|
||||
const rowsById = new Map(treeRows.map((row) => [row.id, row]));
|
||||
const flatSource = scopeTenantId
|
||||
? filterTenantsByScope(tenants, scopeTenantId)
|
||||
: tenants;
|
||||
|
||||
return flatSource.map((tenant) => ({
|
||||
...(rowsById.get(tenant.id) ?? {
|
||||
...tenant,
|
||||
children: [],
|
||||
recursiveMemberCount: Number(tenant.memberCount) || 0,
|
||||
}),
|
||||
depth: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveTenantSelectionIds({
|
||||
currentIds,
|
||||
tenant,
|
||||
checked,
|
||||
tenants,
|
||||
deletableTenants,
|
||||
}: {
|
||||
currentIds: string[];
|
||||
tenant: TenantSummary;
|
||||
checked: boolean;
|
||||
tenants: TenantSummary[];
|
||||
deletableTenants: TenantSummary[];
|
||||
}) {
|
||||
const allowedIds = new Set(deletableTenants.map((item) => item.id));
|
||||
const targetIds = [
|
||||
tenant.id,
|
||||
...collectTenantDescendantIds(tenant.id, tenants),
|
||||
].filter((id) => allowedIds.has(id));
|
||||
const next = new Set(currentIds.filter((id) => allowedIds.has(id)));
|
||||
|
||||
if (checked) {
|
||||
for (const id of targetIds) {
|
||||
next.add(id);
|
||||
}
|
||||
} else {
|
||||
for (const id of targetIds) {
|
||||
next.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(next);
|
||||
}
|
||||
74
adminfront/src/features/tenants/routes/tenantSchemaFields.ts
Normal file
74
adminfront/src/features/tenants/routes/tenantSchemaFields.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export type SchemaFieldType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "date"
|
||||
| "float"
|
||||
| "datetime";
|
||||
|
||||
export type SchemaField = {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
type: SchemaFieldType;
|
||||
required: boolean;
|
||||
adminOnly: boolean;
|
||||
validation?: string;
|
||||
unsigned?: boolean;
|
||||
isLoginId?: boolean;
|
||||
indexed?: boolean;
|
||||
};
|
||||
|
||||
function createFieldId() {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export function isSchemaFieldType(value: unknown): value is SchemaFieldType {
|
||||
return (
|
||||
value === "text" ||
|
||||
value === "number" ||
|
||||
value === "boolean" ||
|
||||
value === "date" ||
|
||||
value === "float" ||
|
||||
value === "datetime"
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeSchemaField(field: unknown): SchemaField {
|
||||
const source =
|
||||
typeof field === "object" && field !== null
|
||||
? (field as Record<string, unknown>)
|
||||
: {};
|
||||
const type = isSchemaFieldType(source.type) ? source.type : "text";
|
||||
const isLoginId = Boolean(source.isLoginId);
|
||||
|
||||
return {
|
||||
id: typeof source.id === "string" ? source.id : createFieldId(),
|
||||
key: typeof source.key === "string" ? source.key : "",
|
||||
label: typeof source.label === "string" ? source.label : "",
|
||||
type,
|
||||
required: Boolean(source.required),
|
||||
adminOnly: Boolean(source.adminOnly),
|
||||
validation: typeof source.validation === "string" ? source.validation : "",
|
||||
unsigned: Boolean(source.unsigned),
|
||||
isLoginId,
|
||||
indexed: isLoginId || Boolean(source.indexed),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSchemaField(): SchemaField {
|
||||
return {
|
||||
id: createFieldId(),
|
||||
key: "",
|
||||
label: "",
|
||||
type: "text",
|
||||
required: false,
|
||||
adminOnly: false,
|
||||
validation: "",
|
||||
unsigned: false,
|
||||
indexed: false,
|
||||
};
|
||||
}
|
||||
359
adminfront/src/features/tenants/routes/worksmobileComparison.ts
Normal file
359
adminfront/src/features/tenants/routes/worksmobileComparison.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import type { WorksmobileComparisonItem } from "../../../lib/adminApi";
|
||||
|
||||
export type WorksmobileComparisonFilter =
|
||||
| "works_only"
|
||||
| "baron_only"
|
||||
| "matched";
|
||||
|
||||
export type WorksmobileComparisonSummary = {
|
||||
total: number;
|
||||
matched: number;
|
||||
missingInWorksmobile: number;
|
||||
missingInBaron: number;
|
||||
missingExternalKey: number;
|
||||
};
|
||||
|
||||
export type WorksmobileComparisonColumnKey =
|
||||
| "status"
|
||||
| "baronId"
|
||||
| "baron"
|
||||
| "baronOrg"
|
||||
| "worksmobileId"
|
||||
| "externalKey"
|
||||
| "worksmobileDomain"
|
||||
| "worksmobile"
|
||||
| "worksmobileOrg"
|
||||
| "manage";
|
||||
|
||||
export type WorksmobileComparisonColumnVisibility = Record<
|
||||
WorksmobileComparisonColumnKey,
|
||||
boolean
|
||||
>;
|
||||
|
||||
export function getDefaultWorksmobileComparisonColumns(): WorksmobileComparisonColumnVisibility {
|
||||
return {
|
||||
status: true,
|
||||
baronId: false,
|
||||
baron: true,
|
||||
baronOrg: true,
|
||||
worksmobileId: false,
|
||||
externalKey: false,
|
||||
worksmobileDomain: true,
|
||||
worksmobile: true,
|
||||
worksmobileOrg: true,
|
||||
manage: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeWorksmobileComparison(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
): WorksmobileComparisonSummary {
|
||||
return rows.reduce<WorksmobileComparisonSummary>(
|
||||
(summary, row) => {
|
||||
if (row.status === "matched") {
|
||||
summary.matched += 1;
|
||||
} else if (row.status === "missing_in_worksmobile") {
|
||||
summary.missingInWorksmobile += 1;
|
||||
} else if (row.status === "missing_in_baron") {
|
||||
summary.missingInBaron += 1;
|
||||
} else if (row.status === "missing_external_key") {
|
||||
summary.missingExternalKey += 1;
|
||||
}
|
||||
return summary;
|
||||
},
|
||||
{
|
||||
total: rows.length,
|
||||
matched: 0,
|
||||
missingInWorksmobile: 0,
|
||||
missingInBaron: 0,
|
||||
missingExternalKey: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorksmobileComparisonStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case "matched":
|
||||
return "일치";
|
||||
case "missing_in_worksmobile":
|
||||
return "WORKS 없음";
|
||||
case "missing_in_baron":
|
||||
return "Baron 없음";
|
||||
case "missing_external_key":
|
||||
return "ex_key 없음";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
export function canCreateWorksmobileRow(row: WorksmobileComparisonItem) {
|
||||
return row.status === "missing_in_worksmobile" && Boolean(row.baronId);
|
||||
}
|
||||
|
||||
const immutableWorksmobileAccountEmails = new Set([
|
||||
"cyhan@samaneng.com",
|
||||
"cyhan1@hanmaceng.co.kr",
|
||||
"cyhan2@baroncs.co.kr",
|
||||
"cyhan3@brsw.kr",
|
||||
"su-@samaneng.com",
|
||||
]);
|
||||
|
||||
const hiddenWorksmobileMemberEmails = new Set([
|
||||
"su-@samaneng.com",
|
||||
"cyhan1@hanmaceng.co.kr",
|
||||
"cyhan2@baroncs.co.kr",
|
||||
"cyhan3@brsw.kr",
|
||||
]);
|
||||
|
||||
function normalizeWorksmobileEmail(email?: string) {
|
||||
return email?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
export function isImmutableWorksmobileAccount(row: WorksmobileComparisonItem) {
|
||||
return (
|
||||
row.resourceType === "USER" &&
|
||||
immutableWorksmobileAccountEmails.has(
|
||||
normalizeWorksmobileEmail(row.worksmobileEmail),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function isHiddenWorksmobileMember(row: WorksmobileComparisonItem) {
|
||||
if (row.resourceType !== "USER") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return [row.worksmobileEmail, row.baronEmail].some((email) =>
|
||||
hiddenWorksmobileMemberEmails.has(normalizeWorksmobileEmail(email)),
|
||||
);
|
||||
}
|
||||
|
||||
export function filterVisibleWorksmobileComparisonRows(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
) {
|
||||
return rows.filter((row) => !isHiddenWorksmobileMember(row));
|
||||
}
|
||||
|
||||
export function getWorksmobileRowSelectionKey(row: WorksmobileComparisonItem) {
|
||||
if (row.baronId) {
|
||||
return `${row.resourceType}:baron:${row.baronId}`;
|
||||
}
|
||||
if (row.worksmobileId) {
|
||||
return `${row.resourceType}:works:${row.worksmobileId}`;
|
||||
}
|
||||
if (row.externalKey) {
|
||||
return `${row.resourceType}:external:${row.externalKey}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function canSelectWorksmobileRow(row: WorksmobileComparisonItem) {
|
||||
return (
|
||||
Boolean(getWorksmobileRowSelectionKey(row)) &&
|
||||
!isImmutableWorksmobileAccount(row)
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorksmobileSelectedActionIds(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
selectedKeys: string[],
|
||||
) {
|
||||
const selected = new Set(selectedKeys);
|
||||
return rows
|
||||
.filter((row) => selected.has(getWorksmobileRowSelectionKey(row)))
|
||||
.map((row) => row.baronId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
export function getWorksmobileSelectedMissingExternalKeyOrgUnitIds(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
selectedKeys: string[],
|
||||
) {
|
||||
return getWorksmobileSelectedWorksOnlyOrgUnitIds(rows, selectedKeys).filter(
|
||||
(id) =>
|
||||
rows.some(
|
||||
(row) =>
|
||||
row.worksmobileId === id && row.status === "missing_external_key",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorksmobileSelectedWorksOnlyOrgUnitIds(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
selectedKeys: string[],
|
||||
) {
|
||||
const selected = new Set(selectedKeys);
|
||||
return rows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.resourceType === "GROUP" &&
|
||||
(row.status === "missing_external_key" ||
|
||||
row.status === "missing_in_baron") &&
|
||||
selected.has(getWorksmobileRowSelectionKey(row)),
|
||||
)
|
||||
.map((row) => row.worksmobileId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
const worksmobileComparisonSearchFields: Array<
|
||||
keyof WorksmobileComparisonItem
|
||||
> = [
|
||||
"baronId",
|
||||
"baronSlug",
|
||||
"baronName",
|
||||
"baronEmail",
|
||||
"baronPrimaryOrgId",
|
||||
"baronPrimaryOrgSlug",
|
||||
"baronPrimaryOrgName",
|
||||
"baronParentId",
|
||||
"baronParentSlug",
|
||||
"baronParentName",
|
||||
"worksmobileId",
|
||||
"externalKey",
|
||||
"worksmobileName",
|
||||
"worksmobileEmail",
|
||||
"worksmobileLevelId",
|
||||
"worksmobileLevelName",
|
||||
"worksmobileTask",
|
||||
"worksmobileDomainId",
|
||||
"worksmobileDomainName",
|
||||
"worksmobilePrimaryOrgId",
|
||||
"worksmobilePrimaryOrgName",
|
||||
"worksmobilePrimaryOrgPositionId",
|
||||
"worksmobilePrimaryOrgPositionName",
|
||||
"baronParentWorksmobileId",
|
||||
"baronParentWorksmobileName",
|
||||
"baronParentWorksmobileEmail",
|
||||
"worksmobileParentId",
|
||||
"worksmobileParentName",
|
||||
"worksmobileParentEmail",
|
||||
"worksmobileParentExternalKey",
|
||||
];
|
||||
|
||||
export function filterWorksmobileComparisonRowsBySearch(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
search: string,
|
||||
) {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((row) =>
|
||||
worksmobileComparisonSearchFields.some((field) => {
|
||||
const value = row[field];
|
||||
if (value === undefined || value === null) {
|
||||
return false;
|
||||
}
|
||||
return String(value).toLowerCase().includes(keyword);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function filterWorksmobileComparisonRows(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
filters: WorksmobileComparisonFilter[],
|
||||
onlyMissingExternalKey = false,
|
||||
) {
|
||||
const allowedStatuses = new Set(
|
||||
filters.flatMap((filter) => worksmobileFilterStatuses[filter]),
|
||||
);
|
||||
if (filters.includes("works_only")) {
|
||||
if (onlyMissingExternalKey) {
|
||||
allowedStatuses.delete("missing_in_baron");
|
||||
}
|
||||
allowedStatuses.add("missing_external_key");
|
||||
}
|
||||
return rows.filter((row) => allowedStatuses.has(row.status));
|
||||
}
|
||||
|
||||
export function formatWorksmobilePersonName(row: WorksmobileComparisonItem) {
|
||||
return [
|
||||
row.worksmobileName,
|
||||
row.worksmobileLevelName ?? row.worksmobileLevelId,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
|
||||
const details: string[] = [];
|
||||
const position =
|
||||
row.worksmobilePrimaryOrgPositionName ??
|
||||
row.worksmobilePrimaryOrgPositionId;
|
||||
if (position) {
|
||||
details.push(`직책 ${position}`);
|
||||
}
|
||||
if (row.worksmobileTask) {
|
||||
details.push(`직무 ${row.worksmobileTask}`);
|
||||
}
|
||||
if (typeof row.worksmobilePrimaryOrgIsManager === "boolean") {
|
||||
details.push(row.worksmobilePrimaryOrgIsManager ? "조직장" : "조직장 아님");
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
export function buildWorksmobilePasswordManageUrl({
|
||||
tenantId,
|
||||
domainId,
|
||||
userIdNo,
|
||||
}: {
|
||||
tenantId?: string;
|
||||
domainId?: number;
|
||||
userIdNo?: string;
|
||||
}) {
|
||||
const normalizedTenantId = tenantId?.trim();
|
||||
const normalizedUserIdNo = userIdNo?.trim();
|
||||
if (
|
||||
!normalizedTenantId ||
|
||||
!domainId ||
|
||||
domainId <= 0 ||
|
||||
!normalizedUserIdNo
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
const url = new URL("https://auth.worksmobile.com/integrate/password/manage");
|
||||
url.searchParams.set("usage", "admin");
|
||||
url.searchParams.set("targetUserTenantId", normalizedTenantId);
|
||||
url.searchParams.set("targetUserDomainId", String(domainId));
|
||||
url.searchParams.set("targetUserIdNo", normalizedUserIdNo);
|
||||
url.searchParams.set(
|
||||
"accessUrl",
|
||||
"https://admin.worksmobile.com/assets/self-close.html",
|
||||
);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function canOpenWorksmobilePasswordManage(
|
||||
row: WorksmobileComparisonItem,
|
||||
tenantId?: string,
|
||||
) {
|
||||
return (
|
||||
row.resourceType === "USER" &&
|
||||
!isImmutableWorksmobileAccount(row) &&
|
||||
Boolean(
|
||||
buildWorksmobilePasswordManageUrl({
|
||||
tenantId,
|
||||
domainId: row.worksmobileDomainId,
|
||||
userIdNo: row.worksmobileId,
|
||||
}),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const comparisonFilterOptions: Array<{
|
||||
value: WorksmobileComparisonFilter;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "baron_only", label: "바론에만 있음" },
|
||||
{ value: "works_only", label: "웍스에만 있음" },
|
||||
{ value: "matched", label: "양쪽 다 있음" },
|
||||
];
|
||||
|
||||
export const userFilterOptions = comparisonFilterOptions;
|
||||
|
||||
const worksmobileFilterStatuses: Record<WorksmobileComparisonFilter, string[]> =
|
||||
{
|
||||
baron_only: ["missing_in_worksmobile"],
|
||||
works_only: ["missing_in_baron"],
|
||||
matched: ["matched"],
|
||||
};
|
||||
@@ -789,11 +789,14 @@ export type WorksmobileOverview = {
|
||||
export type WorksmobileComparisonItem = {
|
||||
resourceType: string;
|
||||
baronId?: string;
|
||||
baronSlug?: string;
|
||||
baronName?: string;
|
||||
baronEmail?: string;
|
||||
baronPrimaryOrgId?: string;
|
||||
baronPrimaryOrgSlug?: string;
|
||||
baronPrimaryOrgName?: string;
|
||||
baronParentId?: string;
|
||||
baronParentSlug?: string;
|
||||
baronParentName?: string;
|
||||
worksmobileId?: string;
|
||||
externalKey?: string;
|
||||
@@ -809,8 +812,13 @@ export type WorksmobileComparisonItem = {
|
||||
worksmobilePrimaryOrgPositionId?: string;
|
||||
worksmobilePrimaryOrgPositionName?: string;
|
||||
worksmobilePrimaryOrgIsManager?: boolean;
|
||||
baronParentWorksmobileId?: string;
|
||||
baronParentWorksmobileName?: string;
|
||||
baronParentWorksmobileEmail?: string;
|
||||
worksmobileParentId?: string;
|
||||
worksmobileParentName?: string;
|
||||
worksmobileParentEmail?: string;
|
||||
worksmobileParentExternalKey?: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
@@ -924,7 +932,17 @@ export async function enqueueWorksmobileOrgUnitSync(
|
||||
orgUnitId: string,
|
||||
) {
|
||||
const { data } = await apiClient.post<WorksmobileOutboxItem>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${orgUnitId}/sync`,
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${encodeURIComponent(orgUnitId)}/sync`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function enqueueWorksmobileOrgUnitDelete(
|
||||
tenantId: string,
|
||||
orgUnitId: string,
|
||||
) {
|
||||
const { data } = await apiClient.post<WorksmobileOutboxItem>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${encodeURIComponent(orgUnitId)}/delete`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -935,7 +935,7 @@ start_import = "Start Import"
|
||||
kicker = "Global Overview"
|
||||
|
||||
[ui.admin.overview.chart]
|
||||
description = "Check the graph by all or selected organizations."
|
||||
description = "Check the graph by all or selected companies."
|
||||
title = "Login request status by company and app"
|
||||
|
||||
[ui.admin.overview.playbook]
|
||||
|
||||
@@ -937,7 +937,7 @@ start_import = "임포트 시작"
|
||||
kicker = "Global Overview"
|
||||
|
||||
[ui.admin.overview.chart]
|
||||
description = "전체 또는 선택한 조직 기준으로 그래프를 확인합니다."
|
||||
description = "전체 또는 선택한 회사 기준으로 그래프를 확인합니다."
|
||||
title = "회사별 앱별 로그인 요청 현황"
|
||||
|
||||
[ui.admin.overview.playbook]
|
||||
|
||||
Reference in New Issue
Block a user