From b49e62449a6067bcd755df6f7f20ebc88c95970b Mon Sep 17 00:00:00 2001 From: geuna Date: Tue, 28 Jul 2026 10:42:37 +0900 Subject: [PATCH 01/11] refactor(frontend): remove dead code and dev-gate UI test page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete teamsDummyData.ts (468 lines, zero imports) and its orphaned wire-type copies - Delete unused alertStore + test; its role is served by noticeStore (drop now-orphaned TAlert type) - Drop dummy team ids (t_a/t_e) from TreeDetailView fallback selection and default expansion — they never match real API data - Route /ui-test only in dev builds via a DEV-gated lazy import so the 1,084-line showcase page and its chunk stay out of production Co-Authored-By: Claude Fable 5 --- frontend/src/App.tsx | 19 +- .../src/components/teams/TreeDetailView.tsx | 10 +- frontend/src/pages/teamsDummyData.ts | 468 ------------------ .../state/store/__tests__/alertStore.test.ts | 35 -- frontend/src/state/store/alertStore.ts | 21 - frontend/src/types/commonTypes.ts | 5 - 6 files changed, 20 insertions(+), 538 deletions(-) delete mode 100644 frontend/src/pages/teamsDummyData.ts delete mode 100644 frontend/src/state/store/__tests__/alertStore.test.ts delete mode 100644 frontend/src/state/store/alertStore.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2a90430..9d46a22 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,10 @@ +import { lazy, Suspense } from "react"; import { Route, Routes } from "react-router"; import LoginPage from "@/pages/LoginPage"; import NotFoundPage from "@/pages/NotFoundPage"; import SessionsPage from "@/pages/SessionsPage"; import TeamsPage from "@/pages/TeamsPage"; -import UITestPage from "@/pages/UITestPage"; import UsersPage from "@/pages/UsersPage"; import WorkspacePage from "@/pages/WorkspacePage"; import LandingRedirect from "@/components/auth/LandingRedirect"; @@ -14,6 +14,12 @@ import AppLayout from "@/components/layout/AppLayout"; import ToastContainer from "@/components/toast/ToastContainer"; import { PATH_LIST } from "@/constants/commonConstants"; +/* Dev-only UI showcase — the import.meta.env.DEV guard is statically false in + production builds, so Rollup drops both the route and the chunk entirely. */ +const UITestPage = import.meta.env.DEV + ? lazy(() => import("@/pages/UITestPage")) + : null; + /** App defines the top-level route table for Rune Console. */ const App = () => { return ( @@ -27,7 +33,16 @@ const App = () => { } /> } /> } /> - } /> + {import.meta.env.DEV && UITestPage && ( + + + + } + /> + )} {/* 404 (SC-04) — reachable regardless of auth; outside RequireAuth so a diff --git a/frontend/src/components/teams/TreeDetailView.tsx b/frontend/src/components/teams/TreeDetailView.tsx index c1d4068..ec6851c 100644 --- a/frontend/src/components/teams/TreeDetailView.tsx +++ b/frontend/src/components/teams/TreeDetailView.tsx @@ -21,6 +21,7 @@ import RenameTeamModal from "@/components/teams/RenameTeamModal"; import RoleChangeConfirmModal from "@/components/teams/RoleChangeConfirmModal"; import { ROLE_OPTIONS } from "@/components/teams/teamOptions"; import TeamTree from "@/components/tree/TeamTree"; +import { CHIP_STATUS } from "@/components/users/memberStatusMap"; import { useAddTeamMemberMutation, useBulkRoleChangeMutation, @@ -34,7 +35,6 @@ import { import { useTeamMembersQuery } from "@/hooks/queries/useTeamMembersQuery"; import { useTeamQuery } from "@/hooks/queries/useTeamQuery"; import { parseErrorCode } from "@/api/parseError"; -import { CHIP_STATUS } from "@/components/users/memberStatusMap"; import { formatDate } from "@/utils/formatDate"; import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; import type { TTeamNode } from "@/types/commonTypes"; @@ -139,7 +139,7 @@ const TreeDetailView = ({ const flatById = new Map(teams.map((t) => [t.id, t])); const teamNodes = buildTeamNodes(teams, null); /* Fallback selection — the first top-level team (SC-06 entry rule). */ - const defaultTeam = findTeamNode(teamNodes, "t_a") ?? teamNodes[0]; + const defaultTeam = teamNodes[0]; const selectedTeam = findTeamNode(teamNodes, selectedTeamId) ?? defaultTeam; const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -476,11 +476,7 @@ const TreeDetailView = ({ query={teamSearch} selectedId={selectedTeam.id} onSelect={(node) => onSelectTeam(node.id)} - defaultExpandedIds={[ - "t_a", - "t_e", - ...ancestorIds(teams, selectedTeam.id), - ]} + defaultExpandedIds={ancestorIds(teams, selectedTeam.id)} className="-mx-1 flex-1" /> diff --git a/frontend/src/pages/teamsDummyData.ts b/frontend/src/pages/teamsDummyData.ts deleted file mode 100644 index 20c8324..0000000 --- a/frontend/src/pages/teamsDummyData.ts +++ /dev/null @@ -1,468 +0,0 @@ -import type { TTeamNode, TTeamTree } from "@/types/teamTypes"; - -/** - * Dummy fixtures shaped exactly like the console API wire format. - * Placeholder data for TeamsPage until the BFF endpoints are live — do - * not ship usages. - * - * Shapes covered: - * - GET /teams/tree (Teams — flat nodes, client builds the tree) - * - GET /users (Users — cross-team global list, paginated) - * - GET /teams/{teamId}/members (Team members — per-team member table, paginated) - */ - -/** Invitation-code lifecycle status on the wire (common contract). */ -export type TApiInvitationStatus = - "invite_pending" | "invite_expired" | "invite_redeemed"; - -/** Session-token liveness on the wire (common contract). */ -export type TApiSessionStatus = "online" | "offline"; - -/** Roles grantable to members (common contract — Admin is console-account only). */ -export type TApiMemberRole = "edit" | "write" | "read"; - -/** One membership entry inside a GET /users item (Users). */ -export type TApiUserMembership = { - teamId: string; - teamName: string; - role: TApiMemberRole; -}; - -/** One item of GET /users (Users). */ -export type TApiUser = { - userId: string; - account: string; - invitationStatus: TApiInvitationStatus; - sessionStatus: TApiSessionStatus; - memberships: TApiUserMembership[]; - lastAccessAt: string | null; - lastInvitedAt: string | null; - sessionExpiredAt: string | null; -}; - -/** One item of GET /teams/{teamId}/members (Team members). */ -export type TApiTeamMember = { - userId: string; - account: string; - role: TApiMemberRole; - invitationStatus: TApiInvitationStatus; - sessionStatus: TApiSessionStatus; - joinedAt: string | null; -}; - -/** Common paginated envelope (common contract — { total, page, size, items }). */ -export type TApiPage = { - total: number; - page: number; - size: number; - items: T[]; -}; - -/** Build a flat tree node — childCount always mirrors childrenIds. */ -const team = ( - id: string, - name: string, - parentId: string | null, - childrenIds: string[], - memberCount: number, -): TTeamNode => ({ - id, - name, - parentId, - childrenIds, - childCount: childrenIds.length, - memberCount, -}); - -/** - * GET /teams/tree — 45 teams, 8 roots, up to 11 levels deep (load-test - * sized: wide enough to overflow the org chart horizontally). - * The original six (t_a~t_f) keep memberCounts consistent with - * DUMMY_USERS; the load-test teams below are not reflected there. - */ -export const DUMMY_TEAMS_TREE: TTeamTree = [ - // ── original fixture ────────────────────────────────────────────── - team("t_a", "플랫폼", null, ["t_b", "t_c", "t_mob"], 2), - team("t_b", "백엔드", "t_a", ["t_api", "t_wrk", "t_db"], 6), - team("t_c", "프론트엔드", "t_a", ["t_con", "t_ds"], 2), - team("t_d", "디자인", null, [], 2), - team("t_e", "보안", null, ["t_f", "t_cmp", "t_iam"], 2), - team("t_f", "볼트", "t_e", ["t_fhe", "t_key"], 1), - // ── load-test teams ─────────────────────────────────────────────── - team("t_mob", "모바일", "t_a", ["t_ios", "t_and"], 4), - // long name — exercises node truncation + title tooltip - team("t_api", "API 게이트웨이 익스피리언스", "t_b", ["t_gw", "t_gql"], 5), - team("t_wrk", "워커", "t_b", [], 3), - team("t_db", "데이터베이스", "t_b", [], 2), - team("t_gw", "게이트웨이", "t_api", ["t_edge"], 2), - // ── deep chain under Gateway (vertical overflow test, depth 11) ──── - team("t_edge", "엣지", "t_gw", ["t_rte"], 2), - team("t_rte", "라우팅", "t_edge", ["t_lb"], 1), - team("t_lb", "로드밸런서", "t_rte", ["t_rl"], 2), - team("t_rl", "속도 제한", "t_lb", ["t_cch"], 1), - team("t_cch", "캐싱", "t_rl", ["t_cdn"], 2), - team("t_cdn", "콘텐츠 전송", "t_cch", ["t_pop"], 3), - team("t_pop", "서울 거점", "t_cdn", [], 2), - team("t_gql", "쿼리 API", "t_api", [], 3), - team("t_con", "콘솔", "t_c", [], 4), - team("t_ds", "디자인 시스템", "t_c", [], 2), - team("t_ios", "아이폰 앱", "t_mob", [], 2), - team("t_and", "안드로이드 앱", "t_mob", [], 2), - team("t_cmp", "컴플라이언스", "t_e", [], 3), - team("t_iam", "접근 제어", "t_e", [], 2), - team("t_fhe", "동형암호 코어", "t_f", [], 4), - team("t_key", "키 관리", "t_f", [], 2), - team("t_data", "데이터", null, ["t_ana", "t_ml"], 1), - team("t_ana", "분석", "t_data", [], 3), - team("t_ml", "머신러닝", "t_data", ["t_trn", "t_inf"], 2), - team("t_trn", "학습", "t_ml", [], 3), - team("t_inf", "추론", "t_ml", [], 2), - team("t_infra", "인프라", null, ["t_sre", "t_net", "t_obs"], 2), - team("t_sre", "사이트 신뢰성", "t_infra", [], 4), - team("t_net", "네트워크", "t_infra", [], 2), - team("t_obs", "관측성", "t_infra", [], 3), - team("t_prod", "프로덕트", null, ["t_bil", "t_onb"], 5), - team("t_bil", "결제", "t_prod", [], 2), - team("t_onb", "온보딩", "t_prod", [], 1), - team("t_gro", "그로스", null, ["t_mkt", "t_sal"], 1), - team("t_mkt", "마케팅", "t_gro", [], 3), - team("t_sal", "영업", "t_gro", [], 2), - team("t_cs", "고객 성공", null, ["t_sup", "t_edu"], 2), - team("t_sup", "지원", "t_cs", [], 3), - team("t_edu", "교육", "t_cs", [], 2), -]; - -/** - * GET /users?page=1&size=10 — 23 users over 3 pages, covering all - * invitation/session status combinations. Per-status timestamp display - * (Users): online → lastAccessAt / pending·expired → lastInvitedAt / - * offline-after-redeemed → sessionExpiredAt. u_9~u_11 are also team B - * members (mirrored in DUMMY_TEAM_B_MEMBERS); u_16 exercises the "+n" - * membership overflow chip and u_17 the long-account truncation. - */ -export const DUMMY_USERS: TApiPage = { - total: 23, - page: 1, - size: 10, - items: [ - { - userId: "u_1", - account: "k@corp.com", - invitationStatus: "invite_redeemed", - sessionStatus: "online", - memberships: [ - { teamId: "t_a", teamName: "플랫폼", role: "edit" }, - { teamId: "t_b", teamName: "백엔드", role: "edit" }, - { teamId: "t_c", teamName: "프론트엔드", role: "edit" }, - ], - lastAccessAt: "2026-07-07T08:12:00Z", - lastInvitedAt: "2026-07-06T09:00:00Z", - sessionExpiredAt: null, - }, - { - userId: "u_2", - account: "m@corp.com", - invitationStatus: "invite_pending", - sessionStatus: "offline", - memberships: [{ teamId: "t_b", teamName: "백엔드", role: "read" }], - lastAccessAt: null, - lastInvitedAt: "2026-07-05T18:20:00Z", - sessionExpiredAt: null, - }, - { - userId: "u_3", - account: "n@corp.com", - invitationStatus: "invite_expired", - sessionStatus: "offline", - memberships: [{ teamId: "t_b", teamName: "백엔드", role: "write" }], - lastAccessAt: null, - lastInvitedAt: "2026-07-03T10:00:00Z", - sessionExpiredAt: null, - }, - { - userId: "u_4", - account: "p@corp.com", - invitationStatus: "invite_pending", - sessionStatus: "offline", - memberships: [{ teamId: "t_c", teamName: "프론트엔드", role: "read" }], - lastAccessAt: null, - lastInvitedAt: "2026-07-06T15:40:00Z", - sessionExpiredAt: null, - }, - { - userId: "u_5", - account: "q@corp.com", - invitationStatus: "invite_expired", - sessionStatus: "offline", - memberships: [ - { teamId: "t_a", teamName: "플랫폼", role: "read" }, - { teamId: "t_d", teamName: "디자인", role: "read" }, - ], - lastAccessAt: null, - lastInvitedAt: "2026-07-02T09:30:00Z", - sessionExpiredAt: null, - }, - { - userId: "u_6", - account: "r@corp.com", - invitationStatus: "invite_redeemed", - sessionStatus: "offline", - memberships: [{ teamId: "t_e", teamName: "보안", role: "write" }], - lastAccessAt: "2026-07-04T11:30:00Z", - lastInvitedAt: "2026-07-01T10:10:00Z", - sessionExpiredAt: "2026-07-06T17:05:00Z", - }, - { - userId: "u_7", - account: "s@corp.com", - invitationStatus: "invite_redeemed", - sessionStatus: "online", - memberships: [ - { teamId: "t_d", teamName: "디자인", role: "edit" }, - { teamId: "t_f", teamName: "볼트", role: "read" }, - ], - lastAccessAt: "2026-07-06T18:40:00Z", - lastInvitedAt: "2026-07-05T15:02:00Z", - sessionExpiredAt: null, - }, - { - userId: "u_8", - account: "t@corp.com", - invitationStatus: "invite_redeemed", - sessionStatus: "online", - memberships: [{ teamId: "t_e", teamName: "보안", role: "read" }], - lastAccessAt: "2026-07-07T07:55:00Z", - lastInvitedAt: "2026-07-04T11:30:00Z", - sessionExpiredAt: null, - }, - { - userId: "u_9", - account: "u@corp.com", - invitationStatus: "invite_redeemed", - sessionStatus: "online", - memberships: [{ teamId: "t_b", teamName: "백엔드", role: "write" }], - lastAccessAt: "2026-07-06T21:10:00Z", - lastInvitedAt: "2026-07-01T09:20:00Z", - sessionExpiredAt: null, - }, - { - userId: "u_10", - account: "v@corp.com", - invitationStatus: "invite_pending", - sessionStatus: "offline", - memberships: [{ teamId: "t_b", teamName: "백엔드", role: "read" }], - lastAccessAt: null, - lastInvitedAt: "2026-07-06T10:20:00Z", - sessionExpiredAt: null, - }, - { - userId: "u_11", - account: "w@corp.com", - invitationStatus: "invite_redeemed", - sessionStatus: "offline", - memberships: [ - { teamId: "t_b", teamName: "백엔드", role: "edit" }, - { teamId: "t_gql", teamName: "쿼리 API", role: "read" }, - ], - lastAccessAt: "2026-07-03T13:40:00Z", - lastInvitedAt: "2026-06-28T09:00:00Z", - sessionExpiredAt: "2026-07-05T09:00:00Z", - }, - { - userId: "u_12", - account: "x@corp.com", - invitationStatus: "invite_redeemed", - sessionStatus: "online", - memberships: [{ teamId: "t_con", teamName: "콘솔", role: "edit" }], - lastAccessAt: "2026-07-07T06:45:00Z", - lastInvitedAt: "2026-07-02T14:00:00Z", - sessionExpiredAt: null, - }, - { - userId: "u_13", - account: "y@corp.com", - invitationStatus: "invite_redeemed", - sessionStatus: "online", - memberships: [ - { teamId: "t_sre", teamName: "사이트 신뢰성", role: "write" }, - { teamId: "t_obs", teamName: "관측성", role: "read" }, - ], - lastAccessAt: "2026-07-06T23:50:00Z", - lastInvitedAt: "2026-07-01T08:10:00Z", - sessionExpiredAt: null, - }, - { - userId: "u_14", - account: "z@corp.com", - invitationStatus: "invite_pending", - sessionStatus: "offline", - memberships: [ - { teamId: "t_fhe", teamName: "동형암호 코어", role: "read" }, - ], - lastAccessAt: null, - lastInvitedAt: "2026-07-07T08:30:00Z", - sessionExpiredAt: null, - }, - { - userId: "u_15", - account: "aa@corp.com", - invitationStatus: "invite_expired", - sessionStatus: "offline", - memberships: [{ teamId: "t_mkt", teamName: "마케팅", role: "read" }], - lastAccessAt: null, - lastInvitedAt: "2026-06-25T16:00:00Z", - sessionExpiredAt: null, - }, - { - userId: "u_16", - account: "ab@corp.com", - invitationStatus: "invite_redeemed", - sessionStatus: "online", - memberships: [ - { teamId: "t_mob", teamName: "모바일", role: "edit" }, - { teamId: "t_ios", teamName: "아이폰 앱", role: "write" }, - { teamId: "t_and", teamName: "안드로이드 앱", role: "write" }, - { teamId: "t_con", teamName: "콘솔", role: "read" }, - { teamId: "t_ds", teamName: "디자인 시스템", role: "read" }, - ], - lastAccessAt: "2026-07-05T14:20:00Z", - lastInvitedAt: "2026-06-30T11:00:00Z", - sessionExpiredAt: null, - }, - { - userId: "u_17", - account: "external.partner.jihoon.kim@verylong-partner-domain.co.kr", - invitationStatus: "invite_redeemed", - sessionStatus: "online", - memberships: [{ teamId: "t_ana", teamName: "분석", role: "read" }], - lastAccessAt: "2026-07-04T09:12:00Z", - lastInvitedAt: "2026-07-01T10:30:00Z", - sessionExpiredAt: null, - }, - { - userId: "u_18", - account: "ac@corp.com", - invitationStatus: "invite_redeemed", - sessionStatus: "offline", - memberships: [{ teamId: "t_net", teamName: "네트워크", role: "write" }], - lastAccessAt: "2026-06-30T18:25:00Z", - lastInvitedAt: "2026-06-27T09:40:00Z", - sessionExpiredAt: "2026-07-02T09:40:00Z", - }, - { - userId: "u_19", - account: "ad@corp.com", - invitationStatus: "invite_pending", - sessionStatus: "offline", - memberships: [{ teamId: "t_prod", teamName: "프로덕트", role: "read" }], - lastAccessAt: null, - lastInvitedAt: "2026-07-05T11:00:00Z", - sessionExpiredAt: null, - }, - { - userId: "u_20", - account: "ae@corp.com", - invitationStatus: "invite_redeemed", - sessionStatus: "online", - memberships: [ - { teamId: "t_trn", teamName: "학습", role: "edit" }, - { teamId: "t_inf", teamName: "추론", role: "edit" }, - ], - lastAccessAt: "2026-07-06T16:05:00Z", - lastInvitedAt: "2026-07-03T13:15:00Z", - sessionExpiredAt: null, - }, - { - userId: "u_21", - account: "af@corp.com", - invitationStatus: "invite_expired", - sessionStatus: "offline", - memberships: [{ teamId: "t_key", teamName: "키 관리", role: "read" }], - lastAccessAt: null, - lastInvitedAt: "2026-06-24T15:30:00Z", - sessionExpiredAt: null, - }, - { - userId: "u_22", - account: "ag@corp.com", - invitationStatus: "invite_redeemed", - sessionStatus: "online", - memberships: [{ teamId: "t_gw", teamName: "게이트웨이", role: "write" }], - lastAccessAt: "2026-07-07T05:58:00Z", - lastInvitedAt: "2026-07-04T10:05:00Z", - sessionExpiredAt: null, - }, - { - userId: "u_23", - account: "ah@corp.com", - invitationStatus: "invite_pending", - sessionStatus: "offline", - memberships: [{ teamId: "t_edu", teamName: "교육", role: "read" }], - lastAccessAt: null, - lastInvitedAt: "2026-07-06T19:45:00Z", - sessionExpiredAt: null, - }, - ], -}; - -/** - * GET /teams/t_b/members?page=1&size=10 — team Backend's member table - * (extends the SC-06 wireframe fixture; roles/statuses mirror the t_b - * memberships in DUMMY_USERS). joinedAt is null until the invite is - * accepted (wireframe shows "—"). - */ -export const DUMMY_TEAM_B_MEMBERS: TApiPage = { - total: 6, - page: 1, - size: 10, - items: [ - { - userId: "u_1", - account: "k@corp.com", - role: "edit", - invitationStatus: "invite_redeemed", - sessionStatus: "online", - joinedAt: "2026-07-02T00:00:00Z", - }, - { - userId: "u_2", - account: "m@corp.com", - role: "read", - invitationStatus: "invite_pending", - sessionStatus: "offline", - joinedAt: null, - }, - { - userId: "u_3", - account: "n@corp.com", - role: "write", - invitationStatus: "invite_expired", - sessionStatus: "offline", - joinedAt: null, - }, - { - userId: "u_9", - account: "u@corp.com", - role: "write", - invitationStatus: "invite_redeemed", - sessionStatus: "online", - joinedAt: "2026-07-02T00:00:00Z", - }, - { - userId: "u_10", - account: "v@corp.com", - role: "read", - invitationStatus: "invite_pending", - sessionStatus: "offline", - joinedAt: null, - }, - { - userId: "u_11", - account: "w@corp.com", - role: "edit", - invitationStatus: "invite_redeemed", - sessionStatus: "offline", - joinedAt: "2026-06-29T00:00:00Z", - }, - ], -}; diff --git a/frontend/src/state/store/__tests__/alertStore.test.ts b/frontend/src/state/store/__tests__/alertStore.test.ts deleted file mode 100644 index ee5adec..0000000 --- a/frontend/src/state/store/__tests__/alertStore.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; - -import { useAlertStore } from "@/state/store/alertStore"; - -const resetAlertStore = () => { - useAlertStore.setState({ - alert: { title: "", content: "", isOpen: false }, - }); -}; - -describe("alertStore", () => { - beforeEach(() => { - resetAlertStore(); - }); - - it("opens the alert with the given content", () => { - const { setAlert } = useAlertStore.getState(); - setAlert({ title: "오류", content: "문제가 발생했습니다." }); - - const { alert } = useAlertStore.getState(); - expect(alert).toEqual({ - title: "오류", - content: "문제가 발생했습니다.", - isOpen: true, - }); - }); - - it("resets to the closed state on closeAlert", () => { - const { setAlert, closeAlert } = useAlertStore.getState(); - setAlert({ title: "오류", content: "문제가 발생했습니다." }); - closeAlert(); - - expect(useAlertStore.getState().alert.isOpen).toBe(false); - }); -}); diff --git a/frontend/src/state/store/alertStore.ts b/frontend/src/state/store/alertStore.ts deleted file mode 100644 index b0eea68..0000000 --- a/frontend/src/state/store/alertStore.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { create } from "zustand"; - -import { type TAlert } from "@/types/commonTypes"; - -type ExtendedTAlert = TAlert & { isOpen: boolean }; - -interface AlertStoreProps { - alert: ExtendedTAlert; - setAlert: (target: TAlert) => void; - closeAlert: () => void; -} - -const initialAlert: ExtendedTAlert = { title: "", content: "", isOpen: false }; - -/** useAlertStore drives the global alert modal (wireframe alert pattern). */ -export const useAlertStore = create((set) => ({ - alert: initialAlert, - setAlert: (target: TAlert) => - set((state) => ({ ...state, alert: { ...target, isOpen: true } })), - closeAlert: () => set((state) => ({ ...state, alert: initialAlert })), -})); diff --git a/frontend/src/types/commonTypes.ts b/frontend/src/types/commonTypes.ts index 6250152..5ba9410 100644 --- a/frontend/src/types/commonTypes.ts +++ b/frontend/src/types/commonTypes.ts @@ -1,8 +1,3 @@ -export type TAlert = { - title: string; - content: string; -}; - /** Option shape for the shared Dropdown element (UIKIT AdminOption). */ export type TDropdownOption = { value: string; From 371d1eb5fd188202d3239da7c987554f8d9836a3 Mon Sep 17 00:00:00 2001 From: geuna Date: Fri, 31 Jul 2026 11:24:47 +0900 Subject: [PATCH 02/11] refactor(frontend): centralize wire values, error copy, and notice copy in constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three constants modules as single sources for strings that were hardcoded per file, so a wording or wire-value change is a one-line edit and typos become compile errors: - constants/apiConstants.ts — wire-contract vocabulary (INVITATION_STATUS, SESSION_STATUS, WORKSPACE_STATUS, SYSTEM_UPDATE_STATE, TEAM_MEMBER_ROLE, ERROR_CODES); the matching union types are now derived from these objects, and the status→label maps in styleConstants are pinned with satisfies so a vocabulary change fails compilation until the labels follow - constants/errorConstants.ts — error-code→copy maps (TEAM_REASON, BATCH_REASON, ADD_MEMBER_REASON, BATCH_REASON_FALLBACK), replacing five per-file re-declarations; the duplicate-team-name copy is shared with the create/rename modals' client-side check. Unmapped-code fallback behavior is unchanged and now documented per call site - constants/noticeConstants.ts — showNotice copy per flow (NOTICE_TEXT, 11 flows, 20 call sites), collapsing cross-file duplicates (resend invitation, create team, remove membership); titles reference MODAL_TITLES where the notice reports that modal's action Also add DEFAULT_PAGE_SIZE to commonConstants (was declared as PAGE_SIZE=10 in three files) and drop UITestPage's duplicate ROLE_OPTIONS in favor of the teamOptions export. MemberStatus chip props stay literal on purpose — they are chip vocabulary behind the CHIP_STATUS seam, not wire values. Co-Authored-By: Claude Fable 5 --- .../src/components/teams/CreateTeamModal.tsx | 3 +- .../src/components/teams/RenameTeamModal.tsx | 3 +- .../src/components/teams/TreeDetailView.tsx | 99 ++++++++++--------- frontend/src/components/teams/teamOptions.ts | 7 +- .../components/update/UpdateFloatingCard.tsx | 10 +- .../components/users/MemberDetailDrawer.tsx | 90 ++++++++++------- .../src/components/users/memberStatusMap.ts | 5 +- .../components/workspace/WorkspaceModal.tsx | 5 +- frontend/src/constants/apiConstants.ts | 67 +++++++++++++ frontend/src/constants/commonConstants.ts | 5 + frontend/src/constants/errorConstants.ts | 46 +++++++++ frontend/src/constants/noticeConstants.ts | 67 +++++++++++++ frontend/src/constants/styleConstants.ts | 15 ++- .../src/hooks/mutations/useUpdateMutation.ts | 3 +- frontend/src/hooks/queries/useUpdateQuery.ts | 3 +- .../src/hooks/queries/useWorkspaceQuery.ts | 9 +- frontend/src/pages/SessionsPage.tsx | 15 +-- frontend/src/pages/TeamsPage.tsx | 16 +-- frontend/src/pages/UITestPage.tsx | 7 +- frontend/src/pages/UsersPage.tsx | 45 +++++---- frontend/src/pages/WorkspacePage.tsx | 3 +- frontend/src/types/commonTypes.ts | 13 +-- frontend/src/types/teamTypes.ts | 20 ++-- frontend/src/types/updateTypes.ts | 7 +- 24 files changed, 401 insertions(+), 162 deletions(-) create mode 100644 frontend/src/constants/apiConstants.ts create mode 100644 frontend/src/constants/errorConstants.ts create mode 100644 frontend/src/constants/noticeConstants.ts diff --git a/frontend/src/components/teams/CreateTeamModal.tsx b/frontend/src/components/teams/CreateTeamModal.tsx index fa91b3f..5857552 100644 --- a/frontend/src/components/teams/CreateTeamModal.tsx +++ b/frontend/src/components/teams/CreateTeamModal.tsx @@ -11,6 +11,7 @@ import { TEAM_NAME_RULE_TEXT, } from "@/components/teams/teamOptions"; import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; +import { TEAM_NAME_DUPLICATE_TEXT } from "@/constants/errorConstants"; import type { TTeamTree } from "@/types/teamTypes"; interface CreateTeamModalProps { @@ -51,7 +52,7 @@ const CreateTeamModal = ({ const nameError = isInvalidFormat ? TEAM_NAME_RULE_TEXT : trimmed && isDuplicate - ? "같은 상위 팀에 동일한 이름이 이미 있습니다." + ? TEAM_NAME_DUPLICATE_TEXT : undefined; return ( diff --git a/frontend/src/components/teams/RenameTeamModal.tsx b/frontend/src/components/teams/RenameTeamModal.tsx index 828c00e..c567b03 100644 --- a/frontend/src/components/teams/RenameTeamModal.tsx +++ b/frontend/src/components/teams/RenameTeamModal.tsx @@ -9,6 +9,7 @@ import { TEAM_NAME_RULE_TEXT, } from "@/components/teams/teamOptions"; import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; +import { TEAM_NAME_DUPLICATE_TEXT } from "@/constants/errorConstants"; import type { TTeamTree } from "@/types/teamTypes"; interface RenameTeamModalProps { @@ -58,7 +59,7 @@ const RenameTeamModal = ({ const nameError = isInvalidFormat ? TEAM_NAME_RULE_TEXT : isDuplicate - ? "같은 상위 팀에 동일한 이름이 이미 있습니다." + ? TEAM_NAME_DUPLICATE_TEXT : undefined; return ( diff --git a/frontend/src/components/teams/TreeDetailView.tsx b/frontend/src/components/teams/TreeDetailView.tsx index ec6851c..f7b00dc 100644 --- a/frontend/src/components/teams/TreeDetailView.tsx +++ b/frontend/src/components/teams/TreeDetailView.tsx @@ -36,7 +36,15 @@ import { useTeamMembersQuery } from "@/hooks/queries/useTeamMembersQuery"; import { useTeamQuery } from "@/hooks/queries/useTeamQuery"; import { parseErrorCode } from "@/api/parseError"; import { formatDate } from "@/utils/formatDate"; -import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; +import { TEAM_MEMBER_ROLE } from "@/constants/apiConstants"; +import { BTN_TEXT, DEFAULT_PAGE_SIZE } from "@/constants/commonConstants"; +import { + ADD_MEMBER_REASON, + BATCH_REASON, + BATCH_REASON_FALLBACK, + TEAM_REASON, +} from "@/constants/errorConstants"; +import { NOTICE_TEXT } from "@/constants/noticeConstants"; import type { TTeamNode } from "@/types/commonTypes"; import type { TTeamMemberRole, TTeamTree } from "@/types/teamTypes"; import { useNoticeStore } from "@/stores/noticeStore"; @@ -95,10 +103,6 @@ const findTeamNode = (nodes: TTeamNode[], id: string): TTeamNode | undefined => undefined, ); -/* 10 rows per page — caps the member table height inside one screen; - the ?size=10 GET /teams/{id}/members query param. */ -const PAGE_SIZE = 10; - /** * TreeDetailView is the SC-06 트리·상세 view: team tree panel (left) + * selected-team card and member table (right). Rendered by TeamsPage @@ -173,10 +177,14 @@ const TreeDetailView = ({ }, [selectedTeam.id]); const { data: detail } = useTeamQuery(selectedTeam.id); - const membersQuery = useTeamMembersQuery(selectedTeam.id, page, PAGE_SIZE); + const membersQuery = useTeamMembersQuery( + selectedTeam.id, + page, + DEFAULT_PAGE_SIZE, + ); const members = membersQuery.data?.items ?? []; const total = membersQuery.data?.total ?? 0; - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const totalPages = Math.max(1, Math.ceil(total / DEFAULT_PAGE_SIZE)); const addMember = useAddTeamMemberMutation(selectedTeam.id); const bulkRole = useBulkRoleChangeMutation(selectedTeam.id); @@ -237,8 +245,8 @@ const TreeDetailView = ({ setSavedRoles((prev) => new Map([...prev, ...pendingRoles])); setPendingRoles(new Map()); showNotice( - MODAL_TITLES.roleChange, - "변경사항이 저장되었습니다.", + NOTICE_TEXT.roleChange.title, + NOTICE_TEXT.roleChange.success, "success", ); }; @@ -260,41 +268,22 @@ const TreeDetailView = ({ setTeamError(null); closeModal(); }; - const TEAM_REASON: Record = { - TEAM_NAME_DUPLICATE: "같은 상위 팀에 동일한 이름이 이미 있습니다.", - TEAM_NAME_INVALID: "팀 이름 형식이 올바르지 않습니다.", - TEAM_HAS_CHILDREN: "하위 팀이 있어 삭제할 수 없습니다.", - }; - /* Partial-failure surface for the two batch endpoints (role change, remove): non-null opens MemberBatchFailureModal listing exactly what failed and why (API design — partial success is not an error). */ const [batchFailures, setBatchFailures] = useState< { account: string; reason: string }[] | null >(null); - const BATCH_REASON: Record = { - USER_NOT_FOUND: "사용자를 찾을 수 없습니다", - NOT_TEAM_MEMBER: "팀 멤버가 아닙니다", - }; - // Any other code (e.g. a transient INTERNAL) shows a generic retry message - // instead of leaking the raw backend code into the failure modal. - const BATCH_REASON_FALLBACK = "처리에 실패했습니다. 다시 시도해 주세요."; const accountOf = (userId: string) => members.find((m) => m.userId === userId)?.account ?? userId; const [addError, setAddError] = useState(null); - const ADD_REASON: Record = { - ALREADY_TEAM_MEMBER: "이미 초대된 사용자입니다.", - USER_NOT_FOUND: "등록되지 않은 계정입니다.", - CANNOT_INVITE_ADMIN: "콘솔 관리자 계정은 추가할 수 없습니다.", - MAIL_UPSTREAM_ERROR: "초대 코드 전송에 실패했습니다. 다시 시도해 주세요.", - }; const roleChanges = [...pendingRoles.entries()].map(([userId, to]) => { const member = members.find((m) => m.userId === userId); return { account: member?.account ?? userId, - from: baseRole(userId, member?.role ?? "read"), + from: baseRole(userId, member?.role ?? TEAM_MEMBER_ROLE.read), to, }; }); @@ -306,7 +295,11 @@ const TreeDetailView = ({ { onSuccess: () => { closeModal(); - showNotice("팀 생성", "팀이 생성되었습니다.", "success"); + showNotice( + NOTICE_TEXT.createTeam.title, + NOTICE_TEXT.createTeam.success, + "success", + ); }, onError: async (res) => { const code = await parseErrorCode(res); @@ -322,7 +315,11 @@ const TreeDetailView = ({ { onSuccess: () => { closeModal(); - showNotice("팀 이름 변경", "팀 이름이 변경되었습니다.", "success"); + showNotice( + NOTICE_TEXT.renameTeam.title, + NOTICE_TEXT.renameTeam.success, + "success", + ); }, onError: async (res) => { const code = await parseErrorCode(res); @@ -341,12 +338,18 @@ const TreeDetailView = ({ { onSuccess: () => { closeModal(); - showNotice("팀 삭제", "팀이 삭제되었습니다.", "success", () => { - onSelectTeam( - teams.find((t) => t.parentId === null && t.id !== selectedTeam.id) - ?.id ?? "", - ); - }); + showNotice( + NOTICE_TEXT.deleteTeam.title, + NOTICE_TEXT.deleteTeam.success, + "success", + () => { + onSelectTeam( + teams.find( + (t) => t.parentId === null && t.id !== selectedTeam.id, + )?.id ?? "", + ); + }, + ); }, onError: async (res) => { const code = await parseErrorCode(res); @@ -362,11 +365,15 @@ const TreeDetailView = ({ { onSuccess: () => { closeModal(); - showNotice("멤버 추가", "멤버를 추가했습니다.", "success"); + showNotice( + NOTICE_TEXT.addTeamMember.title, + NOTICE_TEXT.addTeamMember.success, + "success", + ); }, onError: async (res) => { const code = await parseErrorCode(res); - setAddError(ADD_REASON[code] ?? "멤버 추가에 실패했습니다."); + setAddError(ADD_MEMBER_REASON[code] ?? "멤버 추가에 실패했습니다."); }, }, ); @@ -411,8 +418,8 @@ const TreeDetailView = ({ onError: () => { closeModal(); showNotice( - MODAL_TITLES.roleChange, - "권한 변경에 실패했습니다.", + NOTICE_TEXT.roleChange.title, + NOTICE_TEXT.roleChange.failure, "error", ); }, @@ -434,8 +441,8 @@ const TreeDetailView = ({ ); } else { showNotice( - MODAL_TITLES.removeMembership, - "멤버십이 제거되었습니다.", + NOTICE_TEXT.removeMembership.title, + NOTICE_TEXT.removeMembership.success, "success", ); } @@ -443,8 +450,8 @@ const TreeDetailView = ({ onError: () => { closeModal(); showNotice( - MODAL_TITLES.removeMembership, - "멤버십 제거에 실패했습니다.", + NOTICE_TEXT.removeMembership.title, + NOTICE_TEXT.removeMembership.failure, "error", ); }, @@ -553,7 +560,7 @@ const TreeDetailView = ({ scrollClassName="min-h-[526px]" foot={
diff --git a/frontend/src/components/teams/teamOptions.ts b/frontend/src/components/teams/teamOptions.ts index 2415780..2a468d0 100644 --- a/frontend/src/components/teams/teamOptions.ts +++ b/frontend/src/components/teams/teamOptions.ts @@ -1,3 +1,4 @@ +import { TEAM_MEMBER_ROLE } from "@/constants/apiConstants"; import type { TDropdownOption } from "@/types/commonTypes"; import type { TTeamTree } from "@/types/teamTypes"; @@ -9,9 +10,9 @@ export const TEAM_NAME_RULE_TEXT = /** Grantable member roles (Admin is console-account only — API §0). */ export const ROLE_OPTIONS: TDropdownOption[] = [ - { value: "edit", label: "edit" }, - { value: "write", label: "write" }, - { value: "read", label: "read" }, + { value: TEAM_MEMBER_ROLE.edit, label: TEAM_MEMBER_ROLE.edit }, + { value: TEAM_MEMBER_ROLE.write, label: TEAM_MEMBER_ROLE.write }, + { value: TEAM_MEMBER_ROLE.read, label: TEAM_MEMBER_ROLE.read }, ]; /** All teams in tree order with depth indent (for team-picker dropdowns). diff --git a/frontend/src/components/update/UpdateFloatingCard.tsx b/frontend/src/components/update/UpdateFloatingCard.tsx index 573d808..96f8235 100644 --- a/frontend/src/components/update/UpdateFloatingCard.tsx +++ b/frontend/src/components/update/UpdateFloatingCard.tsx @@ -9,6 +9,7 @@ import { useUpdateQuery, } from "@/hooks/queries/useUpdateQuery"; import { reloadPage } from "@/utils/reloadPage"; +import { SYSTEM_UPDATE_STATE } from "@/constants/apiConstants"; import { BTN_TEXT } from "@/constants/commonConstants"; const DISMISSED_KEY_PREFIX = "runeconsole.system-update.dismissed:"; @@ -88,7 +89,8 @@ const UpdateFloatingCard = () => { if (!status || !queuedTarget) return; const installed = status.currentVersion === queuedTarget; const sameJobSucceeded = - status.targetVersion === queuedTarget && status.state === "succeeded"; + status.targetVersion === queuedTarget && + status.state === SYSTEM_UPDATE_STATE.succeeded; if (!installed && !sameJobSucceeded) return; // Clear first: the next SPA must not enter a reload loop if the helper @@ -103,7 +105,9 @@ const UpdateFloatingCard = () => { const targetVersion = status.targetVersion; const serverActive = isSystemUpdateActive(status.state); const busy = serverActive || updateMutation.isPending; - const failed = !busy && (status.state === "failed" || updateMutation.isError); + const failed = + !busy && + (status.state === SYSTEM_UPDATE_STATE.failed || updateMutation.isError); const initiallyEligible = status.capable && status.updateAvailable; if (!busy && !initiallyEligible) return null; @@ -157,7 +161,7 @@ const UpdateFloatingCard = () => { > - {status.state === "running" + {status.state === SYSTEM_UPDATE_STATE.running ? "백업 및 업데이트를 진행하고 있습니다…" : "업데이트를 준비하고 있습니다…"} diff --git a/frontend/src/components/users/MemberDetailDrawer.tsx b/frontend/src/components/users/MemberDetailDrawer.tsx index 1d711e4..26192f0 100644 --- a/frontend/src/components/users/MemberDetailDrawer.tsx +++ b/frontend/src/components/users/MemberDetailDrawer.tsx @@ -23,7 +23,17 @@ import RoleChangeConfirmModal from "@/components/users/RoleChangeConfirmModal"; import SessionDeactivateModal from "@/components/users/SessionDeactivateModal"; import { parseErrorCode } from "@/api/parseError"; import { formatDate, formatDateTime } from "@/utils/formatDate"; -import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; +import { + ERROR_CODES, + INVITATION_STATUS, + SESSION_STATUS, +} from "@/constants/apiConstants"; +import { BTN_TEXT } from "@/constants/commonConstants"; +import { + BATCH_REASON, + BATCH_REASON_FALLBACK, +} from "@/constants/errorConstants"; +import { NOTICE_TEXT } from "@/constants/noticeConstants"; import { INVITATION_STATUS_VAR } from "@/constants/styleConstants"; import type { TBatchResult, TTeamTree } from "@/types/teamTypes"; import type { TUserListItem } from "@/types/userTypes"; @@ -46,14 +56,14 @@ const styles = { /** Per-status header timestamp (SC-13 no.1 — D13). Session takes priority: an online member shows last access; otherwise the invitation axis drives it. */ const subtitleFor = (user: TUserListItem): string => { - if (user.sessionStatus === "online") { + if (user.sessionStatus === SESSION_STATUS.online) { return `최근 접속 ${formatDate(user.lastAccessAt)}`; } switch (user.invitationStatus) { - case "invite_redeemed": + case INVITATION_STATUS.redeemed: return "초대 코드 사용됨 · 연결 대기 중"; - case "invite_pending": - case "invite_expired": + case INVITATION_STATUS.pending: + case INVITATION_STATUS.expired: return `최근 초대 코드 발송 ${formatDateTime(user.lastInvitedAt)}`; } }; @@ -75,16 +85,6 @@ type TDrawerModal = | "cancel-invitation" | null; -/** Batch-endpoint failure reasons shown by team name (SC-13 — shared - with the team-side codes; the drawer only ever sees these two). */ -const BATCH_REASON: Record = { - TEAM_NOT_FOUND: "팀을 찾을 수 없습니다", - NOT_TEAM_MEMBER: "팀 멤버가 아닙니다", -}; -// Any other code (e.g. a transient INTERNAL) shows a generic retry message -// rather than leaking the raw backend code into the failure modal. -const BATCH_REASON_FALLBACK = "처리에 실패했습니다. 다시 시도해 주세요."; - interface MemberDetailDrawerProps { user: TUserListItem; onClose: () => void; @@ -178,11 +178,15 @@ const MemberDetailDrawer = ({ setResending(true); try { await onResendCode(); - showNotice("초대 코드 재전송", "초대 코드를 재전송했습니다.", "info"); + showNotice( + NOTICE_TEXT.resendInvitation.title, + NOTICE_TEXT.resendInvitation.success, + "info", + ); } catch { showNotice( - "초대 코드 재전송", - "초대 코드 재전송에 실패했습니다. 다시 시도해 주세요.", + NOTICE_TEXT.resendInvitation.title, + NOTICE_TEXT.resendInvitation.failure, "error", ); } finally { @@ -222,15 +226,19 @@ const MemberDetailDrawer = ({ checked: false, }, ]); - showNotice("팀 추가", "팀에 추가되었습니다.", "info"); + showNotice( + NOTICE_TEXT.addMembership.title, + NOTICE_TEXT.addMembership.success, + "info", + ); resetAdd(); } catch (err) { const code = err instanceof Response ? await parseErrorCode(err) : ""; showNotice( - "팀 추가", - code === "ALREADY_TEAM_MEMBER" - ? "이미 소속된 팀입니다." - : "팀 추가에 실패했습니다. 다시 시도해 주세요.", + NOTICE_TEXT.addMembership.title, + code === ERROR_CODES.ALREADY_TEAM_MEMBER + ? NOTICE_TEXT.addMembership.alreadyMember + : NOTICE_TEXT.addMembership.failure, "error", ); } finally { @@ -284,7 +292,7 @@ const MemberDetailDrawer = ({ btnSize="sm" btnColor="grayOutline" className="w-fit" - disabled={user.invitationStatus !== "invite_pending"} + disabled={user.invitationStatus !== INVITATION_STATUS.pending} handleClick={() => setOpenModal("cancel-invitation")} />
@@ -448,7 +456,7 @@ const MemberDetailDrawer = ({ btnSize="sm" btnColor="redOutline" className="w-fit" - disabled={user.sessionStatus !== "online"} + disabled={user.sessionStatus !== SESSION_STATUS.online} handleClick={() => setOpenModal("deactivate")} /> - {Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => ( + {pageWindow(page, totalPages).map((n) => (