forked from baron/baron-sso
조직현황 구조변경. 총괄센터삼안 실 조직 삽입확인
This commit is contained in:
@@ -17,6 +17,7 @@ import { Textarea } from "../../../components/ui/textarea";
|
||||
import { createTenant, fetchTenants } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { DomainTagInput } from "../components/DomainTagInput";
|
||||
import { ParentTenantSelector } from "../components/ParentTenantSelector";
|
||||
import {
|
||||
type ServerDomainConflict,
|
||||
formatDomainConflictMessage,
|
||||
@@ -171,25 +172,17 @@ function TenantCreatePage() {
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parentId" className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.parent", "상위 테넌트 (선택)")}
|
||||
</Label>
|
||||
<select
|
||||
id="parentId"
|
||||
name="parentId"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("ui.common.none", "없음")}</option>
|
||||
{parentQuery.data?.items?.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} ({t.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<ParentTenantSelector
|
||||
id="parentId"
|
||||
label={t(
|
||||
"ui.admin.tenants.create.form.parent",
|
||||
"상위 테넌트 (선택)",
|
||||
)}
|
||||
value={parentId}
|
||||
onChange={setParentId}
|
||||
tenants={parentQuery.data?.items ?? []}
|
||||
noneLabel={t("ui.common.none", "없음")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-slug" className="text-sm font-semibold">
|
||||
|
||||
@@ -72,7 +72,9 @@ import { isSeedTenant } from "../utils/protectedTenants";
|
||||
import {
|
||||
type TenantImportPreviewRow,
|
||||
type TenantImportResolution,
|
||||
buildTenantImportParentOptionGroups,
|
||||
buildTenantImportPreview,
|
||||
inferTenantImportRootParentSlug,
|
||||
parseTenantCSV,
|
||||
serializeTenantImportCSV,
|
||||
} from "../utils/tenantCsvImport";
|
||||
@@ -97,6 +99,119 @@ const getTenantIcon = (type?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const noImportParentRef = "__none__";
|
||||
|
||||
function tenantParentRef(tenantId: string) {
|
||||
return `tenant:${tenantId}`;
|
||||
}
|
||||
|
||||
function previewParentRef(rowNumber: number) {
|
||||
return `row:${rowNumber}`;
|
||||
}
|
||||
|
||||
function slugParentRef(slug: string) {
|
||||
return `slug:${slug}`;
|
||||
}
|
||||
|
||||
function getImportParentGroupLabel(type: string) {
|
||||
switch (type) {
|
||||
case "COMPANY_GROUP":
|
||||
return t(
|
||||
"ui.admin.tenants.import_preview.parent_company_groups",
|
||||
"기존 Company Group",
|
||||
);
|
||||
case "COMPANY":
|
||||
return t(
|
||||
"ui.admin.tenants.import_preview.parent_companies",
|
||||
"기존 Company",
|
||||
);
|
||||
case "ORGANIZATION":
|
||||
return t(
|
||||
"ui.admin.tenants.import_preview.parent_organizations",
|
||||
"기존 Organization",
|
||||
);
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDefaultImportParentRef(
|
||||
preview: TenantImportPreviewRow,
|
||||
previewRows: TenantImportPreviewRow[],
|
||||
tenants: TenantSummary[],
|
||||
) {
|
||||
if (preview.row.parentTenantId) {
|
||||
return tenantParentRef(preview.row.parentTenantId);
|
||||
}
|
||||
if (!preview.row.parentTenantSlug) {
|
||||
return noImportParentRef;
|
||||
}
|
||||
|
||||
const normalizedSlug = preview.row.parentTenantSlug.toLowerCase();
|
||||
const existingTenant = tenants.find(
|
||||
(tenant) => tenant.slug.toLowerCase() === normalizedSlug,
|
||||
);
|
||||
if (existingTenant) {
|
||||
return tenantParentRef(existingTenant.id);
|
||||
}
|
||||
|
||||
const parentPreview = previewRows.find(
|
||||
(candidate) =>
|
||||
candidate.row.rowNumber !== preview.row.rowNumber &&
|
||||
candidate.row.slug.toLowerCase() === normalizedSlug,
|
||||
);
|
||||
if (parentPreview) {
|
||||
return previewParentRef(parentPreview.row.rowNumber);
|
||||
}
|
||||
|
||||
return slugParentRef(preview.row.parentTenantSlug);
|
||||
}
|
||||
|
||||
function selectedImportSlug(
|
||||
preview: TenantImportPreviewRow,
|
||||
selectedCreateSlugs: Record<number, string>,
|
||||
) {
|
||||
return (
|
||||
selectedCreateSlugs[preview.row.rowNumber] || preview.defaultCreateSlug
|
||||
);
|
||||
}
|
||||
|
||||
function resolveImportParentSelection(
|
||||
parentRef: string,
|
||||
previewRows: TenantImportPreviewRow[],
|
||||
selectedMatches: Record<number, string>,
|
||||
selectedCreateSlugs: Record<number, string>,
|
||||
) {
|
||||
if (!parentRef || parentRef === noImportParentRef) {
|
||||
return { parentTenantId: "", parentTenantSlug: "" };
|
||||
}
|
||||
if (parentRef.startsWith("tenant:")) {
|
||||
return {
|
||||
parentTenantId: parentRef.slice("tenant:".length),
|
||||
parentTenantSlug: "",
|
||||
};
|
||||
}
|
||||
if (parentRef.startsWith("slug:")) {
|
||||
return { parentTenantSlug: parentRef.slice("slug:".length) };
|
||||
}
|
||||
if (parentRef.startsWith("row:")) {
|
||||
const rowNumber = Number(parentRef.slice("row:".length));
|
||||
const selected = selectedMatches[rowNumber] ?? "__create__";
|
||||
if (selected && selected !== "__create__") {
|
||||
return { parentTenantId: selected, parentTenantSlug: "" };
|
||||
}
|
||||
const parentPreview = previewRows.find(
|
||||
(preview) => preview.row.rowNumber === rowNumber,
|
||||
);
|
||||
return {
|
||||
parentTenantSlug: parentPreview
|
||||
? selectedImportSlug(parentPreview, selectedCreateSlugs)
|
||||
: "",
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function TenantListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list");
|
||||
@@ -114,6 +229,9 @@ function TenantListPage() {
|
||||
const [selectedCreateSlugs, setSelectedCreateSlugs] = React.useState<
|
||||
Record<number, string>
|
||||
>({});
|
||||
const [selectedParentRefs, setSelectedParentRefs] = React.useState<
|
||||
Record<number, string>
|
||||
>({});
|
||||
const [previewOpen, setPreviewOpen] = React.useState(false);
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
@@ -189,6 +307,7 @@ function TenantListPage() {
|
||||
setPreviewOpen(false);
|
||||
setPreviewRows([]);
|
||||
setSelectedMatches({});
|
||||
setSelectedParentRefs({});
|
||||
query.refetch();
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
@@ -234,6 +353,8 @@ function TenantListPage() {
|
||||
: null;
|
||||
|
||||
const allTenants = query.data?.items ?? [];
|
||||
const importParentOptionGroups =
|
||||
buildTenantImportParentOptionGroups(allTenants);
|
||||
const tenants = React.useMemo(() => {
|
||||
// 1. Calculate recursive counts
|
||||
// buildTenantFullTree returns subTree which represents roots, but it also mutates the mapped nodes internally.
|
||||
@@ -373,7 +494,9 @@ function TenantListPage() {
|
||||
if (!file) return;
|
||||
setImportMessage("");
|
||||
const text = await file.text();
|
||||
const rows = parseTenantCSV(text);
|
||||
const rows = parseTenantCSV(text, {
|
||||
rootParentSlug: inferTenantImportRootParentSlug(file.name, allTenants),
|
||||
});
|
||||
if (rows.length === 0) {
|
||||
setImportMessage(
|
||||
t("msg.admin.tenants.import_empty", "가져올 테넌트 행이 없습니다."),
|
||||
@@ -395,6 +518,14 @@ function TenantListPage() {
|
||||
preview.map((row) => [row.row.rowNumber, row.defaultCreateSlug]),
|
||||
),
|
||||
);
|
||||
setSelectedParentRefs(
|
||||
Object.fromEntries(
|
||||
preview.map((row) => [
|
||||
row.row.rowNumber,
|
||||
resolveDefaultImportParentRef(row, preview, allTenants),
|
||||
]),
|
||||
),
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
};
|
||||
|
||||
@@ -406,7 +537,21 @@ function TenantListPage() {
|
||||
if (selected && selected !== "__create__") {
|
||||
return [
|
||||
preview.row.rowNumber,
|
||||
{ mode: "existing", tenantId: selected },
|
||||
{
|
||||
mode: "existing",
|
||||
tenantId: selected,
|
||||
...resolveImportParentSelection(
|
||||
selectedParentRefs[preview.row.rowNumber] ??
|
||||
resolveDefaultImportParentRef(
|
||||
preview,
|
||||
previewRows,
|
||||
allTenants,
|
||||
),
|
||||
previewRows,
|
||||
selectedMatches,
|
||||
selectedCreateSlugs,
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -417,6 +562,17 @@ function TenantListPage() {
|
||||
slug:
|
||||
selectedCreateSlugs[preview.row.rowNumber] ||
|
||||
preview.defaultCreateSlug,
|
||||
...resolveImportParentSelection(
|
||||
selectedParentRefs[preview.row.rowNumber] ??
|
||||
resolveDefaultImportParentRef(
|
||||
preview,
|
||||
previewRows,
|
||||
allTenants,
|
||||
),
|
||||
previewRows,
|
||||
selectedMatches,
|
||||
selectedCreateSlugs,
|
||||
),
|
||||
},
|
||||
];
|
||||
}),
|
||||
@@ -860,6 +1016,9 @@ function TenantListPage() {
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.import_preview.parent", "상위")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.import_preview.match", "매칭")}
|
||||
</TableHead>
|
||||
@@ -909,6 +1068,94 @@ function TenantListPage() {
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<select
|
||||
className="h-9 w-full min-w-[220px] rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={
|
||||
selectedParentRefs[preview.row.rowNumber] ??
|
||||
resolveDefaultImportParentRef(
|
||||
preview,
|
||||
previewRows,
|
||||
allTenants,
|
||||
)
|
||||
}
|
||||
data-testid={`tenant-import-parent-select-${preview.row.rowNumber}`}
|
||||
onChange={(event) =>
|
||||
setSelectedParentRefs((prev) => ({
|
||||
...prev,
|
||||
[preview.row.rowNumber]: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value={noImportParentRef}>
|
||||
{t("ui.common.none", "없음")}
|
||||
</option>
|
||||
{importParentOptionGroups.map((group) => (
|
||||
<optgroup
|
||||
key={group.type}
|
||||
label={getImportParentGroupLabel(group.type)}
|
||||
>
|
||||
{group.tenants.map((tenant) => (
|
||||
<option
|
||||
key={tenant.id}
|
||||
value={tenantParentRef(tenant.id)}
|
||||
>
|
||||
{tenant.name} ({tenant.slug}) - {tenant.type}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
<optgroup
|
||||
label={t(
|
||||
"ui.admin.tenants.import_preview.csv_parents",
|
||||
"가져오기 CSV",
|
||||
)}
|
||||
>
|
||||
{previewRows
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.row.rowNumber !==
|
||||
preview.row.rowNumber,
|
||||
)
|
||||
.map((candidate) => (
|
||||
<option
|
||||
key={candidate.row.rowNumber}
|
||||
value={previewParentRef(
|
||||
candidate.row.rowNumber,
|
||||
)}
|
||||
>
|
||||
{candidate.row.name} (
|
||||
{selectedImportSlug(
|
||||
candidate,
|
||||
selectedCreateSlugs,
|
||||
)}
|
||||
)
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{(
|
||||
selectedParentRefs[preview.row.rowNumber] ??
|
||||
resolveDefaultImportParentRef(
|
||||
preview,
|
||||
previewRows,
|
||||
allTenants,
|
||||
)
|
||||
).startsWith("slug:") && (
|
||||
<option
|
||||
value={
|
||||
selectedParentRefs[preview.row.rowNumber] ??
|
||||
resolveDefaultImportParentRef(
|
||||
preview,
|
||||
previewRows,
|
||||
allTenants,
|
||||
)
|
||||
}
|
||||
>
|
||||
{preview.row.parentTenantSlug}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
|
||||
@@ -24,10 +24,20 @@ import {
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { DomainTagInput } from "../components/DomainTagInput";
|
||||
import { ParentTenantSelector } from "../components/ParentTenantSelector";
|
||||
import {
|
||||
type ServerDomainConflict,
|
||||
formatDomainConflictMessage,
|
||||
} from "../utils/domainTags";
|
||||
import {
|
||||
ORG_UNIT_TYPE_OPTIONS,
|
||||
TENANT_VISIBILITY_OPTIONS,
|
||||
type TenantVisibility,
|
||||
mergeTenantOrgConfig,
|
||||
readTenantOrgConfig,
|
||||
removeTenantOrgConfig,
|
||||
shouldAllowHanmacOrgConfig,
|
||||
} from "../utils/orgConfig";
|
||||
import { isSeedTenant } from "../utils/protectedTenants";
|
||||
|
||||
export function TenantProfilePage() {
|
||||
@@ -51,9 +61,6 @@ export function TenantProfilePage() {
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
});
|
||||
|
||||
const availableParents =
|
||||
parentQuery.data?.items?.filter((t) => t.id !== tenantId) || [];
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState("COMPANY");
|
||||
const [slug, setSlug] = useState("");
|
||||
@@ -64,9 +71,13 @@ export function TenantProfilePage() {
|
||||
[],
|
||||
);
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [orgUnitType, setOrgUnitType] = useState("");
|
||||
const [tenantVisibility, setTenantVisibility] =
|
||||
useState<TenantVisibility>("public");
|
||||
|
||||
useEffect(() => {
|
||||
if (tenantQuery.data) {
|
||||
const orgConfig = readTenantOrgConfig(tenantQuery.data.config);
|
||||
setName(tenantQuery.data.name);
|
||||
setType(tenantQuery.data.type || "COMPANY");
|
||||
setSlug(tenantQuery.data.slug);
|
||||
@@ -75,12 +86,37 @@ export function TenantProfilePage() {
|
||||
setDomains(tenantQuery.data.domains ?? []);
|
||||
setForceDomainConflicts([]);
|
||||
setParentId(tenantQuery.data.parentId ?? "");
|
||||
setOrgUnitType(orgConfig.orgUnitType);
|
||||
setTenantVisibility(orgConfig.visibility);
|
||||
}
|
||||
}, [tenantQuery.data]);
|
||||
|
||||
const allTenants = parentQuery.data?.items ?? [];
|
||||
const orgConfigCandidate = tenantQuery.data
|
||||
? {
|
||||
...tenantQuery.data,
|
||||
parentId: parentId || undefined,
|
||||
slug,
|
||||
}
|
||||
: undefined;
|
||||
const canEditOrgConfig = orgConfigCandidate
|
||||
? shouldAllowHanmacOrgConfig(orgConfigCandidate, [
|
||||
...allTenants,
|
||||
orgConfigCandidate,
|
||||
])
|
||||
: false;
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (overrideForceDomains?: string[]) =>
|
||||
updateTenant(tenantId, {
|
||||
mutationFn: (overrideForceDomains?: string[]) => {
|
||||
const baseConfig = tenantQuery.data?.config;
|
||||
const config = canEditOrgConfig
|
||||
? mergeTenantOrgConfig(baseConfig, {
|
||||
orgUnitType,
|
||||
visibility: tenantVisibility,
|
||||
})
|
||||
: removeTenantOrgConfig(baseConfig);
|
||||
|
||||
return updateTenant(tenantId, {
|
||||
name,
|
||||
type,
|
||||
slug,
|
||||
@@ -89,7 +125,9 @@ export function TenantProfilePage() {
|
||||
parentId: parentId || undefined,
|
||||
domains,
|
||||
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
|
||||
}),
|
||||
config,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||
@@ -250,31 +288,22 @@ export function TenantProfilePage() {
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parentId" className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.form.parent", "상위 테넌트 (선택)")}
|
||||
</Label>
|
||||
<select
|
||||
id="parentId"
|
||||
name="parentId"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("ui.common.none", "없음 (최상위)")}</option>
|
||||
{availableParents.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} ({t.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t(
|
||||
"ui.admin.tenants.profile.form.parent_help",
|
||||
"하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<ParentTenantSelector
|
||||
id="parentId"
|
||||
label={t(
|
||||
"ui.admin.tenants.profile.form.parent",
|
||||
"상위 테넌트 (선택)",
|
||||
)}
|
||||
value={parentId}
|
||||
onChange={setParentId}
|
||||
tenants={parentQuery.data?.items ?? []}
|
||||
noneLabel={t("ui.common.none", "없음 (최상위)")}
|
||||
helpText={t(
|
||||
"ui.admin.tenants.profile.form.parent_help",
|
||||
"하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
|
||||
)}
|
||||
excludeTenantId={tenantId}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
|
||||
@@ -336,6 +365,45 @@ export function TenantProfilePage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{canEditOrgConfig && (
|
||||
<div className="grid gap-4 rounded-md border border-border/70 p-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.org_unit_type", "조직 세부타입")}
|
||||
</Label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={orgUnitType}
|
||||
onChange={(event) => setOrgUnitType(event.target.value)}
|
||||
>
|
||||
<option value="">{t("ui.common.none", "없음")}</option>
|
||||
{ORG_UNIT_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
|
||||
</Label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={tenantVisibility}
|
||||
onChange={(event) =>
|
||||
setTenantVisibility(event.target.value as TenantVisibility)
|
||||
}
|
||||
>
|
||||
{TENANT_VISIBILITY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMsg}
|
||||
|
||||
Reference in New Issue
Block a user