1
0
forked from baron/baron-sso

동기화 기초구조 마련

This commit is contained in:
2026-05-12 12:25:31 +09:00
parent 3063450ee0
commit 5e649c279f
33 changed files with 3364 additions and 408 deletions

View File

@@ -1,50 +1,50 @@
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
"총괄기획실","0","","","","general-planning@baroncs.co.kr","Y","N","Y","Y","","",""
"인재성장","2","","","","talent-growth@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
"전산관리TF","4","","","","it-admin-tf@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
"기술기획","8","","","","tech-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
"경영기획","0","","","","management-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
"ERP기획","0","","","","erp-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
"디자인기획","0","","","","design-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
"협업증진","0","","","","collaboration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
"솔루션통합","0","","","","solution-integration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
"네이버웍스관리용","2","","","","nw-admin-gpd@baroncs.co.kr","N","N","N","Y","","",""
"기술개발센터","0","","","","rnd-center@baroncs.co.kr","Y","N","Y","Y","","",""
"일반구조물 div","0","","","","structural-division@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"총괄기획실","0","","","","gpd@baroncs.co.kr","Y","N","Y","Y","","",""
"인재성장","2","","","","talent-growth@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"전산관리TF","4","","","","it-admin-tf@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"기술기획","8","","","","tech-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"경영기획","0","","","","management-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"ERP기획","0","","","","erp-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"디자인기획","0","","","","design-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"협업증진","0","","","","collaboration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"솔루션통합","0","","","","solution-integration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"네이버웍스관리용","2","","","","su2@baroncs.co.kr","N","N","N","Y","","",""
"기술개발센터","0","","","","tdc@baroncs.co.kr","Y","N","Y","Y","","",""
"일반구조물 div","0","","","","structural-division@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"DfMA","0","","","","dfma@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"일반구조물","0","","","","structural-design@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"구조물계획","0","","","","structure-planning@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"하부구조","0","","","","substructure@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"CM기획","0","","","","cm-planning@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"터널","0","","","","tunnel@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"CC","0","","","","cost-control@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"CC","0","","","","cost-control@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"공정관리","0","","","","schedule-control@baroncs.co.kr","Y","N","Y","Y","","","CC(cost-control@baroncs.co.kr)"
"단가산출","0","","","","cost-estimate@baroncs.co.kr","Y","N","Y","Y","","","CC(cost-control@baroncs.co.kr)"
"상하수도","0","","","","water-sewer@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"천지인","0","","","","cheonjijin@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"상하수도","0","","","","water-sewer@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"천지인","0","","","","cheonjijin@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"천지인셀","0","","","","cheonjijin-cell@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
"용지도셀","0","","","","land-map-cell@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
"단지설계 개발","0","","","","site-design-dev@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
"인프라솔루션 개발","0","","","","infra-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"인프라솔루션 개발","0","","","","infra-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"비탈면/구조물","0","","","","slope-structures@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
"Way Draw","0","","","","way-draw@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
"Primal 평면","0","","","","primal-plan@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
"Watch BIM","0","","","","watch-bim@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
"구조물S/W","0","","","","structural-software@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"Strana","0","","","","strana@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"그래픽스","0","","","","graphics@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"구조물S/W","0","","","","structural-software@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"Strana","0","","","","strana@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"그래픽스","0","","","","graphics@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"Modeler","0","","","","modeler@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
"HmEG","0","","","","hmeg@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
"EG-BIM Draw","0","","","","eg-bim-draw@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
"Abut&시공통합관제","0","","","","abut-control@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
"웹솔루션","0","","","","web-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"웹솔루션","0","","","","web-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"솔루션개발","0","","","","solution-dev@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
"ERP","0","","","","erp@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
"웹디자인","0","","","","web-design@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
"GSIM개발","0","","","","gsim-dev@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"GSIM개발","0","","","","gsim-dev@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"bCMf","0","","","","bcmf@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
"GSIM","0","","","","gsim@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
"PM","0","","","","project-management@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
"수자원","0","","","","water-resources@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"스마트건설","0","","","","smart-construction@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"시공BIM","0","","","","construction-bim@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"수자원","0","","","","water-resources@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"스마트건설","0","","","","smart-construction@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"시공BIM","0","","","","construction-bim@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
1 조직명 멤버 수 조직장 조직 다국어명 설명 메일링 리스트 마스터에게 메시지방 기능 권한 부여 조직 관련 알림 보내기 조직 공개 외부 도메인 메일 수신 차단 보내는 주소로 사용 가능한 구성원 메일을 보낼 수 있는 구성원 상위 조직
2 총괄기획실 0 general-planning@baroncs.co.kr gpd@baroncs.co.kr Y N Y Y
3 인재성장 2 talent-growth@baroncs.co.kr Y N Y Y 총괄기획실(general-planning@baroncs.co.kr) 총괄기획실(gpd@baroncs.co.kr)
4 전산관리TF 4 it-admin-tf@baroncs.co.kr Y N Y Y 총괄기획실(general-planning@baroncs.co.kr) 총괄기획실(gpd@baroncs.co.kr)
5 기술기획 8 tech-planning@baroncs.co.kr Y N Y Y 총괄기획실(general-planning@baroncs.co.kr) 총괄기획실(gpd@baroncs.co.kr)
6 경영기획 0 management-planning@baroncs.co.kr Y N Y Y 총괄기획실(general-planning@baroncs.co.kr) 총괄기획실(gpd@baroncs.co.kr)
7 ERP기획 0 erp-planning@baroncs.co.kr Y N Y Y 총괄기획실(general-planning@baroncs.co.kr) 총괄기획실(gpd@baroncs.co.kr)
8 디자인기획 0 design-planning@baroncs.co.kr Y N Y Y 총괄기획실(general-planning@baroncs.co.kr) 총괄기획실(gpd@baroncs.co.kr)
9 협업증진 0 collaboration@baroncs.co.kr Y N Y Y 총괄기획실(general-planning@baroncs.co.kr) 총괄기획실(gpd@baroncs.co.kr)
10 솔루션통합 0 solution-integration@baroncs.co.kr Y N Y Y 총괄기획실(general-planning@baroncs.co.kr) 총괄기획실(gpd@baroncs.co.kr)
11 네이버웍스관리용 2 nw-admin-gpd@baroncs.co.kr su2@baroncs.co.kr N N N Y
12 기술개발센터 0 rnd-center@baroncs.co.kr tdc@baroncs.co.kr Y N Y Y
13 일반구조물 div 0 structural-division@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr) 기술개발센터(tdc@baroncs.co.kr)
14 DfMA 0 dfma@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
15 일반구조물 0 structural-design@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
16 구조물계획 0 structure-planning@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
17 하부구조 0 substructure@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
18 CM기획 0 cm-planning@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
19 터널 0 tunnel@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
20 CC 0 cost-control@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr) 기술개발센터(tdc@baroncs.co.kr)
21 공정관리 0 schedule-control@baroncs.co.kr Y N Y Y CC(cost-control@baroncs.co.kr)
22 단가산출 0 cost-estimate@baroncs.co.kr Y N Y Y CC(cost-control@baroncs.co.kr)
23 상하수도 0 water-sewer@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr) 기술개발센터(tdc@baroncs.co.kr)
24 천지인 0 cheonjijin@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr) 기술개발센터(tdc@baroncs.co.kr)
25 천지인셀 0 cheonjijin-cell@baroncs.co.kr Y N Y Y 천지인(cheonjijin@baroncs.co.kr)
26 용지도셀 0 land-map-cell@baroncs.co.kr Y N Y Y 천지인(cheonjijin@baroncs.co.kr)
27 단지설계 개발 0 site-design-dev@baroncs.co.kr Y N Y Y 천지인(cheonjijin@baroncs.co.kr)
28 인프라솔루션 개발 0 infra-solutions@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr) 기술개발센터(tdc@baroncs.co.kr)
29 비탈면/구조물 0 slope-structures@baroncs.co.kr Y N Y Y 인프라솔루션 개발(infra-solutions@baroncs.co.kr)
30 Way Draw 0 way-draw@baroncs.co.kr Y N Y Y 인프라솔루션 개발(infra-solutions@baroncs.co.kr)
31 Primal 평면 0 primal-plan@baroncs.co.kr Y N Y Y 인프라솔루션 개발(infra-solutions@baroncs.co.kr)
32 Watch BIM 0 watch-bim@baroncs.co.kr Y N Y Y 인프라솔루션 개발(infra-solutions@baroncs.co.kr)
33 구조물S/W 0 structural-software@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr) 기술개발센터(tdc@baroncs.co.kr)
34 Strana 0 strana@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr) 기술개발센터(tdc@baroncs.co.kr)
35 그래픽스 0 graphics@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr) 기술개발센터(tdc@baroncs.co.kr)
36 Modeler 0 modeler@baroncs.co.kr Y N Y Y 그래픽스(graphics@baroncs.co.kr)
37 HmEG 0 hmeg@baroncs.co.kr Y N Y Y 그래픽스(graphics@baroncs.co.kr)
38 EG-BIM Draw 0 eg-bim-draw@baroncs.co.kr Y N Y Y 그래픽스(graphics@baroncs.co.kr)
39 Abut&시공통합관제 0 abut-control@baroncs.co.kr Y N Y Y 그래픽스(graphics@baroncs.co.kr)
40 웹솔루션 0 web-solutions@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr) 기술개발센터(tdc@baroncs.co.kr)
41 솔루션개발 0 solution-dev@baroncs.co.kr Y N Y Y 웹솔루션(web-solutions@baroncs.co.kr)
42 ERP 0 erp@baroncs.co.kr Y N Y Y 웹솔루션(web-solutions@baroncs.co.kr)
43 웹디자인 0 web-design@baroncs.co.kr Y N Y Y 웹솔루션(web-solutions@baroncs.co.kr)
44 GSIM개발 0 gsim-dev@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr) 기술개발센터(tdc@baroncs.co.kr)
45 bCMf 0 bcmf@baroncs.co.kr Y N Y Y GSIM개발(gsim-dev@baroncs.co.kr)
46 GSIM 0 gsim@baroncs.co.kr Y N Y Y GSIM개발(gsim-dev@baroncs.co.kr)
47 PM 0 project-management@baroncs.co.kr Y N Y Y GSIM개발(gsim-dev@baroncs.co.kr)
48 수자원 0 water-resources@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr) 기술개발센터(tdc@baroncs.co.kr)
49 스마트건설 0 smart-construction@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr) 기술개발센터(tdc@baroncs.co.kr)
50 시공BIM 0 construction-bim@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr) 기술개발센터(tdc@baroncs.co.kr)

