From 5e649c279f8dd0b207e521351c4e9ad67a593c2d Mon Sep 17 00:00:00 2001 From: Lectom Date: Tue, 12 May 2026 12:25:31 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20=EA=B8=B0?= =?UTF-8?q?=EC=B4=88=EA=B5=AC=EC=A1=B0=20=EB=A7=88=EB=A0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/gpdtdc_org_slugged.csv | 48 +- .../ParentTenantSelector.picker.test.tsx | 137 +++ .../components/ParentTenantSelector.tsx | 244 +++-- .../tenants/routes/TenantCreatePage.tsx | 440 ++++++--- .../tenants/routes/TenantDetailPage.tsx | 39 +- .../tenants/routes/TenantListPage.tsx | 8 + .../tenants/routes/TenantProfilePage.tsx | 129 +-- .../features/tenants/utils/orgConfig.test.ts | 17 + .../src/features/tenants/utils/orgConfig.ts | 4 + .../users/components/UserBulkUploadModal.tsx | 27 +- .../features/users/utils/csvParser.test.ts | 34 + .../src/features/users/utils/csvParser.ts | 53 +- adminfront/src/lib/adminApi.ts | 16 + adminfront/tests/tenants.spec.ts | 346 ++++++- adminfront/tests/users_bulk.spec.ts | 120 ++- backend/internal/handler/tenant_handler.go | 7 +- .../internal/handler/tenant_handler_test.go | 17 +- backend/internal/handler/user_handler.go | 265 +++++- backend/internal/handler/user_handler_test.go | 224 +++++ .../internal/service/worksmobile_client.go | 194 +++- .../service/worksmobile_client_test.go | 115 ++- .../service/worksmobile_live_flow_test.go | 872 ++++++++++++++++++ .../internal/service/worksmobile_mapper.go | 44 + .../service/worksmobile_mapper_test.go | 19 + .../service/worksmobile_relay_worker.go | 6 +- .../service/worksmobile_sync_service.go | 43 +- .../service/worksmobile_sync_service_test.go | 38 +- .../orgchart/hanmacFamilyOrder.test.ts | 54 ++ .../features/orgchart/hanmacFamilyOrder.ts | 65 ++ .../src/features/orgchart/pickerTree.test.ts | 34 + orgfront/src/features/orgchart/pickerTree.ts | 15 +- .../features/orgchart/routes/OrgChartPage.tsx | 36 +- orgfront/tests/orgchart-picker.spec.ts | 62 ++ 33 files changed, 3364 insertions(+), 408 deletions(-) create mode 100644 adminfront/src/features/tenants/components/ParentTenantSelector.picker.test.tsx create mode 100644 orgfront/src/features/orgchart/hanmacFamilyOrder.test.ts create mode 100644 orgfront/src/features/orgchart/hanmacFamilyOrder.ts diff --git a/adminfront/gpdtdc_org_slugged.csv b/adminfront/gpdtdc_org_slugged.csv index 9534a9b2..e450d0fa 100644 --- a/adminfront/gpdtdc_org_slugged.csv +++ b/adminfront/gpdtdc_org_slugged.csv @@ -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)" diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.picker.test.tsx b/adminfront/src/features/tenants/components/ParentTenantSelector.picker.test.tsx new file mode 100644 index 00000000..51197cf4 --- /dev/null +++ b/adminfront/src/features/tenants/components/ParentTenantSelector.picker.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + 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"); + }); +}); diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx index 0e352553..5a21b7ea 100644 --- a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx +++ b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx @@ -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 (
- -
- - +
+ + {labelAction}
- + readOnly + /> +
+ + {localPickerLabel && ( + + )} + {selectedTenant ? ( + <> + + {selectedTenant.slug} · {selectedTenant.type} + + + + ) : ( + {noneLabel} + )} + {contextLabel && ( + + {contextLabel} + + )} +
{helpText && (

{helpText}

)} + + + + + {t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")} + + + {t( + "msg.admin.tenants.parent.picker_description", + "org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다.", + )} + + +