forked from baron/baron-sso
feat: integrate orgfront and expose internal ids
This commit is contained in:
709
orgfront/src/features/orgchart/routes/OrgPickerPage.tsx
Normal file
709
orgfront/src/features/orgchart/routes/OrgPickerPage.tsx
Normal file
@@ -0,0 +1,709 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, ChevronDown, ChevronRight, Search, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { fetchTenants, fetchUsers } from "../../../lib/adminApi";
|
||||
import { buildOrgPickerTree, flattenDescendants } from "../pickerTree";
|
||||
import {
|
||||
type OrgPickerEmbedOptions,
|
||||
type OrgPickerMode,
|
||||
type OrgPickerResult,
|
||||
type OrgPickerSelectableType,
|
||||
type OrgPickerSelection,
|
||||
type OrgPickerTreeNode,
|
||||
buildOrgPickerEmbedSrc,
|
||||
nodeKey,
|
||||
parseOrgPickerEmbedOptions,
|
||||
parseOrgPickerMode,
|
||||
parseOrgPickerSelectableType,
|
||||
} from "../pickerTypes";
|
||||
|
||||
function canSelectNode(
|
||||
node: OrgPickerTreeNode,
|
||||
select: OrgPickerSelectableType,
|
||||
) {
|
||||
return select === "both" || select === node.type;
|
||||
}
|
||||
|
||||
function toSelection(node: OrgPickerTreeNode): OrgPickerSelection {
|
||||
return {
|
||||
type: node.type,
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
};
|
||||
}
|
||||
|
||||
function collectSelectedNodes({
|
||||
roots,
|
||||
selectedKeys,
|
||||
includeDescendants,
|
||||
select,
|
||||
}: {
|
||||
roots: OrgPickerTreeNode[];
|
||||
selectedKeys: Set<string>;
|
||||
includeDescendants: boolean;
|
||||
select: OrgPickerSelectableType;
|
||||
}) {
|
||||
const selected = new Map<string, OrgPickerTreeNode>();
|
||||
const visit = (node: OrgPickerTreeNode) => {
|
||||
const key = nodeKey(node);
|
||||
if (selectedKeys.has(key) && canSelectNode(node, select)) {
|
||||
selected.set(key, node);
|
||||
if (includeDescendants && node.type === "tenant") {
|
||||
for (const descendant of flattenDescendants(node)) {
|
||||
if (canSelectNode(descendant, select)) {
|
||||
selected.set(nodeKey(descendant), descendant);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of node.children) visit(child);
|
||||
};
|
||||
|
||||
for (const root of roots) visit(root);
|
||||
return Array.from(selected.values()).map(toSelection);
|
||||
}
|
||||
|
||||
function collectCheckedKeys({
|
||||
roots,
|
||||
selectedKeys,
|
||||
includeDescendants,
|
||||
select,
|
||||
}: {
|
||||
roots: OrgPickerTreeNode[];
|
||||
selectedKeys: Set<string>;
|
||||
includeDescendants: boolean;
|
||||
select: OrgPickerSelectableType;
|
||||
}) {
|
||||
const checkedKeys = new Set(selectedKeys);
|
||||
if (!includeDescendants) return checkedKeys;
|
||||
|
||||
const visit = (node: OrgPickerTreeNode) => {
|
||||
const key = nodeKey(node);
|
||||
if (selectedKeys.has(key) && node.type === "tenant") {
|
||||
for (const descendant of flattenDescendants(node)) {
|
||||
if (canSelectNode(descendant, select)) {
|
||||
checkedKeys.add(nodeKey(descendant));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of node.children) visit(child);
|
||||
};
|
||||
|
||||
for (const root of roots) visit(root);
|
||||
return checkedKeys;
|
||||
}
|
||||
|
||||
function postPickerMessage(message: unknown) {
|
||||
window.parent.postMessage(message, "*");
|
||||
}
|
||||
|
||||
function collectSearchValues(value: unknown, depth = 0): string[] {
|
||||
if (value == null || depth > 4) return [];
|
||||
if (
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
return [String(value)];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item) => collectSearchValues(item, depth + 1));
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return Object.entries(value as Record<string, unknown>).flatMap(
|
||||
([key, item]) => [key, ...collectSearchValues(item, depth + 1)],
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getNodeSearchValues(node: OrgPickerTreeNode) {
|
||||
const tenantSearchValues = node.tenant
|
||||
? collectSearchValues({
|
||||
id: node.tenant.id,
|
||||
type: node.tenant.type,
|
||||
name: node.tenant.name,
|
||||
slug: node.tenant.slug,
|
||||
description: node.tenant.description,
|
||||
status: node.tenant.status,
|
||||
domains: node.tenant.domains,
|
||||
parentId: node.tenant.parentId,
|
||||
config: node.tenant.config,
|
||||
memberCount: node.tenant.memberCount,
|
||||
createdAt: node.tenant.createdAt,
|
||||
updatedAt: node.tenant.updatedAt,
|
||||
})
|
||||
: [];
|
||||
|
||||
return [
|
||||
node.type,
|
||||
node.id,
|
||||
node.name,
|
||||
node.parentId ?? "",
|
||||
...tenantSearchValues,
|
||||
...collectSearchValues(node.user),
|
||||
].map((value) => value.toLowerCase());
|
||||
}
|
||||
|
||||
function nodeMatchesSearch(node: OrgPickerTreeNode, query: string) {
|
||||
return getNodeSearchValues(node).some((value) => value.includes(query));
|
||||
}
|
||||
|
||||
function filterPickerTree(
|
||||
roots: OrgPickerTreeNode[],
|
||||
rawQuery: string,
|
||||
): OrgPickerTreeNode[] {
|
||||
const query = rawQuery.trim().toLowerCase();
|
||||
if (!query) return roots;
|
||||
|
||||
const filterNode = (node: OrgPickerTreeNode): OrgPickerTreeNode | null => {
|
||||
const filteredChildren = node.children
|
||||
.map(filterNode)
|
||||
.filter((child): child is OrgPickerTreeNode => Boolean(child));
|
||||
|
||||
if (nodeMatchesSearch(node, query) || filteredChildren.length > 0) {
|
||||
return {
|
||||
...node,
|
||||
children: filteredChildren,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return roots
|
||||
.map(filterNode)
|
||||
.filter((node): node is OrgPickerTreeNode => Boolean(node));
|
||||
}
|
||||
|
||||
function OrgPickerTree({
|
||||
roots,
|
||||
mode,
|
||||
select,
|
||||
selectedKeys,
|
||||
onSingleSelect,
|
||||
onToggle,
|
||||
}: {
|
||||
roots: OrgPickerTreeNode[];
|
||||
mode: OrgPickerMode;
|
||||
select: OrgPickerSelectableType;
|
||||
selectedKeys: Set<string>;
|
||||
onSingleSelect: (node: OrgPickerTreeNode) => void;
|
||||
onToggle: (node: OrgPickerTreeNode, checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1" data-testid="org-picker-tree">
|
||||
{roots.map((node) => (
|
||||
<OrgPickerTreeItem
|
||||
key={nodeKey(node)}
|
||||
mode={mode}
|
||||
node={node}
|
||||
onSingleSelect={onSingleSelect}
|
||||
onToggle={onToggle}
|
||||
select={select}
|
||||
selectedKeys={selectedKeys}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrgPickerTreeItem({
|
||||
node,
|
||||
mode,
|
||||
select,
|
||||
selectedKeys,
|
||||
onSingleSelect,
|
||||
onToggle,
|
||||
depth = 0,
|
||||
}: {
|
||||
node: OrgPickerTreeNode;
|
||||
mode: OrgPickerMode;
|
||||
select: OrgPickerSelectableType;
|
||||
selectedKeys: Set<string>;
|
||||
onSingleSelect: (node: OrgPickerTreeNode) => void;
|
||||
onToggle: (node: OrgPickerTreeNode, checked: boolean) => void;
|
||||
depth?: number;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = React.useState(true);
|
||||
const selectable = canSelectNode(node, select);
|
||||
const hasChildren = node.children.length > 0;
|
||||
const key = nodeKey(node);
|
||||
const checked = selectedKeys.has(key);
|
||||
const label = `${node.name} 선택`;
|
||||
const email = node.type === "user" ? node.user?.email : undefined;
|
||||
const nameTestId =
|
||||
node.type === "tenant"
|
||||
? "org-picker-node-name-tenant"
|
||||
: "org-picker-node-name-user";
|
||||
const content = (
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span
|
||||
className={`truncate font-semibold leading-5 ${
|
||||
node.type === "tenant" ? "text-[#0a2114]" : ""
|
||||
}`}
|
||||
data-testid={nameTestId}
|
||||
>
|
||||
{node.name}
|
||||
</span>
|
||||
{email ? (
|
||||
<span className="truncate text-xs leading-5 text-muted-foreground">
|
||||
{email}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
className={`group flex min-h-7 items-center gap-1.5 rounded-sm py-0.5 pr-1.5 transition ${
|
||||
mode === "single" && checked
|
||||
? "bg-primary/15 text-foreground ring-2 ring-primary/60 shadow-sm"
|
||||
: "hover:bg-secondary/50"
|
||||
} ${depth > 0 ? "pl-4" : "pl-1"}`}
|
||||
data-selected={mode === "single" && checked ? "true" : undefined}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-6 w-6 shrink-0 place-items-center rounded-sm text-muted-foreground transition hover:bg-secondary"
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
aria-label={`${node.name} ${isOpen ? "접기" : "펼치기"}`}
|
||||
>
|
||||
{isOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="h-6 w-6 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
{mode === "multiple" && selectable ? (
|
||||
<input
|
||||
aria-label={label}
|
||||
checked={checked}
|
||||
className="h-3.5 w-3.5 rounded border-border"
|
||||
onChange={(event) => onToggle(node, event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{mode === "single" && selectable ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={checked}
|
||||
className={`min-w-0 flex-1 rounded-sm px-1 text-left outline-none transition focus-visible:ring-2 focus-visible:ring-ring ${
|
||||
checked ? "text-primary" : ""
|
||||
}`}
|
||||
data-selected={checked ? "true" : undefined}
|
||||
onClick={() => onSingleSelect(node)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div className="min-w-0 flex-1">{content}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && hasChildren ? (
|
||||
<div className="ml-4">
|
||||
{node.children.map((child) => (
|
||||
<OrgPickerTreeItem
|
||||
depth={depth + 1}
|
||||
key={nodeKey(child)}
|
||||
mode={mode}
|
||||
node={child}
|
||||
onSingleSelect={onSingleSelect}
|
||||
onToggle={onToggle}
|
||||
select={select}
|
||||
selectedKeys={selectedKeys}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrgPickerEmbedPage() {
|
||||
const location = useLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const mode = parseOrgPickerMode(searchParams.get("mode"));
|
||||
const select = parseOrgPickerSelectableType(searchParams.get("select"));
|
||||
const rootTenantId = searchParams.get("rootTenantId") || undefined;
|
||||
const tenantId =
|
||||
searchParams.get("tenantId") ||
|
||||
searchParams.get("companyTenantId") ||
|
||||
undefined;
|
||||
const [includeDescendants, setIncludeDescendants] = React.useState(
|
||||
searchParams.get("includeDescendants") !== "false",
|
||||
);
|
||||
const showDescendantToggle =
|
||||
searchParams.get("showDescendantToggle") !== "false";
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [selectedKeys, setSelectedKeys] = React.useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const tenantsQuery = useQuery({
|
||||
queryKey: ["org-picker-tenants"],
|
||||
queryFn: () => fetchTenants(10000, 0),
|
||||
});
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["org-picker-users"],
|
||||
queryFn: () => fetchUsers(5000, 0),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
postPickerMessage({ type: "orgfront:picker:ready" });
|
||||
}, []);
|
||||
|
||||
const tree = React.useMemo(() => {
|
||||
return buildOrgPickerTree({
|
||||
tenants: tenantsQuery.data?.items ?? [],
|
||||
users: select === "tenant" ? [] : (usersQuery.data?.items ?? []),
|
||||
rootTenantId,
|
||||
tenantId,
|
||||
});
|
||||
}, [rootTenantId, select, tenantId, tenantsQuery.data, usersQuery.data]);
|
||||
|
||||
const selectedItems = React.useMemo(
|
||||
() =>
|
||||
collectSelectedNodes({
|
||||
roots: tree.roots,
|
||||
selectedKeys,
|
||||
includeDescendants: mode === "multiple" && includeDescendants,
|
||||
select,
|
||||
}),
|
||||
[includeDescendants, mode, select, selectedKeys, tree.roots],
|
||||
);
|
||||
const checkedKeys = React.useMemo(
|
||||
() =>
|
||||
collectCheckedKeys({
|
||||
roots: tree.roots,
|
||||
selectedKeys,
|
||||
includeDescendants: mode === "multiple" && includeDescendants,
|
||||
select,
|
||||
}),
|
||||
[includeDescendants, mode, select, selectedKeys, tree.roots],
|
||||
);
|
||||
const filteredRoots = React.useMemo(
|
||||
() => filterPickerTree(tree.roots, searchQuery),
|
||||
[searchQuery, tree.roots],
|
||||
);
|
||||
|
||||
const handleSingleSelect = (node: OrgPickerTreeNode) => {
|
||||
setSelectedKeys(new Set([nodeKey(node)]));
|
||||
};
|
||||
|
||||
const handleToggle = (node: OrgPickerTreeNode, checked: boolean) => {
|
||||
setSelectedKeys((current) => {
|
||||
const next = new Set(current);
|
||||
const key = nodeKey(node);
|
||||
if (checked) next.add(key);
|
||||
else next.delete(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const confirmSelection = () => {
|
||||
const payload: OrgPickerResult = {
|
||||
mode,
|
||||
selections: selectedItems,
|
||||
};
|
||||
postPickerMessage({ type: "orgfront:picker:confirm", payload });
|
||||
};
|
||||
|
||||
const cancelSelection = () => {
|
||||
postPickerMessage({ type: "orgfront:picker:cancel" });
|
||||
};
|
||||
|
||||
const isLoading = tenantsQuery.isLoading || usersQuery.isLoading;
|
||||
const isError = tenantsQuery.isError || usersQuery.isError;
|
||||
|
||||
React.useEffect(() => {
|
||||
const htmlOverflow = document.documentElement.style.overflow;
|
||||
const bodyOverflow = document.body.style.overflow;
|
||||
document.documentElement.style.overflow = "hidden";
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
document.documentElement.style.overflow = htmlOverflow;
|
||||
document.body.style.overflow = bodyOverflow;
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isError) return;
|
||||
postPickerMessage({
|
||||
type: "orgfront:picker:error",
|
||||
error: "org_picker_load_failed",
|
||||
});
|
||||
}, [isError]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center bg-background p-6 text-muted-foreground">
|
||||
조직 선택기를 불러오는 중...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center bg-background p-6 text-destructive">
|
||||
조직 선택기를 불러올 수 없습니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
|
||||
<main className="flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
className="shrink-0 border-b border-border bg-background p-2"
|
||||
data-testid="org-picker-search-section"
|
||||
>
|
||||
<div className="grid grid-cols-[minmax(0,1fr),auto] items-end gap-2">
|
||||
<div>
|
||||
<label className="sr-only" htmlFor="org-picker-search">
|
||||
조직/구성원 검색
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
size={16}
|
||||
/>
|
||||
<input
|
||||
id="org-picker-search"
|
||||
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm"
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="ID, 이름, 이메일, 메타데이터"
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "multiple" && showDescendantToggle ? (
|
||||
<label
|
||||
className="inline-flex h-9 items-center gap-2 whitespace-nowrap text-sm"
|
||||
data-testid="org-picker-descendant-toggle"
|
||||
>
|
||||
<input
|
||||
checked={includeDescendants}
|
||||
className="h-3.5 w-3.5"
|
||||
onChange={(event) =>
|
||||
setIncludeDescendants(event.target.checked)
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>하위 선택</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto p-3"
|
||||
data-testid="org-picker-tree-scroll"
|
||||
>
|
||||
{filteredRoots.length > 0 ? (
|
||||
<OrgPickerTree
|
||||
mode={mode}
|
||||
onSingleSelect={handleSingleSelect}
|
||||
onToggle={handleToggle}
|
||||
roots={filteredRoots}
|
||||
select={select}
|
||||
selectedKeys={checkedKeys}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid min-h-40 place-items-center rounded-md border border-dashed border-border bg-background p-6 text-center text-sm text-muted-foreground">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-border bg-background px-3 py-2">
|
||||
<div className="min-w-0 text-sm text-muted-foreground">
|
||||
{selectedItems.length > 0
|
||||
? `${selectedItems.length}개 항목 선택됨`
|
||||
: "선택된 항목이 없습니다."}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={cancelSelection} type="button" variant="outline">
|
||||
<X size={16} />
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
disabled={selectedItems.length === 0}
|
||||
onClick={confirmSelection}
|
||||
type="button"
|
||||
>
|
||||
<Check size={16} />
|
||||
선택 완료
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrgPickerPage() {
|
||||
const location = useLocation();
|
||||
const [options, setOptions] = React.useState<OrgPickerEmbedOptions>(() =>
|
||||
parseOrgPickerEmbedOptions(location.search),
|
||||
);
|
||||
const pickerSrc = buildOrgPickerEmbedSrc(options);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Picker Workbench
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold">조직 선택기</h1>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-card px-3 py-2 text-sm text-muted-foreground">
|
||||
{pickerSrc}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-3 rounded-md border border-border bg-card p-4 md:grid-cols-2 lg:grid-cols-[1fr,1fr,1fr,auto,auto,auto,auto] lg:items-end">
|
||||
<label className="space-y-1 text-sm font-medium">
|
||||
<span className="block text-muted-foreground">선택 모드</span>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||
value={options.mode}
|
||||
onChange={(event) =>
|
||||
setOptions((current) => ({
|
||||
...current,
|
||||
mode: event.target.value as OrgPickerMode,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="multiple">복수 선택</option>
|
||||
<option value="single">단일 선택</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-medium">
|
||||
<span className="block text-muted-foreground">선택 대상</span>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||
value={options.select}
|
||||
onChange={(event) =>
|
||||
setOptions((current) => ({
|
||||
...current,
|
||||
select: event.target.value as OrgPickerSelectableType,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="both">조직&구성원</option>
|
||||
<option value="tenant">조직</option>
|
||||
<option value="user">구성원</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-medium">
|
||||
<span className="block text-muted-foreground">tenant ID</span>
|
||||
<input
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||
onChange={(event) =>
|
||||
setOptions((current) => ({
|
||||
...current,
|
||||
tenantId: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="company-baron"
|
||||
type="text"
|
||||
value={options.tenantId}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
|
||||
<input
|
||||
checked={options.includeDescendants}
|
||||
disabled={options.mode === "single"}
|
||||
onChange={(event) =>
|
||||
setOptions((current) => ({
|
||||
...current,
|
||||
includeDescendants: event.target.checked,
|
||||
}))
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>하위 포함</span>
|
||||
</label>
|
||||
|
||||
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
|
||||
<input
|
||||
checked={options.showDescendantToggle}
|
||||
disabled={options.mode === "single"}
|
||||
onChange={(event) =>
|
||||
setOptions((current) => ({
|
||||
...current,
|
||||
showDescendantToggle: event.target.checked,
|
||||
}))
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>하위 선택 스위치 표시</span>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-medium">
|
||||
<span className="block text-muted-foreground">임베딩 너비</span>
|
||||
<input
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||
max={1600}
|
||||
min={240}
|
||||
onChange={(event) =>
|
||||
setOptions((current) => ({
|
||||
...current,
|
||||
width: Number.parseInt(event.target.value || "400", 10),
|
||||
}))
|
||||
}
|
||||
type="number"
|
||||
value={options.width}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-medium">
|
||||
<span className="block text-muted-foreground">임베딩 높이</span>
|
||||
<input
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||
max={1600}
|
||||
min={240}
|
||||
onChange={(event) =>
|
||||
setOptions((current) => ({
|
||||
...current,
|
||||
height: Number.parseInt(event.target.value || "600", 10),
|
||||
}))
|
||||
}
|
||||
type="number"
|
||||
value={options.height}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div
|
||||
className="max-w-full resize overflow-auto rounded-md border border-border bg-card"
|
||||
style={{
|
||||
width: options.width,
|
||||
height: options.height,
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
className="h-full w-full bg-background"
|
||||
src={pickerSrc}
|
||||
title="조직 선택기 테스트"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user