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

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import { filterParentTenants } from "./ParentTenantSelector";
const tenants: TenantSummary[] = [
{
id: "company-1",
type: "COMPANY",
name: "Saman Engineering",
slug: "saman",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "group-1",
type: "COMPANY_GROUP",
name: "Hanmac Family",
slug: "hanmac-family",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "org-1",
type: "ORGANIZATION",
name: "기획부",
slug: "planning",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
},
];
describe("filterParentTenants", () => {
it("searches parent candidates by name and slug", () => {
expect(
filterParentTenants(tenants, "saman", false).map((t) => t.id),
).toEqual(["company-1"]);
expect(
filterParentTenants(tenants, "family", false).map((t) => t.id),
).toEqual(["group-1"]);
});
it("can limit parent candidates to company and company group tenants", () => {
expect(filterParentTenants(tenants, "", true).map((t) => t.id)).toEqual([
"company-1",
"group-1",
]);
});
});

View File

@@ -0,0 +1,114 @@
import { Search } from "lucide-react";
import { useMemo, useState } from "react";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import type { TenantSummary } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type ParentTenantSelectorProps = {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
tenants: TenantSummary[];
noneLabel: string;
helpText?: string;
excludeTenantId?: string;
};
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,
value,
onChange,
tenants,
noneLabel,
helpText,
excludeTenantId,
}: ParentTenantSelectorProps) {
const [search, setSearch] = useState("");
const [companyOnly, setCompanyOnly] = useState(false);
const filteredTenants = useMemo(
() => filterParentTenants(tenants, search, companyOnly, excludeTenantId),
[tenants, search, companyOnly, excludeTenantId],
);
const selectedTenant = tenants.find((tenant) => tenant.id === value);
const optionTenants =
selectedTenant &&
!filteredTenants.some((tenant) => tenant.id === selectedTenant.id)
? [selectedTenant, ...filteredTenants]
: filteredTenants;
return (
<div className="space-y-2">
<Label htmlFor={id} className="text-sm font-semibold">
{label}
</Label>
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto]">
<label className="relative block">
<Search
aria-hidden="true"
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
id={`${id}-search`}
value={search}
onChange={(event) => setSearch(event.target.value)}
className="pl-9"
placeholder={t(
"ui.admin.tenants.parent.search_placeholder",
"이름 또는 slug 검색",
)}
/>
</label>
<label className="flex h-9 items-center gap-2 rounded-md border border-input px-3 text-sm">
<input
type="checkbox"
checked={companyOnly}
onChange={(event) => setCompanyOnly(event.target.checked)}
className="h-4 w-4"
/>
{t("ui.admin.tenants.parent.company_only", "회사/그룹사만 표시")}
</label>
</div>
<select
id={id}
name={id}
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={value}
onChange={(event) => onChange(event.target.value)}
>
<option value="">{noneLabel}</option>
{optionTenants.map((tenant) => (
<option key={tenant.id} value={tenant.id}>
{tenant.name} ({tenant.slug}) - {tenant.type}
</option>
))}
</select>
{helpText && (
<p className="mt-1 text-xs text-muted-foreground">{helpText}</p>
)}
</div>
);
}

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}

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
mergeTenantOrgConfig,
readTenantOrgConfig,
shouldAllowHanmacOrgConfig,
} from "./orgConfig";
function tenant(
id: string,
type: string,
name: string,
slug: string,
parentId?: string,
): TenantSummary {
return {
id,
type,
name,
slug,
description: "",
status: "active",
parentId,
memberCount: 0,
createdAt: "2026-05-11T00:00:00.000Z",
updatedAt: "2026-05-11T00:00:00.000Z",
};
}
describe("tenant org config", () => {
it("allows org config only for hanmac-family descendants", () => {
const family = tenant(
"family",
"COMPANY_GROUP",
"한맥가족",
"hanmac-family",
);
const saman = tenant("saman", "COMPANY", "삼안", "saman", "family");
const team = tenant("team", "USER_GROUP", "기획팀", "planning", "saman");
const outsider = tenant("outsider", "COMPANY", "외부", "outsider");
const tenants = [family, saman, team, outsider];
expect(shouldAllowHanmacOrgConfig(team, tenants)).toBe(true);
expect(shouldAllowHanmacOrgConfig(family, tenants)).toBe(false);
expect(shouldAllowHanmacOrgConfig(outsider, tenants)).toBe(false);
});
it("reads and writes tenant visibility and org unit type", () => {
expect(
readTenantOrgConfig({ visibility: "private", orgUnitType: "팀" }),
).toEqual({ orgUnitType: "팀", visibility: "private" });
expect(
mergeTenantOrgConfig(
{ userSchema: [], visibility: "private", orgUnitType: "팀" },
{ orgUnitType: "", visibility: "internal" },
),
).toEqual({ userSchema: [], visibility: "internal" });
});
});

