1
0
forked from baron/baron-sso

조직현황 구조변경. 총괄센터삼안 실 조직 삽입확인

This commit is contained in:
2026-05-11 20:13:54 +09:00
parent d3853fac2a
commit 3063450ee0
59 changed files with 5086 additions and 549 deletions

View File

@@ -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">

View File

@@ -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

View File

@@ -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}