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개 앱에 동시 반영. +- **개발 가속화**: 신규 프론트엔드 프로젝트 시작 시 인프라 세팅 시간 단축. +- **기술적 일관성**: 모든 프론트엔드가 동일한 기술 표준과 디자인 시스템을 공유.