View File

@@ -0,0 +1,92 @@
import type { TenantSummary } from "../../../lib/adminApi";
export const ORG_UNIT_TYPE_OPTIONS = [
"실",
"팀",
"디비전",
"셀",
"본부",
"지역본부",
"부",
] as const;
export const TENANT_VISIBILITY_OPTIONS = [
{ label: "공개", value: "public" },
{ label: "내부", value: "internal" },
{ label: "비공개", value: "private" },
] as const;
export type TenantVisibility =
(typeof TENANT_VISIBILITY_OPTIONS)[number]["value"];
export type TenantOrgConfig = {
orgUnitType: string;
visibility: TenantVisibility;
};
const ORG_UNIT_TYPE_SET = new Set<string>(ORG_UNIT_TYPE_OPTIONS);
const TENANT_VISIBILITY_SET = new Set<string>(
TENANT_VISIBILITY_OPTIONS.map((option) => option.value),
);
export function shouldAllowHanmacOrgConfig(
tenant: Pick<TenantSummary, "id" | "parentId" | "slug">,
tenants: Array<Pick<TenantSummary, "id" | "parentId" | "slug">>,
) {
if (tenant.slug.toLowerCase() === "hanmac-family") return false;
const byId = new Map(tenants.map((item) => [item.id, item]));
let parentId = tenant.parentId;
const visited = new Set<string>();
while (parentId) {
if (visited.has(parentId)) return false;
visited.add(parentId);
const parent = byId.get(parentId);
if (!parent) return false;
if (parent.slug.toLowerCase() === "hanmac-family") return true;
parentId = parent.parentId;
}
return false;
}
export function readTenantOrgConfig(
config: Record<string, unknown> | undefined,
): TenantOrgConfig {
const rawVisibility = String(config?.visibility ?? "public").toLowerCase();
const rawOrgUnitType = String(config?.orgUnitType ?? "");
return {
orgUnitType: ORG_UNIT_TYPE_SET.has(rawOrgUnitType) ? rawOrgUnitType : "",
visibility: TENANT_VISIBILITY_SET.has(rawVisibility)
? (rawVisibility as TenantVisibility)
: "public",
};
}
export function mergeTenantOrgConfig(
config: Record<string, unknown> | undefined,
next: TenantOrgConfig,
) {
const { orgUnitType: _orgUnitType, ...rest } = config ?? {};
const merged = { ...rest };
merged.visibility = next.visibility;
if (next.orgUnitType) {
merged.orgUnitType = next.orgUnitType;
}
return merged;
}
export function removeTenantOrgConfig(
config: Record<string, unknown> | undefined,
) {
const {
orgUnitType: _orgUnitType,
visibility: _visibility,
...rest
} = config ?? {};
return rest;
}

View File

