From 262c988226db53cf56be6bfadd523c7176c40f71 Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 8 May 2026 14:54:48 +0900 Subject: [PATCH 1/4] =?UTF-8?q?adminfront=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=ED=95=B4=EA=B2=B0=20=EB=B0=8F=20UI/Lint?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테넌트 목록 테이블 헤더 스타일 보정(nowrap) 및 삭제 버튼 추가 - 초기 설정(Seed) 테넌트 삭제 보호 로직 적용 - 사용자 상태 변경 및 대표 조직 지정 UI를 Switch로 변경하여 테스트와 동기화 - Playwright 테스트 코드의 선택자 및 상호작용 로직 업데이트 - Biome을 통한 린트 오류 및 타입 안정성(AxiosError) 확보 - 프론트엔드 모노레포 통합 마스터 플랜 문서 추가 --- .../src/components/layout/AppLayout.tsx | 2 +- .../tenants/routes/TenantGroupsPage.tsx | 922 +++++++++--------- .../tenants/routes/TenantListPage.tsx | 35 +- .../tenants/routes/TenantUsersPage.tsx | 74 +- .../routes/TenantWorksmobilePage.test.ts | 4 +- .../tenants/utils/protectedTenants.ts | 4 +- .../src/features/users/UserCreatePage.tsx | 12 +- .../src/features/users/UserDetailPage.tsx | 8 +- .../users/components/UserBulkUploadModal.tsx | 2 +- adminfront/tests/users.spec.ts | 5 +- adminfront/tests/users_live.spec.ts | 2 +- docs/frontend-monorepo-masterplan.md | 76 ++ 12 files changed, 654 insertions(+), 492 deletions(-) create mode 100644 docs/frontend-monorepo-masterplan.md diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 78f97232..fa05a182 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -18,13 +18,13 @@ import * as React from "react"; import { useEffect, useRef, useState } from "react"; import { useAuth } from "react-oidc-context"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; +import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker"; import { fetchMe } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; import { shouldAttemptSlidingSessionRenew, shouldAttemptUnlimitedSessionRenew, } from "../../lib/sessionSliding"; -import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker"; import LanguageSelector from "../common/LanguageSelector"; import RoleSwitcher from "./RoleSwitcher"; diff --git a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx index da214dc2..de5faac4 100644 --- a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx @@ -250,7 +250,9 @@ function TenantGroupsPage() { // Modal States const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false); const [isMoveMemberModalOpen, setIsMoveMemberModalOpen] = useState(false); - const [memberActionTargetUserId, setMemberActionTargetUserId] = useState(null); + const [memberActionTargetUserId, setMemberActionTargetUserId] = useState< + string | null + >(null); const [userSearchTerm, setUserSearchTerm] = useState(""); const [groupSearchTerm, setGroupSearchTerm] = useState(""); @@ -398,496 +400,504 @@ function TenantGroupsPage() { <>
- {/* 그룹 생성 폼 */} - - - - {" "} - {t("ui.admin.groups.create.title", "새 그룹 생성")} - - - {t( - "ui.admin.groups.create.description", - "새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.", - )} - - - -
- - setNewGroupName(e.target.value)} - placeholder={t( - "ui.admin.groups.form.name_placeholder", - "예: 개발팀, 인사팀", - )} - /> -
-
- - setNewGroupUnitType(e.target.value)} - placeholder={t( - "ui.admin.groups.form.unit_level_placeholder", - "예: 본부, 팀, 셀", - )} - /> -
-
- - -
-
- - setNewGroupNameDesc(e.target.value)} - placeholder={t( - "ui.admin.groups.form.desc_placeholder", - "그룹 용도 설명", - )} - /> -
- -
-
- - {/* 그룹 목록 (트리 뷰) */} - - -
- - {t("ui.admin.groups.list.title", "User Groups")} + {/* 그룹 생성 폼 */} + + + + {" "} + {t("ui.admin.groups.create.title", "새 그룹 생성")} {t( - "msg.admin.groups.list.subtitle", - "이 테넌트에 정의된 사용자 그룹 목록입니다.", + "ui.admin.groups.create.description", + "새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.", )} -
-
- -
-
- -
-
- - - - - {t("ui.admin.groups.table.name", "NAME")} - - - {t("ui.admin.groups.table.members", "MEMBERS")} - - - {t("ui.admin.groups.table.actions", "ACTIONS")} - - - - - {groupsQuery.isLoading && ( - - - {t("msg.admin.groups.list.loading", "로딩 중...")} - - - )} - {!groupsQuery.isLoading && groupTree.length === 0 && ( - - - {t( - "msg.admin.groups.list.empty", - "아직 등록된 그룹이 없습니다.", - )} - - - )} - {groupTree.map((node) => ( - { - if ( - window.confirm( - t( - "msg.admin.groups.list.delete_confirm", - "그룹을 삭제하시겠습니까?", - ), - ) - ) { - deleteMutation.mutate(id); - } - }} - onAddSubGroup={handleAddSubGroup} - addMemberMutation={addMemberMutation} - removeMemberMutation={removeMemberMutation} - /> - ))} - -
+ + +
+ + setNewGroupName(e.target.value)} + placeholder={t( + "ui.admin.groups.form.name_placeholder", + "예: 개발팀, 인사팀", + )} + />
-
- - +
+ + setNewGroupUnitType(e.target.value)} + placeholder={t( + "ui.admin.groups.form.unit_level_placeholder", + "예: 본부, 팀, 셀", + )} + /> +
+
+ + +
+
+ + setNewGroupNameDesc(e.target.value)} + placeholder={t( + "ui.admin.groups.form.desc_placeholder", + "그룹 용도 설명", + )} + /> +
+ + + + + {/* 그룹 목록 (트리 뷰) */} + + +
+ + {t("ui.admin.groups.list.title", "User Groups")} + + + {t( + "msg.admin.groups.list.subtitle", + "이 테넌트에 정의된 사용자 그룹 목록입니다.", + )} + +
+
+ +
+
+ +
+
+ + + + + {t("ui.admin.groups.table.name", "NAME")} + + + {t("ui.admin.groups.table.members", "MEMBERS")} + + + {t("ui.admin.groups.table.actions", "ACTIONS")} + + + + + {groupsQuery.isLoading && ( + + + {t("msg.admin.groups.list.loading", "로딩 중...")} + + + )} + {!groupsQuery.isLoading && groupTree.length === 0 && ( + + + {t( + "msg.admin.groups.list.empty", + "아직 등록된 그룹이 없습니다.", + )} + + + )} + {groupTree.map((node) => ( + { + if ( + window.confirm( + t( + "msg.admin.groups.list.delete_confirm", + "그룹을 삭제하시겠습니까?", + ), + ) + ) { + deleteMutation.mutate(id); + } + }} + onAddSubGroup={handleAddSubGroup} + addMemberMutation={addMemberMutation} + removeMemberMutation={removeMemberMutation} + /> + ))} + +
+
+
+
+
+
+ + {/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */} + {currentGroup && ( + + + + + {t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", { + name: currentGroup.name, + })} + + + {t( + "ui.admin.groups.detail.members_subtitle", + "그룹에 속한 멤버들을 확인하고 관리합니다.", + )} + + + +
+ +
+
+
+ + + + + {t("ui.admin.groups.members.table.name", "이름")} + + + {t("ui.admin.groups.members.table.email", "이메일")} + + + {t("ui.admin.groups.members.table.actions", "관리")} + + + + + {currentGroup.members?.length === 0 && ( + + + {t( + "msg.admin.groups.members.empty", + "멤버가 없습니다.", + )} + + + )} + {currentGroup.members?.map((user) => ( + + + {user.name} + + + {user.email} + + +
+ + +
+
+
+ ))} +
+
+
+
+
+
+ )}
- {/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */} - {currentGroup && ( - - - - - {t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", { - name: currentGroup.name, - })} - - - {t( - "ui.admin.groups.detail.members_subtitle", - "그룹에 속한 멤버들을 확인하고 관리합니다.", - )} - - - -
- -
-
-
- - - - - {t("ui.admin.groups.members.table.name", "이름")} - - - {t("ui.admin.groups.members.table.email", "이메일")} - - - {t("ui.admin.groups.members.table.actions", "관리")} - - - - - {currentGroup.members?.length === 0 && ( - - - {t( - "msg.admin.groups.members.empty", - "멤버가 없습니다.", - )} - - - )} - {currentGroup.members?.map((user) => ( - - - {user.name} - - - {user.email} - - -
- - -
-
-
- ))} -
-
-
-
-
-
- )} -
- - {/* Add Member Modal */} - { - setIsAddMemberModalOpen(val); - if (!val) setUserSearchTerm(""); - }} - > - - + {/* Add Member Modal */} + { + setIsAddMemberModalOpen(val); + if (!val) setUserSearchTerm(""); + }} + > + + - {t("ui.admin.groups.members.add_modal_title", "그룹에 멤버 추가")} + {t("ui.admin.groups.members.add_modal_title", "그룹에 멤버 추가")} - {t( - "msg.admin.groups.members.add_modal_desc", - "이 테넌트에 속한 사용자 중 추가할 멤버를 검색하여 선택하세요.", - )} + {t( + "msg.admin.groups.members.add_modal_desc", + "이 테넌트에 속한 사용자 중 추가할 멤버를 검색하여 선택하세요.", + )} - -
+ +
- - setUserSearchTerm(e.target.value)} - /> + + setUserSearchTerm(e.target.value)} + />
-
- {usersQuery.isLoading ? ( -
- {t("ui.common.loading", "로딩 중...")} -
- ) : ( - users - .filter((u) => { - const term = userSearchTerm.toLowerCase(); - return ( - u.name.toLowerCase().includes(term) || - u.email.toLowerCase().includes(term) - ); - }) - .filter( - (u) => - !currentGroup?.members?.some((m) => m.id === u.id), - ) // Exclude existing members - .map((user) => ( -
-
-

{user.name}

-

- {user.email} -

-
- -
- )) - )} - {users.length > 0 && - users.filter( - (u) => !currentGroup?.members?.some((m) => m.id === u.id), - ).length === 0 && ( +
+ {usersQuery.isLoading ? (
- {t("msg.admin.groups.members.all_added", "모든 테넌트 멤버가 이미 이 그룹에 속해 있습니다.")} + {t("ui.common.loading", "로딩 중...")}
+ ) : ( + users + .filter((u) => { + const term = userSearchTerm.toLowerCase(); + return ( + u.name.toLowerCase().includes(term) || + u.email.toLowerCase().includes(term) + ); + }) + .filter( + (u) => !currentGroup?.members?.some((m) => m.id === u.id), + ) // Exclude existing members + .map((user) => ( +
+
+

{user.name}

+

+ {user.email} +

+
+ +
+ )) )} -
+ {users.length > 0 && + users.filter( + (u) => !currentGroup?.members?.some((m) => m.id === u.id), + ).length === 0 && ( +
+ {t( + "msg.admin.groups.members.all_added", + "모든 테넌트 멤버가 이미 이 그룹에 속해 있습니다.", + )} +
+ )} +
-
- +
+ - -
-
+ +
+
- {/* Move Member Modal */} - { - setIsMoveMemberModalOpen(val); - if (!val) { + {/* Move Member Modal */} + { + setIsMoveMemberModalOpen(val); + if (!val) { setMemberActionTargetUserId(null); setGroupSearchTerm(""); - } - }} - > - - + } + }} + > + + - {t("ui.admin.groups.members.move_modal_title", "부서 이동")} + {t("ui.admin.groups.members.move_modal_title", "부서 이동")} - {t( - "msg.admin.groups.members.move_modal_desc", - "선택한 멤버를 이동할 대상 그룹을 선택하세요.", - )} + {t( + "msg.admin.groups.members.move_modal_desc", + "선택한 멤버를 이동할 대상 그룹을 선택하세요.", + )} - -
+ +
- - setGroupSearchTerm(e.target.value)} - /> + + setGroupSearchTerm(e.target.value)} + />
-
- {groupsQuery.isLoading ? ( -
- {t("ui.common.loading", "로딩 중...")} -
- ) : groupsQuery.data && groupsQuery.data.length > 0 ? ( - groupsQuery.data - .filter((g) => - g.name - .toLowerCase() - .includes(groupSearchTerm.toLowerCase()), - ) - .filter((g) => g.id !== currentGroup?.id) // Exclude current group - .map((group) => ( -
-
- - {group.name} -
- -
- )) - ) : ( -
- {t("msg.admin.groups.list.no_results", "그룹이 없습니다.")} -
- )} -
+
+ + {group.name} +
+ +
+ )) + ) : ( +
+ {t("msg.admin.groups.list.no_results", "그룹이 없습니다.")} +
+ )} +
- - + + - -
-
- - ); - } + + +
+ + ); +} - export default TenantGroupsPage; +export default TenantGroupsPage; diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 3f073d06..e115c713 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -633,7 +633,7 @@ function TenantListPage() { /> requestSort("id")} >
@@ -642,7 +642,7 @@ function TenantListPage() {
requestSort("name")} >
@@ -651,7 +651,7 @@ function TenantListPage() {
requestSort("type")} >
@@ -660,7 +660,7 @@ function TenantListPage() {
requestSort("slug")} >
@@ -669,7 +669,7 @@ function TenantListPage() {
requestSort("status")} >
@@ -678,7 +678,7 @@ function TenantListPage() {
requestSort("recursiveMemberCount")} >
@@ -687,7 +687,7 @@ function TenantListPage() {
requestSort("updatedAt")} >
@@ -695,6 +695,9 @@ function TenantListPage() { {getSortIcon("updatedAt")}
+ + {t("ui.common.actions", "액션")} + @@ -796,8 +799,24 @@ function TenantListPage() { ) : "-"} + + + - ))} + ))}{" "} diff --git a/adminfront/src/features/tenants/routes/TenantUsersPage.tsx b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx index e1013a06..2f91f404 100644 --- a/adminfront/src/features/tenants/routes/TenantUsersPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx @@ -1,5 +1,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Mail, MoreHorizontal, Plus, User, UserPlus, UserMinus, Loader2 } from "lucide-react"; +import type { AxiosError } from "axios"; +import { + Loader2, + Mail, + MoreHorizontal, + Plus, + User, + UserMinus, + UserPlus, +} from "lucide-react"; import { Link, useParams } from "react-router-dom"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; @@ -52,18 +61,34 @@ function TenantUsersPage() { mutationFn: ({ userId, slug }: { userId: string; slug: string }) => updateUser(userId, { tenantSlug: slug, isRemoveTenant: true }), onSuccess: () => { - toast.success(t("msg.admin.tenants.members.remove_success", "조직에서 제외되었습니다.")); + toast.success( + t( + "msg.admin.tenants.members.remove_success", + "조직에서 제외되었습니다.", + ), + ); usersQuery.refetch(); queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] }); }, - onError: (err: any) => { - toast.error(err.response?.data?.error || t("msg.admin.tenants.members.remove_error", "제외 실패")); + onError: (err: AxiosError<{ error?: string }>) => { + toast.error( + err.response?.data?.error || + t("msg.admin.tenants.members.remove_error", "제외 실패"), + ); }, }); const handleRemoveMember = (userId: string, userName: string) => { if (!tenantSlug) return; - if (window.confirm(t("msg.admin.tenants.members.remove_confirm", "'{{name}}'님을 이 조직에서 제외하시겠습니까?", { name: userName }))) { + if ( + window.confirm( + t( + "msg.admin.tenants.members.remove_confirm", + "'{{name}}'님을 이 조직에서 제외하시겠습니까?", + { name: userName }, + ), + ) + ) { removeTenantMutation.mutate({ userId, slug: tenantSlug }); } }; @@ -122,8 +147,13 @@ function TenantUsersPage() {
- - {t("ui.common.loading", "Loading...")} + + + {t("ui.common.loading", "Loading...")} +
@@ -142,7 +172,9 @@ function TenantUsersPage() { ) : ( users.map((user) => ( - {user.name} + + {user.name} +
@@ -159,7 +191,9 @@ function TenantUsersPage() { {t(`ui.common.status.${user.status}`, user.status)} @@ -167,7 +201,11 @@ function TenantUsersPage() { - @@ -175,16 +213,24 @@ function TenantUsersPage() { - {t("ui.admin.tenants.members.view_profile", "상세 정보")} + {t( + "ui.admin.tenants.members.view_profile", + "상세 정보", + )} - handleRemoveMember(user.id, user.name)} + onClick={() => + handleRemoveMember(user.id, user.name) + } disabled={removeTenantMutation.isPending} > - {t("ui.admin.tenants.members.remove", "조직에서 제외")} + {t( + "ui.admin.tenants.members.remove", + "조직에서 제외", + )} diff --git a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts index 597c6411..44126799 100644 --- a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts +++ b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts @@ -1,16 +1,16 @@ import { describe, expect, it } from "vitest"; import { buildWorksmobilePasswordManageUrl, - canOpenWorksmobilePasswordManage, canCreateWorksmobileRow, + canOpenWorksmobilePasswordManage, canSelectWorksmobileRow, filterWorksmobileComparisonRows, formatWorksmobileOrgDetails, formatWorksmobilePersonName, getDefaultWorksmobileComparisonColumns, + getWorksmobileComparisonStatusLabel, getWorksmobileRowSelectionKey, getWorksmobileSelectedActionIds, - getWorksmobileComparisonStatusLabel, isImmutableWorksmobileAccount, summarizeWorksmobileComparison, userFilterOptions, diff --git a/adminfront/src/features/tenants/utils/protectedTenants.ts b/adminfront/src/features/tenants/utils/protectedTenants.ts index 96342cd4..a9c6c42d 100644 --- a/adminfront/src/features/tenants/utils/protectedTenants.ts +++ b/adminfront/src/features/tenants/utils/protectedTenants.ts @@ -1,8 +1,8 @@ -import type { TenantSummary } from "../../../lib/adminApi"; -import { parseTenantCSV } from "./tenantCsvImport"; // Vite ?raw import는 seed CSV를 빌드 타임 상수로 번들합니다. // eslint-disable-next-line import/no-unresolved import seedTenantCSVRaw from "../../../../seed-tenant.csv?raw"; +import type { TenantSummary } from "../../../lib/adminApi"; +import { parseTenantCSV } from "./tenantCsvImport"; const seedTenantSlugs = new Set( parseTenantCSV(seedTenantCSVRaw) diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index 8f2e1725..c6b83fb3 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -30,6 +30,7 @@ import { } from "../../components/ui/dialog"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; +import { Switch } from "../../components/ui/switch"; import { Tabs, TabsContent, @@ -741,15 +742,22 @@ function UserCreatePage() { )}
diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index 958685cd..501c3017 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -1161,17 +1161,21 @@ function UserDetailPage() { )} diff --git a/adminfront/src/features/users/components/UserBulkUploadModal.tsx b/adminfront/src/features/users/components/UserBulkUploadModal.tsx index e9659b50..ca8e43d6 100644 --- a/adminfront/src/features/users/components/UserBulkUploadModal.tsx +++ b/adminfront/src/features/users/components/UserBulkUploadModal.tsx @@ -33,12 +33,12 @@ import { type TenantImportPreviewRow, buildTenantImportPreview, } from "../../tenants/utils/tenantCsvImport"; +import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker"; import { parseUserCSV } from "../utils/csvParser"; import { type HanmacImportEmailPreview, buildHanmacImportEmailPreview, } from "../utils/hanmacImportEmail"; -import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker"; interface UserBulkUploadModalProps { onSuccess?: () => void; diff --git a/adminfront/tests/users.spec.ts b/adminfront/tests/users.spec.ts index f676ae66..4f2d859e 100644 --- a/adminfront/tests/users.spec.ts +++ b/adminfront/tests/users.spec.ts @@ -462,8 +462,7 @@ test.describe("User Management", () => { "John Doe john@test.com 010-1111-2222", ); - await page.getByTestId("user-status-select-u-1").click(); - await page.getByRole("option", { name: /비활성|inactive/i }).click(); + await page.getByTestId("user-status-toggle-u-1").click(); await expect .poll(() => updatePayload) .toMatchObject({ status: "inactive" }); @@ -567,7 +566,7 @@ test.describe("User Management", () => { }); await expect(page.getByText("기술기획")).toBeVisible(); - await page.getByLabel(/조직장/i).check(); + await page.getByRole("switch", { name: /대표 조직/i }).click(); await page.getByLabel(/^직무$/i).fill("플랫폼 운영"); await page.getByLabel(/^직급$/i).fill("책임"); diff --git a/adminfront/tests/users_live.spec.ts b/adminfront/tests/users_live.spec.ts index d67d4800..779fd4bb 100644 --- a/adminfront/tests/users_live.spec.ts +++ b/adminfront/tests/users_live.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "@playwright/test"; import fs from "node:fs"; +import { expect, test } from "@playwright/test"; const liveE2E = process.env.LIVE_BACKEND_E2E === "1"; const oidcAuthority = "https://sso.hmac.kr/oidc"; diff --git a/docs/frontend-monorepo-masterplan.md b/docs/frontend-monorepo-masterplan.md new file mode 100644 index 00000000..992b0abf --- /dev/null +++ b/docs/frontend-monorepo-masterplan.md @@ -0,0 +1,76 @@ +# [Master Plan] 프론트엔드 모노레포 통합 및 공용 모듈 마이그레이션 + +## 1. 개요 (Overview) +현재 `adminfront`, `devfront`, `orgfront` 세 개의 프론트엔드 프로젝트는 동일한 기술 스택을 사용함에도 불구하고 코드가 각자 복제되어 관리되고 있습니다. 이를 **NPM Workspaces 기반의 모노레포** 구조로 개편하여 중복을 제거하고, 기술적 일관성을 확보하는 것이 본 설계의 목적입니다. + +--- + +## 2. 아키텍처 설계 (Architecture) + +### 2.1. 디렉토리 구조 +프로젝트 루트를 Workspace로 설정하고, 모든 앱과 공용 모듈을 `packages/` 하위로 통합 관리합니다. + +```text +baron-sso/ +├── package.json # [New] 루트 레벨 Workspace 및 의존성 관리 +├── packages/ # 재사용 가능한 내부 패키지 +│ ├── shared-ui/ # Shadcn UI, 제네릭 AppLayout, 브랜드 자산(로고/아이콘) +│ ├── shared-utils/ # apiClient 팩토리, i18n 엔진, 공용 로케일(사전 병합) +│ ├── shared-auth/ # OIDC 설정, AuthGuard 본체, 세션 관리(Sliding Session) +│ ├── shared-types/ # 도메인 모델 및 API TypeScript 타입 +│ └── shared-config/ # Tailwind, Biome, TSConfig 공통 설정 +├── adminfront/ # Feature 중심 유지 + Shared 패키지 참조 +├── devfront/ # App Shell 공유 + Shared 패키지 참조 +└── orgfront/ # App Shell 공유 + Shared 패키지 참조 +``` + +### 2.2. 모노레포 도입의 이유 (Why Workspaces?) +1. **깔끔한 임포트**: `../../common` 같은 상대 경로 대신 `@shared/ui`와 같은 패키지 이름으로 참조 가능. +2. **의존성 단일화**: 모든 앱이 동일한 라이브러리 버전을 사용하여 런타임 에러 방지. +3. **설정 자동화**: Vite와 TypeScript가 로컬 패키지를 자동으로 인식하여 HMR 및 타입 체킹 지원. + +--- + +## 3. 핵심 기술 설계 (Technical Governance) + +### 3.1. 의존성 관리 (Single Version Policy) +- `react`, `react-router-dom`, `tailwindcss`, `@tanstack/react-query` 등 핵심 라이브러리 버전을 루트 `package.json`에서 고정 관리합니다. +- 각 패키지는 필요한 경우에만 별도 의존성을 가지며, 가능한 루트 버전을 따릅니다. + +### 3.2. i18n 및 에셋 전략 +- **동적 병합(Dictionary Merge)**: 공용 문구(Shared)를 먼저 로드하고, 앱별 특화 문구를 그 위에 덮어쓰는(Merge) 로직을 `shared-utils`에서 제공합니다. +- **자산 중앙화**: 브랜드 로고, 파비콘 등 공통 정적 자산은 `shared-ui/assets`에서 관리합니다. + +### 3.3. 제네릭 AppLayout 엔진 +- `AppLayout.tsx`는 더 이상 하드코딩된 메뉴를 갖지 않습니다. +- 메뉴(`navItems`), 브랜드 로고, 역할 스위처 표시 여부 등을 **설정 객체(Config)**로 주입받아 렌더링하는 범용 엔진으로 리팩토링합니다. + +--- + +## 4. 실행 로드맵 (Execution Roadmap) + +### **[1단계] 인프라 구축 (Infrastructure)** +- 루트 `package.json` Workspace 설정. +- `shared-config` 패키지 생성 및 Tailwind/Biome 설정 중앙화. +- 각 앱에서 `@shared/config`를 참조하도록 설정 변경. + +### **[2단계] 기초 모듈 추출 (Base Migration)** +- 중복도가 가장 높은 `components/ui/*`를 `shared-ui`로 이동. +- `apiClient`, `i18n`, `utils` 등 순수 유틸리티 코드를 `shared-utils`로 이동. +- 각 앱의 임포트 경로를 `@shared/*`로 일괄 치환. + +### **[3단계] 플랫폼 레이어 통합 (Platform)** +- `AppLayout.tsx`를 제네릭하게 리팩토링하여 `shared-ui`로 이동. +- `AuthGuard`, `LoginPage`, `sessionSliding` 등 인증 관련 핵심 로직을 `shared-auth`로 통합. + +### **[4단계] 앱별 적용 및 최적화 (Optimization)** +- `devfront`와 `orgfront`를 우선적으로 공용 레이아웃 기반으로 전환. +- `adminfront`는 비즈니스 로직(Feature)은 유지하되 기반 레이어만 교체. +- 빌드 테스트 및 트리쉐이킹(번들 최적화) 확인. + +--- + +## 5. 기대 효과 (Expected Benefits) +- **관리 비용 절감**: 공통 UI 수정 시 3개 앱에 동시 반영. +- **개발 가속화**: 신규 프론트엔드 프로젝트 시작 시 인프라 세팅 시간 단축. +- **기술적 일관성**: 모든 프론트엔드가 동일한 기술 표준과 디자인 시스템을 공유. From 8bf8520d624a096cfb89357f5c7987c6dc89dbad Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 8 May 2026 15:09:58 +0900 Subject: [PATCH 2/4] =?UTF-8?q?adminfront=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=B6=94=EA=B0=80=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(=EB=8C=80=ED=91=9C=20=EC=A1=B0=EC=A7=81=20=EB=8F=85=EC=A0=90?= =?UTF-8?q?=20=EC=84=A0=ED=83=9D=20=EB=B0=8F=20=EB=B3=B4=ED=98=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 한맥가족 대표 조직(isOwner) 선택 시 독점(Exclusive) 선택 로직 적용 - 주 소속(isPrimary) 테넌트의 경우 대표 조직 해제 불가하도록 Switch 비활성화 처리 - UserDetailPage 및 UserCreatePage 공통 로직 보정 --- adminfront/src/features/users/UserCreatePage.tsx | 12 +++++++++--- adminfront/src/features/users/UserDetailPage.tsx | 13 ++++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index c6b83fb3..01622ab3 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -290,9 +290,15 @@ function UserCreatePage() { patch: Partial, ) => { setAdditionalAppointments((current) => - current.map((appointment, currentIndex) => - currentIndex === index ? { ...appointment, ...patch } : appointment, - ), + current.map((appointment, currentIndex) => { + if (currentIndex === index) { + return { ...appointment, ...patch }; + } + if (patch.isOwner === true) { + return { ...appointment, isOwner: false }; + } + return appointment; + }), ); }; diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index 501c3017..f115e165 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -536,9 +536,15 @@ function UserDetailPage() { patch: Partial, ) => { setAdditionalAppointments((current) => - current.map((appointment, currentIndex) => - currentIndex === index ? { ...appointment, ...patch } : appointment, - ), + current.map((appointment, currentIndex) => { + if (currentIndex === index) { + return { ...appointment, ...patch }; + } + if (patch.isOwner === true) { + return { ...appointment, isOwner: false }; + } + return appointment; + }), ); }; @@ -1168,6 +1174,7 @@ function UserDetailPage() { isOwner: checked === true, }) } + disabled={appointment.isPrimary} aria-label={t( "ui.admin.users.detail.form.appointment_owner", "대표 조직", From 819ac0004016f2230e9cf438085717cc114e0f2d Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 8 May 2026 15:18:14 +0900 Subject: [PATCH 3/4] =?UTF-8?q?adminfront=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=84=B8=EB=B6=80=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserDetailPage/UserCreatePage의 데이터 제출(onSubmit) 시 한맥가족의 대표 조직 관련 필드 매핑 로직 보정 - POST(Create)와 PUT(Update) 간의 백엔드 API 기대 페이로드 차이에 맞춘 조건부 필드 설정 - tests/users.spec.ts의 한맥가족 대표 조직 변경 테스트용 모크 데이터 보정 --- adminfront/src/features/users/UserCreatePage.tsx | 9 +++++++++ adminfront/src/features/users/UserDetailPage.tsx | 16 +++++++++++++++- adminfront/tests/users.spec.ts | 4 ++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index 01622ab3..14245071 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -409,6 +409,7 @@ function UserCreatePage() { tenantId: appointment.tenantId, tenantSlug: appointment.tenantSlug, tenantName: appointment.tenantName, + isPrimary: appointment.isOwner, isOwner: appointment.isOwner, jobTitle: appointment.jobTitle, position: appointment.position, @@ -424,6 +425,14 @@ function UserCreatePage() { return; } + const primary = appointments.find((a) => a.isOwner); + if (primary) { + metadata.primaryTenantId = primary.tenantId; + metadata.primaryTenantSlug = primary.tenantSlug; + metadata.primaryTenantName = primary.tenantName; + metadata.primaryTenantIsOwner = true; + } + payload.additionalAppointments = appointments; payload.metadata = { ...metadata, diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index f115e165..0e7b67ef 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -754,12 +754,26 @@ function UserDetailPage() { tenantId: appointment.tenantId, tenantSlug: appointment.tenantSlug, tenantName: appointment.tenantName, + isPrimary: appointment.isOwner, isOwner: appointment.isOwner, jobTitle: appointment.jobTitle, position: appointment.position, })); - payload.tenantSlug = undefined; + const primary = appointments.find((a) => a.isOwner); + if (primary) { + payload.tenantSlug = primary.tenantSlug; + payload.primaryTenantId = primary.tenantId; + payload.primaryTenantName = primary.tenantName; + payload.primaryTenantIsOwner = true; + metadata.primaryTenantId = primary.tenantId; + metadata.primaryTenantSlug = primary.tenantSlug; + metadata.primaryTenantName = primary.tenantName; + metadata.primaryTenantIsOwner = true; + } else { + payload.tenantSlug = undefined; + } + payload.department = undefined; payload.position = undefined; payload.jobTitle = undefined; diff --git a/adminfront/tests/users.spec.ts b/adminfront/tests/users.spec.ts index 4f2d859e..25ff96b5 100644 --- a/adminfront/tests/users.spec.ts +++ b/adminfront/tests/users.spec.ts @@ -766,7 +766,7 @@ test.describe("User Management", () => { tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35", tenantSlug: "tech-planning", tenantName: "기술기획", - isOwner: false, + isOwner: true, jobTitle: "플랫폼 운영", position: "책임", }, @@ -774,7 +774,7 @@ test.describe("User Management", () => { tenantId: "hanmac-team-id", tenantSlug: "hanmac-team", tenantName: "한맥팀", - isOwner: true, + isOwner: false, jobTitle: "개발", position: "선임", }, From 7a9dff372b0662509a97fa5938d8797a8ff7bace Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 8 May 2026 15:26:19 +0900 Subject: [PATCH 4/4] =?UTF-8?q?adminfront=20=ED=83=80=EC=9E=85=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95=20(UserCreatePage=20metadata=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EB=AA=85=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/features/users/UserCreatePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index 14245071..9a832991 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -353,7 +353,7 @@ function UserCreatePage() { setGeneratedPassword(null); setCreatedEmail(null); - const metadata = { + const metadata: Record = { ...(data.metadata ?? {}), hanmacFamily: userType === "hanmac" && isHanmacFamily, userType,