View File

@@ -0,0 +1,137 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import { ParentTenantSelector } 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: "",
},
];
describe("ParentTenantSelector picker", () => {
it("opens an org-chart picker modal and applies tenant selection messages", async () => {
const onChange = vi.fn();
render(
<ParentTenantSelector
id="parentId"
label="상위 테넌트"
value=""
onChange={onChange}
tenants={tenants}
noneLabel="없음"
/>,
);
fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
const pickerSrc = screen.getByTitle("테넌트 선택").getAttribute("src");
expect(pickerSrc).toContain("/login");
expect(decodeURIComponent(pickerSrc ?? "")).toContain("/embed/picker");
fireEvent(
window,
new MessageEvent("message", {
data: {
type: "orgfront:picker:confirm",
payload: {
selections: [
{
type: "tenant",
id: "company-1",
name: "Saman Engineering",
},
],
},
},
}),
);
await waitFor(() => expect(onChange).toHaveBeenCalledWith("company-1"));
});
it("keeps the current tenant out of picker message selections", async () => {
const onChange = vi.fn();
render(
<ParentTenantSelector
id="parentId"
label="상위 테넌트"
value=""
onChange={onChange}
tenants={tenants}
noneLabel="없음"
excludeTenantId="company-1"
/>,
);
fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ }));
fireEvent(
window,
new MessageEvent("message", {
data: {
type: "orgfront:picker:confirm",
payload: {
selections: [
{
type: "tenant",
id: "company-1",
name: "Saman Engineering",
},
],
},
},
}),
);
await waitFor(() => expect(onChange).not.toHaveBeenCalled());
});
it("selects a non-hanmac parent from the local tenant picker", async () => {
const onChange = vi.fn();
render(
<ParentTenantSelector
id="parentId"
label="상위 테넌트"
value=""
onChange={onChange}
tenants={tenants}
noneLabel="없음"
orgChartPickerLabel="한맥가족에서 선택"
localPickerLabel="다른 테넌트 선택"
localTenantFilter={(tenant) => tenant.slug !== "hanmac-family"}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "다른 테넌트 선택" }));
fireEvent.change(
screen.getByPlaceholderText("테넌트 이름 또는 슬러그 검색"),
{ target: { value: "saman" } },
);
fireEvent.click(screen.getByRole("button", { name: /Saman Engineering/ }));
expect(onChange).toHaveBeenCalledWith("company-1");
});
});