@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
buildTenantImportParentOptionGroups,
buildTenantImportPreview,
inferTenantImportRootParentSlug,
parseTenantCSV,
serializeTenantImportCSV,
} from "./tenantCsvImport";
@@ -31,9 +33,37 @@ const tenants: TenantSummary[] = [
createdAt: "",
updatedAt: "",
},
{
id: "tenant-3",
type: "COMPANY_GROUP",
name: "Hanmac Family",
slug: "hanmac-family",
description: "",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "tenant-4",
type: "ORGANIZATION",
name: "기획부",
slug: "planning",
description: "",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
];
describe("tenantCsvImport", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("parses tenant CSV rows with the supported import columns", () => {
const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com\n",
@@ -87,7 +117,7 @@ describe("tenantCsvImport", () => {
});
expect(csv).toContain(
"tenant-1,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com",
"tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com",
);
});
@@ -110,7 +140,7 @@ describe("tenantCsvImport", () => {
});
expect(csv).toContain(
"staging-new-tenant-id,Hanmac Technology,COMPANY,,hanmac-imported,Memo,hanmac.example.com",
"staging-new-tenant-id,Hanmac Technology,COMPANY,,,hanmac-imported,Memo,hanmac.example.com",
);
expect(csv).not.toContain("local-tenant-id");
});
@@ -138,10 +168,10 @@ describe("tenantCsvImport", () => {
});
expect(csv).toContain(
"staging-parent-id,Parent Tenant,COMPANY,,parent-staging,,",
"staging-parent-id,Parent Tenant,COMPANY,,,parent-staging,,",
);
expect(csv).toContain(
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,child-staging,,",
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,,child-staging,,",
);
expect(csv).not.toContain("local-parent-id");
expect(csv).not.toContain("local-child-id");
@@ -171,7 +201,157 @@ describe("tenantCsvImport", () => {
expect(rows[1].parentTenantSlug).toBe("parent-slug");
expect(csv).toContain(
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,child-slug,,",
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
);
});
it("keeps parent_tenant_slug in the serialized CSV as a fallback for hierarchy import", () => {
const rows = parseTenantCSV(
[
"name,type,parent_tenant_slug,slug,memo,email_domain",
"Parent Tenant,COMPANY,,parent-slug,,",
"Child Tenant,ORGANIZATION,parent-slug,child-slug,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: {
mode: "create",
tenantId: "staging-parent-id",
slug: "parent-slug",
},
3: {
mode: "create",
tenantId: "staging-child-id",
slug: "child-slug",
},
});
expect(csv.split("\n")[0]).toBe(
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain",
);
expect(csv).toContain(
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
);
});
it("parses Naver Works organization CSV columns into tenant import rows", () => {
const rows = parseTenantCSV(
[
'"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","상위 조직"',
'"기술개발센터","1","","","","tdc@samaneng.com",""',
'"기획부","1","","","","planning@samaneng.com","기술개발센터(tdc@samaneng.com)"',
'"업무팀","0","","","","t_226wn@samaneng.com","기획부(planning@samaneng.com)"',
].join("\n"),
{ rootParentSlug: "saman" },
);
expect(rows).toMatchObject([
{
name: "기술개발센터",
type: "ORGANIZATION",
slug: "tdc",
parentTenantSlug: "saman",
},
{
name: "기획부",
type: "ORGANIZATION",
slug: "planning",
parentTenantSlug: "tdc",
},
{
name: "업무팀",
type: "ORGANIZATION",
slug: "t-226wn",
parentTenantSlug: "planning",
},
]);
});
it("infers root parent slug from an organization CSV file prefix that matches an existing slug", () => {
expect(inferTenantImportRootParentSlug("saman_org.csv", tenants)).toBe(
"saman",
);
expect(
inferTenantImportRootParentSlug("/tmp/hanmac-family_org.csv", tenants),
).toBe("hanmac-family");
expect(
inferTenantImportRootParentSlug("saman_org_slugged.csv", tenants),
).toBe("saman");
expect(inferTenantImportRootParentSlug("unknown_org.csv", tenants)).toBe(
"",
);
expect(inferTenantImportRootParentSlug("tenant-import.csv", tenants)).toBe(
"",
);
});
it("groups existing parent candidates by company group, company, and organization", () => {
const groups = buildTenantImportParentOptionGroups(tenants);
expect(groups.map((group) => group.type)).toEqual([
"COMPANY_GROUP",
"COMPANY",
"ORGANIZATION",
]);
expect(
groups.map((group) => group.tenants.map((tenant) => tenant.id)),
).toEqual([["tenant-3"], ["tenant-1", "tenant-2"], ["tenant-4"]]);
});
it("keeps generated ids stable and follows edited parent slugs for child rows", () => {
const randomUUID = vi
.fn()
.mockReturnValueOnce("parent-generated-id")
.mockReturnValueOnce("child-generated-id");
vi.stubGlobal("crypto", { randomUUID });
const rows = parseTenantCSV(
[
"name,type,parent_tenant_slug,slug,memo,email_domain",
"기술개발센터,ORGANIZATION,saman,t-536fc,,",
"일반구조물 div,ORGANIZATION,t-536fc,t-568cz,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: { mode: "create", slug: "tech-center" },
3: { mode: "create", slug: "structure-div" },
});
expect(csv).toContain(
"parent-generated-id,기술개발센터,ORGANIZATION,,saman,tech-center,,",
);
expect(csv).toContain(
"child-generated-id,일반구조물 div,ORGANIZATION,parent-generated-id,tech-center,structure-div,,",
);
});
it("serializes explicit parent tenant selections from the import preview", () => {
const rows = parseTenantCSV(
[
"name,type,parent_tenant_slug,slug,memo,email_domain",
"기술개발센터,ORGANIZATION,saman,t-536fc,,",
"일반구조물 div,ORGANIZATION,t-536fc,t-568cz,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: {
mode: "create",
slug: "tech-center",
parentTenantId: "tenant-2",
parentTenantSlug: "",
},
3: {
mode: "create",
slug: "structure-div",
parentTenantSlug: "tech-center",
},
});
expect(csv).toContain("기술개발센터,ORGANIZATION,tenant-2,,tech-center,,");
expect(csv).toContain(",일반구조물 div,ORGANIZATION,");
expect(csv).toContain(",tech-center,structure-div,,");
});
});