View File

@@ -1,9 +1,21 @@
import { Search } from "lucide-react";
import { useMemo, useState } from "react";
import { Input } from "../../../components/ui/input";
import { Building2, X } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { Button } from "../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { Label } from "../../../components/ui/label";
import type { TenantSummary } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
parseOrgChartTenantSelection,
} from "../../users/orgChartPicker";
type ParentTenantSelectorProps = {
id: string;
@@ -14,6 +26,11 @@ type ParentTenantSelectorProps = {
noneLabel: string;
helpText?: string;
excludeTenantId?: string;
labelAction?: ReactNode;
contextLabel?: string;
orgChartPickerLabel?: string;
localPickerLabel?: string;
localTenantFilter?: (tenant: TenantSummary) => boolean;
};
const companyParentTypes = new Set(["COMPANY", "COMPANY_GROUP"]);
@@ -45,70 +62,187 @@ export function ParentTenantSelector({
noneLabel,
helpText,
excludeTenantId,
labelAction,
contextLabel,
orgChartPickerLabel,
localPickerLabel,
localTenantFilter,
}: ParentTenantSelectorProps) {
const [search, setSearch] = useState("");
const [companyOnly, setCompanyOnly] = useState(false);
const filteredTenants = useMemo(
() => filterParentTenants(tenants, search, companyOnly, excludeTenantId),
[tenants, search, companyOnly, excludeTenantId],
);
const [pickerOpen, setPickerOpen] = useState(false);
const [localPickerOpen, setLocalPickerOpen] = useState(false);
const [localSearch, setLocalSearch] = useState("");
const selectedTenant = tenants.find((tenant) => tenant.id === value);
const optionTenants =
selectedTenant &&
!filteredTenants.some((tenant) => tenant.id === selectedTenant.id)
? [selectedTenant, ...filteredTenants]
: filteredTenants;
const localCandidates = filterParentTenants(
localTenantFilter ? tenants.filter(localTenantFilter) : tenants,
localSearch,
false,
excludeTenantId,
);
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
import.meta.env.ORGFRONT_URL,
);
useEffect(() => {
if (!pickerOpen) return;
const onMessage = (event: MessageEvent) => {
const selection = parseOrgChartTenantSelection(event.data);
if (!selection) return;
if (excludeTenantId && selection.id === excludeTenantId) return;
onChange(selection.id);
setPickerOpen(false);
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [excludeTenantId, onChange, pickerOpen]);
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 className="flex min-h-8 flex-wrap items-center justify-between gap-2">
<Label className="text-sm font-semibold">
{label}
</Label>
{labelAction}
</div>
<select
<input
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"
type="hidden"
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>
readOnly
/>
<div className="flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setPickerOpen(true)}
>
<Building2 className="h-4 w-4" />
{orgChartPickerLabel ??
selectedTenant?.name ??
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</Button>
{localPickerLabel && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setLocalPickerOpen(true)}
>
<Building2 className="h-4 w-4" />
{localPickerLabel}
</Button>
)}
{selectedTenant ? (
<>
<span className="text-xs text-muted-foreground">
{selectedTenant.slug} · {selectedTenant.type}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onChange("")}
aria-label={noneLabel}
>
<X className="h-4 w-4" />
</Button>
</>
) : (
<span className="text-xs text-muted-foreground">{noneLabel}</span>
)}
{contextLabel && (
<span className="rounded-md border px-2 py-1 text-xs font-medium text-muted-foreground">
{contextLabel}
</span>
)}
</div>
{helpText && (
<p className="mt-1 text-xs text-muted-foreground">{helpText}</p>
)}
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="max-w-[460px] p-4">
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.parent.picker_description",
"org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다.",
)}
</DialogDescription>
</DialogHeader>
<iframe
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
src={pickerUrl}
className="h-[600px] w-full rounded-md border"
/>
</DialogContent>
</Dialog>
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
<DialogContent className="max-w-[460px] p-4">
<DialogHeader>
<DialogTitle>
{localPickerLabel ??
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.parent.local_picker_description",
"테넌트 목록에서 상위 테넌트로 사용할 항목을 선택합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<input
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={localSearch}
onChange={(event) => setLocalSearch(event.target.value)}
placeholder={t(
"ui.admin.tenants.parent.local_search_placeholder",
"테넌트 이름 또는 슬러그 검색",
)}
/>
<div className="max-h-[360px] space-y-2 overflow-y-auto">
{localCandidates.map((tenant) => (
<Button
key={tenant.id}
type="button"
variant="outline"
className="h-auto w-full justify-start px-3 py-2 text-left"
onClick={() => {
onChange(tenant.id);
setLocalPickerOpen(false);
setLocalSearch("");
}}
>
<span>
<span className="block text-sm font-medium">
{tenant.name}
</span>
<span className="block text-xs text-muted-foreground">
{tenant.slug} · {tenant.type}
</span>
</span>
</Button>
))}
{localCandidates.length === 0 && (
<p className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
{t(
"msg.admin.tenants.parent.local_picker_empty",
"선택할 수 있는 테넌트가 없습니다.",
)}
</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Building2, Sparkles } from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
@@ -22,6 +22,13 @@ import {
type ServerDomainConflict,
formatDomainConflictMessage,
} from "../utils/domainTags";
import {
ORG_UNIT_TYPE_OPTIONS,
TENANT_VISIBILITY_OPTIONS,
type TenantVisibility,
mergeTenantOrgConfig,
shouldAllowHanmacOrgConfig,
} from "../utils/orgConfig";
function TenantCreatePage() {
const navigate = useNavigate();
@@ -29,6 +36,9 @@ function TenantCreatePage() {
const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState("");
const [parentId, setParentId] = useState("");
const [parentStepConfirmed, setParentStepConfirmed] = useState(false);
const [orgUnitType, setOrgUnitType] = useState("");
const [visibility, setVisibility] = useState<TenantVisibility>("public");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
const [domains, setDomains] = useState<string[]>([]);
@@ -40,6 +50,31 @@ function TenantCreatePage() {
queryKey: ["tenants", { limit: 1000 }],
queryFn: () => fetchTenants(1000, 0),
});
const tenants = parentQuery.data?.items ?? [];
const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId);
const canConfigureHanmacOrg = useMemo(() => {
if (!selectedParentTenant) return false;
if (selectedParentTenant.slug.toLowerCase() === "hanmac-family") {
return true;
}
return shouldAllowHanmacOrgConfig(selectedParentTenant, tenants);
}, [selectedParentTenant, tenants]);
const canEditTenantDetails =
parentStepConfirmed || Boolean(selectedParentTenant);
const parentContextLabel = selectedParentTenant
? canConfigureHanmacOrg
? t("ui.admin.tenants.create.parent_context.hanmac", "한맥가족 하위 테넌트")
: t("ui.admin.tenants.create.parent_context.general", "일반 하위 테넌트")
: parentStepConfirmed
? t("ui.admin.tenants.create.parent_context.root", "최상위 테넌트")
: t(
"ui.admin.tenants.create.parent_context.pick_required",
"상위 테넌트 선택 필요",
);
const handleParentChange = (nextParentId: string) => {
setParentId(nextParentId);
setParentStepConfirmed(false);
};
const mutation = useMutation({
mutationFn: (overrideForceDomains?: string[]) =>
@@ -51,6 +86,9 @@ function TenantCreatePage() {
description: description || undefined,
status,
domains,
config: canConfigureHanmacOrg
? mergeTenantOrgConfig(undefined, { orgUnitType, visibility })
: undefined,
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
}),
onSuccess: () => {
@@ -115,152 +153,266 @@ function TenantCreatePage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tenant-name" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input
id="tenant-name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t(
"ui.admin.tenants.create.form.name_placeholder",
"테넌트 이름을 입력하세요",
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tenant-type" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.type", "테넌트 유형")}
</Label>
<select
id="tenant-type"
name="type"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="ORGANIZATION">
{t(
"domain.tenant_type.organization",
"ORGANIZATION (정규 조직)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</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">
{t("ui.admin.tenants.create.form.slug", "슬러그 (Slug)")}
</Label>
<Input
id="tenant-slug"
name="slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder={t(
"ui.admin.tenants.create.form.slug_placeholder",
"tenant-slug",
)}
/>
</div>
<div className="space-y-2">
<Label
htmlFor="tenant-description"
className="text-sm font-semibold"
<div
data-testid="tenant-parent-org-config-layout"
className="grid gap-4 md:grid-cols-4"
>
<div
data-testid="tenant-parent-picker-slot"
className={
canConfigureHanmacOrg ? "md:col-span-2" : "md:col-span-4"
}
>
{t("ui.admin.tenants.create.form.description", "설명")}
</Label>
<Textarea
id="tenant-description"
name="description"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-domains" className="text-sm font-semibold">
{t(
"ui.admin.tenants.create.form.domains_label",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<DomainTagInput
id="tenant-domains"
value={domains}
onChange={setDomains}
tenants={parentQuery.data?.items ?? []}
confirmedConflicts={forceDomainConflicts}
onConfirmedConflictsChange={setForceDomainConflicts}
placeholder={t(
"ui.admin.tenants.create.form.domains_placeholder",
"example.com, example.kr",
)}
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.tenants.create.form.domains_help",
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
)}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.status", "상태")}
</Label>
<div className="flex gap-3">
<Button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
<ParentTenantSelector
id="parentId"
label={t(
"ui.admin.tenants.create.form.parent",
"상위 테넌트 (선택)",
)}
value={parentId}
onChange={handleParentChange}
tenants={tenants}
noneLabel={t("ui.common.none", "없음")}
contextLabel={parentContextLabel}
orgChartPickerLabel={t(
"ui.admin.tenants.create.form.pick_hanmac_parent",
"한맥가족에서 선택",
)}
localPickerLabel={t(
"ui.admin.tenants.create.form.pick_other_parent",
"다른 테넌트 선택",
)}
localTenantFilter={(tenant) =>
tenant.slug.toLowerCase() !== "hanmac-family" &&
!shouldAllowHanmacOrgConfig(tenant, tenants)
}
labelAction={
!selectedParentTenant ? (
<Button
type="button"
variant={parentStepConfirmed ? "default" : "outline"}
size="sm"
onClick={() => setParentStepConfirmed(true)}
>
{t(
"ui.admin.tenants.create.form.root_tenant",
"최상위 테넌트로 생성",
)}
</Button>
) : null
}
/>
</div>
{canConfigureHanmacOrg && (
<>
<div
data-testid="tenant-org-unit-type-slot"
className="space-y-2"
>
<Label
htmlFor="tenant-org-unit-type"
className="text-sm font-semibold"
>
{t(
"ui.admin.tenants.profile.org_unit_type",
"조직 세부타입",
)}
</Label>
<select
id="tenant-org-unit-type"
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
data-testid="tenant-visibility-slot"
className="space-y-2"
>
<Label
htmlFor="tenant-visibility"
className="text-sm font-semibold"
>
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
</Label>
<select
id="tenant-visibility"
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={visibility}
onChange={(event) =>
setVisibility(event.target.value as TenantVisibility)
}
>
{TENANT_VISIBILITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</>
)}
</div>
{canEditTenantDetails && (
<>
<div className="space-y-2">
<Label htmlFor="tenant-name" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input
id="tenant-name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t(
"ui.admin.tenants.create.form.name_placeholder",
"테넌트 이름을 입력하세요",
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label
htmlFor="tenant-type"
className="text-sm font-semibold"
>
{t("ui.admin.tenants.create.form.type", "테넌트 유형")}
</Label>
<select
id="tenant-type"
name="type"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="ORGANIZATION">
{t(
"domain.tenant_type.organization",
"ORGANIZATION (정규 조직)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-slug" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.slug", "슬러그 (Slug)")}
</Label>
<Input
id="tenant-slug"
name="slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder={t(
"ui.admin.tenants.create.form.slug_placeholder",
"tenant-slug",
)}
/>
</div>
<div className="space-y-2">
<Label
htmlFor="tenant-description"
className="text-sm font-semibold"
>
{t("ui.admin.tenants.create.form.description", "설명")}
</Label>
<Textarea
id="tenant-description"
name="description"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label
htmlFor="tenant-domains"
className="text-sm font-semibold"
>
{t(
"ui.admin.tenants.create.form.domains_label",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<DomainTagInput
id="tenant-domains"
value={domains}
onChange={setDomains}
tenants={tenants}
confirmedConflicts={forceDomainConflicts}
onConfirmedConflictsChange={setForceDomainConflicts}
placeholder={t(
"ui.admin.tenants.create.form.domains_placeholder",
"example.com, example.kr",
)}
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.tenants.create.form.domains_help",
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
)}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.status", "상태")}
</Label>
<div className="flex gap-3">
<Button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
</div>
</div>
</>
)}
{!canEditTenantDetails && (
<div className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
{t(
"msg.admin.tenants.create.pick_parent_first",
"상위 테넌트를 먼저 선택하세요.",
)}
</div>
)}
{errorMsg && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">

View File

@@ -1,6 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import { Copy } from "lucide-react";
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
@@ -40,10 +42,39 @@ function TenantDetailPage() {
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{tenantQuery.data?.name ??
t("ui.admin.tenants.detail.loading", "불러오는 중...")}
</h2>
<div
className="flex flex-wrap items-center gap-3"
data-testid="tenant-detail-title-row"
>
<h2 className="text-3xl font-semibold">
{tenantQuery.data?.name ??
t("ui.admin.tenants.detail.loading", "불러오는 중...")}
</h2>
{tenantQuery.data?.id && (
<div
className="flex items-center gap-1.5"
data-testid="tenant-detail-uuid"
>
<code className="select-all rounded-md border border-border bg-muted/40 px-2 py-1 font-mono text-xs text-foreground">
{tenantQuery.data.id}
</code>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
void navigator.clipboard?.writeText(tenantQuery.data.id);
}}
aria-label="테넌트 UUID 복사"
title="테넌트 UUID 복사"
data-testid="tenant-detail-copy-uuid"
>
<Copy className="h-4 w-4" />
</Button>
</div>
)}
</div>
<p className="text-sm text-[var(--color-muted)]">
{t(
"ui.admin.tenants.detail.header_subtitle",

View File

@@ -141,6 +141,14 @@ function resolveDefaultImportParentRef(
tenants: TenantSummary[],
) {
if (preview.row.parentTenantId) {
const parentPreview = previewRows.find(
(candidate) =>
candidate.row.rowNumber !== preview.row.rowNumber &&
candidate.row.tenantId === preview.row.parentTenantId,
);
if (parentPreview) {
return previewParentRef(parentPreview.row.rowNumber);
}
return tenantParentRef(preview.row.parentTenantId);
}
if (!preview.row.parentTenantSlug) {

View File

@@ -288,22 +288,82 @@ export function TenantProfilePage() {
</option>
</select>
</div>
<ParentTenantSelector
id="parentId"
label={t(
"ui.admin.tenants.profile.form.parent",
"상위 테넌트 (선택)",
<div
data-testid="tenant-parent-org-config-layout"
className="grid gap-4 md:grid-cols-4"
>
<div
data-testid="tenant-parent-picker-slot"
className={canEditOrgConfig ? "md:col-span-2" : "md:col-span-4"}
>
<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>
{canEditOrgConfig && (
<>
<div
data-testid="tenant-org-unit-type-slot"
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
data-testid="tenant-visibility-slot"
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>
</>
)}
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>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
@@ -365,45 +425,6 @@ 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

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
ORG_UNIT_TYPE_OPTIONS,
mergeTenantOrgConfig,
readTenantOrgConfig,
shouldAllowHanmacOrgConfig,
@@ -49,6 +50,9 @@ describe("tenant org config", () => {
expect(
readTenantOrgConfig({ visibility: "private", orgUnitType: "팀" }),
).toEqual({ orgUnitType: "팀", visibility: "private" });
expect(
readTenantOrgConfig({ visibility: "internal", orgUnitType: "센터" }),
).toEqual({ orgUnitType: "센터", visibility: "internal" });
expect(
mergeTenantOrgConfig(
@@ -57,4 +61,17 @@ describe("tenant org config", () => {
),
).toEqual({ userSchema: [], visibility: "internal" });
});
it("includes task-force and executive-direct org unit types", () => {
expect(ORG_UNIT_TYPE_OPTIONS).toEqual(
expect.arrayContaining(["TF", "TF팀", "임원직속"]),
);
expect(readTenantOrgConfig({ orgUnitType: "TF" }).orgUnitType).toBe("TF");
expect(readTenantOrgConfig({ orgUnitType: "TF팀" }).orgUnitType).toBe(
"TF팀",
);
expect(readTenantOrgConfig({ orgUnitType: "임원직속" }).orgUnitType).toBe(
"임원직속",
);
});
});

View File

@@ -3,11 +3,15 @@ import type { TenantSummary } from "../../../lib/adminApi";
export const ORG_UNIT_TYPE_OPTIONS = [
"실",
"팀",
"TF",
"TF팀",
"센터",
"디비전",
"셀",
"본부",
"지역본부",
"부",
"임원직속",
] as const;
export const TENANT_VISIBILITY_OPTIONS = [

View File

@@ -206,7 +206,10 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
const resolveUserImportTenants = async () => {
const tenants = tenantQuery.data?.items ?? [];
const tenantSlugByKey = new Map<string, string>();
const tenantByKey = new Map<
string,
{ id: string; slug: string; emailDomain: string }
>();
for (const preview of tenantPreviewRows) {
const key = tenantImportKeyFromRow(preview.row);
@@ -215,7 +218,11 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
if (selected !== "__create__") {
const tenant = tenants.find((item) => item.id === selected);
if (tenant) {
tenantSlugByKey.set(key, tenant.slug);
tenantByKey.set(key, {
id: tenant.id,
slug: tenant.slug,
emailDomain: preview.row.emailDomain,
});
}
continue;
}
@@ -231,27 +238,33 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
domains: splitTenantImportDomains(preview.row.emailDomain),
status: "active",
});
tenantSlugByKey.set(key, created.slug);
tenantByKey.set(key, {
id: created.id,
slug: created.slug,
emailDomain: preview.row.emailDomain,
});
}
return previewData.map((user, index) => {
const key = tenantImportKeyFromUser(user);
const tenantSlug = key ? tenantSlugByKey.get(key) : user.tenantSlug;
const resolvedTenant = key ? tenantByKey.get(key) : undefined;
const emailPreview = hanmacEmailPreviews[index];
const { tenantImport: _tenantImport, ...payload } = user;
return {
...payload,
email: emailPreview?.finalEmail ?? payload.email,
tenantSlug,
tenantId: resolvedTenant?.id ?? payload.tenantId,
tenantSlug: resolvedTenant?.slug ?? payload.tenantSlug,
emailDomain: resolvedTenant?.emailDomain ?? payload.emailDomain,
};
});
};
const downloadTemplate = () => {
const headers =
"email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id";
"email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
const example =
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001";
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
const blob = new Blob([`${headers}\n${example}`], {
type: "text/csv;charset=utf-8;",
});

View File

@@ -82,7 +82,9 @@ test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-sl
const result = parseUserCSV(csv);
expect(result[0]).toMatchObject({
tenantId: "local-tenant-id",
tenantSlug: "missing-slug",
emailDomain: "missing.example.com",
tenantImport: {
sourceTenantId: "local-tenant-id",
slug: "missing-slug",
@@ -94,4 +96,36 @@ test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-sl
},
});
});
it("should parse one nullable additional appointment from numbered columns", () => {
const csv = `email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1
dual@test.com,Dual User,010-0000-0000,user,primary-tenant,개발팀,책임,팀장,Backend,EMP001,second-tenant,센터,수석,,Architecture,EMP002
nullable@test.com,Nullable User,010-1111-1111,user,primary-tenant,개발팀,책임,팀장,Backend,EMP003,,,,,,`;
const result = parseUserCSV(csv);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
tenantSlug: "primary-tenant",
department: "개발팀",
grade: "책임",
position: "팀장",
jobTitle: "Backend",
metadata: {
employee_id: "EMP001",
},
additionalAppointments: [
{
tenantSlug: "second-tenant",
department: "센터",
grade: "수석",
jobTitle: "Architecture",
metadata: {
employee_id: "EMP002",
},
},
],
});
expect(result[1].additionalAppointments).toBeUndefined();
});
});

View File

@@ -1,4 +1,4 @@
import type { BulkUserItem } from "../../../lib/adminApi";
import type { BulkUserAppointment, BulkUserItem } from "../../../lib/adminApi";
export function parseUserCSV(text: string): BulkUserItem[] {
const records = parseCSVRecords(text.replace(/^\uFEFF/, ""));
@@ -15,6 +15,11 @@ export function parseUserCSV(text: string): BulkUserItem[] {
const item: Partial<BulkUserItem> & { metadata: Record<string, string> } = {
metadata: {},
};
const additionalAppointment: BulkUserAppointment & {
metadata: Record<string, string>;
} = {
metadata: {},
};
for (let index = 0; index < headers.length; index++) {
const header = headers[index];
@@ -38,6 +43,7 @@ export function parseUserCSV(text: string): BulkUserItem[] {
slug: value,
};
} else if (header === "tenant_id") {
item.tenantId = value;
item.tenantImport = {
...(item.tenantImport ?? {}),
sourceTenantId: value,
@@ -73,6 +79,7 @@ export function parseUserCSV(text: string): BulkUserItem[] {
memo: value,
};
} else if (header === "email_domain" || header === "tenant_domain") {
item.emailDomain = value;
item.tenantImport = {
...(item.tenantImport ?? {}),
emailDomain: value,
@@ -85,6 +92,20 @@ export function parseUserCSV(text: string): BulkUserItem[] {
item.position = value;
} else if (header === "jobtitle") {
item.jobTitle = value;
} else if (header === "employee_id") {
item.metadata.employee_id = value;
} else if (header === "tenant_slug1") {
additionalAppointment.tenantSlug = value;
} else if (header === "department1") {
additionalAppointment.department = value;
} else if (header === "grade1") {
additionalAppointment.grade = value;
} else if (header === "position1") {
additionalAppointment.position = value;
} else if (header === "jobtitle1") {
additionalAppointment.jobTitle = value;
} else if (header === "employee_id1") {
additionalAppointment.metadata.employee_id = value;
} else if (header === "lastname") {
item.metadata.naverworks_last_name = value;
} else if (header === "firstname") {
@@ -149,6 +170,11 @@ export function parseUserCSV(text: string): BulkUserItem[] {
}
applyNaverWorksFallbacks(item);
if (additionalAppointment.tenantSlug) {
item.additionalAppointments = [
cleanAdditionalAppointment(additionalAppointment),
];
}
if (item.email && item.name) {
data.push(item as BulkUserItem);
@@ -158,6 +184,31 @@ export function parseUserCSV(text: string): BulkUserItem[] {
return data;
}
function cleanAdditionalAppointment(
appointment: BulkUserAppointment & { metadata: Record<string, string> },
) {
const metadata =
Object.keys(appointment.metadata).length > 0
? appointment.metadata
: undefined;
return {
...(appointment.tenantId ? { tenantId: appointment.tenantId } : {}),
...(appointment.tenantSlug ? { tenantSlug: appointment.tenantSlug } : {}),
...(appointment.tenantName ? { tenantName: appointment.tenantName } : {}),
...(appointment.isPrimary !== undefined
? { isPrimary: appointment.isPrimary }
: {}),
...(appointment.isOwner !== undefined
? { isOwner: appointment.isOwner }
: {}),
...(appointment.department ? { department: appointment.department } : {}),
...(appointment.grade ? { grade: appointment.grade } : {}),
...(appointment.position ? { position: appointment.position } : {}),
...(appointment.jobTitle ? { jobTitle: appointment.jobTitle } : {}),
...(metadata ? { metadata } : {}),
};
}
function normalizeHeader(header: string) {
return header
.trim()

View File

@@ -546,17 +546,33 @@ export type UserAppointment = {
position?: string;
};
export type BulkUserAppointment = {
tenantId?: string;
tenantSlug?: string;
tenantName?: string;
isPrimary?: boolean;
isOwner?: boolean;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
metadata?: Record<string, unknown>;
};
export type BulkUserItem = {
email: string;
loginId?: string;
name: string;
phone?: string;
role?: string;
tenantId?: string;
tenantSlug?: string;
emailDomain?: string;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
additionalAppointments?: BulkUserAppointment[];
tenantImport?: {
sourceTenantId?: string;
slug?: string;

View File

@@ -110,6 +110,9 @@ test.describe("Tenants Management", () => {
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
timeout: 20000,
});
await page
.getByRole("button", { name: "최상위 테넌트로 생성" })
.click();
const nameInput = page.locator('input[name="name"]').first();
await nameInput.fill("New Tenant");
@@ -119,14 +122,221 @@ test.describe("Tenants Management", () => {
await page.locator("textarea").first().fill("Description");
const submitBtn = page
.locator("button")
.filter({ hasText: /생성|Create/i })
.first();
const submitBtn = page.getByRole("button", { name: /^생성$/ });
await submitBtn.click();
await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 });
});
test("should ask for parent tenant before tenant details", async ({
page,
}) => {
const tenants = [
{
id: "family-1",
name: "한맥가족",
slug: "hanmac-family",
status: "active",
type: "COMPANY_GROUP",
memberCount: 0,
parentId: null,
},
{
id: "company-1",
name: "삼안",
slug: "saman",
status: "active",
type: "COMPANY",
memberCount: 0,
parentId: "family-1",
},
{
id: "outside-1",
name: "외부회사",
slug: "outside",
status: "active",
type: "COMPANY",
memberCount: 0,
parentId: null,
},
];
await page.route("**/api/v1/admin/tenants**", async (route) => {
const headers = { "Access-Control-Allow-Origin": "*" };
return route.fulfill({
json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 },
headers,
});
});
await page.goto("/tenants/new");
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
timeout: 20000,
});
await expect(
page.getByRole("button", { name: "한맥가족에서 선택" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "다른 테넌트 선택" }),
).toBeVisible();
const parentLabelTop = await page
.getByText(/상위 테넌트/)
.first()
.evaluate((element) => element.getBoundingClientRect().top);
const rootButtonTop = await page
.getByRole("button", { name: "최상위 테넌트로 생성" })
.evaluate((element) => element.getBoundingClientRect().top);
expect(Math.abs(parentLabelTop - rootButtonTop)).toBeLessThan(10);
await expect(page.locator('input[name="name"]')).toHaveCount(0);
await page.getByRole("button", { name: "다른 테넌트 선택" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.getByPlaceholder("테넌트 이름 또는 슬러그 검색").fill("outside");
await page.getByRole("button", { name: /외부회사/ }).click();
await expect(
page
.getByTestId("tenant-parent-picker-slot")
.getByText("outside · COMPANY"),
).toBeVisible();
await expect(page.getByText("일반 하위 테넌트")).toBeVisible();
await expect(page.locator('input[name="name"]')).toBeVisible();
await expect(page.getByLabel("조직 세부타입")).toHaveCount(0);
await expect(page.getByLabel("공개 범위")).toHaveCount(0);
await page
.getByTestId("tenant-parent-picker-slot")
.getByRole("button", { name: "한맥가족에서 선택" })
.click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.evaluate(() => {
window.postMessage(
{
type: "orgfront:picker:confirm",
payload: {
selections: [
{ type: "tenant", id: "family-1", name: "한맥가족" },
],
},
},
window.location.origin,
);
});
await expect(page.getByText("hanmac-family · COMPANY_GROUP")).toBeVisible();
await expect(page.getByText("한맥가족 하위 테넌트")).toBeVisible();
await expect(page.locator('input[name="name"]')).toBeVisible();
await expect(page.getByLabel("조직 세부타입")).toBeVisible();
await expect(page.getByLabel("공개 범위")).toBeVisible();
});
test("should create a hanmac-family child tenant with org config", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 800 });
let createBody = "";
const tenants = [
{
id: "family-1",
name: "한맥가족",
slug: "hanmac-family",
status: "active",
type: "COMPANY_GROUP",
memberCount: 0,
parentId: null,
},
{
id: "company-1",
name: "삼안",
slug: "saman",
status: "active",
type: "COMPANY",
memberCount: 0,
parentId: "family-1",
},
];
await page.route("**/api/v1/admin/tenants**", async (route) => {
const method = route.request().method();
const headers = { "Access-Control-Allow-Origin": "*" };
if (method === "GET") {
return route.fulfill({
json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 },
headers,
});
}
if (method === "POST") {
createBody = route.request().postData() ?? "";
return route.fulfill({
json: { id: "created-tenant-id", name: "신규 센터" },
headers,
});
}
return route.fulfill({ json: {}, headers });
});
await page.goto("/tenants/new");
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
timeout: 20000,
});
await page.getByRole("button", { name: "한맥가족에서 선택" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.evaluate(() => {
window.postMessage(
{
type: "orgfront:picker:confirm",
payload: {
selections: [
{ type: "tenant", id: "family-1", name: "한맥가족" },
],
},
},
window.location.origin,
);
});
await expect(page.getByText("hanmac-family · COMPANY_GROUP")).toBeVisible();
await expect(page.getByLabel("조직 세부타입")).toBeVisible();
await expect(page.getByLabel("공개 범위")).toBeVisible();
const layout = page.getByTestId("tenant-parent-org-config-layout");
const parentWidth = await page
.getByTestId("tenant-parent-picker-slot")
.evaluate((element) => element.getBoundingClientRect().width);
const orgUnitWidth = await page
.getByTestId("tenant-org-unit-type-slot")
.evaluate((element) => element.getBoundingClientRect().width);
const visibilityWidth = await page
.getByTestId("tenant-visibility-slot")
.evaluate((element) => element.getBoundingClientRect().width);
const columns = await layout.evaluate((element) =>
window.getComputedStyle(element).gridTemplateColumns,
);
expect(columns.split(" ").length).toBe(4);
expect(parentWidth).toBeGreaterThan(orgUnitWidth * 1.7);
expect(parentWidth).toBeLessThan(orgUnitWidth * 2.3);
expect(Math.abs(orgUnitWidth - visibilityWidth)).toBeLessThan(8);
await page.locator('input[name="name"]').first().fill("신규 센터");
await page.locator('input[name="slug"]').first().fill("new-center");
await page.getByLabel("조직 세부타입").selectOption("센터");
await page.getByLabel("공개 범위").selectOption("internal");
await page
.locator("button")
.filter({ hasText: /생성|Create/i })
.first()
.click();
await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 });
expect(JSON.parse(createBody)).toMatchObject({
parentId: "family-1",
config: {
orgUnitType: "센터",
visibility: "internal",
},
});
});
test("should export and import tenant CSV without organization/user combined import", async ({
page,
browserName,
@@ -320,11 +530,11 @@ test.describe("Tenants Management", () => {
expect(importBody).not.toContain("local-parent-id");
expect(importBody).not.toContain("local-child-id");
const parentMatch = importBody.match(
/([0-9a-f-]{36}),Parent Tenant,COMPANY,,parent-created/,
/([0-9a-f-]{36}),Parent Tenant,COMPANY,,,parent-created/,
);
expect(parentMatch?.[1]).toBeTruthy();
expect(importBody).toContain(
`,Child Tenant,USER_GROUP,${parentMatch?.[1]},child-created`,
`,Child Tenant,USER_GROUP,${parentMatch?.[1]},parent-created,child-created`,
);
});
@@ -333,11 +543,11 @@ test.describe("Tenants Management", () => {
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
timeout: 20000,
});
await page
.getByRole("button", { name: "최상위 테넌트로 생성" })
.click();
const submitBtn = page
.locator("button")
.filter({ hasText: /생성|Create/i })
.first();
const submitBtn = page.getByRole("button", { name: /^생성$/ });
await expect(submitBtn).toBeDisabled();
await page.locator('input[name="name"]').first().fill("Valid Name");
@@ -408,4 +618,120 @@ test.describe("Tenants Management", () => {
.first(),
).toBeVisible();
});
test("should show tenant UUID at the top of tenant detail profile", async ({
page,
}) => {
const tenantUuid = "11111111-2222-4333-8444-555555555555";
const tenant = {
id: tenantUuid,
name: "Tenant With UUID",
slug: "tenant-with-uuid",
status: "active",
type: "COMPANY",
memberCount: 0,
parentId: null,
config: {},
domains: [],
updatedAt: new Date().toISOString(),
};
await page.route("**/api/v1/admin/tenants**", async (route) => {
const url = route.request().url();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes(`/admin/tenants/${tenantUuid}`)) {
return route.fulfill({ json: tenant, headers });
}
return route.fulfill({
json: { items: [tenant], total: 1, limit: 1000, offset: 0 },
headers,
});
});
await page.goto(`/tenants/${tenantUuid}`);
const titleRow = page.getByTestId("tenant-detail-title-row");
await expect(titleRow).toBeVisible({ timeout: 20000 });
await expect(titleRow).toContainText("Tenant With UUID");
await expect(titleRow).toContainText(tenantUuid);
await expect(titleRow).not.toContainText("Tenant UUID");
await expect(page.getByTestId("tenant-detail-copy-uuid")).toBeVisible();
});
test("should place hanmac org config beside parent tenant picker", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 800 });
const tenants = [
{
id: "family-1",
name: "한맥가족",
slug: "hanmac-family",
status: "active",
type: "COMPANY_GROUP",
memberCount: 0,
parentId: null,
config: {},
},
{
id: "company-1",
name: "삼안",
slug: "saman",
status: "active",
type: "COMPANY",
memberCount: 0,
parentId: "family-1",
config: {},
},
{
id: "team-1",
name: "기획팀",
slug: "planning",
status: "active",
type: "USER_GROUP",
memberCount: 0,
parentId: "company-1",
config: { orgUnitType: "팀", visibility: "internal" },
},
];
await page.route("**/api/v1/admin/tenants**", async (route) => {
const url = route.request().url();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes("/admin/tenants/team-1")) {
return route.fulfill({ json: tenants[2], headers });
}
return route.fulfill({
json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 },
headers,
});
});
await page.goto("/tenants/team-1");
const layout = page.getByTestId("tenant-parent-org-config-layout");
await expect(layout).toBeVisible({ timeout: 20000 });
await expect(layout).toContainText("상위 테넌트");
await expect(layout).toContainText("조직 세부타입");
await expect(layout).toContainText("공개 범위");
const columns = await layout.evaluate((element) =>
window.getComputedStyle(element).gridTemplateColumns,
);
expect(columns.split(" ").length).toBe(4);
const parentWidth = await page
.getByTestId("tenant-parent-picker-slot")
.evaluate((element) => element.getBoundingClientRect().width);
const orgUnitWidth = await page
.getByTestId("tenant-org-unit-type-slot")
.evaluate((element) => element.getBoundingClientRect().width);
const visibilityWidth = await page
.getByTestId("tenant-visibility-slot")
.evaluate((element) => element.getBoundingClientRect().width);
expect(parentWidth).toBeGreaterThan(orgUnitWidth * 1.7);
expect(parentWidth).toBeLessThan(orgUnitWidth * 2.3);
expect(Math.abs(orgUnitWidth - visibilityWidth)).toBeLessThan(8);
});
});

View File

@@ -119,7 +119,7 @@ test.describe("Users Bulk Upload", () => {
const requests: string[] = [];
let bulkPayload = "";
await page.route("**/api/v1/admin/tenants", async (route) => {
await page.route("**/api/v1/admin/tenants**", async (route) => {
const method = route.request().method();
requests.push(`${method} ${route.request().url()}`);
@@ -184,6 +184,124 @@ test.describe("Users Bulk Upload", () => {
await expect(page.getByText("new@test.com")).toBeVisible();
expect(requests.some((request) => request.startsWith("POST "))).toBe(true);
expect(bulkPayload).toContain('"tenantId":"staging-missing-tenant-id"');
expect(bulkPayload).toContain('"tenantSlug":"missing-slug"');
expect(bulkPayload).toContain('"emailDomain":"missing.example.com"');
});
test("should include one nullable additional appointment from numbered CSV columns", async ({
page,
}) => {
let bulkPayload = "";
await page.unroute("**/api/v1/**");
await page.route("**/api/v1/user/me", async (route) => {
return route.fulfill({
json: {
id: "admin-user",
name: "Admin",
role: "super_admin",
manageableTenants: [],
},
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.route(/\/api\/v1\/admin\/users(?:\?|$)/, async (route) => {
return route.fulfill({
json: { items: [], total: 0, limit: 50, offset: 0 },
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.route(/\/api\/v1\/admin\/tenants(?:\?|$)/, async (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
json: {
items: [
{
id: "tenant-primary-id",
name: "Primary Tenant",
slug: "primary-tenant",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "tenant-second-id",
name: "Second Tenant",
slug: "second-tenant",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 2,
limit: 1000,
offset: 0,
},
headers: { "Access-Control-Allow-Origin": "*" },
});
}
return route.fulfill({
status: 201,
json: {
id: "tenant-created-id",
name: "Primary Tenant",
slug: "primary-tenant",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.route("**/api/v1/admin/users/bulk", async (route) => {
bulkPayload = route.request().postData() ?? "";
return route.fulfill({
json: {
results: [{ email: "dual@test.com", success: true, userId: "u-1" }],
},
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.goto("/users");
await expect(page.getByTestId("page-title")).toContainText(
/사용자|Users/i,
{ timeout: 20000 },
);
await page.getByTestId("bulk-import-btn").click();
await page.locator('input[type="file"]').setInputFiles({
name: "users.csv",
mimeType: "text/csv",
buffer: Buffer.from(
[
"email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1",
"dual@test.com,Dual User,010-0000-0000,user,primary-tenant,개발팀,책임,팀장,Backend,EMP001,second-tenant,센터,수석,,Architecture,EMP002",
].join("\n"),
),
});
await page.getByTestId("bulk-start-btn").click();
await expect(page.getByText("dual@test.com")).toBeVisible();
const payload = JSON.parse(bulkPayload);
expect(payload.users[0].tenantSlug).toBe("primary-tenant");
expect(payload.users[0].metadata.employee_id).toBe("EMP001");
expect(payload.users[0].additionalAppointments).toEqual([
{
tenantSlug: "second-tenant",
department: "센터",
grade: "수석",
jobTitle: "Architecture",
metadata: {
employee_id: "EMP002",
},
},
]);
});
});