View File

@@ -12,6 +12,12 @@ export type TenantCSVRow = {
emailDomain: string;
};
export type TenantCSVParseOptions = {
rootParentSlug?: string;
};
type TenantCSVSourceKey = keyof TenantCSVRow | "mailingList" | "parentOrg";
export type TenantImportCandidate = {
tenantId: string;
name: string;
@@ -28,6 +34,16 @@ export type TenantImportPreviewRow = {
conflicts: TenantImportConflict[];
};
export type TenantImportParentOptionGroupType =
| "COMPANY_GROUP"
| "COMPANY"
| "ORGANIZATION";
export type TenantImportParentOptionGroup = {
type: TenantImportParentOptionGroupType;
tenants: TenantSummary[];
};
export type TenantImportConflict =
| "external_tenant_id"
| "slug_exists"
@@ -37,12 +53,15 @@ export type TenantImportResolution =
| {
mode: "existing";
tenantId: string;
parentTenantId?: string;
parentTenantSlug?: string;
}
| {
mode: "create";
tenantId?: string;
slug?: string;
parentTenantId?: string;
parentTenantSlug?: string;
}
| {
mode: "skip";
@@ -53,16 +72,18 @@ const importHeaders = [
"name",
"type",
"parent_tenant_id",
"parent_tenant_slug",
"slug",
"memo",
"email_domain",
];
const headerAliases: Record<string, keyof TenantCSVRow> = {
const headerAliases: Record<string, TenantCSVSourceKey> = {
id: "tenantId",
tenantid: "tenantId",
tenant_id: "tenantId",
name: "name",
: "name",
type: "type",
parentid: "parentTenantId",
parent_id: "parentTenantId",
@@ -70,9 +91,12 @@ const headerAliases: Record<string, keyof TenantCSVRow> = {
parent_tenant_id: "parentTenantId",
parenttenantslug: "parentTenantSlug",
parent_tenant_slug: "parentTenantSlug",
_조직: "parentOrg",
slug: "slug",
memo: "memo",
description: "memo",
: "memo",
_리스트: "mailingList",
"email-domain": "emailDomain",
emaildomain: "emailDomain",
email_domain: "emailDomain",
@@ -80,39 +104,96 @@ const headerAliases: Record<string, keyof TenantCSVRow> = {
domains: "emailDomain",
};
export function parseTenantCSV(text: string): TenantCSVRow[] {
export function parseTenantCSV(
text: string,
options: TenantCSVParseOptions = {},
): TenantCSVRow[] {
const records = parseCSV(text.replace(/^\uFEFF/, ""));
if (records.length === 0) return [];
const header = new Map<keyof TenantCSVRow, number>();
const header = new Map<TenantCSVSourceKey, number>();
records[0].forEach((column, index) => {
const normalized = normalizeHeader(column);
const key = headerAliases[normalized];
if (key) header.set(key, index);
});
return records.slice(1).flatMap((record, index) => {
const isOrgChartCSV = header.has("mailingList") || header.has("parentOrg");
const sourceRows = records.slice(1).flatMap((record, index) => {
if (record.every((value) => value.trim() === "")) return [];
const value = (key: keyof TenantCSVRow) => {
const value = (key: TenantCSVSourceKey) => {
const columnIndex = header.get(key);
if (columnIndex === undefined) return "";
return (record[columnIndex] ?? "").trim();
};
return {
raw: record,
rowNumber: index + 2,
tenantId: value("tenantId"),
name: value("name"),
type: value("type"),
slug: value("slug") || slugFromMailingList(value("mailingList")),
mailingList: value("mailingList"),
parentOrg: value("parentOrg"),
value,
};
});
const slugByName = new Map(
sourceRows
.filter((row) => row.name && row.slug)
.map((row) => [row.name, row.slug] as const),
);
return sourceRows.map(({ rowNumber, name, slug, parentOrg, value }) => {
const parentTenantSlug =
value("parentTenantSlug") ||
slugFromParentOrg(parentOrg, slugByName) ||
(isOrgChartCSV ? options.rootParentSlug || "" : "");
return {
rowNumber,
tenantId: value("tenantId"),
name,
type: value("type") || (isOrgChartCSV ? "ORGANIZATION" : ""),
parentTenantId: value("parentTenantId"),
parentTenantSlug: value("parentTenantSlug"),
slug: value("slug"),
parentTenantSlug,
slug,
memo: value("memo"),
emailDomain: value("emailDomain"),
};
});
}
export function inferTenantImportRootParentSlug(
fileName: string,
tenants: TenantSummary[] = [],
) {
const baseName = fileName.trim().split(/[\\/]/).pop()?.toLowerCase() ?? "";
const [prefix = ""] = baseName.split("_");
if (!prefix) return "";
const existingTenant = tenants.find(
(tenant) => tenant.slug.toLowerCase() === prefix,
);
return existingTenant ? prefix : "";
}
export function buildTenantImportParentOptionGroups(
tenants: TenantSummary[],
): TenantImportParentOptionGroup[] {
const orderedTypes: TenantImportParentOptionGroupType[] = [
"COMPANY_GROUP",
"COMPANY",
"ORGANIZATION",
];
return orderedTypes
.map((type) => ({
type,
tenants: tenants.filter((tenant) => tenant.type?.toUpperCase() === type),
}))
.filter((group) => group.tenants.length > 0);
}
export function buildTenantImportPreview(
rows: TenantCSVRow[],
tenants: TenantSummary[],
@@ -169,27 +250,40 @@ export function serializeTenantImportCSV(
typeof resolution === "object" && resolution.mode === "create"
? resolution.slug || preview.defaultCreateSlug
: preview.row.slug;
const hasParentTenantIdOverride =
typeof resolution === "object" &&
Object.hasOwn(resolution, "parentTenantId");
const hasParentTenantSlugOverride =
typeof resolution === "object" &&
Object.hasOwn(resolution, "parentTenantSlug");
const sourceParentTenantSlug = hasParentTenantSlugOverride
? resolution.parentTenantSlug || ""
: preview.row.parentTenantSlug;
const parentTenantId =
typeof resolution === "object" && resolution.mode === "create"
? (resolution.parentTenantId ??
remapParentTenantId(
preview.row.parentTenantId,
preview.row.parentTenantSlug,
targetTenantIds,
))
typeof resolution === "object"
? hasParentTenantIdOverride
? resolution.parentTenantId || ""
: remapParentTenantId(
preview.row.parentTenantId,
sourceParentTenantSlug,
targetTenantIds,
)
: preview.row.parentTenantId;
const parentTenantSlug = remapParentTenantSlug(
sourceParentTenantSlug,
targetTenantIds,
);
const tenantId =
typeof resolution === "object" && resolution.mode === "create"
? (resolution.tenantId ??
targetTenantIds.bySourceId.get(preview.row.tenantId) ??
createTenantImportId())
: selectedTenantId || preview.row.tenantId;
targetTenantIds.byRowNumber.get(preview.row.rowNumber) ??
selectedTenantId ??
preview.row.tenantId;
lines.push([
tenantId,
preview.row.name,
preview.row.type,
parentTenantId,
parentTenantSlug,
slug,
preview.row.memo,
preview.row.emailDomain,
@@ -202,8 +296,10 @@ function buildTargetTenantIds(
previewRows: TenantImportPreviewRow[],
selectedTenantIds: Record<number, string | TenantImportResolution>,
) {
const byRowNumber = new Map<number, string>();
const bySourceId = new Map<string, string>();
const bySourceSlug = new Map<string, string>();
const bySourceSlugToTargetSlug = new Map<string, string>();
for (const preview of previewRows) {
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
@@ -217,24 +313,38 @@ function buildTargetTenantIds(
: resolution.mode === "existing"
? resolution.tenantId
: resolution.tenantId || createTenantImportId();
const targetSlug =
typeof resolution === "object" && resolution.mode === "create"
? resolution.slug || preview.defaultCreateSlug
: preview.row.slug;
if (targetTenantId) {
byRowNumber.set(preview.row.rowNumber, targetTenantId);
}
if (preview.row.tenantId) {
bySourceId.set(preview.row.tenantId, targetTenantId);
}
if (preview.row.slug) {
bySourceSlug.set(preview.row.slug.toLowerCase(), targetTenantId);
bySourceSlugToTargetSlug.set(preview.row.slug.toLowerCase(), targetSlug);
}
if (targetSlug) {
bySourceSlug.set(targetSlug.toLowerCase(), targetTenantId);
bySourceSlugToTargetSlug.set(targetSlug.toLowerCase(), targetSlug);
}
}
return { bySourceId, bySourceSlug };
return { byRowNumber, bySourceId, bySourceSlug, bySourceSlugToTargetSlug };
}
function remapParentTenantId(
parentTenantId: string,
parentTenantSlug: string,
targetTenantIds: {
byRowNumber: Map<number, string>;
bySourceId: Map<string, string>;
bySourceSlug: Map<string, string>;
bySourceSlugToTargetSlug: Map<string, string>;
},
) {
if (parentTenantId) {
@@ -248,6 +358,20 @@ function remapParentTenantId(
return "";
}
function remapParentTenantSlug(
parentTenantSlug: string,
targetTenantIds: {
bySourceSlugToTargetSlug: Map<string, string>;
},
) {
if (!parentTenantSlug) return "";
return (
targetTenantIds.bySourceSlugToTargetSlug.get(
parentTenantSlug.toLowerCase(),
) ?? parentTenantSlug
);
}
function createTenantImportId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
@@ -377,6 +501,33 @@ function normalizeHeader(value: string) {
return value.trim().toLowerCase().replaceAll(" ", "_");
}
function slugFromMailingList(value: string) {
if (!value) return "";
return normalizeTenantSlug(value.split("@")[0] ?? value);
}
function slugFromParentOrg(value: string, slugByName: Map<string, string>) {
const trimmed = value.trim();
if (!trimmed) return "";
const match = trimmed.match(/\(([^)]+)\)/);
if (match?.[1]) {
return slugFromMailingList(match[1]);
}
return slugByName.get(trimmed) ?? normalizeTenantSlug(trimmed);
}
function normalizeTenantSlug(value: string) {
let slug = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-");
slug = slug.replace(/^-+|-+$/g, "");
if (slug.length > 25) {
slug = slug.slice(0, 25).replace(/-+$/g, "");
}
return slug;
}
function normalizeToken(value: string) {
return value
.trim()

View File

@@ -98,6 +98,7 @@ function createEmptyAppointment(): AppointmentDraft {
tenantName: "",
tenantSlug: "",
isOwner: false,
grade: "",
jobTitle: "",
position: "",
};
@@ -148,6 +149,7 @@ function UserCreatePage() {
phone: "",
tenantSlug: searchParams.get("tenantSlug") || "",
department: "",
grade: "",
position: "",
jobTitle: "",
metadata: {},
@@ -379,6 +381,7 @@ function UserCreatePage() {
}
payload.tenantSlug = data.tenantSlug;
payload.department = data.department;
payload.grade = data.grade;
payload.position = data.position;
payload.jobTitle = data.jobTitle;
}
@@ -411,6 +414,7 @@ function UserCreatePage() {
tenantName: appointment.tenantName,
isPrimary: appointment.isOwner,
isOwner: appointment.isOwner,
grade: appointment.grade,
jobTitle: appointment.jobTitle,
position: appointment.position,
}));
@@ -685,12 +689,20 @@ function UserCreatePage() {
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="position"></Label>
<Label htmlFor="grade"></Label>
<Input
id="grade"
placeholder="수석/책임/선임"
{...register("grade")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="position"></Label>
<Input
id="position"
placeholder="수석/책임/선임"
placeholder="팀장/센터장"
{...register("position")}
/>
</div>
@@ -709,9 +721,11 @@ function UserCreatePage() {
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium"> /</p>
<p className="text-sm font-medium">
//
</p>
<p className="text-xs text-muted-foreground">
, , .
, , , .
</p>
</div>
<Button
@@ -778,9 +792,23 @@ function UserCreatePage() {
</div>
<div
className="grid gap-3 sm:grid-cols-2"
className="grid gap-3 sm:grid-cols-3"
data-testid={`appointment-position-line-${index}`}
>
<div className="space-y-2">
<Label htmlFor={`appointment-grade-${index}`}>
</Label>
<Input
id={`appointment-grade-${index}`}
value={appointment.grade ?? ""}
onChange={(event) =>
updateAppointment(index, {
grade: event.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`appointment-job-title-${index}`}>
@@ -797,7 +825,7 @@ function UserCreatePage() {
</div>
<div className="space-y-2">
<Label htmlFor={`appointment-position-${index}`}>
</Label>
<Input
id={`appointment-position-${index}`}

View File

@@ -125,6 +125,7 @@ function createEmptyAppointment(): AppointmentDraft {
tenantSlug: "",
isPrimary: false,
isOwner: false,
grade: "",
jobTitle: "",
position: "",
};
@@ -379,6 +380,7 @@ function UserDetailPage() {
status: "active",
tenantSlug: "",
department: "",
grade: "",
position: "",
jobTitle: "",
metadata: {},
@@ -622,6 +624,7 @@ function UserDetailPage() {
)?.slug ||
"",
department: user.department || "",
grade: user.grade || "",
position: user.position || "",
jobTitle: user.jobTitle || "",
metadata:
@@ -671,6 +674,7 @@ function UserDetailPage() {
isOwner:
metadata.primaryTenantIsOwner === true &&
tenant.id === fallbackAppointment?.id,
grade: user.grade,
jobTitle: user.jobTitle,
position: user.position,
}))
@@ -683,6 +687,7 @@ function UserDetailPage() {
tenantSlug: fallbackAppointment.slug,
isPrimary: true,
isOwner: metadata.primaryTenantIsOwner === true,
grade: user.grade,
jobTitle: user.jobTitle,
position: user.position,
},
@@ -750,6 +755,7 @@ function UserDetailPage() {
const tenant = await ensurePersonalTenant();
payload.tenantSlug = tenant.slug;
payload.department = undefined;
payload.grade = undefined;
payload.position = undefined;
payload.jobTitle = undefined;
payload.metadata = {
@@ -771,6 +777,7 @@ function UserDetailPage() {
tenantName: appointment.tenantName,
isPrimary: appointment.isOwner,
isOwner: appointment.isOwner,
grade: appointment.grade,
jobTitle: appointment.jobTitle,
position: appointment.position,
}));
@@ -790,6 +797,7 @@ function UserDetailPage() {
}
payload.department = undefined;
payload.grade = undefined;
payload.position = undefined;
payload.jobTitle = undefined;
payload.additionalAppointments = appointments;
@@ -1142,13 +1150,13 @@ function UserDetailPage() {
<p className="text-sm font-medium">
{t(
"ui.admin.users.detail.form.additional_appointments",
"소속별 직급/직무",
"소속별 직급/직책/직무",
)}
</p>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.users.detail.form.additional_appointments_help",
"테넌트별 조직장 여부, 직, 직급을 입력합니다.",
"테넌트별 조직장 여부, 직, 직책, 직무를 입력합니다.",
)}
</p>
</div>
@@ -1226,9 +1234,28 @@ function UserDetailPage() {
</div>
<div
className="grid gap-3 sm:grid-cols-2"
className="grid gap-3 sm:grid-cols-3"
data-testid={`detail-appointment-position-line-${index}`}
>
<div className="space-y-2">
<Label
htmlFor={`detail-appointment-grade-${index}`}
>
{t(
"ui.admin.users.detail.form.grade",
"직급",
)}
</Label>
<Input
id={`detail-appointment-grade-${index}`}
value={appointment.grade ?? ""}
onChange={(event) =>
updateAppointment(index, {
grade: event.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label
htmlFor={`detail-appointment-job-title-${index}`}
@@ -1255,7 +1282,7 @@ function UserDetailPage() {
>
{t(
"ui.admin.users.detail.form.position",
"직",
"직",
)}
</Label>
<Input
@@ -1313,12 +1340,25 @@ function UserDetailPage() {
className="h-11 shadow-sm"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="grade"
className="text-xs font-bold uppercase text-muted-foreground"
>
{t("ui.admin.users.detail.form.grade", "직급")}
</Label>
<Input
id="grade"
{...register("grade")}
className="h-11 shadow-sm"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="position"
className="text-xs font-bold uppercase text-muted-foreground"
>
{t("ui.admin.users.detail.form.position", "직")}
{t("ui.admin.users.detail.form.position", "직")}
</Label>
<Input
id="position"

View File

@@ -249,9 +249,9 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
const downloadTemplate = () => {
const headers =
"email,name,phone,role,tenant_slug,department,position,jobTitle,employee_id";
"email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id";
const example =
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,프론트엔드,EMP001";
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001";
const blob = new Blob([`${headers}\n${example}`], {
type: "text/csv;charset=utf-8;",
});

View File

@@ -57,6 +57,7 @@ test@test.com,Test,baron`;
name: "John Doe",
phone: "+19144812222",
department: "myteam",
grade: "Manager",
position: "Manager",
jobTitle: "Sales management",
tenantImport: {

View File

@@ -79,6 +79,8 @@ export function parseUserCSV(text: string): BulkUserItem[] {
};
} else if (header === "department") {
item.department = value;
} else if (header === "grade") {
item.grade = value;
} else if (header === "position") {
item.position = value;
} else if (header === "jobtitle") {
@@ -100,6 +102,7 @@ export function parseUserCSV(text: string): BulkUserItem[] {
} else if (header === "usertype") {
item.metadata.naverworks_user_type = value;
} else if (header === "level") {
item.grade = value;
item.metadata.naverworks_level = value;
} else if (header === "organization") {
item.metadata.naverworks_organization_path = value;
@@ -247,7 +250,7 @@ function applyNaverWorksFallbacks(
item.phone = `${countryCode}${number}`.replace(/\s/g, "");
}
if (!item.position && item.metadata.naverworks_level) {
item.position = item.metadata.naverworks_level;
if (!item.grade && item.metadata.naverworks_level) {
item.grade = item.metadata.naverworks_level;
}
}

View File

@@ -477,6 +477,7 @@ export type UserSummary = {
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
metadata?: Record<string, unknown>;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
createdAt: string;
@@ -499,6 +500,7 @@ export type UserCreateRequest = {
role?: string;
tenantSlug?: string;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
primaryTenantId?: string;
@@ -523,6 +525,7 @@ export type UserUpdateRequest = {
isAddTenant?: boolean;
isRemoveTenant?: boolean;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
primaryTenantId?: string;
@@ -539,6 +542,7 @@ export type UserAppointment = {
isPrimary?: boolean;
isOwner: boolean;
jobTitle?: string;
grade?: string;
position?: string;
};
@@ -550,6 +554,7 @@ export type BulkUserItem = {
role?: string;
tenantSlug?: string;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
tenantImport?: {
@@ -786,6 +791,7 @@ export async function bulkUpdateUsers(payload: {
tenantSlug?: string;
department?: string;
position?: string;
grade?: string;
jobTitle?: string;
}) {
const requestPayload: typeof payload & { companyCode?: string } = {

View File

@@ -1118,6 +1118,8 @@ email = "Email"
email_placeholder = "user@example.com"
job_title = "Job Title"
job_title_placeholder = "e.g. Frontend Developer"
grade = "Grade"
grade_placeholder = "e.g. Senior"
name = "Name"
name_placeholder = "Name Placeholder"
password = "Password"
@@ -1125,7 +1127,7 @@ password_placeholder = "********"
phone = "Phone number"
phone_placeholder = "010-1234-5678"
position = "Position"
position_placeholder = "e.g. Senior"
position_placeholder = "e.g. Team Lead"
role = "Role"
tenant = "Tenant"
tenant_global = "Tenant Global"
@@ -1147,6 +1149,8 @@ multi_title = "Per-tenant Profile Management"
[ui.admin.users.detail.form]
department = "Department"
department_placeholder = "Department Placeholder"
grade = "Grade"
grade_placeholder = "e.g. Senior"
name = "Name"
name_placeholder = "Name Placeholder"
phone = "Phone number"
@@ -1155,6 +1159,8 @@ role = "Role"
status = "Status"
tenant = "Representative Affiliated Tenant"
tenant_global = "Tenant Global"
position = "Position"
position_placeholder = "e.g. Team Lead"
[ui.admin.users.detail.security]
password = "Password"

View File

@@ -1120,14 +1120,16 @@ email = "이메일"
email_placeholder = "user@example.com"
job_title = "직무"
job_title_placeholder = "프론트엔드 개발"
grade = "직급"
grade_placeholder = "수석/책임/선임"
name = "이름"
name_placeholder = "홍길동"
password = "비밀번호"
password_placeholder = "********"
phone = "전화번호"
phone_placeholder = "010-1234-5678"
position = "직"
position_placeholder = "수석/책임/선임"
position = "직"
position_placeholder = "팀장/센터장"
role = "역할"
tenant = "테넌트"
tenant_global = "시스템 전역"
@@ -1149,6 +1151,8 @@ multi_title = "테넌트별 프로필 관리"
[ui.admin.users.detail.form]
department = "부서"
department_placeholder = "개발팀"
grade = "직급"
grade_placeholder = "수석/책임/선임"
name = "이름"
name_placeholder = "홍길동"
phone = "전화번호"
@@ -1157,6 +1161,8 @@ role = "역할"
status = "상태"
tenant = "대표 소속 테넌트"
tenant_global = "시스템 전역"
position = "직책"
position_placeholder = "팀장/센터장"
[ui.admin.users.detail.security]
password = "비밀번호 변경"