diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2a90430..e94cb4c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,19 +1,25 @@ +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"; import RequireAuth from "@/components/auth/RequireAuth"; import NoticeModal from "@/components/elements/NoticeModal"; +import ToastContainer from "@/components/elements/ToastContainer"; 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/elements/NoticeModal.tsx b/frontend/src/components/elements/NoticeModal.tsx index a789aee..3dc697f 100644 --- a/frontend/src/components/elements/NoticeModal.tsx +++ b/frontend/src/components/elements/NoticeModal.tsx @@ -1,8 +1,8 @@ import Button from "@/components/elements/Button"; import ModalLayout from "@/components/layout/ModalLayout"; +import { useNoticeStore } from "@/state/store/noticeStore"; import { cn } from "@/utils/cn"; import { BTN_TEXT } from "@/constants/commonConstants"; -import { useNoticeStore } from "@/stores/noticeStore"; /** * NoticeModal is the shared blocking result-notice modal. The title names diff --git a/frontend/src/components/elements/Pagination.tsx b/frontend/src/components/elements/Pagination.tsx index 2195a30..99eec5b 100644 --- a/frontend/src/components/elements/Pagination.tsx +++ b/frontend/src/components/elements/Pagination.tsx @@ -10,6 +10,15 @@ const styles = { numActive: "bg-mint text-on-mint font-semibold hover:text-on-mint", }; +/** Sliding 5-page window centered on the current page, clamped at both + ends so exactly min(5, totalPages) numbers always show — the button + count never jumps while paging (1→[1..5], 4→[2..6], 9/10→[6..10]). */ +const pageWindow = (page: number, totalPages: number): number[] => { + const size = Math.min(5, totalPages); + const start = Math.min(Math.max(1, page - 2), totalPages - size + 1); + return Array.from({ length: size }, (_, i) => start + i); +}; + interface PaginationProps { page: number; totalPages: number; @@ -19,8 +28,10 @@ interface PaginationProps { /** * Pagination is the numbered pager (wireframe spec form): ‹ 1 2 3 ›. - * The current page is filled mint; boundary arrows disable. Page-count - * ellipsis (…) is deferred until a screen needs it. + * The current page is filled mint; boundary arrows disable. Large page + * counts show a sliding 5-page window around the current page — the + * session-history table grows without bound, so an unwindowed row of + * hundreds of buttons is not an option. */ const Pagination = ({ page, @@ -39,7 +50,7 @@ const Pagination = ({ > ‹ - {Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => ( + {pageWindow(page, totalPages).map((n) => ( { +const WorkspaceStatus = ({ + status, + onClick, + className, +}: WorkspaceStatusProps) => { const classes = cn( "text-tag inline-flex h-[26px] w-fit cursor-pointer items-center rounded-full border border-current px-2 whitespace-nowrap", WORKSPACE_STATUS_VAR[status].color, diff --git a/frontend/src/components/elements/__tests__/NoticeModal.test.tsx b/frontend/src/components/elements/__tests__/NoticeModal.test.tsx index 911c7e3..e20c02f 100644 --- a/frontend/src/components/elements/__tests__/NoticeModal.test.tsx +++ b/frontend/src/components/elements/__tests__/NoticeModal.test.tsx @@ -2,8 +2,8 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import NoticeModal from "@/components/elements/NoticeModal"; +import { useNoticeStore } from "@/state/store/noticeStore"; import { BTN_TEXT } from "@/constants/commonConstants"; -import { useNoticeStore } from "@/stores/noticeStore"; afterEach(() => { useNoticeStore.setState({ notice: null }); diff --git a/frontend/src/components/elements/__tests__/Pagination.test.tsx b/frontend/src/components/elements/__tests__/Pagination.test.tsx index 1cd06a9..d94e0ab 100644 --- a/frontend/src/components/elements/__tests__/Pagination.test.tsx +++ b/frontend/src/components/elements/__tests__/Pagination.test.tsx @@ -18,6 +18,36 @@ describe("Pagination", () => { ); }); + it("shows a sliding 5-page window centered on the current page", () => { + render( {}} />); + /* Visible: 23 24 [25] 26 27 — always exactly five numbers. */ + for (const n of ["23", "24", "25", "26", "27"]) { + expect(screen.getByRole("button", { name: n })).toBeInTheDocument(); + } + expect(screen.queryByRole("button", { name: "1" })).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "50" }), + ).not.toBeInTheDocument(); + }); + + it("clamps the window at both ends so the button count never jumps", () => { + /* Head: page 1 of 53 → 1 2 3 4 5. */ + const { rerender } = render( + {}} />, + ); + for (const n of ["1", "2", "3", "4", "5"]) { + expect(screen.getByRole("button", { name: n })).toBeInTheDocument(); + } + expect(screen.queryByRole("button", { name: "6" })).not.toBeInTheDocument(); + + /* Near the tail: page 9 of 10 → 6 7 8 9 10. */ + rerender( {}} />); + for (const n of ["6", "7", "8", "9", "10"]) { + expect(screen.getByRole("button", { name: n })).toBeInTheDocument(); + } + expect(screen.queryByRole("button", { name: "5" })).not.toBeInTheDocument(); + }); + it("disables prev on the first page and next on the last", () => { const { rerender } = render( {}} />, diff --git a/frontend/src/components/toast/__tests__/ToastContainer.test.tsx b/frontend/src/components/elements/__tests__/ToastContainer.test.tsx similarity index 93% rename from frontend/src/components/toast/__tests__/ToastContainer.test.tsx rename to frontend/src/components/elements/__tests__/ToastContainer.test.tsx index c2cf909..822e096 100644 --- a/frontend/src/components/toast/__tests__/ToastContainer.test.tsx +++ b/frontend/src/components/elements/__tests__/ToastContainer.test.tsx @@ -1,8 +1,8 @@ import { act, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import ToastContainer from "@/components/toast/ToastContainer"; -import { useToastStore } from "@/stores/toastStore"; +import ToastContainer from "@/components/elements/ToastContainer"; +import { useToastStore } from "@/state/store/toastStore"; describe("ToastContainer", () => { beforeEach(() => { diff --git a/frontend/src/components/navigation/Navbar.tsx b/frontend/src/components/navigation/Navbar.tsx index bae937e..7b72315 100644 --- a/frontend/src/components/navigation/Navbar.tsx +++ b/frontend/src/components/navigation/Navbar.tsx @@ -8,12 +8,12 @@ import WorkspaceModal from "@/components/workspace/WorkspaceModal"; import { useSessionQuery } from "@/hooks/queries/useSessionQuery"; import { useWorkspaceQuery } from "@/hooks/queries/useWorkspaceQuery"; import { postLogout } from "@/api/authAPIs"; +import { useWorkspaceStore } from "@/state/store/workspaceStore"; import { BRAND_WORDMARK, PATH_LIST, QUERY_KEYS, } from "@/constants/commonConstants"; -import { useWorkspaceStore } from "@/stores/workspaceStore"; /** * Navbar is the console top bar (SC-03). Its background and bottom border diff --git a/frontend/src/components/navigation/__tests__/Navbar.test.tsx b/frontend/src/components/navigation/__tests__/Navbar.test.tsx index c9a1628..3af4056 100644 --- a/frontend/src/components/navigation/__tests__/Navbar.test.tsx +++ b/frontend/src/components/navigation/__tests__/Navbar.test.tsx @@ -8,8 +8,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import Navbar from "@/components/navigation/Navbar"; import * as authAPIs from "@/api/authAPIs"; import * as workspaceAPIs from "@/api/workspaceAPIs"; +import { useWorkspaceStore } from "@/state/store/workspaceStore"; import { BTN_TEXT } from "@/constants/commonConstants"; -import { useWorkspaceStore } from "@/stores/workspaceStore"; const jsonRes = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as unknown as Response; diff --git a/frontend/src/components/table/TableEmptyRow.tsx b/frontend/src/components/table/TableEmptyRow.tsx new file mode 100644 index 0000000..6c6aa46 --- /dev/null +++ b/frontend/src/components/table/TableEmptyRow.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; + +interface TableEmptyRowProps { + colSpan: number; + /** Per-table empty copy (검색 결과가 없습니다 · 이력이 없습니다 · …). */ + children: ReactNode; +} + +/** + * TableEmptyRow is the shared zero-rows state for server-paged tables — + * a full-width muted line under the header rule, matching + * TableLoadingRow's height so pending → empty never shifts the layout. + */ +const TableEmptyRow = ({ colSpan, children }: TableEmptyRowProps) => { + return ( + + + {children} + + + ); +}; + +export default TableEmptyRow; diff --git a/frontend/src/components/table/TableLoadingRow.tsx b/frontend/src/components/table/TableLoadingRow.tsx new file mode 100644 index 0000000..7249869 --- /dev/null +++ b/frontend/src/components/table/TableLoadingRow.tsx @@ -0,0 +1,23 @@ +interface TableLoadingRowProps { + colSpan: number; +} + +/** + * TableLoadingRow is the shared pending state for server-paged tables — + * one full-width faint line so every list (users, sessions, team + * members) waits with the same height and copy. + */ +const TableLoadingRow = ({ colSpan }: TableLoadingRowProps) => { + return ( + + + 불러오는 중… + + + ); +}; + +export default TableLoadingRow; diff --git a/frontend/src/components/teams/AddMemberModal.tsx b/frontend/src/components/teams/AddMemberModal.tsx index 518bfff..73167f2 100644 --- a/frontend/src/components/teams/AddMemberModal.tsx +++ b/frontend/src/components/teams/AddMemberModal.tsx @@ -6,16 +6,21 @@ import Input from "@/components/elements/Input"; import MemberStatus from "@/components/elements/MemberStatus"; import Notice from "@/components/elements/Notice"; import ModalLayout from "@/components/layout/ModalLayout"; -import { ROLE_OPTIONS } from "@/components/teams/teamOptions"; +import { EMAIL_FORMAT_ERROR, EMAIL_PATTERN } from "@/utils/email"; import { isSubmittableUsername, normalizeUsernameInput, USERNAME_MAX_LENGTH, validateUsername, } from "@/utils/username"; -import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; - -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +import { + BTN_TEXT, + INPUT_LABELS, + MODAL_TITLES, + PLACEHOLDERS, +} from "@/constants/commonConstants"; +import { MODAL_STYLE_VAR } from "@/constants/styleConstants"; +import { ROLE_OPTIONS } from "@/constants/teamConstants"; interface AddMemberModalProps { teamName: string; @@ -56,18 +61,18 @@ const AddMemberModal = ({ setUsername(normalizeUsernameInput(value))} @@ -75,7 +80,7 @@ const AddMemberModal = ({ /> {error && {error}} - + - + {error && {error}} - + - + 하위 팀이 있는 팀은 삭제할 수 없습니다. 하위 팀을 먼저 삭제한 후 다시 시도해 주세요. @@ -112,7 +117,7 @@ const DeleteTeamModal = ({ return ( - + 삭제하려는 팀의 기억 처리 방식을 선택해 주세요. @@ -136,7 +141,7 @@ const DeleteTeamModal = ({ {error}} - + void; - onConfirm: () => void; -} - -const styles = { - table: "w-full border-collapse text-sm", - th: "border-border text-faint border px-3 py-1.5 text-left font-mono text-tag font-medium tracking-[0.08em]", - td: "border-border text-muted-foreground border px-3 py-1.5", -}; - -/** - * RemoveMembershipModal is the 멤버십 제거 confirmation (SC-14): lists - * exactly the memberships being removed (account · team · role) — only - * what is listed is removed, no sub-team cascade (C10). Mount - * conditionally. - */ -const RemoveMembershipModal = ({ - teamName, - members, - onClose, - onConfirm, -}: RemoveMembershipModalProps) => { - return ( - - - 다음 멤버십을 제거합니다: - - - - 멤버 이름 - 팀 - 권한 - - - - {members.map((member) => ( - - {member.account} - {teamName} - {member.role} - - ))} - - - - 하위 팀 소속은 유지됩니다. 필요할 경우 개별 선택 후 제거하세요. - - - - - - - - ); -}; - -export default RemoveMembershipModal; diff --git a/frontend/src/components/teams/RenameTeamModal.tsx b/frontend/src/components/teams/RenameTeamModal.tsx index 828c00e..19cdeb4 100644 --- a/frontend/src/components/teams/RenameTeamModal.tsx +++ b/frontend/src/components/teams/RenameTeamModal.tsx @@ -4,11 +4,13 @@ import Button from "@/components/elements/Button"; import Input from "@/components/elements/Input"; import Notice from "@/components/elements/Notice"; import ModalLayout from "@/components/layout/ModalLayout"; +import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; +import { TEAM_NAME_DUPLICATE_TEXT } from "@/constants/errorConstants"; +import { MODAL_STYLE_VAR } from "@/constants/styleConstants"; import { TEAM_NAME_PATTERN, TEAM_NAME_RULE_TEXT, -} from "@/components/teams/teamOptions"; -import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; +} from "@/constants/teamConstants"; import type { TTeamTree } from "@/types/teamTypes"; interface RenameTeamModalProps { @@ -58,12 +60,12 @@ const RenameTeamModal = ({ const nameError = isInvalidFormat ? TEAM_NAME_RULE_TEXT : isDuplicate - ? "같은 상위 팀에 동일한 이름이 이미 있습니다." + ? TEAM_NAME_DUPLICATE_TEXT : undefined; return ( - + {error && {error}} - + void; - onConfirm: () => void; -} - -const styles = { - table: "w-full border-collapse text-sm", - th: "border-border text-faint border px-3 py-1.5 text-left font-mono text-tag font-medium tracking-[0.08em]", - td: "border-border text-muted-foreground border px-3 py-1.5", - arrow: "text-faint px-1", - to: "text-foreground font-semibold", -}; - -/** - * RoleChangeConfirmModal is the role 변경 confirmation (SC-06 state E): - * staged dropdown edits are listed (account · current → new) and only - * applied on [변경하기]. Mount conditionally. - */ -const RoleChangeConfirmModal = ({ - changes, - onClose, - onConfirm, -}: RoleChangeConfirmModalProps) => { - return ( - - - 다음 멤버의 권한을 변경합니다: - - - - 멤버 이름 - 권한 변경 - - - - {changes.map((change) => ( - - {change.account} - - {change.from} - - → - - {change.to} - - - ))} - - - - - - - - - ); -}; - -export default RoleChangeConfirmModal; diff --git a/frontend/src/components/teams/TeamCard.tsx b/frontend/src/components/teams/TeamCard.tsx new file mode 100644 index 0000000..409ef72 --- /dev/null +++ b/frontend/src/components/teams/TeamCard.tsx @@ -0,0 +1,60 @@ +import Button from "@/components/elements/Button"; +import { formatDate } from "@/utils/formatDate"; +import { BTN_TEXT } from "@/constants/commonConstants"; + +const styles = { + card: "border-border bg-surface rounded-lg border px-4 py-3", + row: "flex items-center gap-2", + name: "text-lg flex-1 font-semibold", + meta: "text-sm text-muted-foreground mt-1.5", +}; + +interface TeamCardProps { + name: string; + parentName: string; + childrenLabel: string; + memberCount: number; + createdAt?: string; + onRename: () => void; + onDelete: () => void; +} + +/** TeamCard is the selected-team summary card (SC-06 no.6–8): name + + rename/delete actions and the parent/children/member/created meta line. */ +const TeamCard = ({ + name, + parentName, + childrenLabel, + memberCount, + createdAt, + onRename, + onDelete, +}: TeamCardProps) => { + return ( + + + {name} + + + + + 상위 팀: {parentName} | 하위 팀: {childrenLabel} | 멤버: {memberCount}명 + | 생성일: {formatDate(createdAt)} + + + ); +}; + +export default TeamCard; diff --git a/frontend/src/components/teams/TeamMembersTable.tsx b/frontend/src/components/teams/TeamMembersTable.tsx new file mode 100644 index 0000000..ad8971e --- /dev/null +++ b/frontend/src/components/teams/TeamMembersTable.tsx @@ -0,0 +1,169 @@ +import Checkbox from "@/components/elements/Checkbox"; +import Dropdown from "@/components/elements/Dropdown"; +import MemberStatus from "@/components/elements/MemberStatus"; +import Pagination from "@/components/elements/Pagination"; +import Table from "@/components/table/Table"; +import TableCell from "@/components/table/TableCell"; +import TableEmptyRow from "@/components/table/TableEmptyRow"; +import TableErrorRow from "@/components/table/TableErrorRow"; +import TableFoot from "@/components/table/TableFoot"; +import TableHead from "@/components/table/TableHead"; +import TableHeaderCell from "@/components/table/TableHeaderCell"; +import TableLoadingRow from "@/components/table/TableLoadingRow"; +import TableRow from "@/components/table/TableRow"; +import { formatDate } from "@/utils/formatDate"; +import { + ARIA_LABELS, + DEFAULT_PAGE_SIZE, + TABLE_HEADERS, +} from "@/constants/commonConstants"; +import { ROLE_OPTIONS } from "@/constants/teamConstants"; +import { CHIP_STATUS } from "@/constants/userConstants"; +import type { TTeamMember } from "@/types/teamTypes"; + +const styles = { + /* The detail panel is narrower than the users page — typical names + fit the 36% column; longer ones truncate with an ellipsis and + keep the full name in the title tooltip. */ + usernameCell: "max-w-[280px] truncate cursor-default", + timeCell: "text-faint font-mono text-xs whitespace-nowrap", +}; + +interface TeamMembersTableProps { + members: TTeamMember[]; + isPending: boolean; + isError: boolean; + total: number; + page: number; + totalPages: number; + onPageChange: (page: number) => void; + selectedIds: Set; + onToggleOne: (userId: string, checked: boolean) => void; + /** Header select-all over the current page's rows. */ + onToggleAll: (checked: boolean) => void; + /** Row is highlighted while its role pick is staged (unapplied). */ + isRoleStaged: (userId: string) => boolean; + /** Displayed role — the staged pick or the committed baseline. */ + roleOf: (member: TTeamMember) => string; + onRoleChange: (member: TTeamMember, nextRole: string) => void; +} + +/** + * TeamMembersTable is the SC-06 member table (no.11–13): page-scoped + * checkbox selection, per-row staged role dropdowns, and the fixed + * 10-per-page pagination. Pure view — staging/selection state lives in + * the parent's hooks. + */ +const TeamMembersTable = ({ + members, + isPending, + isError, + total, + page, + totalPages, + onPageChange, + selectedIds, + onToggleOne, + onToggleAll, + isRoleStaged, + roleOf, + onRoleChange, +}: TeamMembersTableProps) => { + const allSelected = + members.length > 0 && members.every((m) => selectedIds.has(m.userId)); + + return ( + + + + + + } + > + + + + + {/* Fixed column widths — auto layout would resize per + page's content and shift the headers while paginating. */} + + {TABLE_HEADERS.memberName} + + + {TABLE_HEADERS.memberStatus} + + + {TABLE_HEADERS.roleAlt} + + + {TABLE_HEADERS.joinedAt} + + + + {isPending ? ( + + ) : isError ? ( + + ) : total === 0 ? ( + 멤버가 없습니다. + ) : ( + members.map((member) => ( + + + onToggleOne(member.userId, checked)} + ariaLabel={`${member.account} 선택`} + /> + + + {member.username} + + + + + + onRoleChange(member, next)} + size="sm" + changed={isRoleStaged(member.userId)} + ariaLabel={`${member.account} role`} + className="w-24" + /> + + + {formatDate(member.joinedAt)} + + + )) + )} + + + ); +}; + +export default TeamMembersTable; diff --git a/frontend/src/components/teams/TeamMembersToolbar.tsx b/frontend/src/components/teams/TeamMembersToolbar.tsx new file mode 100644 index 0000000..4dea490 --- /dev/null +++ b/frontend/src/components/teams/TeamMembersToolbar.tsx @@ -0,0 +1,75 @@ +import Button from "@/components/elements/Button"; +import { BTN_TEXT } from "@/constants/commonConstants"; + +const styles = { + row: "flex items-center gap-2", + title: "text-md flex-1 font-semibold", + actions: "flex flex-wrap items-center gap-2", +}; + +interface TeamMembersToolbarProps { + total: number; + /** Staged (not yet applied) role picks — arms 초기화/업데이트. */ + pendingCount: number; + /** Checked rows — arms 제거하기. */ + selectedCount: number; + onResetChanges: () => void; + onUpdateChanges: () => void; + onRemove: () => void; + onAddMember: () => void; +} + +/** TeamMembersToolbar is the 멤버 section header (SC-06 no.9–10): count + + the staged-change / removal / add actions. */ +const TeamMembersToolbar = ({ + total, + pendingCount, + selectedCount, + onResetChanges, + onUpdateChanges, + onRemove, + onAddMember, +}: TeamMembersToolbarProps) => { + return ( + + 멤버 ({total}){" "} + + {/* Drops every staged (not yet applied) dropdown pick back to + its saved role — the committed savedRoles baseline stays. */} + + + + + + + ); +}; + +export default TeamMembersToolbar; diff --git a/frontend/src/components/teams/TreeDetailView.tsx b/frontend/src/components/teams/TreeDetailView.tsx index c1d4068..a6d1297 100644 --- a/frontend/src/components/teams/TreeDetailView.tsx +++ b/frontend/src/components/teams/TreeDetailView.tsx @@ -1,45 +1,55 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import Button from "@/components/elements/Button"; -import Checkbox from "@/components/elements/Checkbox"; -import Dropdown from "@/components/elements/Dropdown"; -import MemberStatus from "@/components/elements/MemberStatus"; -import Pagination from "@/components/elements/Pagination"; -import Table from "@/components/table/Table"; -import TableCell from "@/components/table/TableCell"; -import TableErrorRow from "@/components/table/TableErrorRow"; -import TableFoot from "@/components/table/TableFoot"; -import TableHead from "@/components/table/TableHead"; -import TableHeaderCell from "@/components/table/TableHeaderCell"; -import TableRow from "@/components/table/TableRow"; import AddMemberModal from "@/components/teams/AddMemberModal"; import CreateTeamModal from "@/components/teams/CreateTeamModal"; import DeleteTeamModal from "@/components/teams/DeleteTeamModal"; import MemberBatchFailureModal from "@/components/teams/MemberBatchFailureModal"; -import RemoveMembershipModal from "@/components/teams/RemoveMembershipModal"; import RenameTeamModal from "@/components/teams/RenameTeamModal"; -import RoleChangeConfirmModal from "@/components/teams/RoleChangeConfirmModal"; -import { ROLE_OPTIONS } from "@/components/teams/teamOptions"; +import TeamCard from "@/components/teams/TeamCard"; +import TeamMembersTable from "@/components/teams/TeamMembersTable"; +import TeamMembersToolbar from "@/components/teams/TeamMembersToolbar"; import TeamTree from "@/components/tree/TeamTree"; +import MembershipRemoveModal from "@/components/users/MembershipRemoveModal"; +import RoleChangeConfirmModal from "@/components/users/RoleChangeConfirmModal"; import { useAddTeamMemberMutation, useBulkRoleChangeMutation, useRemoveTeamMembersMutation, } from "@/hooks/mutations/useTeamMemberMutations"; -import { - useCreateTeamMutation, - useDeleteTeamMutation, - useRenameTeamMutation, -} from "@/hooks/mutations/useTeamMutations"; import { useTeamMembersQuery } from "@/hooks/queries/useTeamMembersQuery"; import { useTeamQuery } from "@/hooks/queries/useTeamQuery"; +import { + toBatchFailureRows, + useBatchFailureModal, +} from "@/hooks/useBatchFailureModal"; +import { usePageScopedSelection } from "@/hooks/usePageScopedSelection"; +import { + useServerPagination, + useSyncPaginationTotal, +} from "@/hooks/useServerPagination"; +import { useStagedRoleEdits } from "@/hooks/useStagedRoleEdits"; +import { useTeamCrud } from "@/hooks/useTeamCrud"; 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"; +import { useNoticeStore } from "@/state/store/noticeStore"; +import { + ancestorIds, + buildTeamNodes, + findTeamNode, +} from "@/utils/teamHierarchy"; +import { TEAM_MEMBER_ROLE } from "@/constants/apiConstants"; +import { + BTN_TEXT, + DEFAULT_PAGE_SIZE, + TABLE_HEADERS, +} from "@/constants/commonConstants"; +import { + ADD_MEMBER_REASON, + BATCH_REASON_FALLBACK, +} from "@/constants/errorConstants"; +import { NOTICE_TEXT } from "@/constants/noticeConstants"; import type { TTeamMemberRole, TTeamTree } from "@/types/teamTypes"; -import { useNoticeStore } from "@/stores/noticeStore"; +import type { TRoleChange } from "@/types/userTypes"; const styles = { body: "flex min-h-[340px] flex-1", @@ -47,18 +57,6 @@ const styles = { side: "border-border flex w-50 flex-none flex-col gap-2.5 border-r p-3", /* Right detail area */ main: "flex min-w-0 flex-1 flex-col gap-5 p-4", - teamCard: "border-border bg-surface rounded-lg border px-4 py-3", - teamCardRow: "flex items-center gap-2", - teamName: "text-lg flex-1 font-semibold", - teamMeta: "text-sm text-muted-foreground mt-1.5", - membersRow: "flex items-center gap-2", - membersTitle: "text-md flex-1 font-semibold", - /* The detail panel is narrower than the users page — typical names - fit the 36% column; longer ones truncate with an ellipsis and - keep the full name in the title tooltip. */ - usernameCell: "max-w-[280px] truncate cursor-default", - timeCell: "text-faint font-mono text-xs whitespace-nowrap", - pendingActions: "flex flex-wrap items-center gap-2", }; type TActiveModal = @@ -70,39 +68,13 @@ type TActiveModal = | "removeMembers" | null; -/** - * GET /teams/tree returns flat nodes — the client builds the recursive - * TTeamNode shape the TeamTree component consumes (API design §3). - */ -const buildTeamNodes = ( - teams: TTeamTree, - parentId: string | null, -): TTeamNode[] => - teams - .filter((team) => team.parentId === parentId) - .map((team) => ({ - id: team.id, - name: team.name, - members: team.memberCount, - children: - team.childCount > 0 ? buildTeamNodes(teams, team.id) : undefined, - })); - -const findTeamNode = (nodes: TTeamNode[], id: string): TTeamNode | undefined => - nodes.reduce( - (found, node) => - found ?? (node.id === id ? node : findTeamNode(node.children ?? [], id)), - 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 - * when the view toggle is on 트리·상세. + * when the view toggle is on 트리·상세. The view composes the shared + * hooks (selection, pagination, staged role edits, team CRUD, batch + * failures) and hands rendering to TeamCard/TeamMembersToolbar/ + * TeamMembersTable. */ interface TreeDetailViewProps { /** Flat GET /teams/tree nodes — owned by TeamsPage. Always non-empty @@ -117,43 +89,37 @@ interface TreeDetailViewProps { onSelectTeam: (teamId: string) => void; } -/** Ancestor ids of a team — expanded so a selection handed off from - the org chart is actually visible in the tree. */ -const ancestorIds = (teams: TTeamTree, teamId: string): string[] => { - const flatById = new Map(teams.map((team) => [team.id, team])); - const ids: string[] = []; - let parentId = flatById.get(teamId)?.parentId; - while (parentId) { - ids.push(parentId); - parentId = flatById.get(parentId)?.parentId; - } - return ids; -}; - const TreeDetailView = ({ teams, teamSearch, selectedTeamId, onSelectTeam, }: TreeDetailViewProps) => { - const flatById = new Map(teams.map((t) => [t.id, t])); - const teamNodes = buildTeamNodes(teams, null); + /* Derived once per teams array — this component re-renders on every + keystroke/checkbox/staged edit, and the tree build must not re-run + for those (OrgChart applies the same rule). */ + const flatById = useMemo(() => new Map(teams.map((t) => [t.id, t])), [teams]); + const teamNodes = useMemo(() => buildTeamNodes(teams), [teams]); /* 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()); - const [page, setPage] = useState(1); - /* Role edits are staged (SC-06): dropdown picks collect here and only - apply on [변경사항 업데이트]. savedRoles is the committed baseline - (stands in for the PUT /teams/{id}/members batch until wired). */ - const [pendingRoles, setPendingRoles] = useState< - Map - >(new Map()); - const [savedRoles, setSavedRoles] = useState>( - new Map(), - ); + const { selectedIds, toggleOne, toggleAll, clearSelection } = + usePageScopedSelection(); + const { page, totalPages, setPage, resetPage, syncTotal } = + useServerPagination(); + const { + pendingRoles, + baseRole, + stageRole, + resetStaged, + resetAll, + applyAll, + reconcileBatch, + } = useStagedRoleEdits(); + const { batchFailures, showBatchFailures, closeBatchFailures } = + useBatchFailureModal(); + const showNotice = useNoticeStore((state) => state.showNotice); /* Switching teams must not leak the prior team's member-table state: without this, `page` can point past the new team's last page (no @@ -166,25 +132,27 @@ const TreeDetailView = ({ target (e.g. when the prop doesn't resolve and falls back to defaultTeam). */ useEffect(() => { - setPage(1); - setSelectedIds(new Set()); - setPendingRoles(new Map()); - setSavedRoles(new Map()); + resetPage(); + clearSelection(); + resetAll(); }, [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)); + useSyncPaginationTotal(syncTotal, total); const addMember = useAddTeamMemberMutation(selectedTeam.id); const bulkRole = useBulkRoleChangeMutation(selectedTeam.id); const removeMembers = useRemoveTeamMembersMutation(selectedTeam.id); - const createTeam = useCreateTeamMutation(); - const renameTeam = useRenameTeamMutation(selectedTeam.id); - const deleteTeam = useDeleteTeamMutation(selectedTeam.id); + /* Selected-team card meta — detail query first, flat tree row as the + immediate fallback while the detail loads. */ const flatTeam = flatById.get(selectedTeam.id); const parentName = detail?.parentId ? (flatById.get(detail.parentId)?.name ?? "없음") @@ -195,166 +163,38 @@ const TreeDetailView = ({ const childrenLabel = childCount ? `${childCount}개` : "없음"; const memberCount = detail?.memberCount ?? selectedTeam.members; - /* Select-all is page-scoped; selections persist across page moves. */ - const allSelected = - members.length > 0 && members.every((m) => selectedIds.has(m.userId)); - - const toggleAll = (checked: boolean) => - setSelectedIds((prev) => { - const next = new Set(prev); - members.forEach((m) => - checked ? next.add(m.userId) : next.delete(m.userId), - ); - return next; - }); - - const toggleOne = (userId: string, checked: boolean) => - setSelectedIds((prev) => { - const next = new Set(prev); - if (checked) next.add(userId); - else next.delete(userId); - return next; - }); - - const showNotice = useNoticeStore((state) => state.showNotice); - - const baseRole = (userId: string, fallback: TTeamMemberRole) => - savedRoles.get(userId) ?? fallback; - - const handleRoleChange = ( - userId: string, - fallback: TTeamMemberRole, - nextRole: string, - ) => - setPendingRoles((prev) => { - const next = new Map(prev); - if (nextRole === baseRole(userId, fallback)) next.delete(userId); - else next.set(userId, nextRole as TTeamMemberRole); - return next; - }); - - const applyRoleChanges = () => { - setSavedRoles((prev) => new Map([...prev, ...pendingRoles])); - setPendingRoles(new Map()); - showNotice( - MODAL_TITLES.roleChange, - "변경사항이 저장되었습니다.", - "success", - ); - }; - /* Modals (SC-07~10 + SC-06 state E). All confirm handlers below call their real mutations. */ const [activeModal, setActiveModal] = useState(null); const closeModal = () => setActiveModal(null); - /* Team CRUD (create/rename/delete) inline error — reset whenever a - modal opens or closes so a stale error from a prior attempt never - leaks into a fresh one. */ - const [teamError, setTeamError] = useState(null); + const { + teamError, + clearTeamError, + handleCreate, + handleRename, + handleDelete, + } = useTeamCrud({ + teamId: selectedTeam.id, + onDone: closeModal, + onDeleted: () => + onSelectTeam( + teams.find((t) => t.parentId === null && t.id !== selectedTeam.id) + ?.id ?? "", + ), + }); + /* Team CRUD inline error — reset whenever a modal opens or closes so a + stale error from a prior attempt never leaks into a fresh one. */ const openTeamModal = (modal: TActiveModal) => { - setTeamError(null); + clearTeamError(); setActiveModal(modal); }; const closeTeamModal = () => { - setTeamError(null); + clearTeamError(); 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"), - to, - }; - }); - - const handleCreate = (name: string, parentId: string | null) => { - setTeamError(null); - createTeam.mutate( - { name, parentId }, - { - onSuccess: () => { - closeModal(); - showNotice("팀 생성", "팀이 생성되었습니다.", "success"); - }, - onError: async (res) => { - const code = await parseErrorCode(res); - setTeamError(TEAM_REASON[code] ?? "팀 생성에 실패했습니다."); - }, - }, - ); - }; - const handleRename = (name: string) => { - setTeamError(null); - renameTeam.mutate( - { name }, - { - onSuccess: () => { - closeModal(); - showNotice("팀 이름 변경", "팀 이름이 변경되었습니다.", "success"); - }, - onError: async (res) => { - const code = await parseErrorCode(res); - setTeamError(TEAM_REASON[code] ?? "이름 변경에 실패했습니다."); - }, - }, - ); - }; - const handleDelete = ( - action: "purge" | "transfer", - targetTeamId?: string, - ) => { - setTeamError(null); - deleteTeam.mutate( - { memoryAction: action, targetTeamId }, - { - onSuccess: () => { - closeModal(); - showNotice("팀 삭제", "팀이 삭제되었습니다.", "success", () => { - onSelectTeam( - teams.find((t) => t.parentId === null && t.id !== selectedTeam.id) - ?.id ?? "", - ); - }); - }, - onError: async (res) => { - const code = await parseErrorCode(res); - setTeamError(TEAM_REASON[code] ?? "팀 삭제에 실패했습니다."); - }, - }, - ); - }; const handleInvite = (account: string, role: string, username: string) => { setAddError(null); addMember.mutate( @@ -362,100 +202,88 @@ 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] ?? "멤버 추가에 실패했습니다."); }, }, ); }; - const handleRoleConfirm = () => { + + const accountOf = (userId: string) => + members.find((m) => m.userId === userId)?.account ?? userId; + + /* Staged picks as confirm-modal rows (TRoleChange: label = account). */ + const roleChanges: TRoleChange[] = [...pendingRoles.entries()].map( + ([userId, to]) => { + const member = members.find((m) => m.userId === userId); + return { + label: member?.account ?? userId, + from: baseRole(userId, member?.role ?? TEAM_MEMBER_ROLE.read), + to, + }; + }, + ); + + /* The confirm modal owns the E-1/E-2 result view: a resolved promise + shows the in-modal success message, a rejected one the failure + message ([닫기] alone remains). Partial failures additionally open + the batch-failure modal, mirroring the SC-13 drawer flow. */ + const handleRoleConfirm = async () => { const updates = [...pendingRoles.entries()].map(([userId, role]) => ({ userId, role, })); - bulkRole.mutate( - { updates }, - { - onSuccess: (result) => { - closeModal(); - if (result.failed.length > 0) { - /* Only clear staging for what actually succeeded — keep the - failed entries pending so the user can retry them. */ - const failedIds = new Set(result.failed.map((f) => f.id)); - setSavedRoles( - (prev) => - new Map([ - ...prev, - ...[...pendingRoles.entries()].filter( - ([userId]) => !failedIds.has(userId), - ), - ]), - ); - setPendingRoles( - (prev) => - new Map([...prev].filter(([userId]) => failedIds.has(userId))), - ); - setBatchFailures( - result.failed.map((f) => ({ - account: accountOf(f.id), - reason: BATCH_REASON[f.code] ?? BATCH_REASON_FALLBACK, - })), - ); - } else { - applyRoleChanges(); - } - }, - onError: () => { - closeModal(); - showNotice( - MODAL_TITLES.roleChange, - "권한 변경에 실패했습니다.", - "error", - ); - }, - }, - ); + const result = await bulkRole.mutateAsync({ updates }); + if (result.failed.length > 0) { + reconcileBatch(new Set(result.failed.map((f) => f.id))); + showBatchFailures( + toBatchFailureRows( + result.failed, + accountOf, + () => BATCH_REASON_FALLBACK, + ), + ); + } else { + applyAll(); + } }; - const handleRemoveMembers = () => { + + /* The remove modal closes itself on resolve and swaps to its failure + view on reject — only the full-success notice and the partial-failure + modal are driven from here. */ + const handleRemoveMembers = async () => { const ids = [...selectedIds]; - removeMembers.mutate(ids, { - onSuccess: (result) => { - closeModal(); - setSelectedIds(new Set()); - if (result.failed.length > 0) { - setBatchFailures( - result.failed.map((f) => ({ - account: accountOf(f.id), - reason: BATCH_REASON[f.code] ?? f.code, - })), - ); - } else { - showNotice( - MODAL_TITLES.removeMembership, - "멤버십이 제거되었습니다.", - "success", - ); - } - }, - onError: () => { - closeModal(); - showNotice( - MODAL_TITLES.removeMembership, - "멤버십 제거에 실패했습니다.", - "error", - ); - }, - }); + const result = await removeMembers.mutateAsync(ids); + clearSelection(); + if (result.failed.length > 0) { + showBatchFailures( + toBatchFailureRows(result.failed, accountOf, (code) => code), + ); + } else { + showNotice( + NOTICE_TEXT.removeMembership.title, + NOTICE_TEXT.removeMembership.success, + "success", + ); + } }; - /* SC-14 payload: the checked members' account · current role. */ + /* SC-14 payload: the checked members' account × this team · current + role (TMembershipRemoveTarget — the SC-06 entry is members × the + one selected team). */ const membershipRemovals = members .filter((member) => selectedIds.has(member.userId)) .map((member) => ({ account: member.account, + teamId: selectedTeam.id, + teamName: selectedTeam.name, role: pendingRoles.get(member.userId) ?? baseRole(member.userId, member.role), })); @@ -476,183 +304,58 @@ const TreeDetailView = ({ query={teamSearch} selectedId={selectedTeam.id} onSelect={(node) => onSelectTeam(node.id)} - defaultExpandedIds={[ - "t_a", - "t_e", - ...ancestorIds(teams, selectedTeam.id), - ]} + defaultExpandedIds={ancestorIds(flatById, selectedTeam.id)} className="-mx-1 flex-1" /> {/* Detail area — selected team card + members section (SC-06 no.6–13) */} - - - - {detail?.name ?? selectedTeam.name} - - openTeamModal("rename")} - /> - openTeamModal("delete")} - /> - - - 상위 팀: {parentName} | 하위 팀: {childrenLabel} | 멤버:{" "} - {memberCount}명 | 생성일: {formatDate(detail?.createdAt)} - - + openTeamModal("rename")} + onDelete={() => openTeamModal("delete")} + /> - - 멤버 ({total}){" "} - - {/* Drops every staged (not yet applied) dropdown pick back to - its saved role — the committed savedRoles baseline stays. */} - setPendingRoles(new Map())} - /> - setActiveModal("roleConfirm")} - /> - setActiveModal("removeMembers")} - /> - setActiveModal("addMember")} - /> - - + setActiveModal("roleConfirm")} + onRemove={() => setActiveModal("removeMembers")} + onAddMember={() => setActiveModal("addMember")} + /> - - - - - + + toggleAll( + members.map((m) => m.userId), + checked, + ) + } + isRoleStaged={(userId) => pendingRoles.has(userId)} + roleOf={(member) => + pendingRoles.get(member.userId) ?? + baseRole(member.userId, member.role) } - > - - - - - {/* Fixed column widths — auto layout would resize per - page's content and shift the headers while paginating. */} - 멤버 이름 - 멤버 상태 - 역할 - 합류일 - - - {membersQuery.isPending ? ( - - - 불러오는 중… - - - ) : membersQuery.isError ? ( - - ) : total === 0 ? ( - - - 멤버가 없습니다. - - - ) : ( - members.map((member) => ( - - - toggleOne(member.userId, checked)} - ariaLabel={`${member.account} 선택`} - /> - - - {member.username} - - - - - - - handleRoleChange(member.userId, member.role, next) - } - size="sm" - changed={pendingRoles.has(member.userId)} - ariaLabel={`${member.account} role`} - className="w-24" - /> - - - {formatDate(member.joinedAt)} - - - )) - )} - - + onRoleChange={(member, next) => + stageRole(member.userId, member.role, next) + } + /> {/* Modals — mounted on demand so each opens with fresh state */} @@ -698,15 +401,16 @@ const TreeDetailView = ({ )} {activeModal === "roleConfirm" && ( )} {activeModal === "removeMembers" && ( - @@ -714,7 +418,7 @@ const TreeDetailView = ({ {batchFailures && ( setBatchFailures(null)} + onClose={closeBatchFailures} /> )} diff --git a/frontend/src/components/teams/__tests__/TreeDetailView.test.tsx b/frontend/src/components/teams/__tests__/TreeDetailView.test.tsx index 1720904..1121964 100644 --- a/frontend/src/components/teams/__tests__/TreeDetailView.test.tsx +++ b/frontend/src/components/teams/__tests__/TreeDetailView.test.tsx @@ -7,9 +7,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import TreeDetailView from "@/components/teams/TreeDetailView"; import * as teamAPIs from "@/api/teamAPIs"; import * as teamMemberAPIs from "@/api/teamMemberAPIs"; +import { useNoticeStore } from "@/state/store/noticeStore"; import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; import type { TTeamMember, TTeamTree } from "@/types/teamTypes"; -import { useNoticeStore } from "@/stores/noticeStore"; const jsonRes = (body: unknown) => ({ ok: true, json: async () => body }) as unknown as Response; @@ -554,7 +554,6 @@ describe("TreeDetailView", () => { vi.spyOn(teamMemberAPIs, "bulkRoleChange").mockResolvedValue( jsonRes({ succeeded: ["u_1"], failed: [] }), ); - const showNoticeSpy = vi.spyOn(useNoticeStore.getState(), "showNotice"); renderView(); await screen.findByText("김철수"); await user.click(screen.getByLabelText("kim@corp.com role")); @@ -563,13 +562,14 @@ describe("TreeDetailView", () => { screen.getByRole("button", { name: BTN_TEXT.updateChanges }), ); await user.click(screen.getByRole("button", { name: BTN_TEXT.change })); - await waitFor(() => - expect(showNoticeSpy).toHaveBeenCalledWith( - MODAL_TITLES.roleChange, - "변경사항이 저장되었습니다.", - "success", - ), - ); + /* SC-06 E-1: the result renders inside the confirm modal; [닫기] + alone remains. */ + expect( + await screen.findByText("권한이 변경되었습니다."), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: BTN_TEXT.change }), + ).not.toBeInTheDocument(); }); it("resets every staged role pick back to the saved value via 변경사항 초기화", async () => { @@ -634,7 +634,6 @@ describe("TreeDetailView", () => { status: 500, json: async () => ({ code: "INTERNAL", message: "x" }), } as unknown as Response); - const showNoticeSpy = vi.spyOn(useNoticeStore.getState(), "showNotice"); renderView(); await screen.findByText("김철수"); await user.click(screen.getByLabelText("kim@corp.com role")); @@ -643,13 +642,10 @@ describe("TreeDetailView", () => { screen.getByRole("button", { name: BTN_TEXT.updateChanges }), ); await user.click(screen.getByRole("button", { name: BTN_TEXT.change })); - await waitFor(() => - expect(showNoticeSpy).toHaveBeenCalledWith( - MODAL_TITLES.roleChange, - "권한 변경에 실패했습니다.", - "error", - ), - ); + /* SC-06 E-2: the failure message renders inside the confirm modal. */ + expect( + await screen.findByText("권한 변경에 실패했습니다. 다시 시도해 주세요."), + ).toBeInTheDocument(); }); it("shows a success notice when a full-success member removal completes", async () => { @@ -710,7 +706,6 @@ describe("TreeDetailView", () => { status: 500, json: async () => ({ code: "INTERNAL", message: "x" }), } as unknown as Response); - const showNoticeSpy = vi.spyOn(useNoticeStore.getState(), "showNotice"); renderView(); await screen.findByText("김철수"); await user.click( @@ -721,13 +716,12 @@ describe("TreeDetailView", () => { name: BTN_TEXT.remove, }); await user.click(confirmButtons[confirmButtons.length - 1]); - await waitFor(() => - expect(showNoticeSpy).toHaveBeenCalledWith( - MODAL_TITLES.removeMembership, - "멤버십 제거에 실패했습니다.", - "error", + /* The remove modal swaps to its in-modal failure view (state B). */ + expect( + await screen.findByText( + "멤버십 제거에 실패했습니다. 다시 시도해 주세요.", ), - ); + ).toBeInTheDocument(); }); it("shows the mapped inline error when deleting a childless team hits a server conflict", async () => { diff --git a/frontend/src/components/teams/__tests__/teamModals.test.tsx b/frontend/src/components/teams/__tests__/teamModals.test.tsx index 75ea277..9f0d39e 100644 --- a/frontend/src/components/teams/__tests__/teamModals.test.tsx +++ b/frontend/src/components/teams/__tests__/teamModals.test.tsx @@ -5,9 +5,9 @@ import { describe, expect, it, vi } from "vitest"; import AddMemberModal from "@/components/teams/AddMemberModal"; import CreateTeamModal from "@/components/teams/CreateTeamModal"; import DeleteTeamModal from "@/components/teams/DeleteTeamModal"; -import RemoveMembershipModal from "@/components/teams/RemoveMembershipModal"; import RenameTeamModal from "@/components/teams/RenameTeamModal"; -import RoleChangeConfirmModal from "@/components/teams/RoleChangeConfirmModal"; +import MembershipRemoveModal from "@/components/users/MembershipRemoveModal"; +import RoleChangeConfirmModal from "@/components/users/RoleChangeConfirmModal"; import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; import type { TTeamTree } from "@/types/teamTypes"; @@ -330,20 +330,28 @@ describe("AddMemberModal", () => { }); }); -describe("RemoveMembershipModal", () => { - it("lists removals, always shows the sub-team notice, confirms", async () => { +describe("MembershipRemoveModal (SC-06 entry)", () => { + it("lists removals with the sub-team notice and confirms", async () => { const user = userEvent.setup(); - const onConfirm = vi.fn(); + const onConfirm = vi.fn().mockResolvedValue(undefined); render( - {}} onConfirm={onConfirm} />, ); expect(screen.getByText(MODAL_TITLES.removeMembership)).toBeInTheDocument(); expect(screen.getByText("k@corp.com")).toBeInTheDocument(); + expect(screen.getByText("백엔드")).toBeInTheDocument(); expect(screen.getByText(/하위 팀 소속은 유지됩니다/)).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: BTN_TEXT.remove })); @@ -351,13 +359,14 @@ describe("RemoveMembershipModal", () => { }); }); -describe("RoleChangeConfirmModal", () => { - it("lists staged changes and confirms", async () => { +describe("RoleChangeConfirmModal (SC-06 entry)", () => { + it("lists staged changes and shows the in-modal result after 변경하기", async () => { const user = userEvent.setup(); - const onConfirm = vi.fn(); + const onConfirm = vi.fn().mockResolvedValue(undefined); render( {}} onConfirm={onConfirm} />, @@ -367,5 +376,12 @@ describe("RoleChangeConfirmModal", () => { await user.click(screen.getByRole("button", { name: BTN_TEXT.change })); expect(onConfirm).toHaveBeenCalled(); + /* E-1: the result renders inside the modal; [닫기] alone remains. */ + expect( + await screen.findByText("권한이 변경되었습니다."), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: BTN_TEXT.change }), + ).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/components/teams/teamHierarchy.ts b/frontend/src/components/teams/teamHierarchy.ts deleted file mode 100644 index b5d5d3c..0000000 --- a/frontend/src/components/teams/teamHierarchy.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { TTeamTree } from "@/types/teamTypes"; - -/** - * Team-tree lookups over a flat `TTeamTree` — shared by the invite preview - * (SC-12 no.3) and the membership-removal sub-team notice (SC-14 no.2). - * Pure functions over the tree passed in (from `useTeamsTreeQuery`); trees - * are small, so no memoized id-map is kept at module scope. - */ - -/** Team name for `teamId`, or the id itself if the team is unknown. */ -export const getTeamName = (teams: TTeamTree, teamId: string): string => - teams.find((team) => team.id === teamId)?.name ?? teamId; - -/** All descendant ids of a team, in depth-first tree order. */ -export const getTeamDescendantIds = ( - teams: TTeamTree, - teamId: string, -): string[] => - (teams.find((team) => team.id === teamId)?.childrenIds ?? []).flatMap( - (childId) => [childId, ...getTeamDescendantIds(teams, childId)], - ); diff --git a/frontend/src/components/tree/TeamTree.tsx b/frontend/src/components/tree/TeamTree.tsx index f9db366..9644e13 100644 --- a/frontend/src/components/tree/TeamTree.tsx +++ b/frontend/src/components/tree/TeamTree.tsx @@ -3,12 +3,12 @@ import { useMemo, useState } from "react"; import Feedback from "@/components/elements/Feedback"; import TreeNode from "@/components/tree/TreeNode"; import { cn } from "@/utils/cn"; -import type { TTeamNode } from "@/types/commonTypes"; +import type { TTeamViewNode } from "@/types/teamTypes"; interface TeamTreeProps { - teams: TTeamNode[]; + teams: TTeamViewNode[]; selectedId?: string; - onSelect: (node: TTeamNode) => void; + onSelect: (node: TTeamViewNode) => void; /** Case-insensitive filter — a node matches if it or a descendant matches. */ query?: string; defaultExpandedIds?: string[]; @@ -18,7 +18,7 @@ interface TeamTreeProps { } interface TFilterResult { - nodes: TTeamNode[]; + nodes: TTeamViewNode[]; /** Nodes kept only because a descendant matched — auto-expanded so the match is actually visible (2차-B spec: ancestors stay open). */ autoExpandIds: string[]; @@ -29,19 +29,21 @@ interface TFilterResult { * whole subtree (context); otherwise only children leading to a match * survive. `query` must already be trimmed + lowercased. */ -const filterTree = (teams: TTeamNode[], query: string): TFilterResult => { +const filterTree = (teams: TTeamViewNode[], query: string): TFilterResult => { const autoExpandIds: string[] = []; - const prune = (node: TTeamNode): TTeamNode | null => { + const prune = (node: TTeamViewNode): TTeamViewNode | null => { if (node.name.toLocaleLowerCase().includes(query)) return node; const children = (node.children ?? []) .map(prune) - .filter((child): child is TTeamNode => child !== null); + .filter((child): child is TTeamViewNode => child !== null); if (children.length === 0) return null; autoExpandIds.push(node.id); return { ...node, children }; }; return { - nodes: teams.map(prune).filter((node): node is TTeamNode => node !== null), + nodes: teams + .map(prune) + .filter((node): node is TTeamViewNode => node !== null), autoExpandIds, }; }; diff --git a/frontend/src/components/tree/TeamTreeFooter.tsx b/frontend/src/components/tree/TeamTreeFooter.tsx index a203b6f..93aee0a 100644 --- a/frontend/src/components/tree/TeamTreeFooter.tsx +++ b/frontend/src/components/tree/TeamTreeFooter.tsx @@ -1,5 +1,5 @@ import { cn } from "@/utils/cn"; -import type { TTeamNode } from "@/types/commonTypes"; +import type { TTeamViewNode } from "@/types/teamTypes"; const styles = { wrap: "bg-mint/[2%] grid grid-cols-[auto_1fr_auto] items-center gap-2.5 border-t px-4 py-3", @@ -9,7 +9,7 @@ const styles = { }; interface TeamTreeFooterProps { - node: TTeamNode; + node: TTeamViewNode; className?: string; } diff --git a/frontend/src/components/tree/TreeNode.tsx b/frontend/src/components/tree/TreeNode.tsx index 76bda3a..b7c94e7 100644 --- a/frontend/src/components/tree/TreeNode.tsx +++ b/frontend/src/components/tree/TreeNode.tsx @@ -3,7 +3,7 @@ import type { CSSProperties } from "react"; import IconMinus from "@/components/icons/IconMinus"; import IconPlus from "@/components/icons/IconPlus"; import { cn } from "@/utils/cn"; -import type { TTeamNode } from "@/types/commonTypes"; +import type { TTeamViewNode } from "@/types/teamTypes"; const styles = { row: "grid grid-cols-[24px_1fr] items-center rounded-sm pl-[calc(var(--tree-depth)*18px)] transition-[background-color] duration-[160ms]", @@ -16,11 +16,11 @@ const styles = { }; interface TreeNodeProps { - node: TTeamNode; + node: TTeamViewNode; depth: number; selectedId?: string; expanded: Set; - onSelect: (node: TTeamNode) => void; + onSelect: (node: TTeamViewNode) => void; onToggle: (id: string) => void; /** Active search text (trimmed + lowercased) — emphasizes the match. */ highlight?: string; diff --git a/frontend/src/components/tree/__tests__/TeamTree.test.tsx b/frontend/src/components/tree/__tests__/TeamTree.test.tsx index 6c2f681..eeb4022 100644 --- a/frontend/src/components/tree/__tests__/TeamTree.test.tsx +++ b/frontend/src/components/tree/__tests__/TeamTree.test.tsx @@ -3,9 +3,9 @@ import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import TeamTree from "@/components/tree/TeamTree"; -import type { TTeamNode } from "@/types/commonTypes"; +import type { TTeamViewNode } from "@/types/teamTypes"; -const TEAMS: TTeamNode[] = [ +const TEAMS: TTeamViewNode[] = [ { id: "platform", name: "Platform", @@ -71,7 +71,7 @@ describe("TeamTree", () => { }); it("prunes non-matching siblings but keeps the whole subtree of a self-match", () => { - const forest: TTeamNode[] = [ + const forest: TTeamViewNode[] = [ { id: "a", name: "Alpha", 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/CancelInvitationModal.tsx b/frontend/src/components/users/CancelInvitationModal.tsx index dc05f8e..e6c3fec 100644 --- a/frontend/src/components/users/CancelInvitationModal.tsx +++ b/frontend/src/components/users/CancelInvitationModal.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import Button from "@/components/elements/Button"; import ModalLayout from "@/components/layout/ModalLayout"; import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; +import { MODAL_STYLE_VAR } from "@/constants/styleConstants"; interface CancelInvitationModalProps { account: string; @@ -37,11 +38,11 @@ const CancelInvitationModal = ({ return ( - + {account}의 미사용 초대 코드가 모두 만료됩니다. 유저는 삭제되지 않습니다. - + { @@ -161,8 +166,8 @@ const InviteMemberModal = ({ setUsername(normalizeUsernameInput(value))} @@ -176,7 +181,7 @@ const InviteMemberModal = ({ patchSet(set.id, { teamId })} ariaLabel={`세트 ${index + 1} 팀`} @@ -185,7 +190,7 @@ const InviteMemberModal = ({ patchSet(set.id, { role })} ariaLabel={`세트 ${index + 1} role`} @@ -221,7 +226,11 @@ const InviteMemberModal = ({ 하위 팀 권한 미리보기 [ row.indent ? `└ ${row.teamName}` : row.teamName, row.role, @@ -247,7 +256,7 @@ const InviteMemberModal = ({ )} - + /* table-fixed: the 팀/권한 columns split 50/50 regardless of content, so the per-user tables all line up. */ [m.teamName, m.role])} className="table-fixed" /> @@ -71,7 +76,7 @@ const MemberDeleteModal = ({ if (failed) { return ( - {DELETE_FAILED_MESSAGE} + {DELETE_FAILED_MESSAGE} )} - + { - 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)}`; } }; -/** One staged membership row: baseRole is the saved value. */ -type TMembershipDraft = { - teamId: string; - teamName: string; - baseRole: string; - role: string; - checked: boolean; -}; - type TDrawerModal = | "role-confirm" | "remove" @@ -75,16 +59,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; @@ -117,8 +91,8 @@ interface MemberDetailDrawerProps { * the role-change confirm modal) and checkbox bulk removal (SC-14), * invite-code actions, and member delete (SC-15). Mount with * key={user.userId} so switching members resets the staged state. - * [초대 취소] (D15) and [세션 비활성화] (D12) ship with correct enable - * rules and confirm dialogs. + * The membership machine lives in useMembershipDrafts; this component + * composes it with the account-level actions and the confirm modals. */ const MemberDetailDrawer = ({ user, @@ -132,109 +106,35 @@ const MemberDetailDrawer = ({ onCancelInvitation, teams, }: MemberDetailDrawerProps) => { - const [memberships, setMemberships] = useState(() => - user.memberships.map((m) => ({ - teamId: m.teamId, - teamName: m.teamName, - baseRole: m.role, - role: m.role, - checked: false, - })), - ); + const drafts = useMembershipDrafts({ + user, + teams, + onUpdateRoles, + onRemoveMemberships, + onAddMembership, + }); const [openModal, setOpenModal] = useState(null); const [resending, setResending] = useState(false); - const [addOpen, setAddOpen] = useState(false); - const [addTeamId, setAddTeamId] = useState(""); - const [addRole, setAddRole] = useState(""); - const [adding, setAdding] = useState(false); - const [batchFailures, setBatchFailures] = useState< - { account: string; reason: string }[] | null - >(null); const showNotice = useNoticeStore((state) => state.showNotice); - const teamOptions = buildTeamOptions(teams); - - const changes = memberships.filter((m) => m.role !== m.baseRole); - const selected = memberships.filter((m) => m.checked); - const allChecked = - memberships.length > 0 && memberships.every((m) => m.checked); - - /* Sub-team retention notice (SC-14 no.2): a selected team has a - descendant team whose membership stays after this removal. */ - const remainingIds = memberships - .filter((m) => !m.checked) - .map((m) => m.teamId); - const subteamNotice = selected.some((m) => - getTeamDescendantIds(teams, m.teamId).some((id) => - remainingIds.includes(id), - ), - ); - - const patchMembership = (teamId: string, patch: Partial) => - setMemberships((prev) => - prev.map((m) => (m.teamId === teamId ? { ...m, ...patch } : m)), - ); + const closeModal = () => setOpenModal(null); const handleResend = async () => { setResending(true); try { await onResendCode(); - showNotice("초대 코드 재전송", "초대 코드를 재전송했습니다.", "info"); - } catch { showNotice( - "초대 코드 재전송", - "초대 코드 재전송에 실패했습니다. 다시 시도해 주세요.", - "error", + NOTICE_TEXT.resendInvitation.title, + NOTICE_TEXT.resendInvitation.success, + "info", ); - } finally { - setResending(false); - } - }; - - /* Teams the user already belongs to stay out of the add picker. - Depth indent stripped — the narrow drawer dropdown can't fit - deep-tree indentation (it forces horizontal scrolling in the - menu); teams list flush left in tree order and long names - truncate with an ellipsis. */ - const joinedIds = new Set(memberships.map((m) => m.teamId)); - const addableTeams = teamOptions - .filter((o) => !joinedIds.has(o.value)) - .map(({ value, label }) => ({ value, label })); - - const resetAdd = () => { - setAddOpen(false); - setAddTeamId(""); - setAddRole(""); - }; - - const handleAdd = async () => { - setAdding(true); - try { - await onAddMembership(addTeamId, addRole); - const teamName = - teamOptions.find((o) => o.value === addTeamId)?.label ?? addTeamId; - setMemberships((prev) => [ - ...prev, - { - teamId: addTeamId, - teamName, - baseRole: addRole, - role: addRole, - checked: false, - }, - ]); - showNotice("팀 추가", "팀에 추가되었습니다.", "info"); - resetAdd(); - } catch (err) { - const code = err instanceof Response ? await parseErrorCode(err) : ""; + } catch { showNotice( - "팀 추가", - code === "ALREADY_TEAM_MEMBER" - ? "이미 소속된 팀입니다." - : "팀 추가에 실패했습니다. 다시 시도해 주세요.", + NOTICE_TEXT.resendInvitation.title, + NOTICE_TEXT.resendInvitation.failure, "error", ); } finally { - setAdding(false); + setResending(false); } }; @@ -284,7 +184,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")} /> @@ -293,141 +193,27 @@ const MemberDetailDrawer = ({ - - - 소속 팀 ({memberships.length}) - {selected.length > 0 && ( - - {selected.length} selected - - )} - - - - - - - setMemberships((prev) => - prev.map((m) => ({ ...m, checked })), - ) - } - ariaLabel="전체선택" - /> - - 팀 - 권한 - - - {memberships.length === 0 ? ( - /* No group-role membership — a single placeholder row keeps - the table shape; the team/role cells read "—". */ - - - — - — - - ) : ( - memberships.map((m) => ( - - patchMembership(m.teamId, { checked }) - } - onRoleChange={(role) => patchMembership(m.teamId, { role })} - /> - )) - )} - - - - {/* Action bar: 변경사항 초기화 · 변경사항 업데이트 · 제거하기 · - 팀 추가하기 (SC-13). */} - - {/* Drops every staged (not yet applied) role pick back to its - saved value — checkboxes and committed roles stay. */} - - setMemberships((prev) => - prev.map((m) => ({ ...m, role: m.baseRole })), - ) - } - /> - setOpenModal("role-confirm")} - /> - setOpenModal("remove")} - /> - (addOpen ? resetAdd() : setAddOpen(true))} - /> - - - {/* Team+role picker (SC-13 no.2) — opens just above the action - bar via [팀 추가하기]; teams already joined are excluded. */} - {addOpen && ( - - - - - - )} - + setOpenModal("role-confirm")} + onOpenRemove={() => setOpenModal("remove")} + addOpen={drafts.addOpen} + addTeamId={drafts.addTeamId} + addRole={drafts.addRole} + adding={drafts.adding} + addableTeams={drafts.addableTeams} + onAddTeamIdChange={drafts.setAddTeamId} + onAddRoleChange={drafts.setAddRole} + onToggleAddRow={drafts.toggleAddRow} + onAdd={drafts.handleAdd} + /> @@ -448,7 +234,7 @@ const MemberDetailDrawer = ({ btnSize="sm" btnColor="redOutline" className="w-fit" - disabled={user.sessionStatus !== "online"} + disabled={user.sessionStatus !== SESSION_STATUS.online} handleClick={() => setOpenModal("deactivate")} /> ({ + subjectLabel={TABLE_HEADERS.team} + changes={drafts.changes.map((m) => ({ label: m.teamName, from: m.baseRole, to: m.role, }))} - onConfirm={async () => { - const changedIds = changes.map((m) => m.teamId); - const result = await onUpdateRoles( - changes.map((m) => ({ teamId: m.teamId, role: m.role })), - ); - const failedIds = new Set(result.failed.map((f) => f.id)); - setMemberships((prev) => - prev.map((m) => - changedIds.includes(m.teamId) && !failedIds.has(m.teamId) - ? { ...m, baseRole: m.role } - : m, - ), - ); - if (result.failed.length > 0) { - setBatchFailures( - result.failed.map((f) => ({ - account: - memberships.find((m) => m.teamId === f.id)?.teamName ?? - f.id, - reason: BATCH_REASON[f.code] ?? BATCH_REASON_FALLBACK, - })), - ); - } - }} - onClose={() => setOpenModal(null)} + onConfirm={drafts.confirmRoleChanges} + onClose={closeModal} /> )} {openModal === "remove" && ( ({ + targets={drafts.selected.map((m) => ({ account: user.account, teamId: m.teamId, teamName: m.teamName, role: m.role, }))} - subteamNotice={subteamNotice} - onConfirm={async () => { - const removedIds = selected.map((m) => m.teamId); - const result = await onRemoveMemberships(removedIds); - const failedIds = new Set(result.failed.map((f) => f.id)); - setMemberships((prev) => - prev.filter( - (m) => - !removedIds.includes(m.teamId) || failedIds.has(m.teamId), - ), - ); - if (result.failed.length === 0) { - showNotice( - MODAL_TITLES.removeMembership, - "멤버십이 제거되었습니다.", - "success", - ); - } else { - setBatchFailures( - result.failed.map((f) => ({ - account: - memberships.find((m) => m.teamId === f.id)?.teamName ?? - f.id, - reason: BATCH_REASON[f.code] ?? BATCH_REASON_FALLBACK, - })), - ); - } - }} - onClose={() => setOpenModal(null)} + subteamNotice={drafts.subteamNotice} + onConfirm={drafts.confirmRemovals} + onClose={closeModal} /> )} @@ -543,14 +280,14 @@ const MemberDetailDrawer = ({ targets={[ { account: user.account, - memberships: memberships.map((m) => ({ + memberships: drafts.memberships.map((m) => ({ teamName: m.teamName, role: m.baseRole, })), }, ]} onConfirm={onDeleteMember} - onClose={() => setOpenModal(null)} + onClose={closeModal} /> )} @@ -560,22 +297,26 @@ const MemberDetailDrawer = ({ onConfirm={async () => { try { await onDeactivateSession(); - setOpenModal(null); - showNotice("세션 비활성화", "세션을 비활성화했습니다.", "info"); + closeModal(); + showNotice( + NOTICE_TEXT.deactivateSession.title, + NOTICE_TEXT.deactivateSession.success, + "info", + ); } catch (err) { const code = err instanceof Response ? await parseErrorCode(err) : ""; - setOpenModal(null); + closeModal(); showNotice( - "세션 비활성화", - code === "SESSION_NOT_ACTIVE" - ? "이미 만료된 세션입니다." - : "세션 비활성화에 실패했습니다. 다시 시도해 주세요.", + NOTICE_TEXT.deactivateSession.title, + code === ERROR_CODES.SESSION_NOT_ACTIVE + ? NOTICE_TEXT.deactivateSession.alreadyExpired + : NOTICE_TEXT.deactivateSession.failure, "error", ); } }} - onClose={() => setOpenModal(null)} + onClose={closeModal} /> )} @@ -585,29 +326,33 @@ const MemberDetailDrawer = ({ onConfirm={async () => { try { await onCancelInvitation(); - setOpenModal(null); - showNotice("초대 취소", "초대를 취소했습니다.", "info"); + closeModal(); + showNotice( + NOTICE_TEXT.cancelInvitation.title, + NOTICE_TEXT.cancelInvitation.success, + "info", + ); } catch (err) { const code = err instanceof Response ? await parseErrorCode(err) : ""; - setOpenModal(null); + closeModal(); showNotice( - "초대 취소", - code === "INVITATION_NOT_PENDING" - ? "취소할 초대가 없습니다." - : "초대 취소에 실패했습니다. 다시 시도해 주세요.", + NOTICE_TEXT.cancelInvitation.title, + code === ERROR_CODES.INVITATION_NOT_PENDING + ? NOTICE_TEXT.cancelInvitation.nothingToCancel + : NOTICE_TEXT.cancelInvitation.failure, "error", ); } }} - onClose={() => setOpenModal(null)} + onClose={closeModal} /> )} - {batchFailures && ( + {drafts.batchFailures && ( setBatchFailures(null)} + failures={drafts.batchFailures} + onClose={drafts.closeBatchFailures} /> )} > diff --git a/frontend/src/components/users/MembershipRemoveModal.tsx b/frontend/src/components/users/MembershipRemoveModal.tsx index 91925ed..e8129b3 100644 --- a/frontend/src/components/users/MembershipRemoveModal.tsx +++ b/frontend/src/components/users/MembershipRemoveModal.tsx @@ -4,7 +4,12 @@ import Button from "@/components/elements/Button"; import Notice from "@/components/elements/Notice"; import ModalLayout from "@/components/layout/ModalLayout"; import ModalTable from "@/components/users/ModalTable"; -import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; +import { + BTN_TEXT, + MODAL_TITLES, + TABLE_HEADERS, +} from "@/constants/commonConstants"; +import { MODAL_STYLE_VAR } from "@/constants/styleConstants"; import type { TMembershipRemoveTarget } from "@/types/userTypes"; const REMOVE_FAILED_MESSAGE = `멤버십 제거에 실패했습니다. 다시 시도해 주세요.`; @@ -53,7 +58,7 @@ const MembershipRemoveModal = ({ if (failed) { return ( - {REMOVE_FAILED_MESSAGE} + {REMOVE_FAILED_MESSAGE} 다음 멤버십을 제거합니다: [ target.account, target.teamName, @@ -82,7 +87,7 @@ const MembershipRemoveModal = ({ )} - + void; + onCheckAll: (teamIds: string[], checked: boolean) => void; + onRoleChange: (teamId: string, role: string) => void; + onResetChanges: () => void; + onOpenRoleConfirm: () => void; + onOpenRemove: () => void; + /* [팀 추가하기] picker row (SC-13 no.2). */ + addOpen: boolean; + addTeamId: string; + addRole: string; + adding: boolean; + addableTeams: TDropdownOption[]; + onAddTeamIdChange: (teamId: string) => void; + onAddRoleChange: (role: string) => void; + onToggleAddRow: () => void; + onAdd: () => void; +} + +/** + * MembershipSection is the 소속 팀 block of the member drawer (SC-13): + * the staged-edit membership table, the action bar, and the add-team + * picker row. Pure view — all state lives in useMembershipDrafts. + */ +const MembershipSection = ({ + memberships, + changesCount, + selectedCount, + allChecked, + onCheck, + onCheckAll, + onRoleChange, + onResetChanges, + onOpenRoleConfirm, + onOpenRemove, + addOpen, + addTeamId, + addRole, + adding, + addableTeams, + onAddTeamIdChange, + onAddRoleChange, + onToggleAddRow, + onAdd, +}: MembershipSectionProps) => { + return ( + + + 소속 팀 ({memberships.length}) + {selectedCount > 0 && ( + {selectedCount} selected + )} + + + + + + + onCheckAll( + memberships.map((m) => m.teamId), + checked, + ) + } + ariaLabel={ARIA_LABELS.selectAll} + /> + + {TABLE_HEADERS.team} + + {TABLE_HEADERS.role} + + + + {memberships.length === 0 ? ( + /* No group-role membership — a single placeholder row keeps + the table shape; the team/role cells read "—". */ + + + — + — + + ) : ( + memberships.map((m) => ( + onCheck(m.teamId, checked)} + onRoleChange={(role) => onRoleChange(m.teamId, role)} + /> + )) + )} + + + + {/* Action bar: 변경사항 초기화 · 변경사항 업데이트 · 제거하기 · + 팀 추가하기 (SC-13). */} + + {/* Drops every staged (not yet applied) role pick back to its + saved value — checkboxes and committed roles stay. */} + + + + + + + {/* Team+role picker (SC-13 no.2) — opens just above the action + bar via [팀 추가하기]; teams already joined are excluded. */} + {addOpen && ( + + + + + + )} + + ); +}; + +export default MembershipSection; diff --git a/frontend/src/components/users/RoleChangeConfirmModal.tsx b/frontend/src/components/users/RoleChangeConfirmModal.tsx index c724166..4def71e 100644 --- a/frontend/src/components/users/RoleChangeConfirmModal.tsx +++ b/frontend/src/components/users/RoleChangeConfirmModal.tsx @@ -4,7 +4,12 @@ import Button from "@/components/elements/Button"; import Notice from "@/components/elements/Notice"; import ModalLayout from "@/components/layout/ModalLayout"; import ModalTable from "@/components/users/ModalTable"; -import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; +import { + BTN_TEXT, + MODAL_TITLES, + TABLE_HEADERS, +} from "@/constants/commonConstants"; +import { MODAL_STYLE_VAR } from "@/constants/styleConstants"; import type { TRoleChange } from "@/types/userTypes"; const UPDATE_FAILED_MESSAGE = "권한 변경에 실패했습니다. 다시 시도해 주세요."; @@ -63,7 +68,7 @@ const RoleChangeConfirmModal = ({ 다음 멤버의 권한을 변경합니다: [ change.label, <> @@ -78,7 +83,7 @@ const RoleChangeConfirmModal = ({ {UPDATE_FAILED_MESSAGE} )} - + - + {account}의 세션을 비활성화하시겠습니까? 모든 MCP 세션이 종료됩니다. - + { + const [first, ...rest] = user.memberships; + return first + ? { summary: `${first.teamName} · ${first.role}`, extra: rest.length } + : { summary: "—", extra: 0 }; +}; + +interface UserRowProps { + user: TUserListItem; + selected: boolean; + onSelect: (checked: boolean) => void; + onOpen: () => void; +} + +/** UserRow is one SC-11 list row: checkbox, name, session chip, and the + "first team · role +n" membership summary. Row click opens the drawer. */ +const UserRow = ({ user, selected, onSelect, onOpen }: UserRowProps) => { + const { summary, extra } = membershipSummary(user); + return ( + + {/* Checkbox clicks must not open the drawer (SC-11 no.8) */} + + e.stopPropagation()}> + + + + + {user.username} + + + + + + {summary} + {extra > 0 && ( + `${m.teamName} · ${m.role}`) + .join(", ")} + > + +{extra} + + )} + + + ); +}; + +export default UserRow; diff --git a/frontend/src/components/users/UsersToolbar.tsx b/frontend/src/components/users/UsersToolbar.tsx new file mode 100644 index 0000000..2465232 --- /dev/null +++ b/frontend/src/components/users/UsersToolbar.tsx @@ -0,0 +1,126 @@ +import Button from "@/components/elements/Button"; +import Dropdown from "@/components/elements/Dropdown"; +import SearchInput from "@/components/elements/SearchInput"; +import { ARIA_LABELS, BTN_TEXT } from "@/constants/commonConstants"; +import type { TDropdownOption } from "@/types/commonTypes"; + +interface UsersToolbarProps { + search: string; + sort: string; + statusFilter: string; + groupFilter: string; + sortOptions: TDropdownOption[]; + statusOptions: TDropdownOption[]; + groupOptions: TDropdownOption[]; + /** Setters arrive page-reset-wrapped from the page (stale page = wrong + slice, and carried-over checks would mislead bulk actions). */ + onSearchChange: (value: string) => void; + onSortChange: (value: string) => void; + onStatusChange: (value: string) => void; + onGroupChange: (value: string) => void; + selectedCount: number; + onResend: () => void; + onOpenBulkDelete: () => void; + onOpenInvite: () => void; +} + +/** UsersToolbar is the SC-11 header strip (no.2–6): name search, the + sort/status/team dropdowns, and the bulk actions. Pure view. */ +const UsersToolbar = ({ + search, + sort, + statusFilter, + groupFilter, + sortOptions, + statusOptions, + groupOptions, + onSearchChange, + onSortChange, + onStatusChange, + onGroupChange, + selectedCount, + onResend, + onOpenBulkDelete, + onOpenInvite, +}: UsersToolbarProps) => { + return ( + + + + + {/* filter/order dropdown */} + + + 정렬 기준 + + + + 멤버 상태 + + + + 팀 + + + + + + {/* Actions — second row, left-aligned (SC-11 no.4–6) */} + + + + + + + + ); +}; + +export default UsersToolbar; diff --git a/frontend/src/components/users/__tests__/MemberDetailDrawer.test.tsx b/frontend/src/components/users/__tests__/MemberDetailDrawer.test.tsx index 67d5def..02b0120 100644 --- a/frontend/src/components/users/__tests__/MemberDetailDrawer.test.tsx +++ b/frontend/src/components/users/__tests__/MemberDetailDrawer.test.tsx @@ -3,11 +3,11 @@ import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import MemberDetailDrawer from "@/components/users/MemberDetailDrawer"; +import { useNoticeStore } from "@/state/store/noticeStore"; +import { formatDate, formatDateTime } from "@/utils/formatDate"; import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; import type { TBatchResult, TTeamTree } from "@/types/teamTypes"; import type { TUserListItem } from "@/types/userTypes"; -import { formatDate, formatDateTime } from "@/utils/formatDate"; -import { useNoticeStore } from "@/stores/noticeStore"; /** Minimal team fixture — matches the user's one membership plus a second, unjoined team for the add picker. */ @@ -71,9 +71,7 @@ describe("MemberDetailDrawer", () => { render(); /* Header shows the display name as the title and the account (the identifier) right below it. */ - expect( - screen.getByRole("heading", { name: "김철수" }), - ).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "김철수" })).toBeInTheDocument(); expect(screen.getByText("k@corp.com")).toBeInTheDocument(); expect(screen.getByText("백엔드")).toBeInTheDocument(); }); @@ -105,6 +103,43 @@ describe("MemberDetailDrawer", () => { expect(dashes.length).toBeGreaterThanOrEqual(2); }); + it("renders refetched memberships from props and keeps staged edits on top", async () => { + const user = userEvent.setup(); + const props = baseProps(); + const { rerender } = render(); + + /* Stage a role pick before the fresher server payload lands. */ + await user.click(screen.getByRole("button", { name: "백엔드 role" })); + await user.click(screen.getByRole("option", { name: "write" })); + + /* The detail query (or a post-mutation refetch) resolves with an + extra membership — the drawer must render it without a remount. */ + rerender( + , + ); + + expect(screen.getByText("소속 팀 (2)")).toBeInTheDocument(); + expect(screen.getByText("디자인")).toBeInTheDocument(); + + /* The staged (unapplied) pick survives the refetch: the 백엔드 row + still shows write and the update button stays armed. */ + expect( + screen.getByRole("button", { name: "백엔드 role" }), + ).toHaveTextContent("write"); + expect( + screen.getByRole("button", { name: BTN_TEXT.updateChanges }), + ).toBeEnabled(); + }); + it("stages a role change, confirms, and calls onUpdateRoles with {updates}", async () => { const user = userEvent.setup(); const props = baseProps(); @@ -133,17 +168,17 @@ describe("MemberDetailDrawer", () => { await user.click(screen.getByRole("button", { name: "백엔드 role" })); await user.click(screen.getByRole("option", { name: "write" })); - expect(screen.getByRole("button", { name: "백엔드 role" })).toHaveTextContent( - "write", - ); + expect( + screen.getByRole("button", { name: "백엔드 role" }), + ).toHaveTextContent("write"); await user.click(reset); /* The staged pick is gone: the dropdown shows the saved role again and both staged-change buttons drop back to disabled. Reset is purely client-side staging — no batch call fires. */ - expect(screen.getByRole("button", { name: "백엔드 role" })).toHaveTextContent( - "edit", - ); + expect( + screen.getByRole("button", { name: "백엔드 role" }), + ).toHaveTextContent("edit"); expect(reset).toBeDisabled(); expect( screen.getByRole("button", { name: BTN_TEXT.updateChanges }), diff --git a/frontend/src/components/drawer/__tests__/MembershipRow.test.tsx b/frontend/src/components/users/__tests__/MembershipRow.test.tsx similarity index 96% rename from frontend/src/components/drawer/__tests__/MembershipRow.test.tsx rename to frontend/src/components/users/__tests__/MembershipRow.test.tsx index c2165c3..f4a8d8e 100644 --- a/frontend/src/components/drawer/__tests__/MembershipRow.test.tsx +++ b/frontend/src/components/users/__tests__/MembershipRow.test.tsx @@ -3,7 +3,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import MembershipRow from "@/components/drawer/MembershipRow"; +import MembershipRow from "@/components/users/MembershipRow"; const ROLE_OPTIONS = [ { value: "edit", label: "edit" }, diff --git a/frontend/src/components/workspace/WorkspaceModal.tsx b/frontend/src/components/workspace/WorkspaceModal.tsx index 13c47f9..ca9b2f2 100644 --- a/frontend/src/components/workspace/WorkspaceModal.tsx +++ b/frontend/src/components/workspace/WorkspaceModal.tsx @@ -16,13 +16,15 @@ import { isTransitionalStatus, useWorkspaceQuery, } from "@/hooks/queries/useWorkspaceQuery"; +import { useWorkspaceStore } from "@/state/store/workspaceStore"; +import { WORKSPACE_STATUS } from "@/constants/apiConstants"; import { BTN_TEXT, MODAL_TITLES, PATH_LIST, WORKSPACE_MAX_MEMORIES, } from "@/constants/commonConstants"; -import { useWorkspaceStore } from "@/stores/workspaceStore"; +import { MODAL_STYLE_VAR } from "@/constants/styleConstants"; const FAIL_COPY = { stop: "워크스페이스 중지에 실패했습니다. 다시 시도해 주세요.", @@ -71,7 +73,7 @@ const WorkspaceModal = () => { // connect endpoint the empty-state create uses. const reconnectMutation = useCreateWorkspaceMutation(); - const status = workspace?.status ?? "error"; + const status = workspace?.status ?? WORKSPACE_STATUS.error; /* Transitional phases (+ any request in flight) lock the actions. */ const busy = workspace ? isTransitionalStatus(status) : false; @@ -112,12 +114,12 @@ const WorkspaceModal = () => { } return ( - + 워크스페이스를 삭제하시겠습니까? 삭제 후에는 되돌릴 수 없습니다. - + { return ( {tearingDown ? ( - + 기존 워크스페이스를 삭제하는 중입니다… 삭제가 완료되면 워크스페이스 생성을 시작합니다. ) : ( - + 콘솔이 재설치되어 이 워크스페이스와 연결할 수 없습니다. 기존에 저장된 데이터는 이전 보안 키로 암호화되어 복구할 수 없습니다. @@ -169,7 +171,7 @@ const WorkspaceModal = () => { 삭제 후 재생성하면 빈 워크스페이스로 다시 시작합니다. )} - + { if (workspace?.reconnectRequired) { return ( - + 워크스페이스 연결이 만료되었습니다. 재연결하여 데이터 플레인을 다시 활성화해 주세요. {reconnectMutation.isError && ( - 재연결에 실패했습니다. 다시 시도해 주세요. + + 재연결에 실패했습니다. 다시 시도해 주세요. + )} - + { if (isError && !workspace) { return ( - + 워크스페이스 정보를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요. @@ -256,11 +260,11 @@ const WorkspaceModal = () => { return ( - + {/* Lifecycle actions sit at the content's top-right as quiet TextButtons — the info fields carry the primary reading weight. */} - {status === "stopped" ? ( + {status === WORKSPACE_STATUS.stopped ? ( { queryState = { data: { ...RUNNING, orphaned: true }, isError: false }; render(); expect( - screen.getByText(/콘솔이 재설치되어 이 워크스페이스와 연결할 수 없습니다/), + screen.getByText( + /콘솔이 재설치되어 이 워크스페이스와 연결할 수 없습니다/, + ), ).toBeInTheDocument(); expect( screen.getByRole("button", { name: BTN_TEXT.recreate }), @@ -197,7 +199,9 @@ describe("WorkspaceModal", () => { recreateState = { isPending: false, isError: true }; render(); expect( - screen.getByText("워크스페이스 재생성에 실패했습니다. 다시 시도해 주세요."), + screen.getByText( + "워크스페이스 재생성에 실패했습니다. 다시 시도해 주세요.", + ), ).toBeInTheDocument(); expect( screen.queryByRole("button", { name: BTN_TEXT.recreate }), diff --git a/frontend/src/constants/apiConstants.ts b/frontend/src/constants/apiConstants.ts new file mode 100644 index 0000000..e60b0ad --- /dev/null +++ b/frontend/src/constants/apiConstants.ts @@ -0,0 +1,67 @@ +/** + * Wire-contract vocabulary shared with the console API — the single source + * for status/role/error-code string values. Components must reference these + * (e.g. `INVITATION_STATUS.pending`) instead of typing the raw literal, so a + * backend value rename is a one-line change here and every typo is a compile + * error. The matching union types are derived from these objects in types/ + * (e.g. TInvitationStatus), keeping constant and type in lockstep. + */ + +/** Invitation-code lifecycle status on the wire (common contract). */ +export const INVITATION_STATUS = { + pending: "invite_pending", + expired: "invite_expired", + redeemed: "invite_redeemed", +} as const; + +/** Session-token liveness on the wire (common contract). */ +export const SESSION_STATUS = { + online: "online", + offline: "offline", +} as const; + +/** rune workspace lifecycle phase (console API `phase`). */ +export const WORKSPACE_STATUS = { + provisioning: "provisioning", + running: "running", + stopping: "stopping", + stopped: "stopped", + starting: "starting", + deleting: "deleting", + error: "error", +} as const; + +/** Lifecycle reported by the privileged rune-console update agent. */ +export const SYSTEM_UPDATE_STATE = { + idle: "idle", + queued: "queued", + running: "running", + failed: "failed", + succeeded: "succeeded", +} as const; + +/** Grantable member role (Admin is console-account only — API §0). */ +export const TEAM_MEMBER_ROLE = { + edit: "edit", + write: "write", + read: "read", +} as const; + +/** + * Backend error codes surfaced through the shared error envelope + * (parseErrorCode). Keys mirror the wire value verbatim so call sites read + * the same as the API design doc. + */ +export const ERROR_CODES = { + ALREADY_TEAM_MEMBER: "ALREADY_TEAM_MEMBER", + CANNOT_INVITE_ADMIN: "CANNOT_INVITE_ADMIN", + INVITATION_NOT_PENDING: "INVITATION_NOT_PENDING", + MAIL_UPSTREAM_ERROR: "MAIL_UPSTREAM_ERROR", + NOT_TEAM_MEMBER: "NOT_TEAM_MEMBER", + SESSION_NOT_ACTIVE: "SESSION_NOT_ACTIVE", + TEAM_HAS_CHILDREN: "TEAM_HAS_CHILDREN", + TEAM_NAME_DUPLICATE: "TEAM_NAME_DUPLICATE", + TEAM_NAME_INVALID: "TEAM_NAME_INVALID", + TEAM_NOT_FOUND: "TEAM_NOT_FOUND", + USER_NOT_FOUND: "USER_NOT_FOUND", +} as const; diff --git a/frontend/src/constants/commonConstants.ts b/frontend/src/constants/commonConstants.ts index d5bc00f..775d11a 100644 --- a/frontend/src/constants/commonConstants.ts +++ b/frontend/src/constants/commonConstants.ts @@ -7,6 +7,11 @@ export const BRAND_WORDMARK = "RUNE CONSOLE"; * workspace; the SC-02 modal renders usage as rowCount / max (percent). */ export const WORKSPACE_MAX_MEMORIES = 1000; +/** DEFAULT_PAGE_SIZE is the fixed rows-per-page for every list table + * (users, sessions, team members) — caps the table height inside one + * screen and goes out as the ?size= query param on the list endpoints. */ +export const DEFAULT_PAGE_SIZE = 10; + /** BTN_TEXT is the single source of truth for visible action-button labels * (Button `btnText` / TextButton) across the console screens, so a wording * change lands in one place. Icon-button aria-labels are intentionally out of @@ -59,13 +64,23 @@ export const BTN_TEXT = { deleteMember: "멤버 삭제", } as const; +/** PAGE_TITLES is the page/section vocabulary — shared by the main nav, + * each page's , and the workspace modal title, so the + * same screen is never named two different things. */ +export const PAGE_TITLES = { + teams: "팀 관리", + users: "멤버 관리", + sessions: "세션 기록", + workspace: "워크스페이스 관리", +} as const; + /** MODAL_TITLES is the single source of truth for ModalLayout titles across * the console modals, mirroring BTN_TEXT so a wording change lands in one * place. Titles that embed a name or count are functions; the rest are plain * strings. */ export const MODAL_TITLES = { // Workspace - workspaceManage: "워크스페이스 관리", + workspaceManage: PAGE_TITLES.workspace, workspaceDelete: "워크스페이스 삭제", workspaceOrphaned: "워크스페이스 재생성 필요", workspaceReconnect: "워크스페이스 재연결 필요", @@ -96,11 +111,62 @@ export const PATH_LIST = { } as const; export const NAV_LIST = [ - { title: "팀 관리", url: PATH_LIST.teams }, - { title: "멤버 관리", url: PATH_LIST.users }, - { title: "세션 기록", url: PATH_LIST.sessions }, + { title: PAGE_TITLES.teams, url: PATH_LIST.teams }, + { title: PAGE_TITLES.users, url: PATH_LIST.users }, + { title: PAGE_TITLES.sessions, url: PATH_LIST.sessions }, ] as const; +/** TABLE_HEADERS is the column-header copy shared across the list tables, + * the modal tables, and the sort-option labels that mirror a column. */ +export const TABLE_HEADERS = { + memberName: "멤버 이름", + memberStatus: "멤버 상태", + team: "팀", + teamWithRole: "팀 (권한)", + role: "권한", + /* TreeDetailView's member table says 역할 while every other role column + says 권한 — kept verbatim pending a copy decision; unifying is a + one-line change here once decided. */ + roleAlt: "역할", + roleChange: "권한 변경", + joinedAt: "합류일", + account: "account", + reason: "사유", + user: "사용자", + issuedAt: "발급 시간", + lastAccess: "최근 접속 시간", +} as const; + +/** Form-field copy shared by the invite (SC-12) and add-member (SC-06) + * forms — labels are also how tests and screen readers find the fields. */ +export const INPUT_LABELS = { + emailAccount: "이메일 (account)", + username: "사용자 이름 (username)", +} as const; + +export const PLACEHOLDERS = { + selectTeam: "팀 선택", + selectRole: "권한 선택", + /** Team picker when every team is already joined (SC-13 add row). */ + noAddableTeam: "추가할 팀 없음", + emailExample: "user@corp.com", + username: "사용자 이름", +} as const; + +/** Icon/control aria-labels used on more than one screen — centralized so + * assistive tech hears the same name everywhere (they had already drifted: + * "전체 선택" vs "전체선택"). */ +export const ARIA_LABELS = { + selectAll: "전체 선택", + sort: "정렬", +} as const; + +/** Shared Feedback copy — per-screen titles stay local; only the copy that + * repeats across screens lives here. */ +export const FEEDBACK_TEXT = { + refreshRetry: "새로고침 후 다시 시도해 주세요.", +} as const; + export const QUERY_KEYS = { teamsTree: "teamsTree", users: "users", diff --git a/frontend/src/constants/errorConstants.ts b/frontend/src/constants/errorConstants.ts new file mode 100644 index 0000000..2e5d32b --- /dev/null +++ b/frontend/src/constants/errorConstants.ts @@ -0,0 +1,46 @@ +import { ERROR_CODES } from "@/constants/apiConstants"; + +/** + * Backend error code → user-facing Korean copy, shared by every screen that + * surfaces the shared error envelope (parseErrorCode). The same code can read + * differently per flow (e.g. USER_NOT_FOUND during add vs batch), so maps are + * grouped by context rather than merged into one — pick the map that matches + * the flow. For unmapped codes each call site picks its own fallback: the + * generic retry copy, or the raw backend code itself where that diagnostic + * detail is worth showing (member removal / user delete failure modals). + */ + +/** Duplicate-name copy — shared by the server reason map and the client-side + duplicate check in the create/rename team modals (must stay identical). */ +export const TEAM_NAME_DUPLICATE_TEXT = + "같은 상위 팀에 동일한 이름이 이미 있습니다."; + +/** Team CRUD failures (SC-06/07 — create · rename · delete). */ +export const TEAM_REASON: Record = { + [ERROR_CODES.TEAM_NAME_DUPLICATE]: TEAM_NAME_DUPLICATE_TEXT, + [ERROR_CODES.TEAM_NAME_INVALID]: "팀 이름 형식이 올바르지 않습니다.", + [ERROR_CODES.TEAM_HAS_CHILDREN]: "하위 팀이 있어 삭제할 수 없습니다.", +}; + +/** Per-target failure reasons from the batch endpoints (bulk role change, + membership removal, user delete) — listed in MemberBatchFailureModal. */ +export const BATCH_REASON: Record = { + [ERROR_CODES.USER_NOT_FOUND]: "사용자를 찾을 수 없습니다", + [ERROR_CODES.NOT_TEAM_MEMBER]: "팀 멤버가 아닙니다", + [ERROR_CODES.TEAM_NOT_FOUND]: "팀을 찾을 수 없습니다", +}; + +/** Generic retry copy for an unmapped batch code (e.g. a transient + INTERNAL). Used by the role-change flows; the removal/delete failure + modals instead surface the raw code as a diagnostic hint. */ +export const BATCH_REASON_FALLBACK = "처리에 실패했습니다. 다시 시도해 주세요."; + +/** Add-member flow failures (SC-06 팀에 멤버 추가) — the add context words + the same codes differently (USER_NOT_FOUND = unregistered account). */ +export const ADD_MEMBER_REASON: Record = { + [ERROR_CODES.ALREADY_TEAM_MEMBER]: "이미 초대된 사용자입니다.", + [ERROR_CODES.USER_NOT_FOUND]: "등록되지 않은 계정입니다.", + [ERROR_CODES.CANNOT_INVITE_ADMIN]: "콘솔 관리자 계정은 추가할 수 없습니다.", + [ERROR_CODES.MAIL_UPSTREAM_ERROR]: + "초대 코드 전송에 실패했습니다. 다시 시도해 주세요.", +}; diff --git a/frontend/src/constants/noticeConstants.ts b/frontend/src/constants/noticeConstants.ts new file mode 100644 index 0000000..f3ef8b5 --- /dev/null +++ b/frontend/src/constants/noticeConstants.ts @@ -0,0 +1,64 @@ +import { MODAL_TITLES } from "@/constants/commonConstants"; + +/** + * showNotice copy grouped per flow — {title, success, failure, ...} so a + * flow's wording lives in one place instead of inline at each call site. + * Titles reuse MODAL_TITLES where the notice reports the outcome of that + * modal's action; flows without a matching modal title keep their own. + * Keys beyond success/failure are code-specific bodies (e.g. alreadyMember + * for ALREADY_TEAM_MEMBER) picked by the call site's error handling. + */ +export const NOTICE_TEXT = { + resendInvitation: { + title: "초대 코드 재전송", + success: "초대 코드를 재전송했습니다.", + failure: "초대 코드 재전송에 실패했습니다. 다시 시도해 주세요.", + /** Per-account reason row in the batch-failure modal. */ + failedReason: "재전송 실패", + }, + addMembership: { + title: "팀 추가", + success: "팀에 추가되었습니다.", + alreadyMember: "이미 소속된 팀입니다.", + failure: "팀 추가에 실패했습니다. 다시 시도해 주세요.", + }, + /* Role-change and remove-failure results render INSIDE + RoleChangeConfirmModal/MembershipRemoveModal (SC-06 E-1/E-2) — only + the full-success removal toast goes through showNotice. */ + removeMembership: { + title: MODAL_TITLES.removeMembership, + success: "멤버십이 제거되었습니다.", + }, + deactivateSession: { + title: MODAL_TITLES.deactivateSession, + success: "세션을 비활성화했습니다.", + alreadyExpired: "이미 만료된 세션입니다.", + failure: "세션 비활성화에 실패했습니다. 다시 시도해 주세요.", + }, + cancelInvitation: { + title: MODAL_TITLES.cancelInvitation, + success: "초대를 취소했습니다.", + nothingToCancel: "취소할 초대가 없습니다.", + failure: "초대 취소에 실패했습니다. 다시 시도해 주세요.", + }, + createTeam: { + title: "팀 생성", + success: "팀이 생성되었습니다.", + }, + renameTeam: { + title: MODAL_TITLES.renameTeam, + success: "팀 이름이 변경되었습니다.", + }, + deleteTeam: { + title: "팀 삭제", + success: "팀이 삭제되었습니다.", + }, + addTeamMember: { + title: "멤버 추가", + success: "멤버를 추가했습니다.", + }, + deleteMember: { + title: "멤버 삭제", + success: "멤버를 삭제했습니다.", + }, +} as const; diff --git a/frontend/src/constants/styleConstants.ts b/frontend/src/constants/styleConstants.ts index fad01a1..9858dce 100644 --- a/frontend/src/constants/styleConstants.ts +++ b/frontend/src/constants/styleConstants.ts @@ -3,6 +3,12 @@ * Visual values are translated from UIKIT modules/rune-ui-buttons and * modules/rune-admin-kit CSS — UIKIT is the design source of truth. */ +import type { TMemberStatus } from "@/types/commonTypes"; +import type { TInvitationStatus } from "@/types/teamTypes"; +import type { TWorkspaceStatus } from "@/types/workspaceTypes"; + +/** Status → chip/badge presentation (label + text color). */ +type TStatusStyle = { label: string; color: string }; /* Form controls embed w-full: the parent container constrains width. Metrics are UIKIT values normalized to even px (project rule). */ @@ -79,18 +85,34 @@ export const BADGE_TONE_VAR = { neutral: "bg-muted-foreground/12 text-muted-foreground", } as const; -/* Session chips — the only status a list view shows. */ +/* Shared modal building blocks — the ModalLayout children every confirm + modal composes. One source so the copies can't drift (the body gap had + already split into gap-4 vs gap-5 before this was centralized). */ +export const MODAL_STYLE_VAR = { + /* Centered single-line message (alert/failure bodies). */ + message: "text-center text-base", + /* Vertical form/content stack. */ + body: "flex w-full flex-col gap-4", + /* Button row — one spacing for every confirm modal (the team/workspace + modals used to sit at gap-2 while the users flows used gap-4; unified + on gap-4, 2026-08-03). */ + footer: "flex w-full items-center gap-4", +} as const; + +/* Session chips — the only status a list view shows. The satisfies clause + keys this map to the status union: adding/renaming a status value is a + compile error here until the label map follows. */ export const MEMBER_STATUS_VAR = { online: { label: "온라인", color: "text-mint" }, offline: { label: "오프라인", color: "text-faint" }, -} as const; +} as const satisfies Record; /* Invitation-status labels — shown only in the member detail drawer. */ export const INVITATION_STATUS_VAR = { invite_pending: { label: "초대 수락 대기", color: "text-warning" }, invite_expired: { label: "초대 코드 만료", color: "text-faint" }, invite_redeemed: { label: "초대 코드 사용됨", color: "text-accent-blue" }, -} as const; +} as const satisfies Record; export const WORKSPACE_STATUS_VAR = { provisioning: { label: "생성 중", color: "text-warning" }, @@ -100,4 +122,4 @@ export const WORKSPACE_STATUS_VAR = { starting: { label: "재실행 중", color: "text-warning" }, deleting: { label: "삭제 중", color: "text-warning" }, error: { label: "사용 불가", color: "text-negative" }, -} as const; +} as const satisfies Record; diff --git a/frontend/src/constants/teamConstants.ts b/frontend/src/constants/teamConstants.ts new file mode 100644 index 0000000..ba95277 --- /dev/null +++ b/frontend/src/constants/teamConstants.ts @@ -0,0 +1,15 @@ +import { TEAM_MEMBER_ROLE } from "@/constants/apiConstants"; +import type { TDropdownOption } from "@/types/commonTypes"; + +/** Team name rule: digits, Hangul, Latin letters, and - _ only. */ +export const TEAM_NAME_PATTERN = /^[0-9A-Za-z가-힣_-]+$/; + +export const TEAM_NAME_RULE_TEXT = + "숫자·한글·영어와 - _ 만 사용할 수 있습니다."; + +/** Grantable member roles (Admin is console-account only — API §0). */ +export const ROLE_OPTIONS: TDropdownOption[] = [ + { 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 }, +]; diff --git a/frontend/src/components/users/memberStatusMap.ts b/frontend/src/constants/userConstants.ts similarity index 71% rename from frontend/src/components/users/memberStatusMap.ts rename to frontend/src/constants/userConstants.ts index 2b384f0..93475b1 100644 --- a/frontend/src/components/users/memberStatusMap.ts +++ b/frontend/src/constants/userConstants.ts @@ -1,9 +1,10 @@ +import { SESSION_STATUS } from "@/constants/apiConstants"; import type { TMemberStatus } from "@/types/commonTypes"; import type { TSessionStatus } from "@/types/teamTypes"; /** API session status → MemberStatus chip state. Identity today, but kept as a seam so the chip vocabulary can diverge from the wire later. */ export const CHIP_STATUS: Record = { - online: "online", - offline: "offline", + [SESSION_STATUS.online]: "online", + [SESSION_STATUS.offline]: "offline", }; diff --git a/frontend/src/hooks/__tests__/useBatchFailureModal.test.ts b/frontend/src/hooks/__tests__/useBatchFailureModal.test.ts new file mode 100644 index 0000000..d6b5319 --- /dev/null +++ b/frontend/src/hooks/__tests__/useBatchFailureModal.test.ts @@ -0,0 +1,55 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { + toBatchFailureRows, + useBatchFailureModal, +} from "@/hooks/useBatchFailureModal"; +import { BATCH_REASON_FALLBACK } from "@/constants/errorConstants"; + +describe("useBatchFailureModal", () => { + it("starts closed and opens with the given rows", () => { + const { result } = renderHook(() => useBatchFailureModal()); + expect(result.current.batchFailures).toBeNull(); + act(() => + result.current.showBatchFailures([{ account: "a", reason: "r" }]), + ); + expect(result.current.batchFailures).toEqual([ + { account: "a", reason: "r" }, + ]); + act(() => result.current.closeBatchFailures()); + expect(result.current.batchFailures).toBeNull(); + }); +}); + +describe("toBatchFailureRows", () => { + const failed = [ + { id: "u1", code: "USER_NOT_FOUND", message: "x" }, + { id: "u2", code: "INTERNAL", message: "y" }, + ]; + + it("maps known codes through BATCH_REASON and labels via labelOf", () => { + const rows = toBatchFailureRows( + failed, + (id) => `acct-${id}`, + () => BATCH_REASON_FALLBACK, + ); + expect(rows[0]).toEqual({ + account: "acct-u1", + reason: "사용자를 찾을 수 없습니다", + }); + expect(rows[1]).toEqual({ + account: "acct-u2", + reason: BATCH_REASON_FALLBACK, + }); + }); + + it("supports the raw-code fallback used by removal/delete flows", () => { + const rows = toBatchFailureRows( + failed, + (id) => id, + (code) => code, + ); + expect(rows[1].reason).toBe("INTERNAL"); + }); +}); diff --git a/frontend/src/hooks/__tests__/usePageScopedSelection.test.ts b/frontend/src/hooks/__tests__/usePageScopedSelection.test.ts new file mode 100644 index 0000000..2b267e5 --- /dev/null +++ b/frontend/src/hooks/__tests__/usePageScopedSelection.test.ts @@ -0,0 +1,44 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { usePageScopedSelection } from "@/hooks/usePageScopedSelection"; + +describe("usePageScopedSelection", () => { + it("toggles single ids on and off", () => { + const { result } = renderHook(() => usePageScopedSelection()); + act(() => result.current.toggleOne("a", true)); + act(() => result.current.toggleOne("b", true)); + expect(result.current.selectedIds).toEqual(new Set(["a", "b"])); + act(() => result.current.toggleOne("a", false)); + expect(result.current.selectedIds).toEqual(new Set(["b"])); + }); + + it("toggleAll adds and removes only the given ids", () => { + const { result } = renderHook(() => usePageScopedSelection()); + act(() => result.current.toggleOne("keep", true)); + act(() => result.current.toggleAll(["a", "b"], true)); + expect(result.current.selectedIds).toEqual(new Set(["keep", "a", "b"])); + act(() => result.current.toggleAll(["a", "b"], false)); + expect(result.current.selectedIds).toEqual(new Set(["keep"])); + }); + + it("clearSelection empties the set", () => { + const { result } = renderHook(() => usePageScopedSelection()); + act(() => result.current.toggleAll(["a", "b"], true)); + act(() => result.current.clearSelection()); + expect(result.current.selectedIds.size).toBe(0); + }); + + it("setSelectedIds supports batch-result reconciliation", () => { + const { result } = renderHook(() => usePageScopedSelection()); + act(() => result.current.toggleAll(["ok", "failed"], true)); + act(() => + result.current.setSelectedIds((prev) => { + const next = new Set(prev); + next.delete("ok"); + return next; + }), + ); + expect(result.current.selectedIds).toEqual(new Set(["failed"])); + }); +}); diff --git a/frontend/src/hooks/__tests__/useServerPagination.test.ts b/frontend/src/hooks/__tests__/useServerPagination.test.ts new file mode 100644 index 0000000..29edb2a --- /dev/null +++ b/frontend/src/hooks/__tests__/useServerPagination.test.ts @@ -0,0 +1,53 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { useServerPagination } from "@/hooks/useServerPagination"; + +describe("useServerPagination", () => { + it("starts on page 1 with one page until a total arrives", () => { + const { result } = renderHook(() => useServerPagination(10)); + expect(result.current.page).toBe(1); + expect(result.current.totalPages).toBe(1); + }); + + it("derives totalPages from the reported total", () => { + const { result } = renderHook(() => useServerPagination(10)); + act(() => result.current.syncTotal(35)); + expect(result.current.totalPages).toBe(4); + expect(result.current.page).toBe(1); + }); + + it("clamps the request page when the range shrinks", () => { + const { result } = renderHook(() => useServerPagination(10)); + act(() => result.current.syncTotal(50)); + act(() => result.current.setPage(5)); + expect(result.current.page).toBe(5); + /* A sort/filter change or deletion shrinks the result set. */ + act(() => result.current.syncTotal(21)); + expect(result.current.totalPages).toBe(3); + expect(result.current.page).toBe(3); + }); + + it("never exposes a page beyond totalPages even before the correction", () => { + const { result } = renderHook(() => useServerPagination(10)); + act(() => result.current.setPage(9)); + /* totalPages still 1 — the returned page must stay in range so the + query never asks for an out-of-range slice. */ + expect(result.current.page).toBe(1); + }); + + it("resetPage returns to page 1", () => { + const { result } = renderHook(() => useServerPagination(10)); + act(() => result.current.syncTotal(50)); + act(() => result.current.setPage(4)); + act(() => result.current.resetPage()); + expect(result.current.page).toBe(1); + }); + + it("treats an empty result as a single page", () => { + const { result } = renderHook(() => useServerPagination(10)); + act(() => result.current.syncTotal(0)); + expect(result.current.totalPages).toBe(1); + expect(result.current.page).toBe(1); + }); +}); diff --git a/frontend/src/hooks/mutations/useUpdateMutation.ts b/frontend/src/hooks/mutations/useUpdateMutation.ts index 25e8ce3..d6a72ed 100644 --- a/frontend/src/hooks/mutations/useUpdateMutation.ts +++ b/frontend/src/hooks/mutations/useUpdateMutation.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { postSystemUpdate } from "@/api/updateAPIs"; +import { SYSTEM_UPDATE_STATE } from "@/constants/apiConstants"; import { QUERY_KEYS } from "@/constants/commonConstants"; import type { TSystemUpdateStatus } from "@/types/updateTypes"; @@ -21,7 +22,7 @@ export const useUpdateMutation = () => { ? { ...current, targetVersion: version, - state: "queued", + state: SYSTEM_UPDATE_STATE.queued, } : current, ); diff --git a/frontend/src/hooks/queries/useUpdateQuery.ts b/frontend/src/hooks/queries/useUpdateQuery.ts index 0beb0b6..3fcb826 100644 --- a/frontend/src/hooks/queries/useUpdateQuery.ts +++ b/frontend/src/hooks/queries/useUpdateQuery.ts @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { getSystemUpdate } from "@/api/updateAPIs"; +import { SYSTEM_UPDATE_STATE } from "@/constants/apiConstants"; import { QUERY_KEYS } from "@/constants/commonConstants"; import type { TSystemUpdateStatus } from "@/types/updateTypes"; @@ -8,7 +9,7 @@ const ACTIVE_POLL_MS = 2000; const IDLE_POLL_MS = 60 * 60 * 1000; export const isSystemUpdateActive = (state: TSystemUpdateStatus["state"]) => - state === "queued" || state === "running"; + state === SYSTEM_UPDATE_STATE.queued || state === SYSTEM_UPDATE_STATE.running; /** * Checks for a release without disturbing the app when GitHub or the local diff --git a/frontend/src/hooks/queries/useWorkspaceQuery.ts b/frontend/src/hooks/queries/useWorkspaceQuery.ts index 01506ab..a275a22 100644 --- a/frontend/src/hooks/queries/useWorkspaceQuery.ts +++ b/frontend/src/hooks/queries/useWorkspaceQuery.ts @@ -1,22 +1,23 @@ import { useQuery } from "@tanstack/react-query"; import { getWorkspace } from "@/api/workspaceAPIs"; +import { WORKSPACE_STATUS } from "@/constants/apiConstants"; import { QUERY_KEYS } from "@/constants/commonConstants"; import type { - TWorkspaceStatus, TWorkspace, + TWorkspaceStatus, TWorkspaceWire, -} from "@/types/commonTypes"; +} from "@/types/workspaceTypes"; /** How often to re-poll GET /workspace while a phase is mid-transition. */ const POLL_MS = 10000; /** Phases mid-transition — the query keeps polling while the workspace sits here. */ export const isTransitionalStatus = (status: TWorkspaceStatus): boolean => - status === "provisioning" || - status === "stopping" || - status === "starting" || - status === "deleting"; + status === WORKSPACE_STATUS.provisioning || + status === WORKSPACE_STATUS.stopping || + status === WORKSPACE_STATUS.starting || + status === WORKSPACE_STATUS.deleting; /** * useWorkspaceQuery reads the singular workspace (SC-02). A 404 means "no diff --git a/frontend/src/hooks/useBatchFailureModal.ts b/frontend/src/hooks/useBatchFailureModal.ts new file mode 100644 index 0000000..28d378b --- /dev/null +++ b/frontend/src/hooks/useBatchFailureModal.ts @@ -0,0 +1,43 @@ +import { useState } from "react"; + +import { BATCH_REASON } from "@/constants/errorConstants"; +import type { TBatchResult } from "@/types/teamTypes"; + +/** One row of MemberBatchFailureModal: target label + failure copy. */ +export type TBatchFailureRow = { account: string; reason: string }; + +/** + * useBatchFailureModal owns the partial-failure surface shared by the + * batch endpoints (bulk role change, membership removal, user delete): + * non-null rows open MemberBatchFailureModal listing exactly what failed + * and why (API design — partial success is not an error). + */ +export const useBatchFailureModal = () => { + const [batchFailures, setBatchFailures] = useState( + null, + ); + + const closeBatchFailures = () => setBatchFailures(null); + + return { + batchFailures, + showBatchFailures: setBatchFailures, + closeBatchFailures, + }; +}; + +/** + * Maps a batch result's failures onto modal rows: a per-target label plus + * the shared BATCH_REASON copy. Unmapped codes fall back to whatever the + * caller chooses — the generic retry copy (role-change flows) or the raw + * backend code as a diagnostic hint (removal/delete flows). + */ +export const toBatchFailureRows = ( + failed: TBatchResult["failed"], + labelOf: (id: string) => string, + fallbackFor: (code: string) => string, +): TBatchFailureRow[] => + failed.map((f) => ({ + account: labelOf(f.id), + reason: BATCH_REASON[f.code] ?? fallbackFor(f.code), + })); diff --git a/frontend/src/hooks/useMembershipDrafts.ts b/frontend/src/hooks/useMembershipDrafts.ts new file mode 100644 index 0000000..3407273 --- /dev/null +++ b/frontend/src/hooks/useMembershipDrafts.ts @@ -0,0 +1,240 @@ +import { useState } from "react"; + +import { + toBatchFailureRows, + useBatchFailureModal, +} from "@/hooks/useBatchFailureModal"; +import { usePageScopedSelection } from "@/hooks/usePageScopedSelection"; +import { parseErrorCode } from "@/api/parseError"; +import { useNoticeStore } from "@/state/store/noticeStore"; +import { buildTeamOptions } from "@/utils/buildTeamOptions"; +import { getTeamDescendantIds } from "@/utils/teamHierarchy"; +import { ERROR_CODES } from "@/constants/apiConstants"; +import { BATCH_REASON_FALLBACK } from "@/constants/errorConstants"; +import { NOTICE_TEXT } from "@/constants/noticeConstants"; +import type { TBatchResult, TTeamTree } from "@/types/teamTypes"; +import type { TUserListItem } from "@/types/userTypes"; + +/** One membership row as rendered: server truth (baseRole) with the + staged edits (role pick, checkbox) applied on top. */ +export type TMembershipDraft = { + teamId: string; + teamName: string; + baseRole: string; + role: string; + checked: boolean; +}; + +interface UseMembershipDraftsOptions { + user: TUserListItem; + /** Real team tree (GET /teams/tree) — add picker + sub-team notice. */ + teams: TTeamTree; + onUpdateRoles: ( + changes: { teamId: string; role: string }[], + ) => Promise; + onRemoveMemberships: (teamIds: string[]) => Promise; + onAddMembership: (teamId: string, role: string) => Promise; +} + +/** + * useMembershipDrafts owns the SC-13 membership machine. Server truth + * (user.memberships) flows straight from props — never copied into + * state — so the fresher GET /users/{id} payload and every post-mutation + * refetch render immediately. Only the user's own edits are staged (role + * picks + checkbox selection), re-applied as a diff on top of whatever + * the server currently says. The confirm flows reconcile batch results: + * succeeded targets un-stage (the refetch delivers their new truth), + * failed ones stay staged/checked for a retry and surface in the + * batch-failure modal. + */ +export const useMembershipDrafts = ({ + user, + teams, + onUpdateRoles, + onRemoveMemberships, + onAddMembership, +}: UseMembershipDraftsOptions) => { + const [pendingRoles, setPendingRoles] = useState>( + new Map(), + ); + const { + selectedIds: checkedIds, + toggleOne: setChecked, + toggleAll: setAllChecked, + setSelectedIds: setCheckedIds, + } = usePageScopedSelection(); + const { batchFailures, showBatchFailures, closeBatchFailures } = + useBatchFailureModal(); + const showNotice = useNoticeStore((state) => state.showNotice); + + const memberships: TMembershipDraft[] = user.memberships.map((m) => ({ + teamId: m.teamId, + teamName: m.teamName, + baseRole: m.role, + role: pendingRoles.get(m.teamId) ?? m.role, + checked: checkedIds.has(m.teamId), + })); + + /* A staged pick equal to the (possibly refetched) server role is a + no-op and drops out of `changes` on its own. */ + const changes = memberships.filter((m) => m.role !== m.baseRole); + const selected = memberships.filter((m) => m.checked); + const allChecked = + memberships.length > 0 && memberships.every((m) => m.checked); + + /* Sub-team retention notice (SC-14 no.2): a selected team has a + descendant team whose membership stays after this removal. */ + const remainingIds = memberships + .filter((m) => !m.checked) + .map((m) => m.teamId); + const subteamNotice = selected.some((m) => + getTeamDescendantIds(teams, m.teamId).some((id) => + remainingIds.includes(id), + ), + ); + + /* Failure rows are labeled by team name — the drawer's batch targets + are this one user's memberships. */ + const teamNameOf = (teamId: string) => + memberships.find((m) => m.teamId === teamId)?.teamName ?? teamId; + + const stageRole = (teamId: string, role: string) => + setPendingRoles((prev) => new Map(prev).set(teamId, role)); + const resetStaged = () => setPendingRoles(new Map()); + + /* ── [팀 추가하기] picker row (SC-13 no.2) ─────────────────────── */ + const [addOpen, setAddOpen] = useState(false); + const [addTeamId, setAddTeamId] = useState(""); + const [addRole, setAddRole] = useState(""); + const [adding, setAdding] = useState(false); + + /* Teams the user already belongs to stay out of the add picker. + Depth indent stripped — the narrow drawer dropdown can't fit + deep-tree indentation. */ + const joinedIds = new Set(memberships.map((m) => m.teamId)); + const addableTeams = buildTeamOptions(teams) + .filter((o) => !joinedIds.has(o.value)) + .map(({ value, label }) => ({ value, label })); + + const resetAdd = () => { + setAddOpen(false); + setAddTeamId(""); + setAddRole(""); + }; + const toggleAddRow = () => (addOpen ? resetAdd() : setAddOpen(true)); + + const handleAdd = async () => { + setAdding(true); + try { + /* The mutation invalidates the user detail/list queries — the new + row arrives with the refetch, so nothing is mirrored locally. */ + await onAddMembership(addTeamId, addRole); + showNotice( + NOTICE_TEXT.addMembership.title, + NOTICE_TEXT.addMembership.success, + "info", + ); + resetAdd(); + } catch (err) { + const code = err instanceof Response ? await parseErrorCode(err) : ""; + showNotice( + NOTICE_TEXT.addMembership.title, + code === ERROR_CODES.ALREADY_TEAM_MEMBER + ? NOTICE_TEXT.addMembership.alreadyMember + : NOTICE_TEXT.addMembership.failure, + "error", + ); + } finally { + setAdding(false); + } + }; + + /* ── confirm flows (RoleChangeConfirmModal / MembershipRemoveModal) ── */ + const confirmRoleChanges = async () => { + const changedIds = changes.map((m) => m.teamId); + const result = await onUpdateRoles( + changes.map((m) => ({ teamId: m.teamId, role: m.role })), + ); + const failedIds = new Set(result.failed.map((f) => f.id)); + /* Applied roles come back with the invalidation refetch — drop their + staged picks and keep only the failed ones staged for a retry. */ + setPendingRoles((prev) => { + const next = new Map(prev); + for (const teamId of changedIds) { + if (!failedIds.has(teamId)) next.delete(teamId); + } + return next; + }); + if (result.failed.length > 0) { + showBatchFailures( + toBatchFailureRows( + result.failed, + teamNameOf, + () => BATCH_REASON_FALLBACK, + ), + ); + } + }; + + const confirmRemovals = async () => { + const removedIds = selected.map((m) => m.teamId); + const result = await onRemoveMemberships(removedIds); + const failedIds = new Set(result.failed.map((f) => f.id)); + /* Removed rows drop out with the invalidation refetch — clear their + staged edits; failed rows keep their check for a retry. */ + setCheckedIds((prev) => { + const next = new Set(prev); + for (const teamId of removedIds) { + if (!failedIds.has(teamId)) next.delete(teamId); + } + return next; + }); + setPendingRoles((prev) => { + const next = new Map(prev); + for (const teamId of removedIds) { + if (!failedIds.has(teamId)) next.delete(teamId); + } + return next; + }); + if (result.failed.length === 0) { + showNotice( + NOTICE_TEXT.removeMembership.title, + NOTICE_TEXT.removeMembership.success, + "success", + ); + } else { + showBatchFailures( + toBatchFailureRows( + result.failed, + teamNameOf, + () => BATCH_REASON_FALLBACK, + ), + ); + } + }; + + return { + memberships, + changes, + selected, + allChecked, + subteamNotice, + stageRole, + setChecked, + setAllChecked, + resetStaged, + addOpen, + addTeamId, + addRole, + adding, + addableTeams, + setAddTeamId, + setAddRole, + toggleAddRow, + handleAdd, + confirmRoleChanges, + confirmRemovals, + batchFailures, + closeBatchFailures, + }; +}; diff --git a/frontend/src/hooks/usePageScopedSelection.ts b/frontend/src/hooks/usePageScopedSelection.ts new file mode 100644 index 0000000..dbd17c9 --- /dev/null +++ b/frontend/src/hooks/usePageScopedSelection.ts @@ -0,0 +1,38 @@ +import { useState } from "react"; + +/** + * usePageScopedSelection owns a checkbox column's Set-of-ids selection + * (users page, team member table, drawer membership rows). + * + * "Page-scoped" is a caller contract: whatever changes the visible rows + * (page move, filter change, team switch) should call clearSelection so a + * checked row never rides along into a bulk action taken on a different + * slice. setSelectedIds is exposed for batch-result reconciliation — + * dropping succeeded targets while failed ones stay selected for a retry. + */ +export const usePageScopedSelection = () => { + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const toggleOne = (id: string, selected: boolean) => + setSelectedIds((prev) => { + const next = new Set(prev); + if (selected) next.add(id); + else next.delete(id); + return next; + }); + + /** Header select-all: add/remove the given (visible) ids in one shot. */ + const toggleAll = (ids: string[], selected: boolean) => + setSelectedIds((prev) => { + const next = new Set(prev); + ids.forEach((id) => { + if (selected) next.add(id); + else next.delete(id); + }); + return next; + }); + + const clearSelection = () => setSelectedIds(new Set()); + + return { selectedIds, toggleOne, toggleAll, clearSelection, setSelectedIds }; +}; diff --git a/frontend/src/hooks/useServerPagination.ts b/frontend/src/hooks/useServerPagination.ts new file mode 100644 index 0000000..9efab16 --- /dev/null +++ b/frontend/src/hooks/useServerPagination.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useState } from "react"; + +import { DEFAULT_PAGE_SIZE } from "@/constants/commonConstants"; + +/** + * useServerPagination owns the page state for a server-paged table. + * + * totalPages tracks the last response's total (kept as state, not derived, + * so a page/sort transition under keepPreviousData never flashes an interim + * value), and the returned `page` is clamped against it BEFORE the query + * call — an out-of-range request never fires. When a response shrinks the + * range (filter/sort change, deletions emptying the last page), the stored + * page is corrected so Pagination and later renders resume from a valid + * value instead of the stale, too-high one. + * + * Wiring: pass `page` to the list query, then report each response's total + * back with one effect — `useEffect(() => syncTotal(total), [total, + * syncTotal])`. + */ +export const useServerPagination = (pageSize: number = DEFAULT_PAGE_SIZE) => { + const [rawPage, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const page = Math.min(rawPage, totalPages); + + const syncTotal = useCallback( + (total: number) => { + const next = Math.max(1, Math.ceil(total / pageSize)); + setTotalPages(next); + setPage((prev) => Math.min(prev, next)); + }, + [pageSize], + ); + + const resetPage = useCallback(() => setPage(1), []); + + return { page, totalPages, setPage, resetPage, syncTotal, pageSize }; +}; + +/** Companion one-liner so callers don't hand-roll the report-back effect. */ +export const useSyncPaginationTotal = ( + syncTotal: (total: number) => void, + total: number, +) => { + useEffect(() => { + syncTotal(total); + }, [syncTotal, total]); +}; diff --git a/frontend/src/hooks/useStagedRoleEdits.ts b/frontend/src/hooks/useStagedRoleEdits.ts new file mode 100644 index 0000000..0ad4f57 --- /dev/null +++ b/frontend/src/hooks/useStagedRoleEdits.ts @@ -0,0 +1,78 @@ +import { useState } from "react"; + +import type { TTeamMemberRole } from "@/types/teamTypes"; + +/** + * useStagedRoleEdits owns the SC-06 staged role-edit machine: dropdown + * picks collect in pendingRoles and only apply on [변경사항 업데이트]; + * savedRoles is the committed baseline shown until the invalidation + * refetch delivers the server truth (the list query keeps previous data + * visible during the refetch). + */ +export const useStagedRoleEdits = () => { + const [pendingRoles, setPendingRoles] = useState< + Map + >(new Map()); + const [savedRoles, setSavedRoles] = useState>( + new Map(), + ); + + /** Committed role for a member — the staged baseline or the wire value. */ + const baseRole = (userId: string, fallback: TTeamMemberRole) => + savedRoles.get(userId) ?? fallback; + + /** Stage a dropdown pick; picking the base value back un-stages it. */ + const stageRole = ( + userId: string, + fallback: TTeamMemberRole, + nextRole: string, + ) => + setPendingRoles((prev) => { + const next = new Map(prev); + if (nextRole === baseRole(userId, fallback)) next.delete(userId); + else next.set(userId, nextRole as TTeamMemberRole); + return next; + }); + + /** [변경사항 초기화] — staged picks drop; the committed baseline stays. */ + const resetStaged = () => setPendingRoles(new Map()); + + /** Team switch — nothing staged or committed may leak across teams. */ + const resetAll = () => { + setPendingRoles(new Map()); + setSavedRoles(new Map()); + }; + + /** Full batch success — commit every staged pick into the baseline. */ + const applyAll = () => { + setSavedRoles((prev) => new Map([...prev, ...pendingRoles])); + setPendingRoles(new Map()); + }; + + /** Partial batch failure — commit only what succeeded and keep the + failed entries staged so the user can retry them. */ + const reconcileBatch = (failedIds: Set) => { + setSavedRoles( + (prev) => + new Map([ + ...prev, + ...[...pendingRoles.entries()].filter( + ([userId]) => !failedIds.has(userId), + ), + ]), + ); + setPendingRoles( + (prev) => new Map([...prev].filter(([userId]) => failedIds.has(userId))), + ); + }; + + return { + pendingRoles, + baseRole, + stageRole, + resetStaged, + resetAll, + applyAll, + reconcileBatch, + }; +}; diff --git a/frontend/src/hooks/useTeamCrud.ts b/frontend/src/hooks/useTeamCrud.ts new file mode 100644 index 0000000..440aec3 --- /dev/null +++ b/frontend/src/hooks/useTeamCrud.ts @@ -0,0 +1,118 @@ +import { useState } from "react"; + +import { + useCreateTeamMutation, + useDeleteTeamMutation, + useRenameTeamMutation, +} from "@/hooks/mutations/useTeamMutations"; +import { parseErrorCode } from "@/api/parseError"; +import { useNoticeStore } from "@/state/store/noticeStore"; +import { TEAM_REASON } from "@/constants/errorConstants"; +import { NOTICE_TEXT } from "@/constants/noticeConstants"; + +interface UseTeamCrudOptions { + /** Rename/delete target — pass "" when only the create flow is used + (the mutations are lazy, so an unused id never fires). */ + teamId: string; + /** Close the owning modal after a successful mutation. */ + onDone: () => void; + /** Post-delete hand-off (SC-08 — reselect another root team). */ + onDeleted?: () => void; +} + +/** + * useTeamCrud owns the team create/rename/delete orchestration shared by + * TreeDetailView (SC-07~09) and TeamsPage's empty-state create (SC-06 B): + * one TEAM_REASON error mapping into the modals' inline error, one + * success-notice wiring. teamError is reset on every attempt; callers + * clear it when opening/closing a modal so a stale error never leaks + * into a fresh one. + */ +export const useTeamCrud = ({ + teamId, + onDone, + onDeleted, +}: UseTeamCrudOptions) => { + const [teamError, setTeamError] = useState(null); + const createTeam = useCreateTeamMutation(); + const renameTeam = useRenameTeamMutation(teamId); + const deleteTeam = useDeleteTeamMutation(teamId); + const showNotice = useNoticeStore((state) => state.showNotice); + + const clearTeamError = () => setTeamError(null); + + const handleCreate = (name: string, parentId: string | null) => { + setTeamError(null); + createTeam.mutate( + { name, parentId }, + { + onSuccess: () => { + onDone(); + showNotice( + NOTICE_TEXT.createTeam.title, + NOTICE_TEXT.createTeam.success, + "success", + ); + }, + onError: async (res) => { + const code = await parseErrorCode(res); + setTeamError(TEAM_REASON[code] ?? "팀 생성에 실패했습니다."); + }, + }, + ); + }; + + const handleRename = (name: string) => { + setTeamError(null); + renameTeam.mutate( + { name }, + { + onSuccess: () => { + onDone(); + showNotice( + NOTICE_TEXT.renameTeam.title, + NOTICE_TEXT.renameTeam.success, + "success", + ); + }, + onError: async (res) => { + const code = await parseErrorCode(res); + setTeamError(TEAM_REASON[code] ?? "이름 변경에 실패했습니다."); + }, + }, + ); + }; + + const handleDelete = ( + action: "purge" | "transfer", + targetTeamId?: string, + ) => { + setTeamError(null); + deleteTeam.mutate( + { memoryAction: action, targetTeamId }, + { + onSuccess: () => { + onDone(); + showNotice( + NOTICE_TEXT.deleteTeam.title, + NOTICE_TEXT.deleteTeam.success, + "success", + onDeleted, + ); + }, + onError: async (res) => { + const code = await parseErrorCode(res); + setTeamError(TEAM_REASON[code] ?? "팀 삭제에 실패했습니다."); + }, + }, + ); + }; + + return { + teamError, + clearTeamError, + handleCreate, + handleRename, + handleDelete, + }; +}; diff --git a/frontend/src/hooks/useUserBatchActions.ts b/frontend/src/hooks/useUserBatchActions.ts new file mode 100644 index 0000000..536a866 --- /dev/null +++ b/frontend/src/hooks/useUserBatchActions.ts @@ -0,0 +1,149 @@ +import type { Dispatch, SetStateAction } from "react"; + +import { + useDeleteUsers, + useInviteMutation, + useResendInvitation, +} from "@/hooks/mutations/useInvitationMutations"; +import { + toBatchFailureRows, + useBatchFailureModal, +} from "@/hooks/useBatchFailureModal"; +import { parseErrorCode } from "@/api/parseError"; +import { useNoticeStore } from "@/state/store/noticeStore"; +import { ERROR_CODES } from "@/constants/apiConstants"; +import { NOTICE_TEXT } from "@/constants/noticeConstants"; +import type { TTeamMemberRole } from "@/types/teamTypes"; +import type { + TInvitePayload, + TInviteResult, + TUserListItem, +} from "@/types/userTypes"; + +interface UseUserBatchActionsOptions { + /** Selection reconciliation — deleted targets drop out, failed ones + stay selected for a retry. */ + setSelectedIds: Dispatch>>; + /** Called with the ids that were actually deleted (drawer close-out). */ + onDeleted: (deletedIds: string[]) => void; +} + +/** + * useUserBatchActions owns the SC-11 bulk flows — invite (SC-12), invite- + * code resend, and batch delete (SC-15) — including their notice/batch- + * failure surfaces. Pure orchestration over the invitation mutations; the + * page supplies selection reconciliation and the drawer close-out. + */ +export const useUserBatchActions = ({ + setSelectedIds, + onDeleted, +}: UseUserBatchActionsOptions) => { + const invite = useInviteMutation(); + const resend = useResendInvitation(); + const deleteUsersMutation = useDeleteUsers(); + const { batchFailures, showBatchFailures, closeBatchFailures } = + useBatchFailureModal(); + const showNotice = useNoticeStore((state) => state.showNotice); + + /** POST /invitations — server judges duplicates and target states; only + the staged team/role sets are sent (buildInvitePreview's sub-team + expansion is display-only, the server performs the real expansion). */ + const inviteMember = async ( + payload: TInvitePayload, + ): Promise => { + try { + await invite.mutateAsync({ + account: payload.email, + username: payload.username, + memberships: payload.sets.map((set) => ({ + teamId: set.teamId, + role: set.role as TTeamMemberRole, + })), + }); + return "success"; + } catch (err) { + if (err instanceof Response) { + const code = await parseErrorCode(err); + return code === ERROR_CODES.ALREADY_TEAM_MEMBER + ? "duplicate-account" + : "error"; + } + return "error"; + } + }; + + /** POST /invitations/resend for one account (drawer action). */ + const resendCode = (userId: string) => resend.mutateAsync(userId); + + /** POST /invitations/resend (per target) — status never changes (D10). + Selection stays intact on partial failure so the user can retry. */ + const resendCodes = async (targets: TUserListItem[]) => { + const results = await Promise.allSettled( + targets.map((u) => resend.mutateAsync(u.userId)), + ); + const failed = targets.filter((_, i) => results[i].status === "rejected"); + if (failed.length === 0) { + showNotice( + NOTICE_TEXT.resendInvitation.title, + NOTICE_TEXT.resendInvitation.success, + "info", + ); + return; + } + showBatchFailures( + failed.map((u) => ({ + account: u.account, + reason: NOTICE_TEXT.resendInvitation.failedReason, + })), + ); + }; + + /** DELETE /users (batch) — memberships, session token, and unused + invite codes go together (D13). Full success clears the targets + from selection and closes the drawer if it pointed at one of + them; partial failure shows the failure modal (account + reason) + and leaves the still-failed ids selected for retry. Throws only + on full failure, so MemberDeleteModal/the drawer's onDeleteMember + contract (resolve unless every target failed) is unaffected. */ + const deleteMembers = async (targets: TUserListItem[]) => { + const userIds = targets.map((u) => u.userId); + const result = await deleteUsersMutation.mutateAsync(userIds); + const failedIds = new Set(result.failed.map((f) => f.id)); + const succeededIds = userIds.filter((id) => !failedIds.has(id)); + + setSelectedIds((prev) => { + const next = new Set(prev); + succeededIds.forEach((id) => next.delete(id)); + return next; + }); + onDeleted(succeededIds); + + if (result.failed.length === 0) { + showNotice( + NOTICE_TEXT.deleteMember.title, + NOTICE_TEXT.deleteMember.success, + "info", + ); + return; + } + if (succeededIds.length === 0) { + throw new Error("delete failed for every target"); + } + showBatchFailures( + toBatchFailureRows( + result.failed, + (id) => targets.find((u) => u.userId === id)?.account ?? id, + (code) => code, + ), + ); + }; + + return { + inviteMember, + resendCode, + resendCodes, + deleteMembers, + batchFailures, + closeBatchFailures, + }; +}; diff --git a/frontend/src/pages/SessionsPage.tsx b/frontend/src/pages/SessionsPage.tsx index 84097a9..f474884 100644 --- a/frontend/src/pages/SessionsPage.tsx +++ b/frontend/src/pages/SessionsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import Button from "@/components/elements/Button"; import Dropdown from "@/components/elements/Dropdown"; @@ -6,14 +6,27 @@ import Feedback from "@/components/elements/Feedback"; import Pagination from "@/components/elements/Pagination"; import Table from "@/components/table/Table"; import TableCell from "@/components/table/TableCell"; +import TableEmptyRow from "@/components/table/TableEmptyRow"; import TableFoot from "@/components/table/TableFoot"; import TableHead from "@/components/table/TableHead"; import TableHeaderCell from "@/components/table/TableHeaderCell"; +import TableLoadingRow from "@/components/table/TableLoadingRow"; import TableRow from "@/components/table/TableRow"; import { useInvitationHistoryQuery } from "@/hooks/queries/useInvitationHistoryQuery"; +import { + useServerPagination, + useSyncPaginationTotal, +} from "@/hooks/useServerPagination"; import { cn } from "@/utils/cn"; import { formatDateTime } from "@/utils/formatDate"; -import { BTN_TEXT } from "@/constants/commonConstants"; +import { + ARIA_LABELS, + BTN_TEXT, + DEFAULT_PAGE_SIZE, + FEEDBACK_TEXT, + PAGE_TITLES, + TABLE_HEADERS, +} from "@/constants/commonConstants"; import type { TDropdownOption } from "@/types/commonTypes"; const styles = { @@ -26,14 +39,11 @@ const styles = { sort query params (console API design §6). No status filter or issuance button: issuance lives in user/team management. */ const SORT_OPTIONS: TDropdownOption[] = [ - { value: "username", label: "멤버 이름" }, + { value: "username", label: TABLE_HEADERS.memberName }, { value: "issued_at", label: "최근 발급 시간" }, - { value: "last_access", label: "최근 접속 시간" }, + { value: "last_access", label: TABLE_HEADERS.lastAccess }, ]; -/* 10 rows per page, fixed (SC-16 no.4) — the ?size=10 query param. */ -const PAGE_SIZE = 10; - /** * SessionsPage is the session management screen (SC-16): the token * issuance/access history table (state A) with a 3-way sort and fixed @@ -44,37 +54,24 @@ const PAGE_SIZE = 10; */ const SessionsPage = () => { const [sort, setSort] = useState("last_access"); - const [page, setPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - const currentPage = Math.min(page, totalPages); - const historyQuery = useInvitationHistoryQuery(sort, currentPage, PAGE_SIZE); + const { page, totalPages, setPage, resetPage, syncTotal } = + useServerPagination(); + const historyQuery = useInvitationHistoryQuery(sort, page, DEFAULT_PAGE_SIZE); const rows = historyQuery.data?.items ?? []; const total = historyQuery.data?.total ?? 0; - - /* totalPages tracks the last response's total (a page/sort transition - keeps the previous value via keepPreviousData until the new page - resolves); currentPage clamps against it before the query call - above, so the request itself is always in range. This effect only - corrects the stored `page` once totalPages shrinks (e.g. a sort - change reduces the result count), so Pagination and later renders - resume from a valid value instead of the stale, too-high one. */ - useEffect(() => { - const nextTotalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); - setTotalPages(nextTotalPages); - if (page > nextTotalPages) setPage(nextTotalPages); - }, [total, page]); + useSyncPaginationTotal(syncTotal, total); /* Sort change resets to page 1 (SC-16 no.4). */ const changeSort = (value: string) => { setSort(value); - setPage(1); + resetPage(); }; /* ── SC-16 state B — 조회 실패 ──────────────────────────────────── */ if (historyQuery.isError) { return ( - + { default left-aligned 92px row. */ className="flex min-h-45 flex-col items-center justify-center text-center" title="이력 정보를 불러올 수 없습니다." - description="새로고침 후 다시 시도해 주세요." + description={FEEDBACK_TEXT.refreshRetry} action={ { /* ── SC-16 state A — 기본 ───────────────────────────────────────── */ return ( - + { value={sort} onChange={changeSort} size="sm" - ariaLabel="정렬" + ariaLabel={ARIA_LABELS.sort} className="w-40" /> } foot={ @@ -135,30 +132,20 @@ const SessionsPage = () => { {/* Fixed column widths — auto layout would resize per page's content and shift the headers while paginating. */} - 사용자 - 발급 시간 - 최근 접속 시간 + + {TABLE_HEADERS.user} + + + {TABLE_HEADERS.issuedAt} + + + {TABLE_HEADERS.lastAccess} + - {historyQuery.isPending && ( - - - 불러오는 중… - - - )} + {historyQuery.isPending && } {!historyQuery.isPending && total === 0 && ( - - - 이력이 없습니다. - - + 이력이 없습니다. )} {rows.map((row) => ( /* Reissues are separate rows (D11) — username alone is not diff --git a/frontend/src/pages/TeamsPage.tsx b/frontend/src/pages/TeamsPage.tsx index 7ed5b80..a1e78b4 100644 --- a/frontend/src/pages/TeamsPage.tsx +++ b/frontend/src/pages/TeamsPage.tsx @@ -7,18 +7,14 @@ import SearchInput from "@/components/elements/SearchInput"; import CreateTeamModal from "@/components/teams/CreateTeamModal"; import OrgChart from "@/components/teams/OrgChart"; import TreeDetailView from "@/components/teams/TreeDetailView"; -import { useCreateTeamMutation } from "@/hooks/mutations/useTeamMutations"; import { useTeamsTreeQuery } from "@/hooks/queries/useTeamsTreeQuery"; -import { parseErrorCode } from "@/api/parseError"; +import { useTeamCrud } from "@/hooks/useTeamCrud"; import { cn } from "@/utils/cn"; -import { BTN_TEXT } from "@/constants/commonConstants"; -import { useNoticeStore } from "@/stores/noticeStore"; - -/** Create-team error codes → SC-07 copy (shared with TreeDetailView). */ -const CREATE_TEAM_REASON: Record = { - TEAM_NAME_DUPLICATE: "같은 상위 팀에 동일한 이름이 이미 있습니다.", - TEAM_NAME_INVALID: "팀 이름 형식이 올바르지 않습니다.", -}; +import { + BTN_TEXT, + FEEDBACK_TEXT, + PAGE_TITLES, +} from "@/constants/commonConstants"; const feedbackPanel = "m-6 flex min-h-[340px] flex-col items-center justify-center gap-3 text-center"; @@ -57,28 +53,17 @@ const TeamsPage = () => { /* SC-06 state B (팀 0개) create action — the tree panel's [새 팀 만들기] is gone when there are no teams, so the empty panel owns the create - flow (same mutation/error mapping as TreeDetailView's SC-07). */ + flow (same mutation/error mapping as TreeDetailView's SC-07, via the + shared useTeamCrud hook). */ const [createOpen, setCreateOpen] = useState(false); - const [createError, setCreateError] = useState(null); - const createTeam = useCreateTeamMutation(); - const showNotice = useNoticeStore((s) => s.showNotice); - - const handleCreate = (name: string, parentId: string | null) => { - setCreateError(null); - createTeam.mutate( - { name, parentId }, - { - onSuccess: () => { - setCreateOpen(false); - showNotice("팀 생성", "팀이 생성되었습니다.", "success"); - }, - onError: async (res) => { - const code = await parseErrorCode(res); - setCreateError(CREATE_TEAM_REASON[code] ?? "팀 생성에 실패했습니다."); - }, - }, - ); - }; + const { + teamError: createError, + clearTeamError, + handleCreate, + } = useTeamCrud({ + teamId: "", + onDone: () => setCreateOpen(false), + }); /* 트리·상세 is the entry view (its first top-level team auto-selected); 조직도 is reached by the view toggle. */ @@ -120,18 +105,18 @@ const TeamsPage = () => { if (isPending) { return ( - + ); } if (isError) { return ( - + { } return ( - + { btnColor="mintFilled" className="w-fit" handleClick={() => { - setCreateError(null); + clearTeamError(); setCreateOpen(true); }} /> diff --git a/frontend/src/pages/UITestPage.tsx b/frontend/src/pages/UITestPage.tsx index 6b4af32..6b80250 100644 --- a/frontend/src/pages/UITestPage.tsx +++ b/frontend/src/pages/UITestPage.tsx @@ -1,6 +1,5 @@ import { Fragment, useEffect, useState } from "react"; -import MembershipRow from "@/components/drawer/MembershipRow"; import Badge from "@/components/elements/Badge"; import Button from "@/components/elements/Button"; import Checkbox from "@/components/elements/Checkbox"; @@ -26,6 +25,8 @@ import TableRow from "@/components/table/TableRow"; import TableToolbar from "@/components/table/TableToolbar"; import TeamTree from "@/components/tree/TeamTree"; import TeamTreeFooter from "@/components/tree/TeamTreeFooter"; +import MembershipRow from "@/components/users/MembershipRow"; +import { useToastStore } from "@/state/store/toastStore"; import { cn } from "@/utils/cn"; import { BTN_TEXT } from "@/constants/commonConstants"; import { @@ -34,14 +35,11 @@ import { MEMBER_STATUS_VAR, WORKSPACE_STATUS_VAR, } from "@/constants/styleConstants"; -import type { - TMemberStatus, - TTeamNode, - TWorkspaceStatus, -} from "@/types/commonTypes"; +import { ROLE_OPTIONS } from "@/constants/teamConstants"; +import type { TMemberStatus } from "@/types/commonTypes"; import type { TBTNColor } from "@/types/styleTypes"; -import type { TInvitationStatus } from "@/types/teamTypes"; -import { useToastStore } from "@/stores/toastStore"; +import type { TInvitationStatus, TTeamViewNode } from "@/types/teamTypes"; +import type { TWorkspaceStatus } from "@/types/workspaceTypes"; type TUITestModal = "alert" | "confirm" | "wide" | "scroll" | null; @@ -56,12 +54,6 @@ const BTN_THEMES: { color: TBTNColor; role: string; text: string }[] = [ { color: "redOutline", role: "outline · danger", text: "멤버 삭제" }, ]; -const ROLE_OPTIONS = [ - { value: "edit", label: "edit" }, - { value: "write", label: "write" }, - { value: "read", label: "read" }, -]; - const TEAM_OPTIONS = [ { value: "platform", label: "플랫폼팀" }, { value: "fe", label: "프론트엔드", depth: 1 }, @@ -119,7 +111,7 @@ const SESSION_ROWS = [ { account: "a@corp.com", issuedAt: "2026-07-05 18:20", connectedAt: "" }, ]; -const TEAM_FIXTURE: TTeamNode[] = [ +const TEAM_FIXTURE: TTeamViewNode[] = [ { id: "platform", name: "Platform", @@ -207,7 +199,9 @@ const UITestPage = () => { const [tableSearch, setTableSearch] = useState(""); const [tablePage, setTablePage] = useState(1); const [treeQuery, setTreeQuery] = useState(""); - const [treeSelected, setTreeSelected] = useState(TEAM_FIXTURE[0]); + const [treeSelected, setTreeSelected] = useState( + TEAM_FIXTURE[0], + ); const [drawerOpen, setDrawerOpen] = useState(false); const [memberships, setMemberships] = useState( MEMBERSHIP_FIXTURE.map((m) => ({ ...m, role: m.baseRole })), diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index 0801305..4845a19 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -1,30 +1,22 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import Button from "@/components/elements/Button"; import Checkbox from "@/components/elements/Checkbox"; -import Dropdown from "@/components/elements/Dropdown"; import Feedback from "@/components/elements/Feedback"; -import MemberStatus from "@/components/elements/MemberStatus"; import Pagination from "@/components/elements/Pagination"; -import SearchInput from "@/components/elements/SearchInput"; import Table from "@/components/table/Table"; -import TableCell from "@/components/table/TableCell"; +import TableEmptyRow from "@/components/table/TableEmptyRow"; import TableFoot from "@/components/table/TableFoot"; import TableHead from "@/components/table/TableHead"; import TableHeaderCell from "@/components/table/TableHeaderCell"; -import TableRow from "@/components/table/TableRow"; +import TableLoadingRow from "@/components/table/TableLoadingRow"; import MemberBatchFailureModal from "@/components/teams/MemberBatchFailureModal"; -import { buildTeamOptions } from "@/components/teams/teamOptions"; import InviteMemberModal from "@/components/users/InviteMemberModal"; import MemberDeleteModal from "@/components/users/MemberDeleteModal"; import MemberDetailDrawer from "@/components/users/MemberDetailDrawer"; -import { CHIP_STATUS } from "@/components/users/memberStatusMap"; -import { - useCancelInvitation, - useDeleteUsers, - useInviteMutation, - useResendInvitation, -} from "@/hooks/mutations/useInvitationMutations"; +import UserRow from "@/components/users/UserRow"; +import UsersToolbar from "@/components/users/UsersToolbar"; +import { useCancelInvitation } from "@/hooks/mutations/useInvitationMutations"; import { useAddUserMembership, useBulkUserRoleChange, @@ -35,40 +27,40 @@ import { useTeamsTreeQuery } from "@/hooks/queries/useTeamsTreeQuery"; import { useUserQuery } from "@/hooks/queries/useUserQuery"; import { useUsersQuery } from "@/hooks/queries/useUsersQuery"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; -import { parseErrorCode } from "@/api/parseError"; -import { BTN_TEXT } from "@/constants/commonConstants"; +import { usePageScopedSelection } from "@/hooks/usePageScopedSelection"; +import { + useServerPagination, + useSyncPaginationTotal, +} from "@/hooks/useServerPagination"; +import { useUserBatchActions } from "@/hooks/useUserBatchActions"; +import { buildTeamOptions } from "@/utils/buildTeamOptions"; +import { SESSION_STATUS } from "@/constants/apiConstants"; +import { + ARIA_LABELS, + BTN_TEXT, + DEFAULT_PAGE_SIZE, + FEEDBACK_TEXT, + PAGE_TITLES, + TABLE_HEADERS, +} from "@/constants/commonConstants"; import type { TDropdownOption } from "@/types/commonTypes"; import type { TTeamMemberRole, TTeamTree } from "@/types/teamTypes"; -import type { - TInvitePayload, - TInviteResult, - TUserListItem, -} from "@/types/userTypes"; -import { useNoticeStore } from "@/stores/noticeStore"; const styles = { page: "flex flex-col gap-3.5 p-4", - /* Wide enough for typical names at the 40% column; anything longer - (up to the 50-char username cap) truncates with an ellipsis and - keeps the full name in the title tooltip. */ - usernameCell: "max-w-[400px] truncate", - overflowChip: - "border-border text-faint ml-1.5 rounded-full border px-2 text-xs", }; /* Filter/sort option sets (SC-11 no.2–3). "all" stands in for 전체. The list shows only the session axis, so the filter matches it. */ const STATUS_OPTIONS: TDropdownOption[] = [ { value: "all", label: "전체" }, - { value: "online", label: "온라인" }, - { value: "offline", label: "오프라인" }, + { value: SESSION_STATUS.online, label: "온라인" }, + { value: SESSION_STATUS.offline, label: "오프라인" }, ]; /* Depth indent stripped — the 150px filter trigger can't fit deep-tree indentation (it forces horizontal scrolling in the menu); teams list - flush left in tree order and long names truncate with an ellipsis. - Computed in-component (buildTeamOptions depends on the real teams - query result — no static dummy list anymore). */ + flush left in tree order and long names truncate with an ellipsis. */ const buildGroupOptions = (teams: TTeamTree): TDropdownOption[] => [ { value: "all", label: "전체" }, ...buildTeamOptions(teams).map(({ value, label }) => ({ value, label })), @@ -76,50 +68,31 @@ const buildGroupOptions = (teams: TTeamTree): TDropdownOption[] => [ const SORT_OPTIONS: TDropdownOption[] = [ { value: "last_invited", label: "최근 초대 코드 발송" }, - { value: "username", label: "멤버 이름" }, + { value: "username", label: TABLE_HEADERS.memberName }, ]; -/** First membership as "team · role"; the rest collapse into "+n". */ -const membershipSummary = (user: TUserListItem) => { - const [first, ...rest] = user.memberships; - return first - ? { summary: `${first.teamName} · ${first.role}`, extra: rest.length } - : { summary: "—", extra: 0 }; -}; - -/* 10 rows per page — caps the table height inside one screen; also the - ?size=10 GET /users query param. */ -const PAGE_SIZE = 10; - -/** Batch-delete failure reasons shown by account (DELETE /users). */ -const BATCH_REASON: Record = { - USER_NOT_FOUND: "사용자를 찾을 수 없습니다", -}; - /** * UsersPage is the user management screen (SC-11): cross-team user * list with search/filters/sort, bulk actions, and pagination, plus * the invite modal (SC-12), member detail drawer (SC-13), and delete * confirm (SC-15). The list is driven by GET /users (useUsersQuery) — * search/status/team/sort/page all become query params, and the - * server returns the already filtered/sorted/paged rows. The drawer's - * detail (GET /users/{id}), role/membership batch, session deactivate, - * invite/resend/cancel, and delete mutations are all wired to the API. + * server returns the already filtered/sorted/paged rows. Bulk flows + * (invite/resend/delete) live in useUserBatchActions; the drawer's + * membership machine lives in useMembershipDrafts. */ const UsersPage = () => { const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [groupFilter, setGroupFilter] = useState("all"); const [sort, setSort] = useState("last_invited"); - const [selectedIds, setSelectedIds] = useState>(new Set()); - const [page, setPage] = useState(1); const [inviteOpen, setInviteOpen] = useState(false); const [drawerUserId, setDrawerUserId] = useState(null); const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); - const [batchFailures, setBatchFailures] = useState< - { account: string; reason: string }[] | null - >(null); - const showNotice = useNoticeStore((state) => state.showNotice); + const { selectedIds, toggleOne, toggleAll, clearSelection, setSelectedIds } = + usePageScopedSelection(); + const { page, totalPages, setPage, resetPage, syncTotal } = + useServerPagination(); const { data: teams } = useTeamsTreeQuery(); const detailQuery = useUserQuery(drawerUserId ?? ""); @@ -127,12 +100,25 @@ const UsersPage = () => { const removeMemberships = useRemoveUserMemberships(drawerUserId ?? ""); const addMembership = useAddUserMembership(drawerUserId ?? ""); const deactivateSession = useDeactivateUserSession(drawerUserId ?? ""); - const invite = useInviteMutation(); - const resend = useResendInvitation(); const cancel = useCancelInvitation(); - const deleteUsersMutation = useDeleteUsers(); const groupOptions = buildGroupOptions(teams ?? []); + const { + inviteMember, + resendCode, + resendCodes, + deleteMembers, + batchFailures, + closeBatchFailures, + } = useUserBatchActions({ + setSelectedIds, + onDeleted: (deletedIds) => { + if (drawerUserId && deletedIds.includes(drawerUserId)) { + setDrawerUserId(null); + } + }, + }); + const debouncedSearch = useDebouncedValue(search, 300); const usersQuery = useUsersQuery({ search: debouncedSearch.trim(), @@ -140,17 +126,11 @@ const UsersPage = () => { teamId: groupFilter, sort, page, - size: PAGE_SIZE, + size: DEFAULT_PAGE_SIZE, }); const users = usersQuery.data?.items ?? []; const total = usersQuery.data?.total ?? 0; - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); - const currentPage = Math.min(page, totalPages); - - /* keep the requested page within range so the query never asks for an out-of-range page */ - useEffect(() => { - if (page > totalPages) setPage(totalPages); - }, [page, totalPages]); + useSyncPaginationTotal(syncTotal, total); /* A search/filter is active whenever it would narrow the server-side result — distinguishes "no members at all" (state B) from "no @@ -170,124 +150,29 @@ const UsersPage = () => { (setter: (value: T) => void) => (value: T) => { setter(value); - setPage(1); - setSelectedIds(new Set()); + resetPage(); + clearSelection(); }; /* Moving to another page clears the selection too — checks are page-scoped, and a checked row on the old page shouldn't ride along into a bulk action taken on a different page. */ const goToPage = (next: number) => { setPage(next); - setSelectedIds(new Set()); + clearSelection(); }; /* Select-all is page-scoped. */ const allSelected = users.length > 0 && users.every((u) => selectedIds.has(u.userId)); - const toggleAll = (checked: boolean) => - setSelectedIds((prev) => { - const next = new Set(prev); - users.forEach((u) => - checked ? next.add(u.userId) : next.delete(u.userId), - ); - return next; - }); - - const toggleOne = (userId: string, checked: boolean) => - setSelectedIds((prev) => { - const next = new Set(prev); - if (checked) next.add(userId); - else next.delete(userId); - return next; - }); - - /** POST /invitations — server judges duplicates and target states; only - the staged team/role sets are sent (buildInvitePreview's sub-team - expansion is display-only, the server performs the real expansion). */ - const inviteMember = async ( - payload: TInvitePayload, - ): Promise => { - try { - await invite.mutateAsync({ - account: payload.email, - username: payload.username, - memberships: payload.sets.map((set) => ({ - teamId: set.teamId, - role: set.role as TTeamMemberRole, - })), - }); - return "success"; - } catch (err) { - if (err instanceof Response) { - const code = await parseErrorCode(err); - return code === "ALREADY_TEAM_MEMBER" ? "duplicate-account" : "error"; - } - return "error"; - } - }; - - /** POST /invitations/resend (per target) — status never changes (D10). - Selection stays intact on partial failure so the user can retry. */ - const resendCodes = async (targets: TUserListItem[]) => { - const results = await Promise.allSettled( - targets.map((u) => resend.mutateAsync(u.userId)), - ); - const failed = targets.filter((_, i) => results[i].status === "rejected"); - if (failed.length === 0) { - showNotice("초대 코드 재전송", "초대 코드를 재전송했습니다.", "info"); - return; - } - setBatchFailures( - failed.map((u) => ({ account: u.account, reason: "재전송 실패" })), - ); - }; - - /** DELETE /users (batch) — memberships, session token, and unused - invite codes go together (D13). Full success clears the targets - from selection and closes the drawer if it pointed at one of - them; partial failure shows the failure modal (account + reason) - and leaves the still-failed ids selected for retry. Throws only - on full failure, so MemberDeleteModal/the drawer's onDeleteMember - contract (resolve unless every target failed) is unaffected. */ - const deleteMembers = async (targets: TUserListItem[]) => { - const userIds = targets.map((u) => u.userId); - const result = await deleteUsersMutation.mutateAsync(userIds); - const failedIds = new Set(result.failed.map((f) => f.id)); - const succeededIds = userIds.filter((id) => !failedIds.has(id)); - - setSelectedIds((prev) => { - const next = new Set(prev); - succeededIds.forEach((id) => next.delete(id)); - return next; - }); - if (drawerUserId && succeededIds.includes(drawerUserId)) { - setDrawerUserId(null); - } - - if (result.failed.length === 0) { - showNotice("멤버 삭제", "멤버를 삭제했습니다.", "info"); - return; - } - if (succeededIds.length === 0) { - throw new Error("delete failed for every target"); - } - setBatchFailures( - result.failed.map((f) => ({ - account: targets.find((u) => u.userId === f.id)?.account ?? f.id, - reason: BATCH_REASON[f.code] ?? f.code, - })), - ); - }; - /* ── SC-11 state C — 조회 실패 ──────────────────────────────────── */ if (usersQuery.isError) { return ( - + { all hidden) ─── */ if (!usersQuery.isPending && total === 0 && !hasActiveFilter) { return ( - + { } return ( - + { pagination never shifts the layout. */ scrollClassName="min-h-[526px]" toolbar={ - - - - - {/* filter/order dropdown */} - - - 정렬 기준 - - - - 멤버 상태 - - - - 팀 - - - - - - {/* Actions — second row, left-aligned (SC-11 no.4–6) */} - - resendCodes(selectedUsers)} - /> - setBulkDeleteOpen(true)} - /> - setInviteOpen(true)} - /> - - - + resendCodes(selectedUsers)} + onOpenBulkDelete={() => setBulkDeleteOpen(true)} + onOpenInvite={() => setInviteOpen(true)} + /> } foot={ @@ -435,77 +261,41 @@ const UsersPage = () => { + toggleAll( + users.map((u) => u.userId), + checked, + ) + } + ariaLabel={ARIA_LABELS.selectAll} /> {/* Fixed column widths — auto layout would resize per page's content and shift the headers while paginating. */} - 멤버 이름 - 멤버 상태 - 팀 (권한) + + {TABLE_HEADERS.memberName} + + + {TABLE_HEADERS.memberStatus} + + + {TABLE_HEADERS.teamWithRole} + - {usersQuery.isPending && ( - - - 불러오는 중… - - - )} + {usersQuery.isPending && } {!usersQuery.isPending && users.length === 0 && ( - - - 검색 결과가 없습니다. - - + 검색 결과가 없습니다. )} - {users.map((user) => { - const { summary, extra } = membershipSummary(user); - return ( - setDrawerUserId(user.userId)} - > - {/* Checkbox clicks must not open the drawer (SC-11 no.8) */} - - e.stopPropagation()}> - toggleOne(user.userId, checked)} - ariaLabel={`${user.account} 선택`} - /> - - - - {user.username} - - - - - - {summary} - {extra > 0 && ( - `${m.teamName} · ${m.role}`) - .join(", ")} - > - +{extra} - - )} - - - ); - })} + {users.map((user) => ( + toggleOne(user.userId, checked)} + onOpen={() => setDrawerUserId(user.userId)} + /> + ))} @@ -543,7 +333,7 @@ const UsersPage = () => { await deactivateSession.mutateAsync(); }} onResendCode={async () => { - await resend.mutateAsync(drawerUser.userId); + await resendCode(drawerUser.userId); }} onCancelInvitation={async () => { await cancel.mutateAsync(drawerUser.userId); @@ -570,7 +360,7 @@ const UsersPage = () => { {batchFailures && ( setBatchFailures(null)} + onClose={closeBatchFailures} /> )} diff --git a/frontend/src/pages/WorkspacePage.tsx b/frontend/src/pages/WorkspacePage.tsx index 7108f89..c66eeee 100644 --- a/frontend/src/pages/WorkspacePage.tsx +++ b/frontend/src/pages/WorkspacePage.tsx @@ -8,8 +8,9 @@ import { isTransitionalStatus, useWorkspaceQuery, } from "@/hooks/queries/useWorkspaceQuery"; -import { PATH_LIST } from "@/constants/commonConstants"; -import { useWorkspaceStore } from "@/stores/workspaceStore"; +import { useWorkspaceStore } from "@/state/store/workspaceStore"; +import { WORKSPACE_STATUS } from "@/constants/apiConstants"; +import { PAGE_TITLES, PATH_LIST } from "@/constants/commonConstants"; const panelClass = "m-6 flex min-h-[340px] flex-col items-center justify-center gap-3 text-center"; @@ -41,7 +42,7 @@ const WorkspacePage = () => { const [createdHere, setCreatedHere] = useState(false); useEffect(() => { - if (workspace?.status === "running" && createdHere) { + if (workspace?.status === WORKSPACE_STATUS.running && createdHere) { setCreatedHere(false); openModal(); } @@ -62,7 +63,7 @@ const WorkspacePage = () => { const exists = workspace != null; const transitional = exists && isTransitionalStatus(workspace.status); - if (isLoading) return ; + if (isLoading) return ; /* A workspace exists → go to the console. The one exception is our own create still provisioning: stay and keep the spinner until it runs. */ @@ -84,7 +85,7 @@ const WorkspacePage = () => { transitional; return ( - + {creating ? ( { "page", ); /* username asc — a@corp.com's row ("a 사용자") leads the first page. */ - expect(await screen.findByText(usernameOf("a@corp.com"))).toBeInTheDocument(); + expect( + await screen.findByText(usernameOf("a@corp.com")), + ).toBeInTheDocument(); }); it("shows the fixed page-size footer", async () => { diff --git a/frontend/src/pages/__tests__/UsersPage.test.tsx b/frontend/src/pages/__tests__/UsersPage.test.tsx index 0d2d464..67db614 100644 --- a/frontend/src/pages/__tests__/UsersPage.test.tsx +++ b/frontend/src/pages/__tests__/UsersPage.test.tsx @@ -8,9 +8,9 @@ import UsersPage from "@/pages/UsersPage"; import * as invitationAPIs from "@/api/invitationAPIs"; import * as teamAPIs from "@/api/teamAPIs"; import * as userAPIs from "@/api/userAPIs"; +import { useNoticeStore } from "@/state/store/noticeStore"; import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; import type { TUserListItem } from "@/types/userTypes"; -import { useNoticeStore } from "@/stores/noticeStore"; const jsonRes = (body: unknown) => ({ ok: true, json: async () => body }) as unknown as Response; @@ -182,7 +182,10 @@ describe("UsersPage", () => { total: 12, // > PAGE_SIZE → a second page exists page: 1, size: 10, - items: [user("u_1", "k@corp.com", "김철수"), user("u_2", "m@corp.com", "박미영")], + items: [ + user("u_1", "k@corp.com", "김철수"), + user("u_2", "m@corp.com", "박미영"), + ], }); const typer = userEvent.setup(); renderPage(); @@ -269,10 +272,7 @@ describe("UsersPage", () => { screen.getByPlaceholderText("user@corp.com"), "new@corp.com", ); - await typer.type( - screen.getByLabelText("사용자 이름 (username)"), - "김신입", - ); + await typer.type(screen.getByLabelText("사용자 이름 (username)"), "김신입"); await typer.click(screen.getByRole("button", { name: "세트 1 팀" })); await typer.click(screen.getByRole("option", { name: "백엔드" })); await typer.click(screen.getByRole("button", { name: "세트 1 role" })); diff --git a/frontend/src/pages/__tests__/WorkspacePage.test.tsx b/frontend/src/pages/__tests__/WorkspacePage.test.tsx index 70a873f..0e0601f 100644 --- a/frontend/src/pages/__tests__/WorkspacePage.test.tsx +++ b/frontend/src/pages/__tests__/WorkspacePage.test.tsx @@ -3,8 +3,8 @@ import { render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import WorkspacePage from "@/pages/WorkspacePage"; -import type { TWorkspace } from "@/types/commonTypes"; -import { useWorkspaceStore } from "@/stores/workspaceStore"; +import { useWorkspaceStore } from "@/state/store/workspaceStore"; +import type { TWorkspace } from "@/types/workspaceTypes"; /* Server state is mocked; the page renders only while no workspace exists (query → null), which is exactly the post-teardown handoff situation. */ @@ -70,8 +70,6 @@ describe("WorkspacePage", () => { expect( screen.getByText(/워크스페이스를 생성하는 중입니다/), ).toBeInTheDocument(); - expect( - screen.queryByText("생성된 워크스페이스가 없습니다."), - ).toBeNull(); + expect(screen.queryByText("생성된 워크스페이스가 없습니다.")).toBeNull(); }); }); 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/stores/__tests__/noticeStore.test.ts b/frontend/src/state/store/__tests__/noticeStore.test.ts similarity index 96% rename from frontend/src/stores/__tests__/noticeStore.test.ts rename to frontend/src/state/store/__tests__/noticeStore.test.ts index ddf01c3..c62862d 100644 --- a/frontend/src/stores/__tests__/noticeStore.test.ts +++ b/frontend/src/state/store/__tests__/noticeStore.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useNoticeStore } from "@/stores/noticeStore"; +import { useNoticeStore } from "@/state/store/noticeStore"; describe("noticeStore", () => { beforeEach(() => { diff --git a/frontend/src/stores/__tests__/workspaceStore.test.ts b/frontend/src/state/store/__tests__/workspaceStore.test.ts similarity index 94% rename from frontend/src/stores/__tests__/workspaceStore.test.ts rename to frontend/src/state/store/__tests__/workspaceStore.test.ts index 3c2ded8..81f089e 100644 --- a/frontend/src/stores/__tests__/workspaceStore.test.ts +++ b/frontend/src/state/store/__tests__/workspaceStore.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { useWorkspaceStore } from "@/stores/workspaceStore"; +import { useWorkspaceStore } from "@/state/store/workspaceStore"; const reset = () => useWorkspaceStore.setState({ modalOpen: false, deleteConfirmOpen: 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/stores/noticeStore.ts b/frontend/src/state/store/noticeStore.ts similarity index 100% rename from frontend/src/stores/noticeStore.ts rename to frontend/src/state/store/noticeStore.ts diff --git a/frontend/src/stores/toastStore.ts b/frontend/src/state/store/toastStore.ts similarity index 100% rename from frontend/src/stores/toastStore.ts rename to frontend/src/state/store/toastStore.ts diff --git a/frontend/src/stores/workspaceStore.ts b/frontend/src/state/store/workspaceStore.ts similarity index 100% rename from frontend/src/stores/workspaceStore.ts rename to frontend/src/state/store/workspaceStore.ts diff --git a/frontend/src/types/commonTypes.ts b/frontend/src/types/commonTypes.ts index 6250152..4085a6d 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; @@ -15,64 +10,5 @@ export type TDropdownOption = { /** Session chip state — the only status a list view renders. */ export type TMemberStatus = "online" | "offline"; -/** - * rune workspace lifecycle phase (wireframe SC-03 badge; console API - * `phase`). `provisioning` is the transient state right after create, - * before the endpoint/row count exist. - */ -export type TWorkspaceStatus = - | "provisioning" - | "running" - | "stopping" - | "stopped" - | "starting" - | "deleting" - | "error"; - -/** - * rune workspace record surfaced in the console (wireframe SC-02 state D), - * mapped from the API `GET /workspace` body. The workspace name is never - * exposed — it is a hash-like random value stored DB-side only. endpoint and - * rowCount are null until the workspace finishes provisioning. - */ -export type TWorkspace = { - status: TWorkspaceStatus; - endpoint: string | null; - rowCount: number | null; - /** - * The workspace exists in the cloud but was created by a different console - * install than this one (a reinstall minted a fresh team_secret), so its - * stored data is encrypted under a key we no longer hold and it can only be - * deleted + recreated. Absent/false on a healthy workspace. - */ - orphaned: boolean; - /** - * The data-plane credential expired and a background reconnect cannot - * re-bootstrap it — the user must drive a reconnect (POST /workspace). The - * cloud workspace itself is healthy; only the local engine link is stale. - * Mutually exclusive with orphaned (recreate supersedes reconnect). - */ - reconnectRequired: boolean; -}; - -/** Wire shape of `GET /workspace` (console API design 2026-07-13, §Workspace). */ -export type TWorkspaceWire = { - phase: TWorkspaceStatus; - endpointUrl: string | null; - rows: number | null; - /** true when the workspace no longer matches this console (reinstall). */ - orphaned?: boolean; - /** true when the data-plane credential expired and needs a user-driven reconnect. */ - reconnect?: boolean; -}; - -/** Recursive team-tree node (UIKIT AdminTeamNode, wireframe SC-06). */ -export type TTeamNode = { - id: string; - name: string; - members: number; - children?: TTeamNode[]; -}; - /** Toast tone — semantic colors are state, not decoration. */ export type TToastTone = "info" | "success" | "error"; diff --git a/frontend/src/types/teamTypes.ts b/frontend/src/types/teamTypes.ts index 41de255..1c0e96b 100644 --- a/frontend/src/types/teamTypes.ts +++ b/frontend/src/types/teamTypes.ts @@ -1,3 +1,9 @@ +import type { + INVITATION_STATUS, + SESSION_STATUS, + TEAM_MEMBER_ROLE, +} from "@/constants/apiConstants"; + export type TTeamNode = { id: string; name: string; @@ -9,15 +15,27 @@ export type TTeamNode = { export type TTeamTree = TTeamNode[]; -/** Grantable member role (Admin is console-account only — API §0). */ -export type TTeamMemberRole = "edit" | "write" | "read"; +/** Recursive team-tree node the tree/org views consume (UIKIT + AdminTeamNode, wireframe SC-06) — built client-side from the flat + TTeamTree. Distinct from TTeamNode, the flat wire row above. */ +export type TTeamViewNode = { + id: string; + name: string; + members: number; + children?: TTeamViewNode[]; +}; + +/** Grantable member role — derived from TEAM_MEMBER_ROLE (single source). */ +export type TTeamMemberRole = + (typeof TEAM_MEMBER_ROLE)[keyof typeof TEAM_MEMBER_ROLE]; -/** Invitation-code lifecycle status on the wire (common contract). */ +/** Invitation-code lifecycle status — derived from INVITATION_STATUS. */ export type TInvitationStatus = - "invite_pending" | "invite_expired" | "invite_redeemed"; + (typeof INVITATION_STATUS)[keyof typeof INVITATION_STATUS]; -/** Session-token liveness on the wire (common contract). */ -export type TSessionStatus = "online" | "offline"; +/** Session-token liveness — derived from SESSION_STATUS. */ +export type TSessionStatus = + (typeof SESSION_STATUS)[keyof typeof SESSION_STATUS]; /** GET /teams/{id} detail. */ export type TTeamDetail = { diff --git a/frontend/src/types/updateTypes.ts b/frontend/src/types/updateTypes.ts index 95c6ea7..501551a 100644 --- a/frontend/src/types/updateTypes.ts +++ b/frontend/src/types/updateTypes.ts @@ -1,6 +1,9 @@ -/** Lifecycle reported by the privileged rune-console update agent. */ +import type { SYSTEM_UPDATE_STATE } from "@/constants/apiConstants"; + +/** Lifecycle reported by the privileged rune-console update agent — + derived from SYSTEM_UPDATE_STATE (single source). */ export type TSystemUpdateState = - "idle" | "queued" | "running" | "failed" | "succeeded"; + (typeof SYSTEM_UPDATE_STATE)[keyof typeof SYSTEM_UPDATE_STATE]; /** Wire contract for GET /api/v1/system/update. */ export type TSystemUpdateStatus = { diff --git a/frontend/src/types/workspaceTypes.ts b/frontend/src/types/workspaceTypes.ts new file mode 100644 index 0000000..d495628 --- /dev/null +++ b/frontend/src/types/workspaceTypes.ts @@ -0,0 +1,47 @@ +import type { WORKSPACE_STATUS } from "@/constants/apiConstants"; + +/** + * rune workspace lifecycle phase (wireframe SC-03 badge; console API + * `phase`). `provisioning` is the transient state right after create, + * before the endpoint/row count exist. Derived from WORKSPACE_STATUS + * (single source). + */ +export type TWorkspaceStatus = + (typeof WORKSPACE_STATUS)[keyof typeof WORKSPACE_STATUS]; + +/** + * rune workspace record surfaced in the console (wireframe SC-02 state D), + * mapped from the API `GET /workspace` body. The workspace name is never + * exposed — it is a hash-like random value stored DB-side only. endpoint and + * rowCount are null until the workspace finishes provisioning. + */ +export type TWorkspace = { + status: TWorkspaceStatus; + endpoint: string | null; + rowCount: number | null; + /** + * The workspace exists in the cloud but was created by a different console + * install than this one (a reinstall minted a fresh team_secret), so its + * stored data is encrypted under a key we no longer hold and it can only be + * deleted + recreated. Absent/false on a healthy workspace. + */ + orphaned: boolean; + /** + * The data-plane credential expired and a background reconnect cannot + * re-bootstrap it — the user must drive a reconnect (POST /workspace). The + * cloud workspace itself is healthy; only the local engine link is stale. + * Mutually exclusive with orphaned (recreate supersedes reconnect). + */ + reconnectRequired: boolean; +}; + +/** Wire shape of `GET /workspace` (console API design 2026-07-13, §Workspace). */ +export type TWorkspaceWire = { + phase: TWorkspaceStatus; + endpointUrl: string | null; + rows: number | null; + /** true when the workspace no longer matches this console (reinstall). */ + orphaned?: boolean; + /** true when the data-plane credential expired and needs a user-driven reconnect. */ + reconnect?: boolean; +}; diff --git a/frontend/src/components/teams/__tests__/teamHierarchy.test.ts b/frontend/src/utils/__tests__/teamHierarchy.test.ts similarity index 93% rename from frontend/src/components/teams/__tests__/teamHierarchy.test.ts rename to frontend/src/utils/__tests__/teamHierarchy.test.ts index 937ce51..547386a 100644 --- a/frontend/src/components/teams/__tests__/teamHierarchy.test.ts +++ b/frontend/src/utils/__tests__/teamHierarchy.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - getTeamDescendantIds, - getTeamName, -} from "@/components/teams/teamHierarchy"; +import { getTeamDescendantIds, getTeamName } from "@/utils/teamHierarchy"; import type { TTeamTree } from "@/types/teamTypes"; /** Minimal 3-node chain: t_1 root → t_2 child → t_3 grandchild. */ diff --git a/frontend/src/components/teams/teamOptions.ts b/frontend/src/utils/buildTeamOptions.ts similarity index 59% rename from frontend/src/components/teams/teamOptions.ts rename to frontend/src/utils/buildTeamOptions.ts index 2415780..0342397 100644 --- a/frontend/src/components/teams/teamOptions.ts +++ b/frontend/src/utils/buildTeamOptions.ts @@ -1,19 +1,6 @@ import type { TDropdownOption } from "@/types/commonTypes"; import type { TTeamTree } from "@/types/teamTypes"; -/** Team name rule: digits, Hangul, Latin letters, and - _ only. */ -export const TEAM_NAME_PATTERN = /^[0-9A-Za-z가-힣_-]+$/; - -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" }, -]; - /** All teams in tree order with depth indent (for team-picker dropdowns). Pure function over the real `teams` query result — used by the team CRUD modals (create/rename/delete) and the Users page pickers. */ diff --git a/frontend/src/utils/email.ts b/frontend/src/utils/email.ts new file mode 100644 index 0000000..6087511 --- /dev/null +++ b/frontend/src/utils/email.ts @@ -0,0 +1,7 @@ +/** Email (account) field rules — shared by the invite form (SC-12) and the + * team add-member form (SC-06), mirroring the username.ts convention of + * keeping a field's pattern and its validation copy together. */ + +export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export const EMAIL_FORMAT_ERROR = "올바른 이메일 형식이 아닙니다."; diff --git a/frontend/src/utils/formatDate.ts b/frontend/src/utils/formatDate.ts index 2dea4a6..091991c 100644 --- a/frontend/src/utils/formatDate.ts +++ b/frontend/src/utils/formatDate.ts @@ -6,19 +6,23 @@ const KST_TIME_ZONE = "Asia/Seoul"; +/* Constructed once at module load — Intl.DateTimeFormat construction is + one of the costlier Intl operations and these run in every table cell + on every render; the options never change. */ +const KST_FORMATTER = new Intl.DateTimeFormat("en-US", { + timeZone: KST_TIME_ZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", +}); + /** Break an ISO instant into zero-padded KST calendar parts. */ const kstParts = (iso: string): Record => { - const formatter = new Intl.DateTimeFormat("en-US", { - timeZone: KST_TIME_ZONE, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hourCycle: "h23", - }); const parts: Record = {}; - for (const { type, value } of formatter.formatToParts(new Date(iso))) { + for (const { type, value } of KST_FORMATTER.formatToParts(new Date(iso))) { parts[type] = value; } return parts; diff --git a/frontend/src/components/users/invitePreview.ts b/frontend/src/utils/invitePreview.ts similarity index 95% rename from frontend/src/components/users/invitePreview.ts rename to frontend/src/utils/invitePreview.ts index 65e4027..c6d2832 100644 --- a/frontend/src/components/users/invitePreview.ts +++ b/frontend/src/utils/invitePreview.ts @@ -1,7 +1,4 @@ -import { - getTeamDescendantIds, - getTeamName, -} from "@/components/teams/teamHierarchy"; +import { getTeamDescendantIds, getTeamName } from "@/utils/teamHierarchy"; import type { TTeamTree } from "@/types/teamTypes"; import type { TInviteSet } from "@/types/userTypes"; diff --git a/frontend/src/utils/teamHierarchy.ts b/frontend/src/utils/teamHierarchy.ts new file mode 100644 index 0000000..1f6f410 --- /dev/null +++ b/frontend/src/utils/teamHierarchy.ts @@ -0,0 +1,70 @@ +import type { TTeamTree, TTeamViewNode } from "@/types/teamTypes"; + +/** + * Team-tree lookups over a flat `TTeamTree` — shared by the invite preview + * (SC-12 no.3) and the membership-removal sub-team notice (SC-14 no.2). + * Pure functions over the tree passed in (from `useTeamsTreeQuery`); trees + * are small, so no memoized id-map is kept at module scope. + */ + +/** Team name for `teamId`, or the id itself if the team is unknown. */ +export const getTeamName = (teams: TTeamTree, teamId: string): string => + teams.find((team) => team.id === teamId)?.name ?? teamId; + +/** All descendant ids of a team, in depth-first tree order. */ +export const getTeamDescendantIds = ( + teams: TTeamTree, + teamId: string, +): string[] => + (teams.find((team) => team.id === teamId)?.childrenIds ?? []).flatMap( + (childId) => [childId, ...getTeamDescendantIds(teams, childId)], + ); + +/** + * GET /teams/tree returns flat nodes — the client builds the recursive + * TTeamViewNode shape the TeamTree component consumes (API design §3). + * Single pass over a children index (not a filter per parent), so the + * build stays linear in team count. Callers memoize per teams array. + */ +export const buildTeamNodes = (teams: TTeamTree): TTeamViewNode[] => { + const childrenOf = new Map(); + for (const team of teams) { + const siblings = childrenOf.get(team.parentId); + if (siblings) siblings.push(team); + else childrenOf.set(team.parentId, [team]); + } + const build = (parentId: string | null): TTeamViewNode[] => + (childrenOf.get(parentId) ?? []).map((team) => ({ + id: team.id, + name: team.name, + members: team.memberCount, + children: team.childCount > 0 ? build(team.id) : undefined, + })); + return build(null); +}; + +/** Depth-first lookup in a built view-node tree. */ +export const findTeamNode = ( + nodes: TTeamViewNode[], + id: string, +): TTeamViewNode | undefined => + nodes.reduce( + (found, node) => + found ?? (node.id === id ? node : findTeamNode(node.children ?? [], id)), + undefined, + ); + +/** Ancestor ids of a team — expanded so a selection handed off from + the org chart is actually visible in the tree. */ +export const ancestorIds = ( + flatById: Map, + teamId: string, +): string[] => { + const ids: string[] = []; + let parentId = flatById.get(teamId)?.parentId; + while (parentId) { + ids.push(parentId); + parentId = flatById.get(parentId)?.parentId; + } + return ids; +};
+
하위 팀이 있는 팀은 삭제할 수 없습니다. 하위 팀을 먼저 삭제한 후 다시 시도해 주세요. @@ -112,7 +117,7 @@ const DeleteTeamModal = ({ return ( - + 삭제하려는 팀의 기억 처리 방식을 선택해 주세요. @@ -136,7 +141,7 @@ const DeleteTeamModal = ({ {error}} - + void; - onConfirm: () => void; -} - -const styles = { - table: "w-full border-collapse text-sm", - th: "border-border text-faint border px-3 py-1.5 text-left font-mono text-tag font-medium tracking-[0.08em]", - td: "border-border text-muted-foreground border px-3 py-1.5", -}; - -/** - * RemoveMembershipModal is the 멤버십 제거 confirmation (SC-14): lists - * exactly the memberships being removed (account · team · role) — only - * what is listed is removed, no sub-team cascade (C10). Mount - * conditionally. - */ -const RemoveMembershipModal = ({ - teamName, - members, - onClose, - onConfirm, -}: RemoveMembershipModalProps) => { - return ( - - - 다음 멤버십을 제거합니다: - - - - 멤버 이름 - 팀 - 권한 - - - - {members.map((member) => ( - - {member.account} - {teamName} - {member.role} - - ))} - - - - 하위 팀 소속은 유지됩니다. 필요할 경우 개별 선택 후 제거하세요. - - - - - - - - ); -}; - -export default RemoveMembershipModal; diff --git a/frontend/src/components/teams/RenameTeamModal.tsx b/frontend/src/components/teams/RenameTeamModal.tsx index 828c00e..19cdeb4 100644 --- a/frontend/src/components/teams/RenameTeamModal.tsx +++ b/frontend/src/components/teams/RenameTeamModal.tsx @@ -4,11 +4,13 @@ import Button from "@/components/elements/Button"; import Input from "@/components/elements/Input"; import Notice from "@/components/elements/Notice"; import ModalLayout from "@/components/layout/ModalLayout"; +import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; +import { TEAM_NAME_DUPLICATE_TEXT } from "@/constants/errorConstants"; +import { MODAL_STYLE_VAR } from "@/constants/styleConstants"; import { TEAM_NAME_PATTERN, TEAM_NAME_RULE_TEXT, -} from "@/components/teams/teamOptions"; -import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; +} from "@/constants/teamConstants"; import type { TTeamTree } from "@/types/teamTypes"; interface RenameTeamModalProps { @@ -58,12 +60,12 @@ const RenameTeamModal = ({ const nameError = isInvalidFormat ? TEAM_NAME_RULE_TEXT : isDuplicate - ? "같은 상위 팀에 동일한 이름이 이미 있습니다." + ? TEAM_NAME_DUPLICATE_TEXT : undefined; return ( - + {error && {error}} - + void; - onConfirm: () => void; -} - -const styles = { - table: "w-full border-collapse text-sm", - th: "border-border text-faint border px-3 py-1.5 text-left font-mono text-tag font-medium tracking-[0.08em]", - td: "border-border text-muted-foreground border px-3 py-1.5", - arrow: "text-faint px-1", - to: "text-foreground font-semibold", -}; - -/** - * RoleChangeConfirmModal is the role 변경 confirmation (SC-06 state E): - * staged dropdown edits are listed (account · current → new) and only - * applied on [변경하기]. Mount conditionally. - */ -const RoleChangeConfirmModal = ({ - changes, - onClose, - onConfirm, -}: RoleChangeConfirmModalProps) => { - return ( - - - 다음 멤버의 권한을 변경합니다: - - - - 멤버 이름 - 권한 변경 - - - - {changes.map((change) => ( - - {change.account} - - {change.from} - - → - - {change.to} - - - ))} - - - - - - - - - ); -}; - -export default RoleChangeConfirmModal; diff --git a/frontend/src/components/teams/TeamCard.tsx b/frontend/src/components/teams/TeamCard.tsx new file mode 100644 index 0000000..409ef72 --- /dev/null +++ b/frontend/src/components/teams/TeamCard.tsx @@ -0,0 +1,60 @@ +import Button from "@/components/elements/Button"; +import { formatDate } from "@/utils/formatDate"; +import { BTN_TEXT } from "@/constants/commonConstants"; + +const styles = { + card: "border-border bg-surface rounded-lg border px-4 py-3", + row: "flex items-center gap-2", + name: "text-lg flex-1 font-semibold", + meta: "text-sm text-muted-foreground mt-1.5", +}; + +interface TeamCardProps { + name: string; + parentName: string; + childrenLabel: string; + memberCount: number; + createdAt?: string; + onRename: () => void; + onDelete: () => void; +} + +/** TeamCard is the selected-team summary card (SC-06 no.6–8): name + + rename/delete actions and the parent/children/member/created meta line. */ +const TeamCard = ({ + name, + parentName, + childrenLabel, + memberCount, + createdAt, + onRename, + onDelete, +}: TeamCardProps) => { + return ( + + + {name} + + + + + 상위 팀: {parentName} | 하위 팀: {childrenLabel} | 멤버: {memberCount}명 + | 생성일: {formatDate(createdAt)} + + + ); +}; + +export default TeamCard; diff --git a/frontend/src/components/teams/TeamMembersTable.tsx b/frontend/src/components/teams/TeamMembersTable.tsx new file mode 100644 index 0000000..ad8971e --- /dev/null +++ b/frontend/src/components/teams/TeamMembersTable.tsx @@ -0,0 +1,169 @@ +import Checkbox from "@/components/elements/Checkbox"; +import Dropdown from "@/components/elements/Dropdown"; +import MemberStatus from "@/components/elements/MemberStatus"; +import Pagination from "@/components/elements/Pagination"; +import Table from "@/components/table/Table"; +import TableCell from "@/components/table/TableCell"; +import TableEmptyRow from "@/components/table/TableEmptyRow"; +import TableErrorRow from "@/components/table/TableErrorRow"; +import TableFoot from "@/components/table/TableFoot"; +import TableHead from "@/components/table/TableHead"; +import TableHeaderCell from "@/components/table/TableHeaderCell"; +import TableLoadingRow from "@/components/table/TableLoadingRow"; +import TableRow from "@/components/table/TableRow"; +import { formatDate } from "@/utils/formatDate"; +import { + ARIA_LABELS, + DEFAULT_PAGE_SIZE, + TABLE_HEADERS, +} from "@/constants/commonConstants"; +import { ROLE_OPTIONS } from "@/constants/teamConstants"; +import { CHIP_STATUS } from "@/constants/userConstants"; +import type { TTeamMember } from "@/types/teamTypes"; + +const styles = { + /* The detail panel is narrower than the users page — typical names + fit the 36% column; longer ones truncate with an ellipsis and + keep the full name in the title tooltip. */ + usernameCell: "max-w-[280px] truncate cursor-default", + timeCell: "text-faint font-mono text-xs whitespace-nowrap", +}; + +interface TeamMembersTableProps { + members: TTeamMember[]; + isPending: boolean; + isError: boolean; + total: number; + page: number; + totalPages: number; + onPageChange: (page: number) => void; + selectedIds: Set; + onToggleOne: (userId: string, checked: boolean) => void; + /** Header select-all over the current page's rows. */ + onToggleAll: (checked: boolean) => void; + /** Row is highlighted while its role pick is staged (unapplied). */ + isRoleStaged: (userId: string) => boolean; + /** Displayed role — the staged pick or the committed baseline. */ + roleOf: (member: TTeamMember) => string; + onRoleChange: (member: TTeamMember, nextRole: string) => void; +} + +/** + * TeamMembersTable is the SC-06 member table (no.11–13): page-scoped + * checkbox selection, per-row staged role dropdowns, and the fixed + * 10-per-page pagination. Pure view — staging/selection state lives in + * the parent's hooks. + */ +const TeamMembersTable = ({ + members, + isPending, + isError, + total, + page, + totalPages, + onPageChange, + selectedIds, + onToggleOne, + onToggleAll, + isRoleStaged, + roleOf, + onRoleChange, +}: TeamMembersTableProps) => { + const allSelected = + members.length > 0 && members.every((m) => selectedIds.has(m.userId)); + + return ( + + + + + + } + > + + + + + {/* Fixed column widths — auto layout would resize per + page's content and shift the headers while paginating. */} + + {TABLE_HEADERS.memberName} + + + {TABLE_HEADERS.memberStatus} + + + {TABLE_HEADERS.roleAlt} + + + {TABLE_HEADERS.joinedAt} + + + + {isPending ? ( + + ) : isError ? ( + + ) : total === 0 ? ( + 멤버가 없습니다. + ) : ( + members.map((member) => ( + + + onToggleOne(member.userId, checked)} + ariaLabel={`${member.account} 선택`} + /> + + + {member.username} + + + + + + onRoleChange(member, next)} + size="sm" + changed={isRoleStaged(member.userId)} + ariaLabel={`${member.account} role`} + className="w-24" + /> + + + {formatDate(member.joinedAt)} + + + )) + )} + + + ); +}; + +export default TeamMembersTable; diff --git a/frontend/src/components/teams/TeamMembersToolbar.tsx b/frontend/src/components/teams/TeamMembersToolbar.tsx new file mode 100644 index 0000000..4dea490 --- /dev/null +++ b/frontend/src/components/teams/TeamMembersToolbar.tsx @@ -0,0 +1,75 @@ +import Button from "@/components/elements/Button"; +import { BTN_TEXT } from "@/constants/commonConstants"; + +const styles = { + row: "flex items-center gap-2", + title: "text-md flex-1 font-semibold", + actions: "flex flex-wrap items-center gap-2", +}; + +interface TeamMembersToolbarProps { + total: number; + /** Staged (not yet applied) role picks — arms 초기화/업데이트. */ + pendingCount: number; + /** Checked rows — arms 제거하기. */ + selectedCount: number; + onResetChanges: () => void; + onUpdateChanges: () => void; + onRemove: () => void; + onAddMember: () => void; +} + +/** TeamMembersToolbar is the 멤버 section header (SC-06 no.9–10): count + + the staged-change / removal / add actions. */ +const TeamMembersToolbar = ({ + total, + pendingCount, + selectedCount, + onResetChanges, + onUpdateChanges, + onRemove, + onAddMember, +}: TeamMembersToolbarProps) => { + return ( + + 멤버 ({total}){" "} + + {/* Drops every staged (not yet applied) dropdown pick back to + its saved role — the committed savedRoles baseline stays. */} + + + + + + + ); +}; + +export default TeamMembersToolbar; diff --git a/frontend/src/components/teams/TreeDetailView.tsx b/frontend/src/components/teams/TreeDetailView.tsx index c1d4068..a6d1297 100644 --- a/frontend/src/components/teams/TreeDetailView.tsx +++ b/frontend/src/components/teams/TreeDetailView.tsx @@ -1,45 +1,55 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import Button from "@/components/elements/Button"; -import Checkbox from "@/components/elements/Checkbox"; -import Dropdown from "@/components/elements/Dropdown"; -import MemberStatus from "@/components/elements/MemberStatus"; -import Pagination from "@/components/elements/Pagination"; -import Table from "@/components/table/Table"; -import TableCell from "@/components/table/TableCell"; -import TableErrorRow from "@/components/table/TableErrorRow"; -import TableFoot from "@/components/table/TableFoot"; -import TableHead from "@/components/table/TableHead"; -import TableHeaderCell from "@/components/table/TableHeaderCell"; -import TableRow from "@/components/table/TableRow"; import AddMemberModal from "@/components/teams/AddMemberModal"; import CreateTeamModal from "@/components/teams/CreateTeamModal"; import DeleteTeamModal from "@/components/teams/DeleteTeamModal"; import MemberBatchFailureModal from "@/components/teams/MemberBatchFailureModal"; -import RemoveMembershipModal from "@/components/teams/RemoveMembershipModal"; import RenameTeamModal from "@/components/teams/RenameTeamModal"; -import RoleChangeConfirmModal from "@/components/teams/RoleChangeConfirmModal"; -import { ROLE_OPTIONS } from "@/components/teams/teamOptions"; +import TeamCard from "@/components/teams/TeamCard"; +import TeamMembersTable from "@/components/teams/TeamMembersTable"; +import TeamMembersToolbar from "@/components/teams/TeamMembersToolbar"; import TeamTree from "@/components/tree/TeamTree"; +import MembershipRemoveModal from "@/components/users/MembershipRemoveModal"; +import RoleChangeConfirmModal from "@/components/users/RoleChangeConfirmModal"; import { useAddTeamMemberMutation, useBulkRoleChangeMutation, useRemoveTeamMembersMutation, } from "@/hooks/mutations/useTeamMemberMutations"; -import { - useCreateTeamMutation, - useDeleteTeamMutation, - useRenameTeamMutation, -} from "@/hooks/mutations/useTeamMutations"; import { useTeamMembersQuery } from "@/hooks/queries/useTeamMembersQuery"; import { useTeamQuery } from "@/hooks/queries/useTeamQuery"; +import { + toBatchFailureRows, + useBatchFailureModal, +} from "@/hooks/useBatchFailureModal"; +import { usePageScopedSelection } from "@/hooks/usePageScopedSelection"; +import { + useServerPagination, + useSyncPaginationTotal, +} from "@/hooks/useServerPagination"; +import { useStagedRoleEdits } from "@/hooks/useStagedRoleEdits"; +import { useTeamCrud } from "@/hooks/useTeamCrud"; 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"; +import { useNoticeStore } from "@/state/store/noticeStore"; +import { + ancestorIds, + buildTeamNodes, + findTeamNode, +} from "@/utils/teamHierarchy"; +import { TEAM_MEMBER_ROLE } from "@/constants/apiConstants"; +import { + BTN_TEXT, + DEFAULT_PAGE_SIZE, + TABLE_HEADERS, +} from "@/constants/commonConstants"; +import { + ADD_MEMBER_REASON, + BATCH_REASON_FALLBACK, +} from "@/constants/errorConstants"; +import { NOTICE_TEXT } from "@/constants/noticeConstants"; import type { TTeamMemberRole, TTeamTree } from "@/types/teamTypes"; -import { useNoticeStore } from "@/stores/noticeStore"; +import type { TRoleChange } from "@/types/userTypes"; const styles = { body: "flex min-h-[340px] flex-1", @@ -47,18 +57,6 @@ const styles = { side: "border-border flex w-50 flex-none flex-col gap-2.5 border-r p-3", /* Right detail area */ main: "flex min-w-0 flex-1 flex-col gap-5 p-4", - teamCard: "border-border bg-surface rounded-lg border px-4 py-3", - teamCardRow: "flex items-center gap-2", - teamName: "text-lg flex-1 font-semibold", - teamMeta: "text-sm text-muted-foreground mt-1.5", - membersRow: "flex items-center gap-2", - membersTitle: "text-md flex-1 font-semibold", - /* The detail panel is narrower than the users page — typical names - fit the 36% column; longer ones truncate with an ellipsis and - keep the full name in the title tooltip. */ - usernameCell: "max-w-[280px] truncate cursor-default", - timeCell: "text-faint font-mono text-xs whitespace-nowrap", - pendingActions: "flex flex-wrap items-center gap-2", }; type TActiveModal = @@ -70,39 +68,13 @@ type TActiveModal = | "removeMembers" | null; -/** - * GET /teams/tree returns flat nodes — the client builds the recursive - * TTeamNode shape the TeamTree component consumes (API design §3). - */ -const buildTeamNodes = ( - teams: TTeamTree, - parentId: string | null, -): TTeamNode[] => - teams - .filter((team) => team.parentId === parentId) - .map((team) => ({ - id: team.id, - name: team.name, - members: team.memberCount, - children: - team.childCount > 0 ? buildTeamNodes(teams, team.id) : undefined, - })); - -const findTeamNode = (nodes: TTeamNode[], id: string): TTeamNode | undefined => - nodes.reduce( - (found, node) => - found ?? (node.id === id ? node : findTeamNode(node.children ?? [], id)), - 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 - * when the view toggle is on 트리·상세. + * when the view toggle is on 트리·상세. The view composes the shared + * hooks (selection, pagination, staged role edits, team CRUD, batch + * failures) and hands rendering to TeamCard/TeamMembersToolbar/ + * TeamMembersTable. */ interface TreeDetailViewProps { /** Flat GET /teams/tree nodes — owned by TeamsPage. Always non-empty @@ -117,43 +89,37 @@ interface TreeDetailViewProps { onSelectTeam: (teamId: string) => void; } -/** Ancestor ids of a team — expanded so a selection handed off from - the org chart is actually visible in the tree. */ -const ancestorIds = (teams: TTeamTree, teamId: string): string[] => { - const flatById = new Map(teams.map((team) => [team.id, team])); - const ids: string[] = []; - let parentId = flatById.get(teamId)?.parentId; - while (parentId) { - ids.push(parentId); - parentId = flatById.get(parentId)?.parentId; - } - return ids; -}; - const TreeDetailView = ({ teams, teamSearch, selectedTeamId, onSelectTeam, }: TreeDetailViewProps) => { - const flatById = new Map(teams.map((t) => [t.id, t])); - const teamNodes = buildTeamNodes(teams, null); + /* Derived once per teams array — this component re-renders on every + keystroke/checkbox/staged edit, and the tree build must not re-run + for those (OrgChart applies the same rule). */ + const flatById = useMemo(() => new Map(teams.map((t) => [t.id, t])), [teams]); + const teamNodes = useMemo(() => buildTeamNodes(teams), [teams]); /* 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()); - const [page, setPage] = useState(1); - /* Role edits are staged (SC-06): dropdown picks collect here and only - apply on [변경사항 업데이트]. savedRoles is the committed baseline - (stands in for the PUT /teams/{id}/members batch until wired). */ - const [pendingRoles, setPendingRoles] = useState< - Map - >(new Map()); - const [savedRoles, setSavedRoles] = useState>( - new Map(), - ); + const { selectedIds, toggleOne, toggleAll, clearSelection } = + usePageScopedSelection(); + const { page, totalPages, setPage, resetPage, syncTotal } = + useServerPagination(); + const { + pendingRoles, + baseRole, + stageRole, + resetStaged, + resetAll, + applyAll, + reconcileBatch, + } = useStagedRoleEdits(); + const { batchFailures, showBatchFailures, closeBatchFailures } = + useBatchFailureModal(); + const showNotice = useNoticeStore((state) => state.showNotice); /* Switching teams must not leak the prior team's member-table state: without this, `page` can point past the new team's last page (no @@ -166,25 +132,27 @@ const TreeDetailView = ({ target (e.g. when the prop doesn't resolve and falls back to defaultTeam). */ useEffect(() => { - setPage(1); - setSelectedIds(new Set()); - setPendingRoles(new Map()); - setSavedRoles(new Map()); + resetPage(); + clearSelection(); + resetAll(); }, [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)); + useSyncPaginationTotal(syncTotal, total); const addMember = useAddTeamMemberMutation(selectedTeam.id); const bulkRole = useBulkRoleChangeMutation(selectedTeam.id); const removeMembers = useRemoveTeamMembersMutation(selectedTeam.id); - const createTeam = useCreateTeamMutation(); - const renameTeam = useRenameTeamMutation(selectedTeam.id); - const deleteTeam = useDeleteTeamMutation(selectedTeam.id); + /* Selected-team card meta — detail query first, flat tree row as the + immediate fallback while the detail loads. */ const flatTeam = flatById.get(selectedTeam.id); const parentName = detail?.parentId ? (flatById.get(detail.parentId)?.name ?? "없음") @@ -195,166 +163,38 @@ const TreeDetailView = ({ const childrenLabel = childCount ? `${childCount}개` : "없음"; const memberCount = detail?.memberCount ?? selectedTeam.members; - /* Select-all is page-scoped; selections persist across page moves. */ - const allSelected = - members.length > 0 && members.every((m) => selectedIds.has(m.userId)); - - const toggleAll = (checked: boolean) => - setSelectedIds((prev) => { - const next = new Set(prev); - members.forEach((m) => - checked ? next.add(m.userId) : next.delete(m.userId), - ); - return next; - }); - - const toggleOne = (userId: string, checked: boolean) => - setSelectedIds((prev) => { - const next = new Set(prev); - if (checked) next.add(userId); - else next.delete(userId); - return next; - }); - - const showNotice = useNoticeStore((state) => state.showNotice); - - const baseRole = (userId: string, fallback: TTeamMemberRole) => - savedRoles.get(userId) ?? fallback; - - const handleRoleChange = ( - userId: string, - fallback: TTeamMemberRole, - nextRole: string, - ) => - setPendingRoles((prev) => { - const next = new Map(prev); - if (nextRole === baseRole(userId, fallback)) next.delete(userId); - else next.set(userId, nextRole as TTeamMemberRole); - return next; - }); - - const applyRoleChanges = () => { - setSavedRoles((prev) => new Map([...prev, ...pendingRoles])); - setPendingRoles(new Map()); - showNotice( - MODAL_TITLES.roleChange, - "변경사항이 저장되었습니다.", - "success", - ); - }; - /* Modals (SC-07~10 + SC-06 state E). All confirm handlers below call their real mutations. */ const [activeModal, setActiveModal] = useState(null); const closeModal = () => setActiveModal(null); - /* Team CRUD (create/rename/delete) inline error — reset whenever a - modal opens or closes so a stale error from a prior attempt never - leaks into a fresh one. */ - const [teamError, setTeamError] = useState(null); + const { + teamError, + clearTeamError, + handleCreate, + handleRename, + handleDelete, + } = useTeamCrud({ + teamId: selectedTeam.id, + onDone: closeModal, + onDeleted: () => + onSelectTeam( + teams.find((t) => t.parentId === null && t.id !== selectedTeam.id) + ?.id ?? "", + ), + }); + /* Team CRUD inline error — reset whenever a modal opens or closes so a + stale error from a prior attempt never leaks into a fresh one. */ const openTeamModal = (modal: TActiveModal) => { - setTeamError(null); + clearTeamError(); setActiveModal(modal); }; const closeTeamModal = () => { - setTeamError(null); + clearTeamError(); 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"), - to, - }; - }); - - const handleCreate = (name: string, parentId: string | null) => { - setTeamError(null); - createTeam.mutate( - { name, parentId }, - { - onSuccess: () => { - closeModal(); - showNotice("팀 생성", "팀이 생성되었습니다.", "success"); - }, - onError: async (res) => { - const code = await parseErrorCode(res); - setTeamError(TEAM_REASON[code] ?? "팀 생성에 실패했습니다."); - }, - }, - ); - }; - const handleRename = (name: string) => { - setTeamError(null); - renameTeam.mutate( - { name }, - { - onSuccess: () => { - closeModal(); - showNotice("팀 이름 변경", "팀 이름이 변경되었습니다.", "success"); - }, - onError: async (res) => { - const code = await parseErrorCode(res); - setTeamError(TEAM_REASON[code] ?? "이름 변경에 실패했습니다."); - }, - }, - ); - }; - const handleDelete = ( - action: "purge" | "transfer", - targetTeamId?: string, - ) => { - setTeamError(null); - deleteTeam.mutate( - { memoryAction: action, targetTeamId }, - { - onSuccess: () => { - closeModal(); - showNotice("팀 삭제", "팀이 삭제되었습니다.", "success", () => { - onSelectTeam( - teams.find((t) => t.parentId === null && t.id !== selectedTeam.id) - ?.id ?? "", - ); - }); - }, - onError: async (res) => { - const code = await parseErrorCode(res); - setTeamError(TEAM_REASON[code] ?? "팀 삭제에 실패했습니다."); - }, - }, - ); - }; const handleInvite = (account: string, role: string, username: string) => { setAddError(null); addMember.mutate( @@ -362,100 +202,88 @@ 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] ?? "멤버 추가에 실패했습니다."); }, }, ); }; - const handleRoleConfirm = () => { + + const accountOf = (userId: string) => + members.find((m) => m.userId === userId)?.account ?? userId; + + /* Staged picks as confirm-modal rows (TRoleChange: label = account). */ + const roleChanges: TRoleChange[] = [...pendingRoles.entries()].map( + ([userId, to]) => { + const member = members.find((m) => m.userId === userId); + return { + label: member?.account ?? userId, + from: baseRole(userId, member?.role ?? TEAM_MEMBER_ROLE.read), + to, + }; + }, + ); + + /* The confirm modal owns the E-1/E-2 result view: a resolved promise + shows the in-modal success message, a rejected one the failure + message ([닫기] alone remains). Partial failures additionally open + the batch-failure modal, mirroring the SC-13 drawer flow. */ + const handleRoleConfirm = async () => { const updates = [...pendingRoles.entries()].map(([userId, role]) => ({ userId, role, })); - bulkRole.mutate( - { updates }, - { - onSuccess: (result) => { - closeModal(); - if (result.failed.length > 0) { - /* Only clear staging for what actually succeeded — keep the - failed entries pending so the user can retry them. */ - const failedIds = new Set(result.failed.map((f) => f.id)); - setSavedRoles( - (prev) => - new Map([ - ...prev, - ...[...pendingRoles.entries()].filter( - ([userId]) => !failedIds.has(userId), - ), - ]), - ); - setPendingRoles( - (prev) => - new Map([...prev].filter(([userId]) => failedIds.has(userId))), - ); - setBatchFailures( - result.failed.map((f) => ({ - account: accountOf(f.id), - reason: BATCH_REASON[f.code] ?? BATCH_REASON_FALLBACK, - })), - ); - } else { - applyRoleChanges(); - } - }, - onError: () => { - closeModal(); - showNotice( - MODAL_TITLES.roleChange, - "권한 변경에 실패했습니다.", - "error", - ); - }, - }, - ); + const result = await bulkRole.mutateAsync({ updates }); + if (result.failed.length > 0) { + reconcileBatch(new Set(result.failed.map((f) => f.id))); + showBatchFailures( + toBatchFailureRows( + result.failed, + accountOf, + () => BATCH_REASON_FALLBACK, + ), + ); + } else { + applyAll(); + } }; - const handleRemoveMembers = () => { + + /* The remove modal closes itself on resolve and swaps to its failure + view on reject — only the full-success notice and the partial-failure + modal are driven from here. */ + const handleRemoveMembers = async () => { const ids = [...selectedIds]; - removeMembers.mutate(ids, { - onSuccess: (result) => { - closeModal(); - setSelectedIds(new Set()); - if (result.failed.length > 0) { - setBatchFailures( - result.failed.map((f) => ({ - account: accountOf(f.id), - reason: BATCH_REASON[f.code] ?? f.code, - })), - ); - } else { - showNotice( - MODAL_TITLES.removeMembership, - "멤버십이 제거되었습니다.", - "success", - ); - } - }, - onError: () => { - closeModal(); - showNotice( - MODAL_TITLES.removeMembership, - "멤버십 제거에 실패했습니다.", - "error", - ); - }, - }); + const result = await removeMembers.mutateAsync(ids); + clearSelection(); + if (result.failed.length > 0) { + showBatchFailures( + toBatchFailureRows(result.failed, accountOf, (code) => code), + ); + } else { + showNotice( + NOTICE_TEXT.removeMembership.title, + NOTICE_TEXT.removeMembership.success, + "success", + ); + } }; - /* SC-14 payload: the checked members' account · current role. */ + /* SC-14 payload: the checked members' account × this team · current + role (TMembershipRemoveTarget — the SC-06 entry is members × the + one selected team). */ const membershipRemovals = members .filter((member) => selectedIds.has(member.userId)) .map((member) => ({ account: member.account, + teamId: selectedTeam.id, + teamName: selectedTeam.name, role: pendingRoles.get(member.userId) ?? baseRole(member.userId, member.role), })); @@ -476,183 +304,58 @@ const TreeDetailView = ({ query={teamSearch} selectedId={selectedTeam.id} onSelect={(node) => onSelectTeam(node.id)} - defaultExpandedIds={[ - "t_a", - "t_e", - ...ancestorIds(teams, selectedTeam.id), - ]} + defaultExpandedIds={ancestorIds(flatById, selectedTeam.id)} className="-mx-1 flex-1" /> {/* Detail area — selected team card + members section (SC-06 no.6–13) */} - - - - {detail?.name ?? selectedTeam.name} - - openTeamModal("rename")} - /> - openTeamModal("delete")} - /> - - - 상위 팀: {parentName} | 하위 팀: {childrenLabel} | 멤버:{" "} - {memberCount}명 | 생성일: {formatDate(detail?.createdAt)} - - + openTeamModal("rename")} + onDelete={() => openTeamModal("delete")} + /> - - 멤버 ({total}){" "} - - {/* Drops every staged (not yet applied) dropdown pick back to - its saved role — the committed savedRoles baseline stays. */} - setPendingRoles(new Map())} - /> - setActiveModal("roleConfirm")} - /> - setActiveModal("removeMembers")} - /> - setActiveModal("addMember")} - /> - - + setActiveModal("roleConfirm")} + onRemove={() => setActiveModal("removeMembers")} + onAddMember={() => setActiveModal("addMember")} + /> - - - - - + + toggleAll( + members.map((m) => m.userId), + checked, + ) + } + isRoleStaged={(userId) => pendingRoles.has(userId)} + roleOf={(member) => + pendingRoles.get(member.userId) ?? + baseRole(member.userId, member.role) } - > - - - - - {/* Fixed column widths — auto layout would resize per - page's content and shift the headers while paginating. */} - 멤버 이름 - 멤버 상태 - 역할 - 합류일 - - - {membersQuery.isPending ? ( - - - 불러오는 중… - - - ) : membersQuery.isError ? ( - - ) : total === 0 ? ( - - - 멤버가 없습니다. - - - ) : ( - members.map((member) => ( - - - toggleOne(member.userId, checked)} - ariaLabel={`${member.account} 선택`} - /> - - - {member.username} - - - - - - - handleRoleChange(member.userId, member.role, next) - } - size="sm" - changed={pendingRoles.has(member.userId)} - ariaLabel={`${member.account} role`} - className="w-24" - /> - - - {formatDate(member.joinedAt)} - - - )) - )} - - + onRoleChange={(member, next) => + stageRole(member.userId, member.role, next) + } + /> {/* Modals — mounted on demand so each opens with fresh state */} @@ -698,15 +401,16 @@ const TreeDetailView = ({ )} {activeModal === "roleConfirm" && ( )} {activeModal === "removeMembers" && ( - @@ -714,7 +418,7 @@ const TreeDetailView = ({ {batchFailures && ( setBatchFailures(null)} + onClose={closeBatchFailures} /> )} diff --git a/frontend/src/components/teams/__tests__/TreeDetailView.test.tsx b/frontend/src/components/teams/__tests__/TreeDetailView.test.tsx index 1720904..1121964 100644 --- a/frontend/src/components/teams/__tests__/TreeDetailView.test.tsx +++ b/frontend/src/components/teams/__tests__/TreeDetailView.test.tsx @@ -7,9 +7,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import TreeDetailView from "@/components/teams/TreeDetailView"; import * as teamAPIs from "@/api/teamAPIs"; import * as teamMemberAPIs from "@/api/teamMemberAPIs"; +import { useNoticeStore } from "@/state/store/noticeStore"; import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; import type { TTeamMember, TTeamTree } from "@/types/teamTypes"; -import { useNoticeStore } from "@/stores/noticeStore"; const jsonRes = (body: unknown) => ({ ok: true, json: async () => body }) as unknown as Response; @@ -554,7 +554,6 @@ describe("TreeDetailView", () => { vi.spyOn(teamMemberAPIs, "bulkRoleChange").mockResolvedValue( jsonRes({ succeeded: ["u_1"], failed: [] }), ); - const showNoticeSpy = vi.spyOn(useNoticeStore.getState(), "showNotice"); renderView(); await screen.findByText("김철수"); await user.click(screen.getByLabelText("kim@corp.com role")); @@ -563,13 +562,14 @@ describe("TreeDetailView", () => { screen.getByRole("button", { name: BTN_TEXT.updateChanges }), ); await user.click(screen.getByRole("button", { name: BTN_TEXT.change })); - await waitFor(() => - expect(showNoticeSpy).toHaveBeenCalledWith( - MODAL_TITLES.roleChange, - "변경사항이 저장되었습니다.", - "success", - ), - ); + /* SC-06 E-1: the result renders inside the confirm modal; [닫기] + alone remains. */ + expect( + await screen.findByText("권한이 변경되었습니다."), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: BTN_TEXT.change }), + ).not.toBeInTheDocument(); }); it("resets every staged role pick back to the saved value via 변경사항 초기화", async () => { @@ -634,7 +634,6 @@ describe("TreeDetailView", () => { status: 500, json: async () => ({ code: "INTERNAL", message: "x" }), } as unknown as Response); - const showNoticeSpy = vi.spyOn(useNoticeStore.getState(), "showNotice"); renderView(); await screen.findByText("김철수"); await user.click(screen.getByLabelText("kim@corp.com role")); @@ -643,13 +642,10 @@ describe("TreeDetailView", () => { screen.getByRole("button", { name: BTN_TEXT.updateChanges }), ); await user.click(screen.getByRole("button", { name: BTN_TEXT.change })); - await waitFor(() => - expect(showNoticeSpy).toHaveBeenCalledWith( - MODAL_TITLES.roleChange, - "권한 변경에 실패했습니다.", - "error", - ), - ); + /* SC-06 E-2: the failure message renders inside the confirm modal. */ + expect( + await screen.findByText("권한 변경에 실패했습니다. 다시 시도해 주세요."), + ).toBeInTheDocument(); }); it("shows a success notice when a full-success member removal completes", async () => { @@ -710,7 +706,6 @@ describe("TreeDetailView", () => { status: 500, json: async () => ({ code: "INTERNAL", message: "x" }), } as unknown as Response); - const showNoticeSpy = vi.spyOn(useNoticeStore.getState(), "showNotice"); renderView(); await screen.findByText("김철수"); await user.click( @@ -721,13 +716,12 @@ describe("TreeDetailView", () => { name: BTN_TEXT.remove, }); await user.click(confirmButtons[confirmButtons.length - 1]); - await waitFor(() => - expect(showNoticeSpy).toHaveBeenCalledWith( - MODAL_TITLES.removeMembership, - "멤버십 제거에 실패했습니다.", - "error", + /* The remove modal swaps to its in-modal failure view (state B). */ + expect( + await screen.findByText( + "멤버십 제거에 실패했습니다. 다시 시도해 주세요.", ), - ); + ).toBeInTheDocument(); }); it("shows the mapped inline error when deleting a childless team hits a server conflict", async () => { diff --git a/frontend/src/components/teams/__tests__/teamModals.test.tsx b/frontend/src/components/teams/__tests__/teamModals.test.tsx index 75ea277..9f0d39e 100644 --- a/frontend/src/components/teams/__tests__/teamModals.test.tsx +++ b/frontend/src/components/teams/__tests__/teamModals.test.tsx @@ -5,9 +5,9 @@ import { describe, expect, it, vi } from "vitest"; import AddMemberModal from "@/components/teams/AddMemberModal"; import CreateTeamModal from "@/components/teams/CreateTeamModal"; import DeleteTeamModal from "@/components/teams/DeleteTeamModal"; -import RemoveMembershipModal from "@/components/teams/RemoveMembershipModal"; import RenameTeamModal from "@/components/teams/RenameTeamModal"; -import RoleChangeConfirmModal from "@/components/teams/RoleChangeConfirmModal"; +import MembershipRemoveModal from "@/components/users/MembershipRemoveModal"; +import RoleChangeConfirmModal from "@/components/users/RoleChangeConfirmModal"; import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; import type { TTeamTree } from "@/types/teamTypes"; @@ -330,20 +330,28 @@ describe("AddMemberModal", () => { }); }); -describe("RemoveMembershipModal", () => { - it("lists removals, always shows the sub-team notice, confirms", async () => { +describe("MembershipRemoveModal (SC-06 entry)", () => { + it("lists removals with the sub-team notice and confirms", async () => { const user = userEvent.setup(); - const onConfirm = vi.fn(); + const onConfirm = vi.fn().mockResolvedValue(undefined); render( - {}} onConfirm={onConfirm} />, ); expect(screen.getByText(MODAL_TITLES.removeMembership)).toBeInTheDocument(); expect(screen.getByText("k@corp.com")).toBeInTheDocument(); + expect(screen.getByText("백엔드")).toBeInTheDocument(); expect(screen.getByText(/하위 팀 소속은 유지됩니다/)).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: BTN_TEXT.remove })); @@ -351,13 +359,14 @@ describe("RemoveMembershipModal", () => { }); }); -describe("RoleChangeConfirmModal", () => { - it("lists staged changes and confirms", async () => { +describe("RoleChangeConfirmModal (SC-06 entry)", () => { + it("lists staged changes and shows the in-modal result after 변경하기", async () => { const user = userEvent.setup(); - const onConfirm = vi.fn(); + const onConfirm = vi.fn().mockResolvedValue(undefined); render( {}} onConfirm={onConfirm} />, @@ -367,5 +376,12 @@ describe("RoleChangeConfirmModal", () => { await user.click(screen.getByRole("button", { name: BTN_TEXT.change })); expect(onConfirm).toHaveBeenCalled(); + /* E-1: the result renders inside the modal; [닫기] alone remains. */ + expect( + await screen.findByText("권한이 변경되었습니다."), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: BTN_TEXT.change }), + ).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/components/teams/teamHierarchy.ts b/frontend/src/components/teams/teamHierarchy.ts deleted file mode 100644 index b5d5d3c..0000000 --- a/frontend/src/components/teams/teamHierarchy.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { TTeamTree } from "@/types/teamTypes"; - -/** - * Team-tree lookups over a flat `TTeamTree` — shared by the invite preview - * (SC-12 no.3) and the membership-removal sub-team notice (SC-14 no.2). - * Pure functions over the tree passed in (from `useTeamsTreeQuery`); trees - * are small, so no memoized id-map is kept at module scope. - */ - -/** Team name for `teamId`, or the id itself if the team is unknown. */ -export const getTeamName = (teams: TTeamTree, teamId: string): string => - teams.find((team) => team.id === teamId)?.name ?? teamId; - -/** All descendant ids of a team, in depth-first tree order. */ -export const getTeamDescendantIds = ( - teams: TTeamTree, - teamId: string, -): string[] => - (teams.find((team) => team.id === teamId)?.childrenIds ?? []).flatMap( - (childId) => [childId, ...getTeamDescendantIds(teams, childId)], - ); diff --git a/frontend/src/components/tree/TeamTree.tsx b/frontend/src/components/tree/TeamTree.tsx index f9db366..9644e13 100644 --- a/frontend/src/components/tree/TeamTree.tsx +++ b/frontend/src/components/tree/TeamTree.tsx @@ -3,12 +3,12 @@ import { useMemo, useState } from "react"; import Feedback from "@/components/elements/Feedback"; import TreeNode from "@/components/tree/TreeNode"; import { cn } from "@/utils/cn"; -import type { TTeamNode } from "@/types/commonTypes"; +import type { TTeamViewNode } from "@/types/teamTypes"; interface TeamTreeProps { - teams: TTeamNode[]; + teams: TTeamViewNode[]; selectedId?: string; - onSelect: (node: TTeamNode) => void; + onSelect: (node: TTeamViewNode) => void; /** Case-insensitive filter — a node matches if it or a descendant matches. */ query?: string; defaultExpandedIds?: string[]; @@ -18,7 +18,7 @@ interface TeamTreeProps { } interface TFilterResult { - nodes: TTeamNode[]; + nodes: TTeamViewNode[]; /** Nodes kept only because a descendant matched — auto-expanded so the match is actually visible (2차-B spec: ancestors stay open). */ autoExpandIds: string[]; @@ -29,19 +29,21 @@ interface TFilterResult { * whole subtree (context); otherwise only children leading to a match * survive. `query` must already be trimmed + lowercased. */ -const filterTree = (teams: TTeamNode[], query: string): TFilterResult => { +const filterTree = (teams: TTeamViewNode[], query: string): TFilterResult => { const autoExpandIds: string[] = []; - const prune = (node: TTeamNode): TTeamNode | null => { + const prune = (node: TTeamViewNode): TTeamViewNode | null => { if (node.name.toLocaleLowerCase().includes(query)) return node; const children = (node.children ?? []) .map(prune) - .filter((child): child is TTeamNode => child !== null); + .filter((child): child is TTeamViewNode => child !== null); if (children.length === 0) return null; autoExpandIds.push(node.id); return { ...node, children }; }; return { - nodes: teams.map(prune).filter((node): node is TTeamNode => node !== null), + nodes: teams + .map(prune) + .filter((node): node is TTeamViewNode => node !== null), autoExpandIds, }; }; diff --git a/frontend/src/components/tree/TeamTreeFooter.tsx b/frontend/src/components/tree/TeamTreeFooter.tsx index a203b6f..93aee0a 100644 --- a/frontend/src/components/tree/TeamTreeFooter.tsx +++ b/frontend/src/components/tree/TeamTreeFooter.tsx @@ -1,5 +1,5 @@ import { cn } from "@/utils/cn"; -import type { TTeamNode } from "@/types/commonTypes"; +import type { TTeamViewNode } from "@/types/teamTypes"; const styles = { wrap: "bg-mint/[2%] grid grid-cols-[auto_1fr_auto] items-center gap-2.5 border-t px-4 py-3", @@ -9,7 +9,7 @@ const styles = { }; interface TeamTreeFooterProps { - node: TTeamNode; + node: TTeamViewNode; className?: string; } diff --git a/frontend/src/components/tree/TreeNode.tsx b/frontend/src/components/tree/TreeNode.tsx index 76bda3a..b7c94e7 100644 --- a/frontend/src/components/tree/TreeNode.tsx +++ b/frontend/src/components/tree/TreeNode.tsx @@ -3,7 +3,7 @@ import type { CSSProperties } from "react"; import IconMinus from "@/components/icons/IconMinus"; import IconPlus from "@/components/icons/IconPlus"; import { cn } from "@/utils/cn"; -import type { TTeamNode } from "@/types/commonTypes"; +import type { TTeamViewNode } from "@/types/teamTypes"; const styles = { row: "grid grid-cols-[24px_1fr] items-center rounded-sm pl-[calc(var(--tree-depth)*18px)] transition-[background-color] duration-[160ms]", @@ -16,11 +16,11 @@ const styles = { }; interface TreeNodeProps { - node: TTeamNode; + node: TTeamViewNode; depth: number; selectedId?: string; expanded: Set; - onSelect: (node: TTeamNode) => void; + onSelect: (node: TTeamViewNode) => void; onToggle: (id: string) => void; /** Active search text (trimmed + lowercased) — emphasizes the match. */ highlight?: string; diff --git a/frontend/src/components/tree/__tests__/TeamTree.test.tsx b/frontend/src/components/tree/__tests__/TeamTree.test.tsx index 6c2f681..eeb4022 100644 --- a/frontend/src/components/tree/__tests__/TeamTree.test.tsx +++ b/frontend/src/components/tree/__tests__/TeamTree.test.tsx @@ -3,9 +3,9 @@ import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import TeamTree from "@/components/tree/TeamTree"; -import type { TTeamNode } from "@/types/commonTypes"; +import type { TTeamViewNode } from "@/types/teamTypes"; -const TEAMS: TTeamNode[] = [ +const TEAMS: TTeamViewNode[] = [ { id: "platform", name: "Platform", @@ -71,7 +71,7 @@ describe("TeamTree", () => { }); it("prunes non-matching siblings but keeps the whole subtree of a self-match", () => { - const forest: TTeamNode[] = [ + const forest: TTeamViewNode[] = [ { id: "a", name: "Alpha", 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/CancelInvitationModal.tsx b/frontend/src/components/users/CancelInvitationModal.tsx index dc05f8e..e6c3fec 100644 --- a/frontend/src/components/users/CancelInvitationModal.tsx +++ b/frontend/src/components/users/CancelInvitationModal.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import Button from "@/components/elements/Button"; import ModalLayout from "@/components/layout/ModalLayout"; import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; +import { MODAL_STYLE_VAR } from "@/constants/styleConstants"; interface CancelInvitationModalProps { account: string; @@ -37,11 +38,11 @@ const CancelInvitationModal = ({ return ( - + {account}의 미사용 초대 코드가 모두 만료됩니다. 유저는 삭제되지 않습니다. - + { @@ -161,8 +166,8 @@ const InviteMemberModal = ({ setUsername(normalizeUsernameInput(value))} @@ -176,7 +181,7 @@ const InviteMemberModal = ({ patchSet(set.id, { teamId })} ariaLabel={`세트 ${index + 1} 팀`} @@ -185,7 +190,7 @@ const InviteMemberModal = ({ patchSet(set.id, { role })} ariaLabel={`세트 ${index + 1} role`} @@ -221,7 +226,11 @@ const InviteMemberModal = ({ 하위 팀 권한 미리보기 [ row.indent ? `└ ${row.teamName}` : row.teamName, row.role, @@ -247,7 +256,7 @@ const InviteMemberModal = ({ )} - + /* table-fixed: the 팀/권한 columns split 50/50 regardless of content, so the per-user tables all line up. */ [m.teamName, m.role])} className="table-fixed" /> @@ -71,7 +76,7 @@ const MemberDeleteModal = ({ if (failed) { return ( - {DELETE_FAILED_MESSAGE} + {DELETE_FAILED_MESSAGE} )} - + { - 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)}`; } }; -/** One staged membership row: baseRole is the saved value. */ -type TMembershipDraft = { - teamId: string; - teamName: string; - baseRole: string; - role: string; - checked: boolean; -}; - type TDrawerModal = | "role-confirm" | "remove" @@ -75,16 +59,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; @@ -117,8 +91,8 @@ interface MemberDetailDrawerProps { * the role-change confirm modal) and checkbox bulk removal (SC-14), * invite-code actions, and member delete (SC-15). Mount with * key={user.userId} so switching members resets the staged state. - * [초대 취소] (D15) and [세션 비활성화] (D12) ship with correct enable - * rules and confirm dialogs. + * The membership machine lives in useMembershipDrafts; this component + * composes it with the account-level actions and the confirm modals. */ const MemberDetailDrawer = ({ user, @@ -132,109 +106,35 @@ const MemberDetailDrawer = ({ onCancelInvitation, teams, }: MemberDetailDrawerProps) => { - const [memberships, setMemberships] = useState(() => - user.memberships.map((m) => ({ - teamId: m.teamId, - teamName: m.teamName, - baseRole: m.role, - role: m.role, - checked: false, - })), - ); + const drafts = useMembershipDrafts({ + user, + teams, + onUpdateRoles, + onRemoveMemberships, + onAddMembership, + }); const [openModal, setOpenModal] = useState(null); const [resending, setResending] = useState(false); - const [addOpen, setAddOpen] = useState(false); - const [addTeamId, setAddTeamId] = useState(""); - const [addRole, setAddRole] = useState(""); - const [adding, setAdding] = useState(false); - const [batchFailures, setBatchFailures] = useState< - { account: string; reason: string }[] | null - >(null); const showNotice = useNoticeStore((state) => state.showNotice); - const teamOptions = buildTeamOptions(teams); - - const changes = memberships.filter((m) => m.role !== m.baseRole); - const selected = memberships.filter((m) => m.checked); - const allChecked = - memberships.length > 0 && memberships.every((m) => m.checked); - - /* Sub-team retention notice (SC-14 no.2): a selected team has a - descendant team whose membership stays after this removal. */ - const remainingIds = memberships - .filter((m) => !m.checked) - .map((m) => m.teamId); - const subteamNotice = selected.some((m) => - getTeamDescendantIds(teams, m.teamId).some((id) => - remainingIds.includes(id), - ), - ); - - const patchMembership = (teamId: string, patch: Partial) => - setMemberships((prev) => - prev.map((m) => (m.teamId === teamId ? { ...m, ...patch } : m)), - ); + const closeModal = () => setOpenModal(null); const handleResend = async () => { setResending(true); try { await onResendCode(); - showNotice("초대 코드 재전송", "초대 코드를 재전송했습니다.", "info"); - } catch { showNotice( - "초대 코드 재전송", - "초대 코드 재전송에 실패했습니다. 다시 시도해 주세요.", - "error", + NOTICE_TEXT.resendInvitation.title, + NOTICE_TEXT.resendInvitation.success, + "info", ); - } finally { - setResending(false); - } - }; - - /* Teams the user already belongs to stay out of the add picker. - Depth indent stripped — the narrow drawer dropdown can't fit - deep-tree indentation (it forces horizontal scrolling in the - menu); teams list flush left in tree order and long names - truncate with an ellipsis. */ - const joinedIds = new Set(memberships.map((m) => m.teamId)); - const addableTeams = teamOptions - .filter((o) => !joinedIds.has(o.value)) - .map(({ value, label }) => ({ value, label })); - - const resetAdd = () => { - setAddOpen(false); - setAddTeamId(""); - setAddRole(""); - }; - - const handleAdd = async () => { - setAdding(true); - try { - await onAddMembership(addTeamId, addRole); - const teamName = - teamOptions.find((o) => o.value === addTeamId)?.label ?? addTeamId; - setMemberships((prev) => [ - ...prev, - { - teamId: addTeamId, - teamName, - baseRole: addRole, - role: addRole, - checked: false, - }, - ]); - showNotice("팀 추가", "팀에 추가되었습니다.", "info"); - resetAdd(); - } catch (err) { - const code = err instanceof Response ? await parseErrorCode(err) : ""; + } catch { showNotice( - "팀 추가", - code === "ALREADY_TEAM_MEMBER" - ? "이미 소속된 팀입니다." - : "팀 추가에 실패했습니다. 다시 시도해 주세요.", + NOTICE_TEXT.resendInvitation.title, + NOTICE_TEXT.resendInvitation.failure, "error", ); } finally { - setAdding(false); + setResending(false); } }; @@ -284,7 +184,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")} /> @@ -293,141 +193,27 @@ const MemberDetailDrawer = ({ - - - 소속 팀 ({memberships.length}) - {selected.length > 0 && ( - - {selected.length} selected - - )} - - - - - - - setMemberships((prev) => - prev.map((m) => ({ ...m, checked })), - ) - } - ariaLabel="전체선택" - /> - - 팀 - 권한 - - - {memberships.length === 0 ? ( - /* No group-role membership — a single placeholder row keeps - the table shape; the team/role cells read "—". */ - - - — - — - - ) : ( - memberships.map((m) => ( - - patchMembership(m.teamId, { checked }) - } - onRoleChange={(role) => patchMembership(m.teamId, { role })} - /> - )) - )} - - - - {/* Action bar: 변경사항 초기화 · 변경사항 업데이트 · 제거하기 · - 팀 추가하기 (SC-13). */} - - {/* Drops every staged (not yet applied) role pick back to its - saved value — checkboxes and committed roles stay. */} - - setMemberships((prev) => - prev.map((m) => ({ ...m, role: m.baseRole })), - ) - } - /> - setOpenModal("role-confirm")} - /> - setOpenModal("remove")} - /> - (addOpen ? resetAdd() : setAddOpen(true))} - /> - - - {/* Team+role picker (SC-13 no.2) — opens just above the action - bar via [팀 추가하기]; teams already joined are excluded. */} - {addOpen && ( - - - - - - )} - + setOpenModal("role-confirm")} + onOpenRemove={() => setOpenModal("remove")} + addOpen={drafts.addOpen} + addTeamId={drafts.addTeamId} + addRole={drafts.addRole} + adding={drafts.adding} + addableTeams={drafts.addableTeams} + onAddTeamIdChange={drafts.setAddTeamId} + onAddRoleChange={drafts.setAddRole} + onToggleAddRow={drafts.toggleAddRow} + onAdd={drafts.handleAdd} + /> @@ -448,7 +234,7 @@ const MemberDetailDrawer = ({ btnSize="sm" btnColor="redOutline" className="w-fit" - disabled={user.sessionStatus !== "online"} + disabled={user.sessionStatus !== SESSION_STATUS.online} handleClick={() => setOpenModal("deactivate")} /> ({ + subjectLabel={TABLE_HEADERS.team} + changes={drafts.changes.map((m) => ({ label: m.teamName, from: m.baseRole, to: m.role, }))} - onConfirm={async () => { - const changedIds = changes.map((m) => m.teamId); - const result = await onUpdateRoles( - changes.map((m) => ({ teamId: m.teamId, role: m.role })), - ); - const failedIds = new Set(result.failed.map((f) => f.id)); - setMemberships((prev) => - prev.map((m) => - changedIds.includes(m.teamId) && !failedIds.has(m.teamId) - ? { ...m, baseRole: m.role } - : m, - ), - ); - if (result.failed.length > 0) { - setBatchFailures( - result.failed.map((f) => ({ - account: - memberships.find((m) => m.teamId === f.id)?.teamName ?? - f.id, - reason: BATCH_REASON[f.code] ?? BATCH_REASON_FALLBACK, - })), - ); - } - }} - onClose={() => setOpenModal(null)} + onConfirm={drafts.confirmRoleChanges} + onClose={closeModal} /> )} {openModal === "remove" && ( ({ + targets={drafts.selected.map((m) => ({ account: user.account, teamId: m.teamId, teamName: m.teamName, role: m.role, }))} - subteamNotice={subteamNotice} - onConfirm={async () => { - const removedIds = selected.map((m) => m.teamId); - const result = await onRemoveMemberships(removedIds); - const failedIds = new Set(result.failed.map((f) => f.id)); - setMemberships((prev) => - prev.filter( - (m) => - !removedIds.includes(m.teamId) || failedIds.has(m.teamId), - ), - ); - if (result.failed.length === 0) { - showNotice( - MODAL_TITLES.removeMembership, - "멤버십이 제거되었습니다.", - "success", - ); - } else { - setBatchFailures( - result.failed.map((f) => ({ - account: - memberships.find((m) => m.teamId === f.id)?.teamName ?? - f.id, - reason: BATCH_REASON[f.code] ?? BATCH_REASON_FALLBACK, - })), - ); - } - }} - onClose={() => setOpenModal(null)} + subteamNotice={drafts.subteamNotice} + onConfirm={drafts.confirmRemovals} + onClose={closeModal} /> )} @@ -543,14 +280,14 @@ const MemberDetailDrawer = ({ targets={[ { account: user.account, - memberships: memberships.map((m) => ({ + memberships: drafts.memberships.map((m) => ({ teamName: m.teamName, role: m.baseRole, })), }, ]} onConfirm={onDeleteMember} - onClose={() => setOpenModal(null)} + onClose={closeModal} /> )} @@ -560,22 +297,26 @@ const MemberDetailDrawer = ({ onConfirm={async () => { try { await onDeactivateSession(); - setOpenModal(null); - showNotice("세션 비활성화", "세션을 비활성화했습니다.", "info"); + closeModal(); + showNotice( + NOTICE_TEXT.deactivateSession.title, + NOTICE_TEXT.deactivateSession.success, + "info", + ); } catch (err) { const code = err instanceof Response ? await parseErrorCode(err) : ""; - setOpenModal(null); + closeModal(); showNotice( - "세션 비활성화", - code === "SESSION_NOT_ACTIVE" - ? "이미 만료된 세션입니다." - : "세션 비활성화에 실패했습니다. 다시 시도해 주세요.", + NOTICE_TEXT.deactivateSession.title, + code === ERROR_CODES.SESSION_NOT_ACTIVE + ? NOTICE_TEXT.deactivateSession.alreadyExpired + : NOTICE_TEXT.deactivateSession.failure, "error", ); } }} - onClose={() => setOpenModal(null)} + onClose={closeModal} /> )} @@ -585,29 +326,33 @@ const MemberDetailDrawer = ({ onConfirm={async () => { try { await onCancelInvitation(); - setOpenModal(null); - showNotice("초대 취소", "초대를 취소했습니다.", "info"); + closeModal(); + showNotice( + NOTICE_TEXT.cancelInvitation.title, + NOTICE_TEXT.cancelInvitation.success, + "info", + ); } catch (err) { const code = err instanceof Response ? await parseErrorCode(err) : ""; - setOpenModal(null); + closeModal(); showNotice( - "초대 취소", - code === "INVITATION_NOT_PENDING" - ? "취소할 초대가 없습니다." - : "초대 취소에 실패했습니다. 다시 시도해 주세요.", + NOTICE_TEXT.cancelInvitation.title, + code === ERROR_CODES.INVITATION_NOT_PENDING + ? NOTICE_TEXT.cancelInvitation.nothingToCancel + : NOTICE_TEXT.cancelInvitation.failure, "error", ); } }} - onClose={() => setOpenModal(null)} + onClose={closeModal} /> )} - {batchFailures && ( + {drafts.batchFailures && ( setBatchFailures(null)} + failures={drafts.batchFailures} + onClose={drafts.closeBatchFailures} /> )} > diff --git a/frontend/src/components/users/MembershipRemoveModal.tsx b/frontend/src/components/users/MembershipRemoveModal.tsx index 91925ed..e8129b3 100644 --- a/frontend/src/components/users/MembershipRemoveModal.tsx +++ b/frontend/src/components/users/MembershipRemoveModal.tsx @@ -4,7 +4,12 @@ import Button from "@/components/elements/Button"; import Notice from "@/components/elements/Notice"; import ModalLayout from "@/components/layout/ModalLayout"; import ModalTable from "@/components/users/ModalTable"; -import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; +import { + BTN_TEXT, + MODAL_TITLES, + TABLE_HEADERS, +} from "@/constants/commonConstants"; +import { MODAL_STYLE_VAR } from "@/constants/styleConstants"; import type { TMembershipRemoveTarget } from "@/types/userTypes"; const REMOVE_FAILED_MESSAGE = `멤버십 제거에 실패했습니다. 다시 시도해 주세요.`; @@ -53,7 +58,7 @@ const MembershipRemoveModal = ({ if (failed) { return ( - {REMOVE_FAILED_MESSAGE} + {REMOVE_FAILED_MESSAGE} 다음 멤버십을 제거합니다: [ target.account, target.teamName, @@ -82,7 +87,7 @@ const MembershipRemoveModal = ({ )} - + void; + onCheckAll: (teamIds: string[], checked: boolean) => void; + onRoleChange: (teamId: string, role: string) => void; + onResetChanges: () => void; + onOpenRoleConfirm: () => void; + onOpenRemove: () => void; + /* [팀 추가하기] picker row (SC-13 no.2). */ + addOpen: boolean; + addTeamId: string; + addRole: string; + adding: boolean; + addableTeams: TDropdownOption[]; + onAddTeamIdChange: (teamId: string) => void; + onAddRoleChange: (role: string) => void; + onToggleAddRow: () => void; + onAdd: () => void; +} + +/** + * MembershipSection is the 소속 팀 block of the member drawer (SC-13): + * the staged-edit membership table, the action bar, and the add-team + * picker row. Pure view — all state lives in useMembershipDrafts. + */ +const MembershipSection = ({ + memberships, + changesCount, + selectedCount, + allChecked, + onCheck, + onCheckAll, + onRoleChange, + onResetChanges, + onOpenRoleConfirm, + onOpenRemove, + addOpen, + addTeamId, + addRole, + adding, + addableTeams, + onAddTeamIdChange, + onAddRoleChange, + onToggleAddRow, + onAdd, +}: MembershipSectionProps) => { + return ( + + + 소속 팀 ({memberships.length}) + {selectedCount > 0 && ( + {selectedCount} selected + )} + + + + + + + onCheckAll( + memberships.map((m) => m.teamId), + checked, + ) + } + ariaLabel={ARIA_LABELS.selectAll} + /> + + {TABLE_HEADERS.team} + + {TABLE_HEADERS.role} + + + + {memberships.length === 0 ? ( + /* No group-role membership — a single placeholder row keeps + the table shape; the team/role cells read "—". */ + + + — + — + + ) : ( + memberships.map((m) => ( + onCheck(m.teamId, checked)} + onRoleChange={(role) => onRoleChange(m.teamId, role)} + /> + )) + )} + + + + {/* Action bar: 변경사항 초기화 · 변경사항 업데이트 · 제거하기 · + 팀 추가하기 (SC-13). */} + + {/* Drops every staged (not yet applied) role pick back to its + saved value — checkboxes and committed roles stay. */} + + + + + + + {/* Team+role picker (SC-13 no.2) — opens just above the action + bar via [팀 추가하기]; teams already joined are excluded. */} + {addOpen && ( + + + + + + )} + + ); +}; + +export default MembershipSection; diff --git a/frontend/src/components/users/RoleChangeConfirmModal.tsx b/frontend/src/components/users/RoleChangeConfirmModal.tsx index c724166..4def71e 100644 --- a/frontend/src/components/users/RoleChangeConfirmModal.tsx +++ b/frontend/src/components/users/RoleChangeConfirmModal.tsx @@ -4,7 +4,12 @@ import Button from "@/components/elements/Button"; import Notice from "@/components/elements/Notice"; import ModalLayout from "@/components/layout/ModalLayout"; import ModalTable from "@/components/users/ModalTable"; -import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; +import { + BTN_TEXT, + MODAL_TITLES, + TABLE_HEADERS, +} from "@/constants/commonConstants"; +import { MODAL_STYLE_VAR } from "@/constants/styleConstants"; import type { TRoleChange } from "@/types/userTypes"; const UPDATE_FAILED_MESSAGE = "권한 변경에 실패했습니다. 다시 시도해 주세요."; @@ -63,7 +68,7 @@ const RoleChangeConfirmModal = ({ 다음 멤버의 권한을 변경합니다: [ change.label, <> @@ -78,7 +83,7 @@ const RoleChangeConfirmModal = ({ {UPDATE_FAILED_MESSAGE} )} - + - + {account}의 세션을 비활성화하시겠습니까? 모든 MCP 세션이 종료됩니다. - + { + const [first, ...rest] = user.memberships; + return first + ? { summary: `${first.teamName} · ${first.role}`, extra: rest.length } + : { summary: "—", extra: 0 }; +}; + +interface UserRowProps { + user: TUserListItem; + selected: boolean; + onSelect: (checked: boolean) => void; + onOpen: () => void; +} + +/** UserRow is one SC-11 list row: checkbox, name, session chip, and the + "first team · role +n" membership summary. Row click opens the drawer. */ +const UserRow = ({ user, selected, onSelect, onOpen }: UserRowProps) => { + const { summary, extra } = membershipSummary(user); + return ( + + {/* Checkbox clicks must not open the drawer (SC-11 no.8) */} + + e.stopPropagation()}> + + + + + {user.username} + + + + + + {summary} + {extra > 0 && ( + `${m.teamName} · ${m.role}`) + .join(", ")} + > + +{extra} + + )} + + + ); +}; + +export default UserRow; diff --git a/frontend/src/components/users/UsersToolbar.tsx b/frontend/src/components/users/UsersToolbar.tsx new file mode 100644 index 0000000..2465232 --- /dev/null +++ b/frontend/src/components/users/UsersToolbar.tsx @@ -0,0 +1,126 @@ +import Button from "@/components/elements/Button"; +import Dropdown from "@/components/elements/Dropdown"; +import SearchInput from "@/components/elements/SearchInput"; +import { ARIA_LABELS, BTN_TEXT } from "@/constants/commonConstants"; +import type { TDropdownOption } from "@/types/commonTypes"; + +interface UsersToolbarProps { + search: string; + sort: string; + statusFilter: string; + groupFilter: string; + sortOptions: TDropdownOption[]; + statusOptions: TDropdownOption[]; + groupOptions: TDropdownOption[]; + /** Setters arrive page-reset-wrapped from the page (stale page = wrong + slice, and carried-over checks would mislead bulk actions). */ + onSearchChange: (value: string) => void; + onSortChange: (value: string) => void; + onStatusChange: (value: string) => void; + onGroupChange: (value: string) => void; + selectedCount: number; + onResend: () => void; + onOpenBulkDelete: () => void; + onOpenInvite: () => void; +} + +/** UsersToolbar is the SC-11 header strip (no.2–6): name search, the + sort/status/team dropdowns, and the bulk actions. Pure view. */ +const UsersToolbar = ({ + search, + sort, + statusFilter, + groupFilter, + sortOptions, + statusOptions, + groupOptions, + onSearchChange, + onSortChange, + onStatusChange, + onGroupChange, + selectedCount, + onResend, + onOpenBulkDelete, + onOpenInvite, +}: UsersToolbarProps) => { + return ( + + + + + {/* filter/order dropdown */} + + + 정렬 기준 + + + + 멤버 상태 + + + + 팀 + + + + + + {/* Actions — second row, left-aligned (SC-11 no.4–6) */} + + + + + + + + ); +}; + +export default UsersToolbar; diff --git a/frontend/src/components/users/__tests__/MemberDetailDrawer.test.tsx b/frontend/src/components/users/__tests__/MemberDetailDrawer.test.tsx index 67d5def..02b0120 100644 --- a/frontend/src/components/users/__tests__/MemberDetailDrawer.test.tsx +++ b/frontend/src/components/users/__tests__/MemberDetailDrawer.test.tsx @@ -3,11 +3,11 @@ import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import MemberDetailDrawer from "@/components/users/MemberDetailDrawer"; +import { useNoticeStore } from "@/state/store/noticeStore"; +import { formatDate, formatDateTime } from "@/utils/formatDate"; import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; import type { TBatchResult, TTeamTree } from "@/types/teamTypes"; import type { TUserListItem } from "@/types/userTypes"; -import { formatDate, formatDateTime } from "@/utils/formatDate"; -import { useNoticeStore } from "@/stores/noticeStore"; /** Minimal team fixture — matches the user's one membership plus a second, unjoined team for the add picker. */ @@ -71,9 +71,7 @@ describe("MemberDetailDrawer", () => { render(); /* Header shows the display name as the title and the account (the identifier) right below it. */ - expect( - screen.getByRole("heading", { name: "김철수" }), - ).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "김철수" })).toBeInTheDocument(); expect(screen.getByText("k@corp.com")).toBeInTheDocument(); expect(screen.getByText("백엔드")).toBeInTheDocument(); }); @@ -105,6 +103,43 @@ describe("MemberDetailDrawer", () => { expect(dashes.length).toBeGreaterThanOrEqual(2); }); + it("renders refetched memberships from props and keeps staged edits on top", async () => { + const user = userEvent.setup(); + const props = baseProps(); + const { rerender } = render(); + + /* Stage a role pick before the fresher server payload lands. */ + await user.click(screen.getByRole("button", { name: "백엔드 role" })); + await user.click(screen.getByRole("option", { name: "write" })); + + /* The detail query (or a post-mutation refetch) resolves with an + extra membership — the drawer must render it without a remount. */ + rerender( + , + ); + + expect(screen.getByText("소속 팀 (2)")).toBeInTheDocument(); + expect(screen.getByText("디자인")).toBeInTheDocument(); + + /* The staged (unapplied) pick survives the refetch: the 백엔드 row + still shows write and the update button stays armed. */ + expect( + screen.getByRole("button", { name: "백엔드 role" }), + ).toHaveTextContent("write"); + expect( + screen.getByRole("button", { name: BTN_TEXT.updateChanges }), + ).toBeEnabled(); + }); + it("stages a role change, confirms, and calls onUpdateRoles with {updates}", async () => { const user = userEvent.setup(); const props = baseProps(); @@ -133,17 +168,17 @@ describe("MemberDetailDrawer", () => { await user.click(screen.getByRole("button", { name: "백엔드 role" })); await user.click(screen.getByRole("option", { name: "write" })); - expect(screen.getByRole("button", { name: "백엔드 role" })).toHaveTextContent( - "write", - ); + expect( + screen.getByRole("button", { name: "백엔드 role" }), + ).toHaveTextContent("write"); await user.click(reset); /* The staged pick is gone: the dropdown shows the saved role again and both staged-change buttons drop back to disabled. Reset is purely client-side staging — no batch call fires. */ - expect(screen.getByRole("button", { name: "백엔드 role" })).toHaveTextContent( - "edit", - ); + expect( + screen.getByRole("button", { name: "백엔드 role" }), + ).toHaveTextContent("edit"); expect(reset).toBeDisabled(); expect( screen.getByRole("button", { name: BTN_TEXT.updateChanges }), diff --git a/frontend/src/components/drawer/__tests__/MembershipRow.test.tsx b/frontend/src/components/users/__tests__/MembershipRow.test.tsx similarity index 96% rename from frontend/src/components/drawer/__tests__/MembershipRow.test.tsx rename to frontend/src/components/users/__tests__/MembershipRow.test.tsx index c2165c3..f4a8d8e 100644 --- a/frontend/src/components/drawer/__tests__/MembershipRow.test.tsx +++ b/frontend/src/components/users/__tests__/MembershipRow.test.tsx @@ -3,7 +3,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import MembershipRow from "@/components/drawer/MembershipRow"; +import MembershipRow from "@/components/users/MembershipRow"; const ROLE_OPTIONS = [ { value: "edit", label: "edit" }, diff --git a/frontend/src/components/workspace/WorkspaceModal.tsx b/frontend/src/components/workspace/WorkspaceModal.tsx index 13c47f9..ca9b2f2 100644 --- a/frontend/src/components/workspace/WorkspaceModal.tsx +++ b/frontend/src/components/workspace/WorkspaceModal.tsx @@ -16,13 +16,15 @@ import { isTransitionalStatus, useWorkspaceQuery, } from "@/hooks/queries/useWorkspaceQuery"; +import { useWorkspaceStore } from "@/state/store/workspaceStore"; +import { WORKSPACE_STATUS } from "@/constants/apiConstants"; import { BTN_TEXT, MODAL_TITLES, PATH_LIST, WORKSPACE_MAX_MEMORIES, } from "@/constants/commonConstants"; -import { useWorkspaceStore } from "@/stores/workspaceStore"; +import { MODAL_STYLE_VAR } from "@/constants/styleConstants"; const FAIL_COPY = { stop: "워크스페이스 중지에 실패했습니다. 다시 시도해 주세요.", @@ -71,7 +73,7 @@ const WorkspaceModal = () => { // connect endpoint the empty-state create uses. const reconnectMutation = useCreateWorkspaceMutation(); - const status = workspace?.status ?? "error"; + const status = workspace?.status ?? WORKSPACE_STATUS.error; /* Transitional phases (+ any request in flight) lock the actions. */ const busy = workspace ? isTransitionalStatus(status) : false; @@ -112,12 +114,12 @@ const WorkspaceModal = () => { } return ( - + 워크스페이스를 삭제하시겠습니까? 삭제 후에는 되돌릴 수 없습니다. - + { return ( {tearingDown ? ( - + 기존 워크스페이스를 삭제하는 중입니다… 삭제가 완료되면 워크스페이스 생성을 시작합니다. ) : ( - + 콘솔이 재설치되어 이 워크스페이스와 연결할 수 없습니다. 기존에 저장된 데이터는 이전 보안 키로 암호화되어 복구할 수 없습니다. @@ -169,7 +171,7 @@ const WorkspaceModal = () => { 삭제 후 재생성하면 빈 워크스페이스로 다시 시작합니다. )} - + { if (workspace?.reconnectRequired) { return ( - + 워크스페이스 연결이 만료되었습니다. 재연결하여 데이터 플레인을 다시 활성화해 주세요. {reconnectMutation.isError && ( - 재연결에 실패했습니다. 다시 시도해 주세요. + + 재연결에 실패했습니다. 다시 시도해 주세요. + )} - + { if (isError && !workspace) { return ( - + 워크스페이스 정보를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요. @@ -256,11 +260,11 @@ const WorkspaceModal = () => { return ( - + {/* Lifecycle actions sit at the content's top-right as quiet TextButtons — the info fields carry the primary reading weight. */} - {status === "stopped" ? ( + {status === WORKSPACE_STATUS.stopped ? ( { queryState = { data: { ...RUNNING, orphaned: true }, isError: false }; render(); expect( - screen.getByText(/콘솔이 재설치되어 이 워크스페이스와 연결할 수 없습니다/), + screen.getByText( + /콘솔이 재설치되어 이 워크스페이스와 연결할 수 없습니다/, + ), ).toBeInTheDocument(); expect( screen.getByRole("button", { name: BTN_TEXT.recreate }), @@ -197,7 +199,9 @@ describe("WorkspaceModal", () => { recreateState = { isPending: false, isError: true }; render(); expect( - screen.getByText("워크스페이스 재생성에 실패했습니다. 다시 시도해 주세요."), + screen.getByText( + "워크스페이스 재생성에 실패했습니다. 다시 시도해 주세요.", + ), ).toBeInTheDocument(); expect( screen.queryByRole("button", { name: BTN_TEXT.recreate }), diff --git a/frontend/src/constants/apiConstants.ts b/frontend/src/constants/apiConstants.ts new file mode 100644 index 0000000..e60b0ad --- /dev/null +++ b/frontend/src/constants/apiConstants.ts @@ -0,0 +1,67 @@ +/** + * Wire-contract vocabulary shared with the console API — the single source + * for status/role/error-code string values. Components must reference these + * (e.g. `INVITATION_STATUS.pending`) instead of typing the raw literal, so a + * backend value rename is a one-line change here and every typo is a compile + * error. The matching union types are derived from these objects in types/ + * (e.g. TInvitationStatus), keeping constant and type in lockstep. + */ + +/** Invitation-code lifecycle status on the wire (common contract). */ +export const INVITATION_STATUS = { + pending: "invite_pending", + expired: "invite_expired", + redeemed: "invite_redeemed", +} as const; + +/** Session-token liveness on the wire (common contract). */ +export const SESSION_STATUS = { + online: "online", + offline: "offline", +} as const; + +/** rune workspace lifecycle phase (console API `phase`). */ +export const WORKSPACE_STATUS = { + provisioning: "provisioning", + running: "running", + stopping: "stopping", + stopped: "stopped", + starting: "starting", + deleting: "deleting", + error: "error", +} as const; + +/** Lifecycle reported by the privileged rune-console update agent. */ +export const SYSTEM_UPDATE_STATE = { + idle: "idle", + queued: "queued", + running: "running", + failed: "failed", + succeeded: "succeeded", +} as const; + +/** Grantable member role (Admin is console-account only — API §0). */ +export const TEAM_MEMBER_ROLE = { + edit: "edit", + write: "write", + read: "read", +} as const; + +/** + * Backend error codes surfaced through the shared error envelope + * (parseErrorCode). Keys mirror the wire value verbatim so call sites read + * the same as the API design doc. + */ +export const ERROR_CODES = { + ALREADY_TEAM_MEMBER: "ALREADY_TEAM_MEMBER", + CANNOT_INVITE_ADMIN: "CANNOT_INVITE_ADMIN", + INVITATION_NOT_PENDING: "INVITATION_NOT_PENDING", + MAIL_UPSTREAM_ERROR: "MAIL_UPSTREAM_ERROR", + NOT_TEAM_MEMBER: "NOT_TEAM_MEMBER", + SESSION_NOT_ACTIVE: "SESSION_NOT_ACTIVE", + TEAM_HAS_CHILDREN: "TEAM_HAS_CHILDREN", + TEAM_NAME_DUPLICATE: "TEAM_NAME_DUPLICATE", + TEAM_NAME_INVALID: "TEAM_NAME_INVALID", + TEAM_NOT_FOUND: "TEAM_NOT_FOUND", + USER_NOT_FOUND: "USER_NOT_FOUND", +} as const; diff --git a/frontend/src/constants/commonConstants.ts b/frontend/src/constants/commonConstants.ts index d5bc00f..775d11a 100644 --- a/frontend/src/constants/commonConstants.ts +++ b/frontend/src/constants/commonConstants.ts @@ -7,6 +7,11 @@ export const BRAND_WORDMARK = "RUNE CONSOLE"; * workspace; the SC-02 modal renders usage as rowCount / max (percent). */ export const WORKSPACE_MAX_MEMORIES = 1000; +/** DEFAULT_PAGE_SIZE is the fixed rows-per-page for every list table + * (users, sessions, team members) — caps the table height inside one + * screen and goes out as the ?size= query param on the list endpoints. */ +export const DEFAULT_PAGE_SIZE = 10; + /** BTN_TEXT is the single source of truth for visible action-button labels * (Button `btnText` / TextButton) across the console screens, so a wording * change lands in one place. Icon-button aria-labels are intentionally out of @@ -59,13 +64,23 @@ export const BTN_TEXT = { deleteMember: "멤버 삭제", } as const; +/** PAGE_TITLES is the page/section vocabulary — shared by the main nav, + * each page's , and the workspace modal title, so the + * same screen is never named two different things. */ +export const PAGE_TITLES = { + teams: "팀 관리", + users: "멤버 관리", + sessions: "세션 기록", + workspace: "워크스페이스 관리", +} as const; + /** MODAL_TITLES is the single source of truth for ModalLayout titles across * the console modals, mirroring BTN_TEXT so a wording change lands in one * place. Titles that embed a name or count are functions; the rest are plain * strings. */ export const MODAL_TITLES = { // Workspace - workspaceManage: "워크스페이스 관리", + workspaceManage: PAGE_TITLES.workspace, workspaceDelete: "워크스페이스 삭제", workspaceOrphaned: "워크스페이스 재생성 필요", workspaceReconnect: "워크스페이스 재연결 필요", @@ -96,11 +111,62 @@ export const PATH_LIST = { } as const; export const NAV_LIST = [ - { title: "팀 관리", url: PATH_LIST.teams }, - { title: "멤버 관리", url: PATH_LIST.users }, - { title: "세션 기록", url: PATH_LIST.sessions }, + { title: PAGE_TITLES.teams, url: PATH_LIST.teams }, + { title: PAGE_TITLES.users, url: PATH_LIST.users }, + { title: PAGE_TITLES.sessions, url: PATH_LIST.sessions }, ] as const; +/** TABLE_HEADERS is the column-header copy shared across the list tables, + * the modal tables, and the sort-option labels that mirror a column. */ +export const TABLE_HEADERS = { + memberName: "멤버 이름", + memberStatus: "멤버 상태", + team: "팀", + teamWithRole: "팀 (권한)", + role: "권한", + /* TreeDetailView's member table says 역할 while every other role column + says 권한 — kept verbatim pending a copy decision; unifying is a + one-line change here once decided. */ + roleAlt: "역할", + roleChange: "권한 변경", + joinedAt: "합류일", + account: "account", + reason: "사유", + user: "사용자", + issuedAt: "발급 시간", + lastAccess: "최근 접속 시간", +} as const; + +/** Form-field copy shared by the invite (SC-12) and add-member (SC-06) + * forms — labels are also how tests and screen readers find the fields. */ +export const INPUT_LABELS = { + emailAccount: "이메일 (account)", + username: "사용자 이름 (username)", +} as const; + +export const PLACEHOLDERS = { + selectTeam: "팀 선택", + selectRole: "권한 선택", + /** Team picker when every team is already joined (SC-13 add row). */ + noAddableTeam: "추가할 팀 없음", + emailExample: "user@corp.com", + username: "사용자 이름", +} as const; + +/** Icon/control aria-labels used on more than one screen — centralized so + * assistive tech hears the same name everywhere (they had already drifted: + * "전체 선택" vs "전체선택"). */ +export const ARIA_LABELS = { + selectAll: "전체 선택", + sort: "정렬", +} as const; + +/** Shared Feedback copy — per-screen titles stay local; only the copy that + * repeats across screens lives here. */ +export const FEEDBACK_TEXT = { + refreshRetry: "새로고침 후 다시 시도해 주세요.", +} as const; + export const QUERY_KEYS = { teamsTree: "teamsTree", users: "users", diff --git a/frontend/src/constants/errorConstants.ts b/frontend/src/constants/errorConstants.ts new file mode 100644 index 0000000..2e5d32b --- /dev/null +++ b/frontend/src/constants/errorConstants.ts @@ -0,0 +1,46 @@ +import { ERROR_CODES } from "@/constants/apiConstants"; + +/** + * Backend error code → user-facing Korean copy, shared by every screen that + * surfaces the shared error envelope (parseErrorCode). The same code can read + * differently per flow (e.g. USER_NOT_FOUND during add vs batch), so maps are + * grouped by context rather than merged into one — pick the map that matches + * the flow. For unmapped codes each call site picks its own fallback: the + * generic retry copy, or the raw backend code itself where that diagnostic + * detail is worth showing (member removal / user delete failure modals). + */ + +/** Duplicate-name copy — shared by the server reason map and the client-side + duplicate check in the create/rename team modals (must stay identical). */ +export const TEAM_NAME_DUPLICATE_TEXT = + "같은 상위 팀에 동일한 이름이 이미 있습니다."; + +/** Team CRUD failures (SC-06/07 — create · rename · delete). */ +export const TEAM_REASON: Record = { + [ERROR_CODES.TEAM_NAME_DUPLICATE]: TEAM_NAME_DUPLICATE_TEXT, + [ERROR_CODES.TEAM_NAME_INVALID]: "팀 이름 형식이 올바르지 않습니다.", + [ERROR_CODES.TEAM_HAS_CHILDREN]: "하위 팀이 있어 삭제할 수 없습니다.", +}; + +/** Per-target failure reasons from the batch endpoints (bulk role change, + membership removal, user delete) — listed in MemberBatchFailureModal. */ +export const BATCH_REASON: Record = { + [ERROR_CODES.USER_NOT_FOUND]: "사용자를 찾을 수 없습니다", + [ERROR_CODES.NOT_TEAM_MEMBER]: "팀 멤버가 아닙니다", + [ERROR_CODES.TEAM_NOT_FOUND]: "팀을 찾을 수 없습니다", +}; + +/** Generic retry copy for an unmapped batch code (e.g. a transient + INTERNAL). Used by the role-change flows; the removal/delete failure + modals instead surface the raw code as a diagnostic hint. */ +export const BATCH_REASON_FALLBACK = "처리에 실패했습니다. 다시 시도해 주세요."; + +/** Add-member flow failures (SC-06 팀에 멤버 추가) — the add context words + the same codes differently (USER_NOT_FOUND = unregistered account). */ +export const ADD_MEMBER_REASON: Record = { + [ERROR_CODES.ALREADY_TEAM_MEMBER]: "이미 초대된 사용자입니다.", + [ERROR_CODES.USER_NOT_FOUND]: "등록되지 않은 계정입니다.", + [ERROR_CODES.CANNOT_INVITE_ADMIN]: "콘솔 관리자 계정은 추가할 수 없습니다.", + [ERROR_CODES.MAIL_UPSTREAM_ERROR]: + "초대 코드 전송에 실패했습니다. 다시 시도해 주세요.", +}; diff --git a/frontend/src/constants/noticeConstants.ts b/frontend/src/constants/noticeConstants.ts new file mode 100644 index 0000000..f3ef8b5 --- /dev/null +++ b/frontend/src/constants/noticeConstants.ts @@ -0,0 +1,64 @@ +import { MODAL_TITLES } from "@/constants/commonConstants"; + +/** + * showNotice copy grouped per flow — {title, success, failure, ...} so a + * flow's wording lives in one place instead of inline at each call site. + * Titles reuse MODAL_TITLES where the notice reports the outcome of that + * modal's action; flows without a matching modal title keep their own. + * Keys beyond success/failure are code-specific bodies (e.g. alreadyMember + * for ALREADY_TEAM_MEMBER) picked by the call site's error handling. + */ +export const NOTICE_TEXT = { + resendInvitation: { + title: "초대 코드 재전송", + success: "초대 코드를 재전송했습니다.", + failure: "초대 코드 재전송에 실패했습니다. 다시 시도해 주세요.", + /** Per-account reason row in the batch-failure modal. */ + failedReason: "재전송 실패", + }, + addMembership: { + title: "팀 추가", + success: "팀에 추가되었습니다.", + alreadyMember: "이미 소속된 팀입니다.", + failure: "팀 추가에 실패했습니다. 다시 시도해 주세요.", + }, + /* Role-change and remove-failure results render INSIDE + RoleChangeConfirmModal/MembershipRemoveModal (SC-06 E-1/E-2) — only + the full-success removal toast goes through showNotice. */ + removeMembership: { + title: MODAL_TITLES.removeMembership, + success: "멤버십이 제거되었습니다.", + }, + deactivateSession: { + title: MODAL_TITLES.deactivateSession, + success: "세션을 비활성화했습니다.", + alreadyExpired: "이미 만료된 세션입니다.", + failure: "세션 비활성화에 실패했습니다. 다시 시도해 주세요.", + }, + cancelInvitation: { + title: MODAL_TITLES.cancelInvitation, + success: "초대를 취소했습니다.", + nothingToCancel: "취소할 초대가 없습니다.", + failure: "초대 취소에 실패했습니다. 다시 시도해 주세요.", + }, + createTeam: { + title: "팀 생성", + success: "팀이 생성되었습니다.", + }, + renameTeam: { + title: MODAL_TITLES.renameTeam, + success: "팀 이름이 변경되었습니다.", + }, + deleteTeam: { + title: "팀 삭제", + success: "팀이 삭제되었습니다.", + }, + addTeamMember: { + title: "멤버 추가", + success: "멤버를 추가했습니다.", + }, + deleteMember: { + title: "멤버 삭제", + success: "멤버를 삭제했습니다.", + }, +} as const; diff --git a/frontend/src/constants/styleConstants.ts b/frontend/src/constants/styleConstants.ts index fad01a1..9858dce 100644 --- a/frontend/src/constants/styleConstants.ts +++ b/frontend/src/constants/styleConstants.ts @@ -3,6 +3,12 @@ * Visual values are translated from UIKIT modules/rune-ui-buttons and * modules/rune-admin-kit CSS — UIKIT is the design source of truth. */ +import type { TMemberStatus } from "@/types/commonTypes"; +import type { TInvitationStatus } from "@/types/teamTypes"; +import type { TWorkspaceStatus } from "@/types/workspaceTypes"; + +/** Status → chip/badge presentation (label + text color). */ +type TStatusStyle = { label: string; color: string }; /* Form controls embed w-full: the parent container constrains width. Metrics are UIKIT values normalized to even px (project rule). */ @@ -79,18 +85,34 @@ export const BADGE_TONE_VAR = { neutral: "bg-muted-foreground/12 text-muted-foreground", } as const; -/* Session chips — the only status a list view shows. */ +/* Shared modal building blocks — the ModalLayout children every confirm + modal composes. One source so the copies can't drift (the body gap had + already split into gap-4 vs gap-5 before this was centralized). */ +export const MODAL_STYLE_VAR = { + /* Centered single-line message (alert/failure bodies). */ + message: "text-center text-base", + /* Vertical form/content stack. */ + body: "flex w-full flex-col gap-4", + /* Button row — one spacing for every confirm modal (the team/workspace + modals used to sit at gap-2 while the users flows used gap-4; unified + on gap-4, 2026-08-03). */ + footer: "flex w-full items-center gap-4", +} as const; + +/* Session chips — the only status a list view shows. The satisfies clause + keys this map to the status union: adding/renaming a status value is a + compile error here until the label map follows. */ export const MEMBER_STATUS_VAR = { online: { label: "온라인", color: "text-mint" }, offline: { label: "오프라인", color: "text-faint" }, -} as const; +} as const satisfies Record; /* Invitation-status labels — shown only in the member detail drawer. */ export const INVITATION_STATUS_VAR = { invite_pending: { label: "초대 수락 대기", color: "text-warning" }, invite_expired: { label: "초대 코드 만료", color: "text-faint" }, invite_redeemed: { label: "초대 코드 사용됨", color: "text-accent-blue" }, -} as const; +} as const satisfies Record; export const WORKSPACE_STATUS_VAR = { provisioning: { label: "생성 중", color: "text-warning" }, @@ -100,4 +122,4 @@ export const WORKSPACE_STATUS_VAR = { starting: { label: "재실행 중", color: "text-warning" }, deleting: { label: "삭제 중", color: "text-warning" }, error: { label: "사용 불가", color: "text-negative" }, -} as const; +} as const satisfies Record; diff --git a/frontend/src/constants/teamConstants.ts b/frontend/src/constants/teamConstants.ts new file mode 100644 index 0000000..ba95277 --- /dev/null +++ b/frontend/src/constants/teamConstants.ts @@ -0,0 +1,15 @@ +import { TEAM_MEMBER_ROLE } from "@/constants/apiConstants"; +import type { TDropdownOption } from "@/types/commonTypes"; + +/** Team name rule: digits, Hangul, Latin letters, and - _ only. */ +export const TEAM_NAME_PATTERN = /^[0-9A-Za-z가-힣_-]+$/; + +export const TEAM_NAME_RULE_TEXT = + "숫자·한글·영어와 - _ 만 사용할 수 있습니다."; + +/** Grantable member roles (Admin is console-account only — API §0). */ +export const ROLE_OPTIONS: TDropdownOption[] = [ + { 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 }, +]; diff --git a/frontend/src/components/users/memberStatusMap.ts b/frontend/src/constants/userConstants.ts similarity index 71% rename from frontend/src/components/users/memberStatusMap.ts rename to frontend/src/constants/userConstants.ts index 2b384f0..93475b1 100644 --- a/frontend/src/components/users/memberStatusMap.ts +++ b/frontend/src/constants/userConstants.ts @@ -1,9 +1,10 @@ +import { SESSION_STATUS } from "@/constants/apiConstants"; import type { TMemberStatus } from "@/types/commonTypes"; import type { TSessionStatus } from "@/types/teamTypes"; /** API session status → MemberStatus chip state. Identity today, but kept as a seam so the chip vocabulary can diverge from the wire later. */ export const CHIP_STATUS: Record = { - online: "online", - offline: "offline", + [SESSION_STATUS.online]: "online", + [SESSION_STATUS.offline]: "offline", }; diff --git a/frontend/src/hooks/__tests__/useBatchFailureModal.test.ts b/frontend/src/hooks/__tests__/useBatchFailureModal.test.ts new file mode 100644 index 0000000..d6b5319 --- /dev/null +++ b/frontend/src/hooks/__tests__/useBatchFailureModal.test.ts @@ -0,0 +1,55 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { + toBatchFailureRows, + useBatchFailureModal, +} from "@/hooks/useBatchFailureModal"; +import { BATCH_REASON_FALLBACK } from "@/constants/errorConstants"; + +describe("useBatchFailureModal", () => { + it("starts closed and opens with the given rows", () => { + const { result } = renderHook(() => useBatchFailureModal()); + expect(result.current.batchFailures).toBeNull(); + act(() => + result.current.showBatchFailures([{ account: "a", reason: "r" }]), + ); + expect(result.current.batchFailures).toEqual([ + { account: "a", reason: "r" }, + ]); + act(() => result.current.closeBatchFailures()); + expect(result.current.batchFailures).toBeNull(); + }); +}); + +describe("toBatchFailureRows", () => { + const failed = [ + { id: "u1", code: "USER_NOT_FOUND", message: "x" }, + { id: "u2", code: "INTERNAL", message: "y" }, + ]; + + it("maps known codes through BATCH_REASON and labels via labelOf", () => { + const rows = toBatchFailureRows( + failed, + (id) => `acct-${id}`, + () => BATCH_REASON_FALLBACK, + ); + expect(rows[0]).toEqual({ + account: "acct-u1", + reason: "사용자를 찾을 수 없습니다", + }); + expect(rows[1]).toEqual({ + account: "acct-u2", + reason: BATCH_REASON_FALLBACK, + }); + }); + + it("supports the raw-code fallback used by removal/delete flows", () => { + const rows = toBatchFailureRows( + failed, + (id) => id, + (code) => code, + ); + expect(rows[1].reason).toBe("INTERNAL"); + }); +}); diff --git a/frontend/src/hooks/__tests__/usePageScopedSelection.test.ts b/frontend/src/hooks/__tests__/usePageScopedSelection.test.ts new file mode 100644 index 0000000..2b267e5 --- /dev/null +++ b/frontend/src/hooks/__tests__/usePageScopedSelection.test.ts @@ -0,0 +1,44 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { usePageScopedSelection } from "@/hooks/usePageScopedSelection"; + +describe("usePageScopedSelection", () => { + it("toggles single ids on and off", () => { + const { result } = renderHook(() => usePageScopedSelection()); + act(() => result.current.toggleOne("a", true)); + act(() => result.current.toggleOne("b", true)); + expect(result.current.selectedIds).toEqual(new Set(["a", "b"])); + act(() => result.current.toggleOne("a", false)); + expect(result.current.selectedIds).toEqual(new Set(["b"])); + }); + + it("toggleAll adds and removes only the given ids", () => { + const { result } = renderHook(() => usePageScopedSelection()); + act(() => result.current.toggleOne("keep", true)); + act(() => result.current.toggleAll(["a", "b"], true)); + expect(result.current.selectedIds).toEqual(new Set(["keep", "a", "b"])); + act(() => result.current.toggleAll(["a", "b"], false)); + expect(result.current.selectedIds).toEqual(new Set(["keep"])); + }); + + it("clearSelection empties the set", () => { + const { result } = renderHook(() => usePageScopedSelection()); + act(() => result.current.toggleAll(["a", "b"], true)); + act(() => result.current.clearSelection()); + expect(result.current.selectedIds.size).toBe(0); + }); + + it("setSelectedIds supports batch-result reconciliation", () => { + const { result } = renderHook(() => usePageScopedSelection()); + act(() => result.current.toggleAll(["ok", "failed"], true)); + act(() => + result.current.setSelectedIds((prev) => { + const next = new Set(prev); + next.delete("ok"); + return next; + }), + ); + expect(result.current.selectedIds).toEqual(new Set(["failed"])); + }); +}); diff --git a/frontend/src/hooks/__tests__/useServerPagination.test.ts b/frontend/src/hooks/__tests__/useServerPagination.test.ts new file mode 100644 index 0000000..29edb2a --- /dev/null +++ b/frontend/src/hooks/__tests__/useServerPagination.test.ts @@ -0,0 +1,53 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { useServerPagination } from "@/hooks/useServerPagination"; + +describe("useServerPagination", () => { + it("starts on page 1 with one page until a total arrives", () => { + const { result } = renderHook(() => useServerPagination(10)); + expect(result.current.page).toBe(1); + expect(result.current.totalPages).toBe(1); + }); + + it("derives totalPages from the reported total", () => { + const { result } = renderHook(() => useServerPagination(10)); + act(() => result.current.syncTotal(35)); + expect(result.current.totalPages).toBe(4); + expect(result.current.page).toBe(1); + }); + + it("clamps the request page when the range shrinks", () => { + const { result } = renderHook(() => useServerPagination(10)); + act(() => result.current.syncTotal(50)); + act(() => result.current.setPage(5)); + expect(result.current.page).toBe(5); + /* A sort/filter change or deletion shrinks the result set. */ + act(() => result.current.syncTotal(21)); + expect(result.current.totalPages).toBe(3); + expect(result.current.page).toBe(3); + }); + + it("never exposes a page beyond totalPages even before the correction", () => { + const { result } = renderHook(() => useServerPagination(10)); + act(() => result.current.setPage(9)); + /* totalPages still 1 — the returned page must stay in range so the + query never asks for an out-of-range slice. */ + expect(result.current.page).toBe(1); + }); + + it("resetPage returns to page 1", () => { + const { result } = renderHook(() => useServerPagination(10)); + act(() => result.current.syncTotal(50)); + act(() => result.current.setPage(4)); + act(() => result.current.resetPage()); + expect(result.current.page).toBe(1); + }); + + it("treats an empty result as a single page", () => { + const { result } = renderHook(() => useServerPagination(10)); + act(() => result.current.syncTotal(0)); + expect(result.current.totalPages).toBe(1); + expect(result.current.page).toBe(1); + }); +}); diff --git a/frontend/src/hooks/mutations/useUpdateMutation.ts b/frontend/src/hooks/mutations/useUpdateMutation.ts index 25e8ce3..d6a72ed 100644 --- a/frontend/src/hooks/mutations/useUpdateMutation.ts +++ b/frontend/src/hooks/mutations/useUpdateMutation.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { postSystemUpdate } from "@/api/updateAPIs"; +import { SYSTEM_UPDATE_STATE } from "@/constants/apiConstants"; import { QUERY_KEYS } from "@/constants/commonConstants"; import type { TSystemUpdateStatus } from "@/types/updateTypes"; @@ -21,7 +22,7 @@ export const useUpdateMutation = () => { ? { ...current, targetVersion: version, - state: "queued", + state: SYSTEM_UPDATE_STATE.queued, } : current, ); diff --git a/frontend/src/hooks/queries/useUpdateQuery.ts b/frontend/src/hooks/queries/useUpdateQuery.ts index 0beb0b6..3fcb826 100644 --- a/frontend/src/hooks/queries/useUpdateQuery.ts +++ b/frontend/src/hooks/queries/useUpdateQuery.ts @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { getSystemUpdate } from "@/api/updateAPIs"; +import { SYSTEM_UPDATE_STATE } from "@/constants/apiConstants"; import { QUERY_KEYS } from "@/constants/commonConstants"; import type { TSystemUpdateStatus } from "@/types/updateTypes"; @@ -8,7 +9,7 @@ const ACTIVE_POLL_MS = 2000; const IDLE_POLL_MS = 60 * 60 * 1000; export const isSystemUpdateActive = (state: TSystemUpdateStatus["state"]) => - state === "queued" || state === "running"; + state === SYSTEM_UPDATE_STATE.queued || state === SYSTEM_UPDATE_STATE.running; /** * Checks for a release without disturbing the app when GitHub or the local diff --git a/frontend/src/hooks/queries/useWorkspaceQuery.ts b/frontend/src/hooks/queries/useWorkspaceQuery.ts index 01506ab..a275a22 100644 --- a/frontend/src/hooks/queries/useWorkspaceQuery.ts +++ b/frontend/src/hooks/queries/useWorkspaceQuery.ts @@ -1,22 +1,23 @@ import { useQuery } from "@tanstack/react-query"; import { getWorkspace } from "@/api/workspaceAPIs"; +import { WORKSPACE_STATUS } from "@/constants/apiConstants"; import { QUERY_KEYS } from "@/constants/commonConstants"; import type { - TWorkspaceStatus, TWorkspace, + TWorkspaceStatus, TWorkspaceWire, -} from "@/types/commonTypes"; +} from "@/types/workspaceTypes"; /** How often to re-poll GET /workspace while a phase is mid-transition. */ const POLL_MS = 10000; /** Phases mid-transition — the query keeps polling while the workspace sits here. */ export const isTransitionalStatus = (status: TWorkspaceStatus): boolean => - status === "provisioning" || - status === "stopping" || - status === "starting" || - status === "deleting"; + status === WORKSPACE_STATUS.provisioning || + status === WORKSPACE_STATUS.stopping || + status === WORKSPACE_STATUS.starting || + status === WORKSPACE_STATUS.deleting; /** * useWorkspaceQuery reads the singular workspace (SC-02). A 404 means "no diff --git a/frontend/src/hooks/useBatchFailureModal.ts b/frontend/src/hooks/useBatchFailureModal.ts new file mode 100644 index 0000000..28d378b --- /dev/null +++ b/frontend/src/hooks/useBatchFailureModal.ts @@ -0,0 +1,43 @@ +import { useState } from "react"; + +import { BATCH_REASON } from "@/constants/errorConstants"; +import type { TBatchResult } from "@/types/teamTypes"; + +/** One row of MemberBatchFailureModal: target label + failure copy. */ +export type TBatchFailureRow = { account: string; reason: string }; + +/** + * useBatchFailureModal owns the partial-failure surface shared by the + * batch endpoints (bulk role change, membership removal, user delete): + * non-null rows open MemberBatchFailureModal listing exactly what failed + * and why (API design — partial success is not an error). + */ +export const useBatchFailureModal = () => { + const [batchFailures, setBatchFailures] = useState( + null, + ); + + const closeBatchFailures = () => setBatchFailures(null); + + return { + batchFailures, + showBatchFailures: setBatchFailures, + closeBatchFailures, + }; +}; + +/** + * Maps a batch result's failures onto modal rows: a per-target label plus + * the shared BATCH_REASON copy. Unmapped codes fall back to whatever the + * caller chooses — the generic retry copy (role-change flows) or the raw + * backend code as a diagnostic hint (removal/delete flows). + */ +export const toBatchFailureRows = ( + failed: TBatchResult["failed"], + labelOf: (id: string) => string, + fallbackFor: (code: string) => string, +): TBatchFailureRow[] => + failed.map((f) => ({ + account: labelOf(f.id), + reason: BATCH_REASON[f.code] ?? fallbackFor(f.code), + })); diff --git a/frontend/src/hooks/useMembershipDrafts.ts b/frontend/src/hooks/useMembershipDrafts.ts new file mode 100644 index 0000000..3407273 --- /dev/null +++ b/frontend/src/hooks/useMembershipDrafts.ts @@ -0,0 +1,240 @@ +import { useState } from "react"; + +import { + toBatchFailureRows, + useBatchFailureModal, +} from "@/hooks/useBatchFailureModal"; +import { usePageScopedSelection } from "@/hooks/usePageScopedSelection"; +import { parseErrorCode } from "@/api/parseError"; +import { useNoticeStore } from "@/state/store/noticeStore"; +import { buildTeamOptions } from "@/utils/buildTeamOptions"; +import { getTeamDescendantIds } from "@/utils/teamHierarchy"; +import { ERROR_CODES } from "@/constants/apiConstants"; +import { BATCH_REASON_FALLBACK } from "@/constants/errorConstants"; +import { NOTICE_TEXT } from "@/constants/noticeConstants"; +import type { TBatchResult, TTeamTree } from "@/types/teamTypes"; +import type { TUserListItem } from "@/types/userTypes"; + +/** One membership row as rendered: server truth (baseRole) with the + staged edits (role pick, checkbox) applied on top. */ +export type TMembershipDraft = { + teamId: string; + teamName: string; + baseRole: string; + role: string; + checked: boolean; +}; + +interface UseMembershipDraftsOptions { + user: TUserListItem; + /** Real team tree (GET /teams/tree) — add picker + sub-team notice. */ + teams: TTeamTree; + onUpdateRoles: ( + changes: { teamId: string; role: string }[], + ) => Promise; + onRemoveMemberships: (teamIds: string[]) => Promise; + onAddMembership: (teamId: string, role: string) => Promise; +} + +/** + * useMembershipDrafts owns the SC-13 membership machine. Server truth + * (user.memberships) flows straight from props — never copied into + * state — so the fresher GET /users/{id} payload and every post-mutation + * refetch render immediately. Only the user's own edits are staged (role + * picks + checkbox selection), re-applied as a diff on top of whatever + * the server currently says. The confirm flows reconcile batch results: + * succeeded targets un-stage (the refetch delivers their new truth), + * failed ones stay staged/checked for a retry and surface in the + * batch-failure modal. + */ +export const useMembershipDrafts = ({ + user, + teams, + onUpdateRoles, + onRemoveMemberships, + onAddMembership, +}: UseMembershipDraftsOptions) => { + const [pendingRoles, setPendingRoles] = useState>( + new Map(), + ); + const { + selectedIds: checkedIds, + toggleOne: setChecked, + toggleAll: setAllChecked, + setSelectedIds: setCheckedIds, + } = usePageScopedSelection(); + const { batchFailures, showBatchFailures, closeBatchFailures } = + useBatchFailureModal(); + const showNotice = useNoticeStore((state) => state.showNotice); + + const memberships: TMembershipDraft[] = user.memberships.map((m) => ({ + teamId: m.teamId, + teamName: m.teamName, + baseRole: m.role, + role: pendingRoles.get(m.teamId) ?? m.role, + checked: checkedIds.has(m.teamId), + })); + + /* A staged pick equal to the (possibly refetched) server role is a + no-op and drops out of `changes` on its own. */ + const changes = memberships.filter((m) => m.role !== m.baseRole); + const selected = memberships.filter((m) => m.checked); + const allChecked = + memberships.length > 0 && memberships.every((m) => m.checked); + + /* Sub-team retention notice (SC-14 no.2): a selected team has a + descendant team whose membership stays after this removal. */ + const remainingIds = memberships + .filter((m) => !m.checked) + .map((m) => m.teamId); + const subteamNotice = selected.some((m) => + getTeamDescendantIds(teams, m.teamId).some((id) => + remainingIds.includes(id), + ), + ); + + /* Failure rows are labeled by team name — the drawer's batch targets + are this one user's memberships. */ + const teamNameOf = (teamId: string) => + memberships.find((m) => m.teamId === teamId)?.teamName ?? teamId; + + const stageRole = (teamId: string, role: string) => + setPendingRoles((prev) => new Map(prev).set(teamId, role)); + const resetStaged = () => setPendingRoles(new Map()); + + /* ── [팀 추가하기] picker row (SC-13 no.2) ─────────────────────── */ + const [addOpen, setAddOpen] = useState(false); + const [addTeamId, setAddTeamId] = useState(""); + const [addRole, setAddRole] = useState(""); + const [adding, setAdding] = useState(false); + + /* Teams the user already belongs to stay out of the add picker. + Depth indent stripped — the narrow drawer dropdown can't fit + deep-tree indentation. */ + const joinedIds = new Set(memberships.map((m) => m.teamId)); + const addableTeams = buildTeamOptions(teams) + .filter((o) => !joinedIds.has(o.value)) + .map(({ value, label }) => ({ value, label })); + + const resetAdd = () => { + setAddOpen(false); + setAddTeamId(""); + setAddRole(""); + }; + const toggleAddRow = () => (addOpen ? resetAdd() : setAddOpen(true)); + + const handleAdd = async () => { + setAdding(true); + try { + /* The mutation invalidates the user detail/list queries — the new + row arrives with the refetch, so nothing is mirrored locally. */ + await onAddMembership(addTeamId, addRole); + showNotice( + NOTICE_TEXT.addMembership.title, + NOTICE_TEXT.addMembership.success, + "info", + ); + resetAdd(); + } catch (err) { + const code = err instanceof Response ? await parseErrorCode(err) : ""; + showNotice( + NOTICE_TEXT.addMembership.title, + code === ERROR_CODES.ALREADY_TEAM_MEMBER + ? NOTICE_TEXT.addMembership.alreadyMember + : NOTICE_TEXT.addMembership.failure, + "error", + ); + } finally { + setAdding(false); + } + }; + + /* ── confirm flows (RoleChangeConfirmModal / MembershipRemoveModal) ── */ + const confirmRoleChanges = async () => { + const changedIds = changes.map((m) => m.teamId); + const result = await onUpdateRoles( + changes.map((m) => ({ teamId: m.teamId, role: m.role })), + ); + const failedIds = new Set(result.failed.map((f) => f.id)); + /* Applied roles come back with the invalidation refetch — drop their + staged picks and keep only the failed ones staged for a retry. */ + setPendingRoles((prev) => { + const next = new Map(prev); + for (const teamId of changedIds) { + if (!failedIds.has(teamId)) next.delete(teamId); + } + return next; + }); + if (result.failed.length > 0) { + showBatchFailures( + toBatchFailureRows( + result.failed, + teamNameOf, + () => BATCH_REASON_FALLBACK, + ), + ); + } + }; + + const confirmRemovals = async () => { + const removedIds = selected.map((m) => m.teamId); + const result = await onRemoveMemberships(removedIds); + const failedIds = new Set(result.failed.map((f) => f.id)); + /* Removed rows drop out with the invalidation refetch — clear their + staged edits; failed rows keep their check for a retry. */ + setCheckedIds((prev) => { + const next = new Set(prev); + for (const teamId of removedIds) { + if (!failedIds.has(teamId)) next.delete(teamId); + } + return next; + }); + setPendingRoles((prev) => { + const next = new Map(prev); + for (const teamId of removedIds) { + if (!failedIds.has(teamId)) next.delete(teamId); + } + return next; + }); + if (result.failed.length === 0) { + showNotice( + NOTICE_TEXT.removeMembership.title, + NOTICE_TEXT.removeMembership.success, + "success", + ); + } else { + showBatchFailures( + toBatchFailureRows( + result.failed, + teamNameOf, + () => BATCH_REASON_FALLBACK, + ), + ); + } + }; + + return { + memberships, + changes, + selected, + allChecked, + subteamNotice, + stageRole, + setChecked, + setAllChecked, + resetStaged, + addOpen, + addTeamId, + addRole, + adding, + addableTeams, + setAddTeamId, + setAddRole, + toggleAddRow, + handleAdd, + confirmRoleChanges, + confirmRemovals, + batchFailures, + closeBatchFailures, + }; +}; diff --git a/frontend/src/hooks/usePageScopedSelection.ts b/frontend/src/hooks/usePageScopedSelection.ts new file mode 100644 index 0000000..dbd17c9 --- /dev/null +++ b/frontend/src/hooks/usePageScopedSelection.ts @@ -0,0 +1,38 @@ +import { useState } from "react"; + +/** + * usePageScopedSelection owns a checkbox column's Set-of-ids selection + * (users page, team member table, drawer membership rows). + * + * "Page-scoped" is a caller contract: whatever changes the visible rows + * (page move, filter change, team switch) should call clearSelection so a + * checked row never rides along into a bulk action taken on a different + * slice. setSelectedIds is exposed for batch-result reconciliation — + * dropping succeeded targets while failed ones stay selected for a retry. + */ +export const usePageScopedSelection = () => { + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const toggleOne = (id: string, selected: boolean) => + setSelectedIds((prev) => { + const next = new Set(prev); + if (selected) next.add(id); + else next.delete(id); + return next; + }); + + /** Header select-all: add/remove the given (visible) ids in one shot. */ + const toggleAll = (ids: string[], selected: boolean) => + setSelectedIds((prev) => { + const next = new Set(prev); + ids.forEach((id) => { + if (selected) next.add(id); + else next.delete(id); + }); + return next; + }); + + const clearSelection = () => setSelectedIds(new Set()); + + return { selectedIds, toggleOne, toggleAll, clearSelection, setSelectedIds }; +}; diff --git a/frontend/src/hooks/useServerPagination.ts b/frontend/src/hooks/useServerPagination.ts new file mode 100644 index 0000000..9efab16 --- /dev/null +++ b/frontend/src/hooks/useServerPagination.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useState } from "react"; + +import { DEFAULT_PAGE_SIZE } from "@/constants/commonConstants"; + +/** + * useServerPagination owns the page state for a server-paged table. + * + * totalPages tracks the last response's total (kept as state, not derived, + * so a page/sort transition under keepPreviousData never flashes an interim + * value), and the returned `page` is clamped against it BEFORE the query + * call — an out-of-range request never fires. When a response shrinks the + * range (filter/sort change, deletions emptying the last page), the stored + * page is corrected so Pagination and later renders resume from a valid + * value instead of the stale, too-high one. + * + * Wiring: pass `page` to the list query, then report each response's total + * back with one effect — `useEffect(() => syncTotal(total), [total, + * syncTotal])`. + */ +export const useServerPagination = (pageSize: number = DEFAULT_PAGE_SIZE) => { + const [rawPage, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const page = Math.min(rawPage, totalPages); + + const syncTotal = useCallback( + (total: number) => { + const next = Math.max(1, Math.ceil(total / pageSize)); + setTotalPages(next); + setPage((prev) => Math.min(prev, next)); + }, + [pageSize], + ); + + const resetPage = useCallback(() => setPage(1), []); + + return { page, totalPages, setPage, resetPage, syncTotal, pageSize }; +}; + +/** Companion one-liner so callers don't hand-roll the report-back effect. */ +export const useSyncPaginationTotal = ( + syncTotal: (total: number) => void, + total: number, +) => { + useEffect(() => { + syncTotal(total); + }, [syncTotal, total]); +}; diff --git a/frontend/src/hooks/useStagedRoleEdits.ts b/frontend/src/hooks/useStagedRoleEdits.ts new file mode 100644 index 0000000..0ad4f57 --- /dev/null +++ b/frontend/src/hooks/useStagedRoleEdits.ts @@ -0,0 +1,78 @@ +import { useState } from "react"; + +import type { TTeamMemberRole } from "@/types/teamTypes"; + +/** + * useStagedRoleEdits owns the SC-06 staged role-edit machine: dropdown + * picks collect in pendingRoles and only apply on [변경사항 업데이트]; + * savedRoles is the committed baseline shown until the invalidation + * refetch delivers the server truth (the list query keeps previous data + * visible during the refetch). + */ +export const useStagedRoleEdits = () => { + const [pendingRoles, setPendingRoles] = useState< + Map + >(new Map()); + const [savedRoles, setSavedRoles] = useState>( + new Map(), + ); + + /** Committed role for a member — the staged baseline or the wire value. */ + const baseRole = (userId: string, fallback: TTeamMemberRole) => + savedRoles.get(userId) ?? fallback; + + /** Stage a dropdown pick; picking the base value back un-stages it. */ + const stageRole = ( + userId: string, + fallback: TTeamMemberRole, + nextRole: string, + ) => + setPendingRoles((prev) => { + const next = new Map(prev); + if (nextRole === baseRole(userId, fallback)) next.delete(userId); + else next.set(userId, nextRole as TTeamMemberRole); + return next; + }); + + /** [변경사항 초기화] — staged picks drop; the committed baseline stays. */ + const resetStaged = () => setPendingRoles(new Map()); + + /** Team switch — nothing staged or committed may leak across teams. */ + const resetAll = () => { + setPendingRoles(new Map()); + setSavedRoles(new Map()); + }; + + /** Full batch success — commit every staged pick into the baseline. */ + const applyAll = () => { + setSavedRoles((prev) => new Map([...prev, ...pendingRoles])); + setPendingRoles(new Map()); + }; + + /** Partial batch failure — commit only what succeeded and keep the + failed entries staged so the user can retry them. */ + const reconcileBatch = (failedIds: Set) => { + setSavedRoles( + (prev) => + new Map([ + ...prev, + ...[...pendingRoles.entries()].filter( + ([userId]) => !failedIds.has(userId), + ), + ]), + ); + setPendingRoles( + (prev) => new Map([...prev].filter(([userId]) => failedIds.has(userId))), + ); + }; + + return { + pendingRoles, + baseRole, + stageRole, + resetStaged, + resetAll, + applyAll, + reconcileBatch, + }; +}; diff --git a/frontend/src/hooks/useTeamCrud.ts b/frontend/src/hooks/useTeamCrud.ts new file mode 100644 index 0000000..440aec3 --- /dev/null +++ b/frontend/src/hooks/useTeamCrud.ts @@ -0,0 +1,118 @@ +import { useState } from "react"; + +import { + useCreateTeamMutation, + useDeleteTeamMutation, + useRenameTeamMutation, +} from "@/hooks/mutations/useTeamMutations"; +import { parseErrorCode } from "@/api/parseError"; +import { useNoticeStore } from "@/state/store/noticeStore"; +import { TEAM_REASON } from "@/constants/errorConstants"; +import { NOTICE_TEXT } from "@/constants/noticeConstants"; + +interface UseTeamCrudOptions { + /** Rename/delete target — pass "" when only the create flow is used + (the mutations are lazy, so an unused id never fires). */ + teamId: string; + /** Close the owning modal after a successful mutation. */ + onDone: () => void; + /** Post-delete hand-off (SC-08 — reselect another root team). */ + onDeleted?: () => void; +} + +/** + * useTeamCrud owns the team create/rename/delete orchestration shared by + * TreeDetailView (SC-07~09) and TeamsPage's empty-state create (SC-06 B): + * one TEAM_REASON error mapping into the modals' inline error, one + * success-notice wiring. teamError is reset on every attempt; callers + * clear it when opening/closing a modal so a stale error never leaks + * into a fresh one. + */ +export const useTeamCrud = ({ + teamId, + onDone, + onDeleted, +}: UseTeamCrudOptions) => { + const [teamError, setTeamError] = useState(null); + const createTeam = useCreateTeamMutation(); + const renameTeam = useRenameTeamMutation(teamId); + const deleteTeam = useDeleteTeamMutation(teamId); + const showNotice = useNoticeStore((state) => state.showNotice); + + const clearTeamError = () => setTeamError(null); + + const handleCreate = (name: string, parentId: string | null) => { + setTeamError(null); + createTeam.mutate( + { name, parentId }, + { + onSuccess: () => { + onDone(); + showNotice( + NOTICE_TEXT.createTeam.title, + NOTICE_TEXT.createTeam.success, + "success", + ); + }, + onError: async (res) => { + const code = await parseErrorCode(res); + setTeamError(TEAM_REASON[code] ?? "팀 생성에 실패했습니다."); + }, + }, + ); + }; + + const handleRename = (name: string) => { + setTeamError(null); + renameTeam.mutate( + { name }, + { + onSuccess: () => { + onDone(); + showNotice( + NOTICE_TEXT.renameTeam.title, + NOTICE_TEXT.renameTeam.success, + "success", + ); + }, + onError: async (res) => { + const code = await parseErrorCode(res); + setTeamError(TEAM_REASON[code] ?? "이름 변경에 실패했습니다."); + }, + }, + ); + }; + + const handleDelete = ( + action: "purge" | "transfer", + targetTeamId?: string, + ) => { + setTeamError(null); + deleteTeam.mutate( + { memoryAction: action, targetTeamId }, + { + onSuccess: () => { + onDone(); + showNotice( + NOTICE_TEXT.deleteTeam.title, + NOTICE_TEXT.deleteTeam.success, + "success", + onDeleted, + ); + }, + onError: async (res) => { + const code = await parseErrorCode(res); + setTeamError(TEAM_REASON[code] ?? "팀 삭제에 실패했습니다."); + }, + }, + ); + }; + + return { + teamError, + clearTeamError, + handleCreate, + handleRename, + handleDelete, + }; +}; diff --git a/frontend/src/hooks/useUserBatchActions.ts b/frontend/src/hooks/useUserBatchActions.ts new file mode 100644 index 0000000..536a866 --- /dev/null +++ b/frontend/src/hooks/useUserBatchActions.ts @@ -0,0 +1,149 @@ +import type { Dispatch, SetStateAction } from "react"; + +import { + useDeleteUsers, + useInviteMutation, + useResendInvitation, +} from "@/hooks/mutations/useInvitationMutations"; +import { + toBatchFailureRows, + useBatchFailureModal, +} from "@/hooks/useBatchFailureModal"; +import { parseErrorCode } from "@/api/parseError"; +import { useNoticeStore } from "@/state/store/noticeStore"; +import { ERROR_CODES } from "@/constants/apiConstants"; +import { NOTICE_TEXT } from "@/constants/noticeConstants"; +import type { TTeamMemberRole } from "@/types/teamTypes"; +import type { + TInvitePayload, + TInviteResult, + TUserListItem, +} from "@/types/userTypes"; + +interface UseUserBatchActionsOptions { + /** Selection reconciliation — deleted targets drop out, failed ones + stay selected for a retry. */ + setSelectedIds: Dispatch>>; + /** Called with the ids that were actually deleted (drawer close-out). */ + onDeleted: (deletedIds: string[]) => void; +} + +/** + * useUserBatchActions owns the SC-11 bulk flows — invite (SC-12), invite- + * code resend, and batch delete (SC-15) — including their notice/batch- + * failure surfaces. Pure orchestration over the invitation mutations; the + * page supplies selection reconciliation and the drawer close-out. + */ +export const useUserBatchActions = ({ + setSelectedIds, + onDeleted, +}: UseUserBatchActionsOptions) => { + const invite = useInviteMutation(); + const resend = useResendInvitation(); + const deleteUsersMutation = useDeleteUsers(); + const { batchFailures, showBatchFailures, closeBatchFailures } = + useBatchFailureModal(); + const showNotice = useNoticeStore((state) => state.showNotice); + + /** POST /invitations — server judges duplicates and target states; only + the staged team/role sets are sent (buildInvitePreview's sub-team + expansion is display-only, the server performs the real expansion). */ + const inviteMember = async ( + payload: TInvitePayload, + ): Promise => { + try { + await invite.mutateAsync({ + account: payload.email, + username: payload.username, + memberships: payload.sets.map((set) => ({ + teamId: set.teamId, + role: set.role as TTeamMemberRole, + })), + }); + return "success"; + } catch (err) { + if (err instanceof Response) { + const code = await parseErrorCode(err); + return code === ERROR_CODES.ALREADY_TEAM_MEMBER + ? "duplicate-account" + : "error"; + } + return "error"; + } + }; + + /** POST /invitations/resend for one account (drawer action). */ + const resendCode = (userId: string) => resend.mutateAsync(userId); + + /** POST /invitations/resend (per target) — status never changes (D10). + Selection stays intact on partial failure so the user can retry. */ + const resendCodes = async (targets: TUserListItem[]) => { + const results = await Promise.allSettled( + targets.map((u) => resend.mutateAsync(u.userId)), + ); + const failed = targets.filter((_, i) => results[i].status === "rejected"); + if (failed.length === 0) { + showNotice( + NOTICE_TEXT.resendInvitation.title, + NOTICE_TEXT.resendInvitation.success, + "info", + ); + return; + } + showBatchFailures( + failed.map((u) => ({ + account: u.account, + reason: NOTICE_TEXT.resendInvitation.failedReason, + })), + ); + }; + + /** DELETE /users (batch) — memberships, session token, and unused + invite codes go together (D13). Full success clears the targets + from selection and closes the drawer if it pointed at one of + them; partial failure shows the failure modal (account + reason) + and leaves the still-failed ids selected for retry. Throws only + on full failure, so MemberDeleteModal/the drawer's onDeleteMember + contract (resolve unless every target failed) is unaffected. */ + const deleteMembers = async (targets: TUserListItem[]) => { + const userIds = targets.map((u) => u.userId); + const result = await deleteUsersMutation.mutateAsync(userIds); + const failedIds = new Set(result.failed.map((f) => f.id)); + const succeededIds = userIds.filter((id) => !failedIds.has(id)); + + setSelectedIds((prev) => { + const next = new Set(prev); + succeededIds.forEach((id) => next.delete(id)); + return next; + }); + onDeleted(succeededIds); + + if (result.failed.length === 0) { + showNotice( + NOTICE_TEXT.deleteMember.title, + NOTICE_TEXT.deleteMember.success, + "info", + ); + return; + } + if (succeededIds.length === 0) { + throw new Error("delete failed for every target"); + } + showBatchFailures( + toBatchFailureRows( + result.failed, + (id) => targets.find((u) => u.userId === id)?.account ?? id, + (code) => code, + ), + ); + }; + + return { + inviteMember, + resendCode, + resendCodes, + deleteMembers, + batchFailures, + closeBatchFailures, + }; +}; diff --git a/frontend/src/pages/SessionsPage.tsx b/frontend/src/pages/SessionsPage.tsx index 84097a9..f474884 100644 --- a/frontend/src/pages/SessionsPage.tsx +++ b/frontend/src/pages/SessionsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import Button from "@/components/elements/Button"; import Dropdown from "@/components/elements/Dropdown"; @@ -6,14 +6,27 @@ import Feedback from "@/components/elements/Feedback"; import Pagination from "@/components/elements/Pagination"; import Table from "@/components/table/Table"; import TableCell from "@/components/table/TableCell"; +import TableEmptyRow from "@/components/table/TableEmptyRow"; import TableFoot from "@/components/table/TableFoot"; import TableHead from "@/components/table/TableHead"; import TableHeaderCell from "@/components/table/TableHeaderCell"; +import TableLoadingRow from "@/components/table/TableLoadingRow"; import TableRow from "@/components/table/TableRow"; import { useInvitationHistoryQuery } from "@/hooks/queries/useInvitationHistoryQuery"; +import { + useServerPagination, + useSyncPaginationTotal, +} from "@/hooks/useServerPagination"; import { cn } from "@/utils/cn"; import { formatDateTime } from "@/utils/formatDate"; -import { BTN_TEXT } from "@/constants/commonConstants"; +import { + ARIA_LABELS, + BTN_TEXT, + DEFAULT_PAGE_SIZE, + FEEDBACK_TEXT, + PAGE_TITLES, + TABLE_HEADERS, +} from "@/constants/commonConstants"; import type { TDropdownOption } from "@/types/commonTypes"; const styles = { @@ -26,14 +39,11 @@ const styles = { sort query params (console API design §6). No status filter or issuance button: issuance lives in user/team management. */ const SORT_OPTIONS: TDropdownOption[] = [ - { value: "username", label: "멤버 이름" }, + { value: "username", label: TABLE_HEADERS.memberName }, { value: "issued_at", label: "최근 발급 시간" }, - { value: "last_access", label: "최근 접속 시간" }, + { value: "last_access", label: TABLE_HEADERS.lastAccess }, ]; -/* 10 rows per page, fixed (SC-16 no.4) — the ?size=10 query param. */ -const PAGE_SIZE = 10; - /** * SessionsPage is the session management screen (SC-16): the token * issuance/access history table (state A) with a 3-way sort and fixed @@ -44,37 +54,24 @@ const PAGE_SIZE = 10; */ const SessionsPage = () => { const [sort, setSort] = useState("last_access"); - const [page, setPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - const currentPage = Math.min(page, totalPages); - const historyQuery = useInvitationHistoryQuery(sort, currentPage, PAGE_SIZE); + const { page, totalPages, setPage, resetPage, syncTotal } = + useServerPagination(); + const historyQuery = useInvitationHistoryQuery(sort, page, DEFAULT_PAGE_SIZE); const rows = historyQuery.data?.items ?? []; const total = historyQuery.data?.total ?? 0; - - /* totalPages tracks the last response's total (a page/sort transition - keeps the previous value via keepPreviousData until the new page - resolves); currentPage clamps against it before the query call - above, so the request itself is always in range. This effect only - corrects the stored `page` once totalPages shrinks (e.g. a sort - change reduces the result count), so Pagination and later renders - resume from a valid value instead of the stale, too-high one. */ - useEffect(() => { - const nextTotalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); - setTotalPages(nextTotalPages); - if (page > nextTotalPages) setPage(nextTotalPages); - }, [total, page]); + useSyncPaginationTotal(syncTotal, total); /* Sort change resets to page 1 (SC-16 no.4). */ const changeSort = (value: string) => { setSort(value); - setPage(1); + resetPage(); }; /* ── SC-16 state B — 조회 실패 ──────────────────────────────────── */ if (historyQuery.isError) { return ( - + { default left-aligned 92px row. */ className="flex min-h-45 flex-col items-center justify-center text-center" title="이력 정보를 불러올 수 없습니다." - description="새로고침 후 다시 시도해 주세요." + description={FEEDBACK_TEXT.refreshRetry} action={ { /* ── SC-16 state A — 기본 ───────────────────────────────────────── */ return ( - + { value={sort} onChange={changeSort} size="sm" - ariaLabel="정렬" + ariaLabel={ARIA_LABELS.sort} className="w-40" /> } foot={ @@ -135,30 +132,20 @@ const SessionsPage = () => { {/* Fixed column widths — auto layout would resize per page's content and shift the headers while paginating. */} - 사용자 - 발급 시간 - 최근 접속 시간 + + {TABLE_HEADERS.user} + + + {TABLE_HEADERS.issuedAt} + + + {TABLE_HEADERS.lastAccess} + - {historyQuery.isPending && ( - - - 불러오는 중… - - - )} + {historyQuery.isPending && } {!historyQuery.isPending && total === 0 && ( - - - 이력이 없습니다. - - + 이력이 없습니다. )} {rows.map((row) => ( /* Reissues are separate rows (D11) — username alone is not diff --git a/frontend/src/pages/TeamsPage.tsx b/frontend/src/pages/TeamsPage.tsx index 7ed5b80..a1e78b4 100644 --- a/frontend/src/pages/TeamsPage.tsx +++ b/frontend/src/pages/TeamsPage.tsx @@ -7,18 +7,14 @@ import SearchInput from "@/components/elements/SearchInput"; import CreateTeamModal from "@/components/teams/CreateTeamModal"; import OrgChart from "@/components/teams/OrgChart"; import TreeDetailView from "@/components/teams/TreeDetailView"; -import { useCreateTeamMutation } from "@/hooks/mutations/useTeamMutations"; import { useTeamsTreeQuery } from "@/hooks/queries/useTeamsTreeQuery"; -import { parseErrorCode } from "@/api/parseError"; +import { useTeamCrud } from "@/hooks/useTeamCrud"; import { cn } from "@/utils/cn"; -import { BTN_TEXT } from "@/constants/commonConstants"; -import { useNoticeStore } from "@/stores/noticeStore"; - -/** Create-team error codes → SC-07 copy (shared with TreeDetailView). */ -const CREATE_TEAM_REASON: Record = { - TEAM_NAME_DUPLICATE: "같은 상위 팀에 동일한 이름이 이미 있습니다.", - TEAM_NAME_INVALID: "팀 이름 형식이 올바르지 않습니다.", -}; +import { + BTN_TEXT, + FEEDBACK_TEXT, + PAGE_TITLES, +} from "@/constants/commonConstants"; const feedbackPanel = "m-6 flex min-h-[340px] flex-col items-center justify-center gap-3 text-center"; @@ -57,28 +53,17 @@ const TeamsPage = () => { /* SC-06 state B (팀 0개) create action — the tree panel's [새 팀 만들기] is gone when there are no teams, so the empty panel owns the create - flow (same mutation/error mapping as TreeDetailView's SC-07). */ + flow (same mutation/error mapping as TreeDetailView's SC-07, via the + shared useTeamCrud hook). */ const [createOpen, setCreateOpen] = useState(false); - const [createError, setCreateError] = useState(null); - const createTeam = useCreateTeamMutation(); - const showNotice = useNoticeStore((s) => s.showNotice); - - const handleCreate = (name: string, parentId: string | null) => { - setCreateError(null); - createTeam.mutate( - { name, parentId }, - { - onSuccess: () => { - setCreateOpen(false); - showNotice("팀 생성", "팀이 생성되었습니다.", "success"); - }, - onError: async (res) => { - const code = await parseErrorCode(res); - setCreateError(CREATE_TEAM_REASON[code] ?? "팀 생성에 실패했습니다."); - }, - }, - ); - }; + const { + teamError: createError, + clearTeamError, + handleCreate, + } = useTeamCrud({ + teamId: "", + onDone: () => setCreateOpen(false), + }); /* 트리·상세 is the entry view (its first top-level team auto-selected); 조직도 is reached by the view toggle. */ @@ -120,18 +105,18 @@ const TeamsPage = () => { if (isPending) { return ( - + ); } if (isError) { return ( - + { } return ( - + { btnColor="mintFilled" className="w-fit" handleClick={() => { - setCreateError(null); + clearTeamError(); setCreateOpen(true); }} /> diff --git a/frontend/src/pages/UITestPage.tsx b/frontend/src/pages/UITestPage.tsx index 6b4af32..6b80250 100644 --- a/frontend/src/pages/UITestPage.tsx +++ b/frontend/src/pages/UITestPage.tsx @@ -1,6 +1,5 @@ import { Fragment, useEffect, useState } from "react"; -import MembershipRow from "@/components/drawer/MembershipRow"; import Badge from "@/components/elements/Badge"; import Button from "@/components/elements/Button"; import Checkbox from "@/components/elements/Checkbox"; @@ -26,6 +25,8 @@ import TableRow from "@/components/table/TableRow"; import TableToolbar from "@/components/table/TableToolbar"; import TeamTree from "@/components/tree/TeamTree"; import TeamTreeFooter from "@/components/tree/TeamTreeFooter"; +import MembershipRow from "@/components/users/MembershipRow"; +import { useToastStore } from "@/state/store/toastStore"; import { cn } from "@/utils/cn"; import { BTN_TEXT } from "@/constants/commonConstants"; import { @@ -34,14 +35,11 @@ import { MEMBER_STATUS_VAR, WORKSPACE_STATUS_VAR, } from "@/constants/styleConstants"; -import type { - TMemberStatus, - TTeamNode, - TWorkspaceStatus, -} from "@/types/commonTypes"; +import { ROLE_OPTIONS } from "@/constants/teamConstants"; +import type { TMemberStatus } from "@/types/commonTypes"; import type { TBTNColor } from "@/types/styleTypes"; -import type { TInvitationStatus } from "@/types/teamTypes"; -import { useToastStore } from "@/stores/toastStore"; +import type { TInvitationStatus, TTeamViewNode } from "@/types/teamTypes"; +import type { TWorkspaceStatus } from "@/types/workspaceTypes"; type TUITestModal = "alert" | "confirm" | "wide" | "scroll" | null; @@ -56,12 +54,6 @@ const BTN_THEMES: { color: TBTNColor; role: string; text: string }[] = [ { color: "redOutline", role: "outline · danger", text: "멤버 삭제" }, ]; -const ROLE_OPTIONS = [ - { value: "edit", label: "edit" }, - { value: "write", label: "write" }, - { value: "read", label: "read" }, -]; - const TEAM_OPTIONS = [ { value: "platform", label: "플랫폼팀" }, { value: "fe", label: "프론트엔드", depth: 1 }, @@ -119,7 +111,7 @@ const SESSION_ROWS = [ { account: "a@corp.com", issuedAt: "2026-07-05 18:20", connectedAt: "" }, ]; -const TEAM_FIXTURE: TTeamNode[] = [ +const TEAM_FIXTURE: TTeamViewNode[] = [ { id: "platform", name: "Platform", @@ -207,7 +199,9 @@ const UITestPage = () => { const [tableSearch, setTableSearch] = useState(""); const [tablePage, setTablePage] = useState(1); const [treeQuery, setTreeQuery] = useState(""); - const [treeSelected, setTreeSelected] = useState(TEAM_FIXTURE[0]); + const [treeSelected, setTreeSelected] = useState( + TEAM_FIXTURE[0], + ); const [drawerOpen, setDrawerOpen] = useState(false); const [memberships, setMemberships] = useState( MEMBERSHIP_FIXTURE.map((m) => ({ ...m, role: m.baseRole })), diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index 0801305..4845a19 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -1,30 +1,22 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import Button from "@/components/elements/Button"; import Checkbox from "@/components/elements/Checkbox"; -import Dropdown from "@/components/elements/Dropdown"; import Feedback from "@/components/elements/Feedback"; -import MemberStatus from "@/components/elements/MemberStatus"; import Pagination from "@/components/elements/Pagination"; -import SearchInput from "@/components/elements/SearchInput"; import Table from "@/components/table/Table"; -import TableCell from "@/components/table/TableCell"; +import TableEmptyRow from "@/components/table/TableEmptyRow"; import TableFoot from "@/components/table/TableFoot"; import TableHead from "@/components/table/TableHead"; import TableHeaderCell from "@/components/table/TableHeaderCell"; -import TableRow from "@/components/table/TableRow"; +import TableLoadingRow from "@/components/table/TableLoadingRow"; import MemberBatchFailureModal from "@/components/teams/MemberBatchFailureModal"; -import { buildTeamOptions } from "@/components/teams/teamOptions"; import InviteMemberModal from "@/components/users/InviteMemberModal"; import MemberDeleteModal from "@/components/users/MemberDeleteModal"; import MemberDetailDrawer from "@/components/users/MemberDetailDrawer"; -import { CHIP_STATUS } from "@/components/users/memberStatusMap"; -import { - useCancelInvitation, - useDeleteUsers, - useInviteMutation, - useResendInvitation, -} from "@/hooks/mutations/useInvitationMutations"; +import UserRow from "@/components/users/UserRow"; +import UsersToolbar from "@/components/users/UsersToolbar"; +import { useCancelInvitation } from "@/hooks/mutations/useInvitationMutations"; import { useAddUserMembership, useBulkUserRoleChange, @@ -35,40 +27,40 @@ import { useTeamsTreeQuery } from "@/hooks/queries/useTeamsTreeQuery"; import { useUserQuery } from "@/hooks/queries/useUserQuery"; import { useUsersQuery } from "@/hooks/queries/useUsersQuery"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; -import { parseErrorCode } from "@/api/parseError"; -import { BTN_TEXT } from "@/constants/commonConstants"; +import { usePageScopedSelection } from "@/hooks/usePageScopedSelection"; +import { + useServerPagination, + useSyncPaginationTotal, +} from "@/hooks/useServerPagination"; +import { useUserBatchActions } from "@/hooks/useUserBatchActions"; +import { buildTeamOptions } from "@/utils/buildTeamOptions"; +import { SESSION_STATUS } from "@/constants/apiConstants"; +import { + ARIA_LABELS, + BTN_TEXT, + DEFAULT_PAGE_SIZE, + FEEDBACK_TEXT, + PAGE_TITLES, + TABLE_HEADERS, +} from "@/constants/commonConstants"; import type { TDropdownOption } from "@/types/commonTypes"; import type { TTeamMemberRole, TTeamTree } from "@/types/teamTypes"; -import type { - TInvitePayload, - TInviteResult, - TUserListItem, -} from "@/types/userTypes"; -import { useNoticeStore } from "@/stores/noticeStore"; const styles = { page: "flex flex-col gap-3.5 p-4", - /* Wide enough for typical names at the 40% column; anything longer - (up to the 50-char username cap) truncates with an ellipsis and - keeps the full name in the title tooltip. */ - usernameCell: "max-w-[400px] truncate", - overflowChip: - "border-border text-faint ml-1.5 rounded-full border px-2 text-xs", }; /* Filter/sort option sets (SC-11 no.2–3). "all" stands in for 전체. The list shows only the session axis, so the filter matches it. */ const STATUS_OPTIONS: TDropdownOption[] = [ { value: "all", label: "전체" }, - { value: "online", label: "온라인" }, - { value: "offline", label: "오프라인" }, + { value: SESSION_STATUS.online, label: "온라인" }, + { value: SESSION_STATUS.offline, label: "오프라인" }, ]; /* Depth indent stripped — the 150px filter trigger can't fit deep-tree indentation (it forces horizontal scrolling in the menu); teams list - flush left in tree order and long names truncate with an ellipsis. - Computed in-component (buildTeamOptions depends on the real teams - query result — no static dummy list anymore). */ + flush left in tree order and long names truncate with an ellipsis. */ const buildGroupOptions = (teams: TTeamTree): TDropdownOption[] => [ { value: "all", label: "전체" }, ...buildTeamOptions(teams).map(({ value, label }) => ({ value, label })), @@ -76,50 +68,31 @@ const buildGroupOptions = (teams: TTeamTree): TDropdownOption[] => [ const SORT_OPTIONS: TDropdownOption[] = [ { value: "last_invited", label: "최근 초대 코드 발송" }, - { value: "username", label: "멤버 이름" }, + { value: "username", label: TABLE_HEADERS.memberName }, ]; -/** First membership as "team · role"; the rest collapse into "+n". */ -const membershipSummary = (user: TUserListItem) => { - const [first, ...rest] = user.memberships; - return first - ? { summary: `${first.teamName} · ${first.role}`, extra: rest.length } - : { summary: "—", extra: 0 }; -}; - -/* 10 rows per page — caps the table height inside one screen; also the - ?size=10 GET /users query param. */ -const PAGE_SIZE = 10; - -/** Batch-delete failure reasons shown by account (DELETE /users). */ -const BATCH_REASON: Record = { - USER_NOT_FOUND: "사용자를 찾을 수 없습니다", -}; - /** * UsersPage is the user management screen (SC-11): cross-team user * list with search/filters/sort, bulk actions, and pagination, plus * the invite modal (SC-12), member detail drawer (SC-13), and delete * confirm (SC-15). The list is driven by GET /users (useUsersQuery) — * search/status/team/sort/page all become query params, and the - * server returns the already filtered/sorted/paged rows. The drawer's - * detail (GET /users/{id}), role/membership batch, session deactivate, - * invite/resend/cancel, and delete mutations are all wired to the API. + * server returns the already filtered/sorted/paged rows. Bulk flows + * (invite/resend/delete) live in useUserBatchActions; the drawer's + * membership machine lives in useMembershipDrafts. */ const UsersPage = () => { const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [groupFilter, setGroupFilter] = useState("all"); const [sort, setSort] = useState("last_invited"); - const [selectedIds, setSelectedIds] = useState>(new Set()); - const [page, setPage] = useState(1); const [inviteOpen, setInviteOpen] = useState(false); const [drawerUserId, setDrawerUserId] = useState(null); const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); - const [batchFailures, setBatchFailures] = useState< - { account: string; reason: string }[] | null - >(null); - const showNotice = useNoticeStore((state) => state.showNotice); + const { selectedIds, toggleOne, toggleAll, clearSelection, setSelectedIds } = + usePageScopedSelection(); + const { page, totalPages, setPage, resetPage, syncTotal } = + useServerPagination(); const { data: teams } = useTeamsTreeQuery(); const detailQuery = useUserQuery(drawerUserId ?? ""); @@ -127,12 +100,25 @@ const UsersPage = () => { const removeMemberships = useRemoveUserMemberships(drawerUserId ?? ""); const addMembership = useAddUserMembership(drawerUserId ?? ""); const deactivateSession = useDeactivateUserSession(drawerUserId ?? ""); - const invite = useInviteMutation(); - const resend = useResendInvitation(); const cancel = useCancelInvitation(); - const deleteUsersMutation = useDeleteUsers(); const groupOptions = buildGroupOptions(teams ?? []); + const { + inviteMember, + resendCode, + resendCodes, + deleteMembers, + batchFailures, + closeBatchFailures, + } = useUserBatchActions({ + setSelectedIds, + onDeleted: (deletedIds) => { + if (drawerUserId && deletedIds.includes(drawerUserId)) { + setDrawerUserId(null); + } + }, + }); + const debouncedSearch = useDebouncedValue(search, 300); const usersQuery = useUsersQuery({ search: debouncedSearch.trim(), @@ -140,17 +126,11 @@ const UsersPage = () => { teamId: groupFilter, sort, page, - size: PAGE_SIZE, + size: DEFAULT_PAGE_SIZE, }); const users = usersQuery.data?.items ?? []; const total = usersQuery.data?.total ?? 0; - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); - const currentPage = Math.min(page, totalPages); - - /* keep the requested page within range so the query never asks for an out-of-range page */ - useEffect(() => { - if (page > totalPages) setPage(totalPages); - }, [page, totalPages]); + useSyncPaginationTotal(syncTotal, total); /* A search/filter is active whenever it would narrow the server-side result — distinguishes "no members at all" (state B) from "no @@ -170,124 +150,29 @@ const UsersPage = () => { (setter: (value: T) => void) => (value: T) => { setter(value); - setPage(1); - setSelectedIds(new Set()); + resetPage(); + clearSelection(); }; /* Moving to another page clears the selection too — checks are page-scoped, and a checked row on the old page shouldn't ride along into a bulk action taken on a different page. */ const goToPage = (next: number) => { setPage(next); - setSelectedIds(new Set()); + clearSelection(); }; /* Select-all is page-scoped. */ const allSelected = users.length > 0 && users.every((u) => selectedIds.has(u.userId)); - const toggleAll = (checked: boolean) => - setSelectedIds((prev) => { - const next = new Set(prev); - users.forEach((u) => - checked ? next.add(u.userId) : next.delete(u.userId), - ); - return next; - }); - - const toggleOne = (userId: string, checked: boolean) => - setSelectedIds((prev) => { - const next = new Set(prev); - if (checked) next.add(userId); - else next.delete(userId); - return next; - }); - - /** POST /invitations — server judges duplicates and target states; only - the staged team/role sets are sent (buildInvitePreview's sub-team - expansion is display-only, the server performs the real expansion). */ - const inviteMember = async ( - payload: TInvitePayload, - ): Promise => { - try { - await invite.mutateAsync({ - account: payload.email, - username: payload.username, - memberships: payload.sets.map((set) => ({ - teamId: set.teamId, - role: set.role as TTeamMemberRole, - })), - }); - return "success"; - } catch (err) { - if (err instanceof Response) { - const code = await parseErrorCode(err); - return code === "ALREADY_TEAM_MEMBER" ? "duplicate-account" : "error"; - } - return "error"; - } - }; - - /** POST /invitations/resend (per target) — status never changes (D10). - Selection stays intact on partial failure so the user can retry. */ - const resendCodes = async (targets: TUserListItem[]) => { - const results = await Promise.allSettled( - targets.map((u) => resend.mutateAsync(u.userId)), - ); - const failed = targets.filter((_, i) => results[i].status === "rejected"); - if (failed.length === 0) { - showNotice("초대 코드 재전송", "초대 코드를 재전송했습니다.", "info"); - return; - } - setBatchFailures( - failed.map((u) => ({ account: u.account, reason: "재전송 실패" })), - ); - }; - - /** DELETE /users (batch) — memberships, session token, and unused - invite codes go together (D13). Full success clears the targets - from selection and closes the drawer if it pointed at one of - them; partial failure shows the failure modal (account + reason) - and leaves the still-failed ids selected for retry. Throws only - on full failure, so MemberDeleteModal/the drawer's onDeleteMember - contract (resolve unless every target failed) is unaffected. */ - const deleteMembers = async (targets: TUserListItem[]) => { - const userIds = targets.map((u) => u.userId); - const result = await deleteUsersMutation.mutateAsync(userIds); - const failedIds = new Set(result.failed.map((f) => f.id)); - const succeededIds = userIds.filter((id) => !failedIds.has(id)); - - setSelectedIds((prev) => { - const next = new Set(prev); - succeededIds.forEach((id) => next.delete(id)); - return next; - }); - if (drawerUserId && succeededIds.includes(drawerUserId)) { - setDrawerUserId(null); - } - - if (result.failed.length === 0) { - showNotice("멤버 삭제", "멤버를 삭제했습니다.", "info"); - return; - } - if (succeededIds.length === 0) { - throw new Error("delete failed for every target"); - } - setBatchFailures( - result.failed.map((f) => ({ - account: targets.find((u) => u.userId === f.id)?.account ?? f.id, - reason: BATCH_REASON[f.code] ?? f.code, - })), - ); - }; - /* ── SC-11 state C — 조회 실패 ──────────────────────────────────── */ if (usersQuery.isError) { return ( - + { all hidden) ─── */ if (!usersQuery.isPending && total === 0 && !hasActiveFilter) { return ( - + { } return ( - + { pagination never shifts the layout. */ scrollClassName="min-h-[526px]" toolbar={ - - - - - {/* filter/order dropdown */} - - - 정렬 기준 - - - - 멤버 상태 - - - - 팀 - - - - - - {/* Actions — second row, left-aligned (SC-11 no.4–6) */} - - resendCodes(selectedUsers)} - /> - setBulkDeleteOpen(true)} - /> - setInviteOpen(true)} - /> - - - + resendCodes(selectedUsers)} + onOpenBulkDelete={() => setBulkDeleteOpen(true)} + onOpenInvite={() => setInviteOpen(true)} + /> } foot={ @@ -435,77 +261,41 @@ const UsersPage = () => { + toggleAll( + users.map((u) => u.userId), + checked, + ) + } + ariaLabel={ARIA_LABELS.selectAll} /> {/* Fixed column widths — auto layout would resize per page's content and shift the headers while paginating. */} - 멤버 이름 - 멤버 상태 - 팀 (권한) + + {TABLE_HEADERS.memberName} + + + {TABLE_HEADERS.memberStatus} + + + {TABLE_HEADERS.teamWithRole} + - {usersQuery.isPending && ( - - - 불러오는 중… - - - )} + {usersQuery.isPending && } {!usersQuery.isPending && users.length === 0 && ( - - - 검색 결과가 없습니다. - - + 검색 결과가 없습니다. )} - {users.map((user) => { - const { summary, extra } = membershipSummary(user); - return ( - setDrawerUserId(user.userId)} - > - {/* Checkbox clicks must not open the drawer (SC-11 no.8) */} - - e.stopPropagation()}> - toggleOne(user.userId, checked)} - ariaLabel={`${user.account} 선택`} - /> - - - - {user.username} - - - - - - {summary} - {extra > 0 && ( - `${m.teamName} · ${m.role}`) - .join(", ")} - > - +{extra} - - )} - - - ); - })} + {users.map((user) => ( + toggleOne(user.userId, checked)} + onOpen={() => setDrawerUserId(user.userId)} + /> + ))} @@ -543,7 +333,7 @@ const UsersPage = () => { await deactivateSession.mutateAsync(); }} onResendCode={async () => { - await resend.mutateAsync(drawerUser.userId); + await resendCode(drawerUser.userId); }} onCancelInvitation={async () => { await cancel.mutateAsync(drawerUser.userId); @@ -570,7 +360,7 @@ const UsersPage = () => { {batchFailures && ( setBatchFailures(null)} + onClose={closeBatchFailures} /> )} diff --git a/frontend/src/pages/WorkspacePage.tsx b/frontend/src/pages/WorkspacePage.tsx index 7108f89..c66eeee 100644 --- a/frontend/src/pages/WorkspacePage.tsx +++ b/frontend/src/pages/WorkspacePage.tsx @@ -8,8 +8,9 @@ import { isTransitionalStatus, useWorkspaceQuery, } from "@/hooks/queries/useWorkspaceQuery"; -import { PATH_LIST } from "@/constants/commonConstants"; -import { useWorkspaceStore } from "@/stores/workspaceStore"; +import { useWorkspaceStore } from "@/state/store/workspaceStore"; +import { WORKSPACE_STATUS } from "@/constants/apiConstants"; +import { PAGE_TITLES, PATH_LIST } from "@/constants/commonConstants"; const panelClass = "m-6 flex min-h-[340px] flex-col items-center justify-center gap-3 text-center"; @@ -41,7 +42,7 @@ const WorkspacePage = () => { const [createdHere, setCreatedHere] = useState(false); useEffect(() => { - if (workspace?.status === "running" && createdHere) { + if (workspace?.status === WORKSPACE_STATUS.running && createdHere) { setCreatedHere(false); openModal(); } @@ -62,7 +63,7 @@ const WorkspacePage = () => { const exists = workspace != null; const transitional = exists && isTransitionalStatus(workspace.status); - if (isLoading) return ; + if (isLoading) return ; /* A workspace exists → go to the console. The one exception is our own create still provisioning: stay and keep the spinner until it runs. */ @@ -84,7 +85,7 @@ const WorkspacePage = () => { transitional; return ( - + {creating ? ( { "page", ); /* username asc — a@corp.com's row ("a 사용자") leads the first page. */ - expect(await screen.findByText(usernameOf("a@corp.com"))).toBeInTheDocument(); + expect( + await screen.findByText(usernameOf("a@corp.com")), + ).toBeInTheDocument(); }); it("shows the fixed page-size footer", async () => { diff --git a/frontend/src/pages/__tests__/UsersPage.test.tsx b/frontend/src/pages/__tests__/UsersPage.test.tsx index 0d2d464..67db614 100644 --- a/frontend/src/pages/__tests__/UsersPage.test.tsx +++ b/frontend/src/pages/__tests__/UsersPage.test.tsx @@ -8,9 +8,9 @@ import UsersPage from "@/pages/UsersPage"; import * as invitationAPIs from "@/api/invitationAPIs"; import * as teamAPIs from "@/api/teamAPIs"; import * as userAPIs from "@/api/userAPIs"; +import { useNoticeStore } from "@/state/store/noticeStore"; import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; import type { TUserListItem } from "@/types/userTypes"; -import { useNoticeStore } from "@/stores/noticeStore"; const jsonRes = (body: unknown) => ({ ok: true, json: async () => body }) as unknown as Response; @@ -182,7 +182,10 @@ describe("UsersPage", () => { total: 12, // > PAGE_SIZE → a second page exists page: 1, size: 10, - items: [user("u_1", "k@corp.com", "김철수"), user("u_2", "m@corp.com", "박미영")], + items: [ + user("u_1", "k@corp.com", "김철수"), + user("u_2", "m@corp.com", "박미영"), + ], }); const typer = userEvent.setup(); renderPage(); @@ -269,10 +272,7 @@ describe("UsersPage", () => { screen.getByPlaceholderText("user@corp.com"), "new@corp.com", ); - await typer.type( - screen.getByLabelText("사용자 이름 (username)"), - "김신입", - ); + await typer.type(screen.getByLabelText("사용자 이름 (username)"), "김신입"); await typer.click(screen.getByRole("button", { name: "세트 1 팀" })); await typer.click(screen.getByRole("option", { name: "백엔드" })); await typer.click(screen.getByRole("button", { name: "세트 1 role" })); diff --git a/frontend/src/pages/__tests__/WorkspacePage.test.tsx b/frontend/src/pages/__tests__/WorkspacePage.test.tsx index 70a873f..0e0601f 100644 --- a/frontend/src/pages/__tests__/WorkspacePage.test.tsx +++ b/frontend/src/pages/__tests__/WorkspacePage.test.tsx @@ -3,8 +3,8 @@ import { render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import WorkspacePage from "@/pages/WorkspacePage"; -import type { TWorkspace } from "@/types/commonTypes"; -import { useWorkspaceStore } from "@/stores/workspaceStore"; +import { useWorkspaceStore } from "@/state/store/workspaceStore"; +import type { TWorkspace } from "@/types/workspaceTypes"; /* Server state is mocked; the page renders only while no workspace exists (query → null), which is exactly the post-teardown handoff situation. */ @@ -70,8 +70,6 @@ describe("WorkspacePage", () => { expect( screen.getByText(/워크스페이스를 생성하는 중입니다/), ).toBeInTheDocument(); - expect( - screen.queryByText("생성된 워크스페이스가 없습니다."), - ).toBeNull(); + expect(screen.queryByText("생성된 워크스페이스가 없습니다.")).toBeNull(); }); }); 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/stores/__tests__/noticeStore.test.ts b/frontend/src/state/store/__tests__/noticeStore.test.ts similarity index 96% rename from frontend/src/stores/__tests__/noticeStore.test.ts rename to frontend/src/state/store/__tests__/noticeStore.test.ts index ddf01c3..c62862d 100644 --- a/frontend/src/stores/__tests__/noticeStore.test.ts +++ b/frontend/src/state/store/__tests__/noticeStore.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useNoticeStore } from "@/stores/noticeStore"; +import { useNoticeStore } from "@/state/store/noticeStore"; describe("noticeStore", () => { beforeEach(() => { diff --git a/frontend/src/stores/__tests__/workspaceStore.test.ts b/frontend/src/state/store/__tests__/workspaceStore.test.ts similarity index 94% rename from frontend/src/stores/__tests__/workspaceStore.test.ts rename to frontend/src/state/store/__tests__/workspaceStore.test.ts index 3c2ded8..81f089e 100644 --- a/frontend/src/stores/__tests__/workspaceStore.test.ts +++ b/frontend/src/state/store/__tests__/workspaceStore.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { useWorkspaceStore } from "@/stores/workspaceStore"; +import { useWorkspaceStore } from "@/state/store/workspaceStore"; const reset = () => useWorkspaceStore.setState({ modalOpen: false, deleteConfirmOpen: 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/stores/noticeStore.ts b/frontend/src/state/store/noticeStore.ts similarity index 100% rename from frontend/src/stores/noticeStore.ts rename to frontend/src/state/store/noticeStore.ts diff --git a/frontend/src/stores/toastStore.ts b/frontend/src/state/store/toastStore.ts similarity index 100% rename from frontend/src/stores/toastStore.ts rename to frontend/src/state/store/toastStore.ts diff --git a/frontend/src/stores/workspaceStore.ts b/frontend/src/state/store/workspaceStore.ts similarity index 100% rename from frontend/src/stores/workspaceStore.ts rename to frontend/src/state/store/workspaceStore.ts diff --git a/frontend/src/types/commonTypes.ts b/frontend/src/types/commonTypes.ts index 6250152..4085a6d 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; @@ -15,64 +10,5 @@ export type TDropdownOption = { /** Session chip state — the only status a list view renders. */ export type TMemberStatus = "online" | "offline"; -/** - * rune workspace lifecycle phase (wireframe SC-03 badge; console API - * `phase`). `provisioning` is the transient state right after create, - * before the endpoint/row count exist. - */ -export type TWorkspaceStatus = - | "provisioning" - | "running" - | "stopping" - | "stopped" - | "starting" - | "deleting" - | "error"; - -/** - * rune workspace record surfaced in the console (wireframe SC-02 state D), - * mapped from the API `GET /workspace` body. The workspace name is never - * exposed — it is a hash-like random value stored DB-side only. endpoint and - * rowCount are null until the workspace finishes provisioning. - */ -export type TWorkspace = { - status: TWorkspaceStatus; - endpoint: string | null; - rowCount: number | null; - /** - * The workspace exists in the cloud but was created by a different console - * install than this one (a reinstall minted a fresh team_secret), so its - * stored data is encrypted under a key we no longer hold and it can only be - * deleted + recreated. Absent/false on a healthy workspace. - */ - orphaned: boolean; - /** - * The data-plane credential expired and a background reconnect cannot - * re-bootstrap it — the user must drive a reconnect (POST /workspace). The - * cloud workspace itself is healthy; only the local engine link is stale. - * Mutually exclusive with orphaned (recreate supersedes reconnect). - */ - reconnectRequired: boolean; -}; - -/** Wire shape of `GET /workspace` (console API design 2026-07-13, §Workspace). */ -export type TWorkspaceWire = { - phase: TWorkspaceStatus; - endpointUrl: string | null; - rows: number | null; - /** true when the workspace no longer matches this console (reinstall). */ - orphaned?: boolean; - /** true when the data-plane credential expired and needs a user-driven reconnect. */ - reconnect?: boolean; -}; - -/** Recursive team-tree node (UIKIT AdminTeamNode, wireframe SC-06). */ -export type TTeamNode = { - id: string; - name: string; - members: number; - children?: TTeamNode[]; -}; - /** Toast tone — semantic colors are state, not decoration. */ export type TToastTone = "info" | "success" | "error"; diff --git a/frontend/src/types/teamTypes.ts b/frontend/src/types/teamTypes.ts index 41de255..1c0e96b 100644 --- a/frontend/src/types/teamTypes.ts +++ b/frontend/src/types/teamTypes.ts @@ -1,3 +1,9 @@ +import type { + INVITATION_STATUS, + SESSION_STATUS, + TEAM_MEMBER_ROLE, +} from "@/constants/apiConstants"; + export type TTeamNode = { id: string; name: string; @@ -9,15 +15,27 @@ export type TTeamNode = { export type TTeamTree = TTeamNode[]; -/** Grantable member role (Admin is console-account only — API §0). */ -export type TTeamMemberRole = "edit" | "write" | "read"; +/** Recursive team-tree node the tree/org views consume (UIKIT + AdminTeamNode, wireframe SC-06) — built client-side from the flat + TTeamTree. Distinct from TTeamNode, the flat wire row above. */ +export type TTeamViewNode = { + id: string; + name: string; + members: number; + children?: TTeamViewNode[]; +}; + +/** Grantable member role — derived from TEAM_MEMBER_ROLE (single source). */ +export type TTeamMemberRole = + (typeof TEAM_MEMBER_ROLE)[keyof typeof TEAM_MEMBER_ROLE]; -/** Invitation-code lifecycle status on the wire (common contract). */ +/** Invitation-code lifecycle status — derived from INVITATION_STATUS. */ export type TInvitationStatus = - "invite_pending" | "invite_expired" | "invite_redeemed"; + (typeof INVITATION_STATUS)[keyof typeof INVITATION_STATUS]; -/** Session-token liveness on the wire (common contract). */ -export type TSessionStatus = "online" | "offline"; +/** Session-token liveness — derived from SESSION_STATUS. */ +export type TSessionStatus = + (typeof SESSION_STATUS)[keyof typeof SESSION_STATUS]; /** GET /teams/{id} detail. */ export type TTeamDetail = { diff --git a/frontend/src/types/updateTypes.ts b/frontend/src/types/updateTypes.ts index 95c6ea7..501551a 100644 --- a/frontend/src/types/updateTypes.ts +++ b/frontend/src/types/updateTypes.ts @@ -1,6 +1,9 @@ -/** Lifecycle reported by the privileged rune-console update agent. */ +import type { SYSTEM_UPDATE_STATE } from "@/constants/apiConstants"; + +/** Lifecycle reported by the privileged rune-console update agent — + derived from SYSTEM_UPDATE_STATE (single source). */ export type TSystemUpdateState = - "idle" | "queued" | "running" | "failed" | "succeeded"; + (typeof SYSTEM_UPDATE_STATE)[keyof typeof SYSTEM_UPDATE_STATE]; /** Wire contract for GET /api/v1/system/update. */ export type TSystemUpdateStatus = { diff --git a/frontend/src/types/workspaceTypes.ts b/frontend/src/types/workspaceTypes.ts new file mode 100644 index 0000000..d495628 --- /dev/null +++ b/frontend/src/types/workspaceTypes.ts @@ -0,0 +1,47 @@ +import type { WORKSPACE_STATUS } from "@/constants/apiConstants"; + +/** + * rune workspace lifecycle phase (wireframe SC-03 badge; console API + * `phase`). `provisioning` is the transient state right after create, + * before the endpoint/row count exist. Derived from WORKSPACE_STATUS + * (single source). + */ +export type TWorkspaceStatus = + (typeof WORKSPACE_STATUS)[keyof typeof WORKSPACE_STATUS]; + +/** + * rune workspace record surfaced in the console (wireframe SC-02 state D), + * mapped from the API `GET /workspace` body. The workspace name is never + * exposed — it is a hash-like random value stored DB-side only. endpoint and + * rowCount are null until the workspace finishes provisioning. + */ +export type TWorkspace = { + status: TWorkspaceStatus; + endpoint: string | null; + rowCount: number | null; + /** + * The workspace exists in the cloud but was created by a different console + * install than this one (a reinstall minted a fresh team_secret), so its + * stored data is encrypted under a key we no longer hold and it can only be + * deleted + recreated. Absent/false on a healthy workspace. + */ + orphaned: boolean; + /** + * The data-plane credential expired and a background reconnect cannot + * re-bootstrap it — the user must drive a reconnect (POST /workspace). The + * cloud workspace itself is healthy; only the local engine link is stale. + * Mutually exclusive with orphaned (recreate supersedes reconnect). + */ + reconnectRequired: boolean; +}; + +/** Wire shape of `GET /workspace` (console API design 2026-07-13, §Workspace). */ +export type TWorkspaceWire = { + phase: TWorkspaceStatus; + endpointUrl: string | null; + rows: number | null; + /** true when the workspace no longer matches this console (reinstall). */ + orphaned?: boolean; + /** true when the data-plane credential expired and needs a user-driven reconnect. */ + reconnect?: boolean; +}; diff --git a/frontend/src/components/teams/__tests__/teamHierarchy.test.ts b/frontend/src/utils/__tests__/teamHierarchy.test.ts similarity index 93% rename from frontend/src/components/teams/__tests__/teamHierarchy.test.ts rename to frontend/src/utils/__tests__/teamHierarchy.test.ts index 937ce51..547386a 100644 --- a/frontend/src/components/teams/__tests__/teamHierarchy.test.ts +++ b/frontend/src/utils/__tests__/teamHierarchy.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - getTeamDescendantIds, - getTeamName, -} from "@/components/teams/teamHierarchy"; +import { getTeamDescendantIds, getTeamName } from "@/utils/teamHierarchy"; import type { TTeamTree } from "@/types/teamTypes"; /** Minimal 3-node chain: t_1 root → t_2 child → t_3 grandchild. */ diff --git a/frontend/src/components/teams/teamOptions.ts b/frontend/src/utils/buildTeamOptions.ts similarity index 59% rename from frontend/src/components/teams/teamOptions.ts rename to frontend/src/utils/buildTeamOptions.ts index 2415780..0342397 100644 --- a/frontend/src/components/teams/teamOptions.ts +++ b/frontend/src/utils/buildTeamOptions.ts @@ -1,19 +1,6 @@ import type { TDropdownOption } from "@/types/commonTypes"; import type { TTeamTree } from "@/types/teamTypes"; -/** Team name rule: digits, Hangul, Latin letters, and - _ only. */ -export const TEAM_NAME_PATTERN = /^[0-9A-Za-z가-힣_-]+$/; - -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" }, -]; - /** All teams in tree order with depth indent (for team-picker dropdowns). Pure function over the real `teams` query result — used by the team CRUD modals (create/rename/delete) and the Users page pickers. */ diff --git a/frontend/src/utils/email.ts b/frontend/src/utils/email.ts new file mode 100644 index 0000000..6087511 --- /dev/null +++ b/frontend/src/utils/email.ts @@ -0,0 +1,7 @@ +/** Email (account) field rules — shared by the invite form (SC-12) and the + * team add-member form (SC-06), mirroring the username.ts convention of + * keeping a field's pattern and its validation copy together. */ + +export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export const EMAIL_FORMAT_ERROR = "올바른 이메일 형식이 아닙니다."; diff --git a/frontend/src/utils/formatDate.ts b/frontend/src/utils/formatDate.ts index 2dea4a6..091991c 100644 --- a/frontend/src/utils/formatDate.ts +++ b/frontend/src/utils/formatDate.ts @@ -6,19 +6,23 @@ const KST_TIME_ZONE = "Asia/Seoul"; +/* Constructed once at module load — Intl.DateTimeFormat construction is + one of the costlier Intl operations and these run in every table cell + on every render; the options never change. */ +const KST_FORMATTER = new Intl.DateTimeFormat("en-US", { + timeZone: KST_TIME_ZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", +}); + /** Break an ISO instant into zero-padded KST calendar parts. */ const kstParts = (iso: string): Record => { - const formatter = new Intl.DateTimeFormat("en-US", { - timeZone: KST_TIME_ZONE, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hourCycle: "h23", - }); const parts: Record = {}; - for (const { type, value } of formatter.formatToParts(new Date(iso))) { + for (const { type, value } of KST_FORMATTER.formatToParts(new Date(iso))) { parts[type] = value; } return parts; diff --git a/frontend/src/components/users/invitePreview.ts b/frontend/src/utils/invitePreview.ts similarity index 95% rename from frontend/src/components/users/invitePreview.ts rename to frontend/src/utils/invitePreview.ts index 65e4027..c6d2832 100644 --- a/frontend/src/components/users/invitePreview.ts +++ b/frontend/src/utils/invitePreview.ts @@ -1,7 +1,4 @@ -import { - getTeamDescendantIds, - getTeamName, -} from "@/components/teams/teamHierarchy"; +import { getTeamDescendantIds, getTeamName } from "@/utils/teamHierarchy"; import type { TTeamTree } from "@/types/teamTypes"; import type { TInviteSet } from "@/types/userTypes"; diff --git a/frontend/src/utils/teamHierarchy.ts b/frontend/src/utils/teamHierarchy.ts new file mode 100644 index 0000000..1f6f410 --- /dev/null +++ b/frontend/src/utils/teamHierarchy.ts @@ -0,0 +1,70 @@ +import type { TTeamTree, TTeamViewNode } from "@/types/teamTypes"; + +/** + * Team-tree lookups over a flat `TTeamTree` — shared by the invite preview + * (SC-12 no.3) and the membership-removal sub-team notice (SC-14 no.2). + * Pure functions over the tree passed in (from `useTeamsTreeQuery`); trees + * are small, so no memoized id-map is kept at module scope. + */ + +/** Team name for `teamId`, or the id itself if the team is unknown. */ +export const getTeamName = (teams: TTeamTree, teamId: string): string => + teams.find((team) => team.id === teamId)?.name ?? teamId; + +/** All descendant ids of a team, in depth-first tree order. */ +export const getTeamDescendantIds = ( + teams: TTeamTree, + teamId: string, +): string[] => + (teams.find((team) => team.id === teamId)?.childrenIds ?? []).flatMap( + (childId) => [childId, ...getTeamDescendantIds(teams, childId)], + ); + +/** + * GET /teams/tree returns flat nodes — the client builds the recursive + * TTeamViewNode shape the TeamTree component consumes (API design §3). + * Single pass over a children index (not a filter per parent), so the + * build stays linear in team count. Callers memoize per teams array. + */ +export const buildTeamNodes = (teams: TTeamTree): TTeamViewNode[] => { + const childrenOf = new Map(); + for (const team of teams) { + const siblings = childrenOf.get(team.parentId); + if (siblings) siblings.push(team); + else childrenOf.set(team.parentId, [team]); + } + const build = (parentId: string | null): TTeamViewNode[] => + (childrenOf.get(parentId) ?? []).map((team) => ({ + id: team.id, + name: team.name, + members: team.memberCount, + children: team.childCount > 0 ? build(team.id) : undefined, + })); + return build(null); +}; + +/** Depth-first lookup in a built view-node tree. */ +export const findTeamNode = ( + nodes: TTeamViewNode[], + id: string, +): TTeamViewNode | undefined => + nodes.reduce( + (found, node) => + found ?? (node.id === id ? node : findTeamNode(node.children ?? [], id)), + undefined, + ); + +/** Ancestor ids of a team — expanded so a selection handed off from + the org chart is actually visible in the tree. */ +export const ancestorIds = ( + flatById: Map, + teamId: string, +): string[] => { + const ids: string[] = []; + let parentId = flatById.get(teamId)?.parentId; + while (parentId) { + ids.push(parentId); + parentId = flatById.get(parentId)?.parentId; + } + return ids; +};
다음 멤버십을 제거합니다:
다음 멤버의 권한을 변경합니다:
+ 상위 팀: {parentName} | 하위 팀: {childrenLabel} | 멤버: {memberCount}명 + | 생성일: {formatDate(createdAt)} +
- 상위 팀: {parentName} | 하위 팀: {childrenLabel} | 멤버:{" "} - {memberCount}명 | 생성일: {formatDate(detail?.createdAt)} -
{account}의 미사용 초대 코드가 모두 만료됩니다. 유저는 삭제되지 않습니다.
{DELETE_FAILED_MESSAGE}
{REMOVE_FAILED_MESSAGE}
{account}의 세션을 비활성화하시겠습니까? 모든 MCP 세션이 종료됩니다.
워크스페이스를 삭제하시겠습니까? 삭제 후에는 되돌릴 수 없습니다.
기존 워크스페이스를 삭제하는 중입니다… 삭제가 완료되면 워크스페이스 생성을 시작합니다.
콘솔이 재설치되어 이 워크스페이스와 연결할 수 없습니다. 기존에 저장된 데이터는 이전 보안 키로 암호화되어 복구할 수 없습니다. @@ -169,7 +171,7 @@ const WorkspaceModal = () => { 삭제 후 재생성하면 빈 워크스페이스로 다시 시작합니다.
워크스페이스 연결이 만료되었습니다. 재연결하여 데이터 플레인을 다시 활성화해 주세요.
워크스페이스 정보를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요. @@ -256,11 +260,11 @@ const WorkspaceModal = () => { return ( - + {/* Lifecycle actions sit at the content's top-right as quiet TextButtons — the info fields carry the primary reading weight. */} - {status === "stopped" ? ( + {status === WORKSPACE_STATUS.stopped ? ( { queryState = { data: { ...RUNNING, orphaned: true }, isError: false }; render(); expect( - screen.getByText(/콘솔이 재설치되어 이 워크스페이스와 연결할 수 없습니다/), + screen.getByText( + /콘솔이 재설치되어 이 워크스페이스와 연결할 수 없습니다/, + ), ).toBeInTheDocument(); expect( screen.getByRole("button", { name: BTN_TEXT.recreate }), @@ -197,7 +199,9 @@ describe("WorkspaceModal", () => { recreateState = { isPending: false, isError: true }; render(); expect( - screen.getByText("워크스페이스 재생성에 실패했습니다. 다시 시도해 주세요."), + screen.getByText( + "워크스페이스 재생성에 실패했습니다. 다시 시도해 주세요.", + ), ).toBeInTheDocument(); expect( screen.queryByRole("button", { name: BTN_TEXT.recreate }), diff --git a/frontend/src/constants/apiConstants.ts b/frontend/src/constants/apiConstants.ts new file mode 100644 index 0000000..e60b0ad --- /dev/null +++ b/frontend/src/constants/apiConstants.ts @@ -0,0 +1,67 @@ +/** + * Wire-contract vocabulary shared with the console API — the single source + * for status/role/error-code string values. Components must reference these + * (e.g. `INVITATION_STATUS.pending`) instead of typing the raw literal, so a + * backend value rename is a one-line change here and every typo is a compile + * error. The matching union types are derived from these objects in types/ + * (e.g. TInvitationStatus), keeping constant and type in lockstep. + */ + +/** Invitation-code lifecycle status on the wire (common contract). */ +export const INVITATION_STATUS = { + pending: "invite_pending", + expired: "invite_expired", + redeemed: "invite_redeemed", +} as const; + +/** Session-token liveness on the wire (common contract). */ +export const SESSION_STATUS = { + online: "online", + offline: "offline", +} as const; + +/** rune workspace lifecycle phase (console API `phase`). */ +export const WORKSPACE_STATUS = { + provisioning: "provisioning", + running: "running", + stopping: "stopping", + stopped: "stopped", + starting: "starting", + deleting: "deleting", + error: "error", +} as const; + +/** Lifecycle reported by the privileged rune-console update agent. */ +export const SYSTEM_UPDATE_STATE = { + idle: "idle", + queued: "queued", + running: "running", + failed: "failed", + succeeded: "succeeded", +} as const; + +/** Grantable member role (Admin is console-account only — API §0). */ +export const TEAM_MEMBER_ROLE = { + edit: "edit", + write: "write", + read: "read", +} as const; + +/** + * Backend error codes surfaced through the shared error envelope + * (parseErrorCode). Keys mirror the wire value verbatim so call sites read + * the same as the API design doc. + */ +export const ERROR_CODES = { + ALREADY_TEAM_MEMBER: "ALREADY_TEAM_MEMBER", + CANNOT_INVITE_ADMIN: "CANNOT_INVITE_ADMIN", + INVITATION_NOT_PENDING: "INVITATION_NOT_PENDING", + MAIL_UPSTREAM_ERROR: "MAIL_UPSTREAM_ERROR", + NOT_TEAM_MEMBER: "NOT_TEAM_MEMBER", + SESSION_NOT_ACTIVE: "SESSION_NOT_ACTIVE", + TEAM_HAS_CHILDREN: "TEAM_HAS_CHILDREN", + TEAM_NAME_DUPLICATE: "TEAM_NAME_DUPLICATE", + TEAM_NAME_INVALID: "TEAM_NAME_INVALID", + TEAM_NOT_FOUND: "TEAM_NOT_FOUND", + USER_NOT_FOUND: "USER_NOT_FOUND", +} as const; diff --git a/frontend/src/constants/commonConstants.ts b/frontend/src/constants/commonConstants.ts index d5bc00f..775d11a 100644 --- a/frontend/src/constants/commonConstants.ts +++ b/frontend/src/constants/commonConstants.ts @@ -7,6 +7,11 @@ export const BRAND_WORDMARK = "RUNE CONSOLE"; * workspace; the SC-02 modal renders usage as rowCount / max (percent). */ export const WORKSPACE_MAX_MEMORIES = 1000; +/** DEFAULT_PAGE_SIZE is the fixed rows-per-page for every list table + * (users, sessions, team members) — caps the table height inside one + * screen and goes out as the ?size= query param on the list endpoints. */ +export const DEFAULT_PAGE_SIZE = 10; + /** BTN_TEXT is the single source of truth for visible action-button labels * (Button `btnText` / TextButton) across the console screens, so a wording * change lands in one place. Icon-button aria-labels are intentionally out of @@ -59,13 +64,23 @@ export const BTN_TEXT = { deleteMember: "멤버 삭제", } as const; +/** PAGE_TITLES is the page/section vocabulary — shared by the main nav, + * each page's , and the workspace modal title, so the + * same screen is never named two different things. */ +export const PAGE_TITLES = { + teams: "팀 관리", + users: "멤버 관리", + sessions: "세션 기록", + workspace: "워크스페이스 관리", +} as const; + /** MODAL_TITLES is the single source of truth for ModalLayout titles across * the console modals, mirroring BTN_TEXT so a wording change lands in one * place. Titles that embed a name or count are functions; the rest are plain * strings. */ export const MODAL_TITLES = { // Workspace - workspaceManage: "워크스페이스 관리", + workspaceManage: PAGE_TITLES.workspace, workspaceDelete: "워크스페이스 삭제", workspaceOrphaned: "워크스페이스 재생성 필요", workspaceReconnect: "워크스페이스 재연결 필요", @@ -96,11 +111,62 @@ export const PATH_LIST = { } as const; export const NAV_LIST = [ - { title: "팀 관리", url: PATH_LIST.teams }, - { title: "멤버 관리", url: PATH_LIST.users }, - { title: "세션 기록", url: PATH_LIST.sessions }, + { title: PAGE_TITLES.teams, url: PATH_LIST.teams }, + { title: PAGE_TITLES.users, url: PATH_LIST.users }, + { title: PAGE_TITLES.sessions, url: PATH_LIST.sessions }, ] as const; +/** TABLE_HEADERS is the column-header copy shared across the list tables, + * the modal tables, and the sort-option labels that mirror a column. */ +export const TABLE_HEADERS = { + memberName: "멤버 이름", + memberStatus: "멤버 상태", + team: "팀", + teamWithRole: "팀 (권한)", + role: "권한", + /* TreeDetailView's member table says 역할 while every other role column + says 권한 — kept verbatim pending a copy decision; unifying is a + one-line change here once decided. */ + roleAlt: "역할", + roleChange: "권한 변경", + joinedAt: "합류일", + account: "account", + reason: "사유", + user: "사용자", + issuedAt: "발급 시간", + lastAccess: "최근 접속 시간", +} as const; + +/** Form-field copy shared by the invite (SC-12) and add-member (SC-06) + * forms — labels are also how tests and screen readers find the fields. */ +export const INPUT_LABELS = { + emailAccount: "이메일 (account)", + username: "사용자 이름 (username)", +} as const; + +export const PLACEHOLDERS = { + selectTeam: "팀 선택", + selectRole: "권한 선택", + /** Team picker when every team is already joined (SC-13 add row). */ + noAddableTeam: "추가할 팀 없음", + emailExample: "user@corp.com", + username: "사용자 이름", +} as const; + +/** Icon/control aria-labels used on more than one screen — centralized so + * assistive tech hears the same name everywhere (they had already drifted: + * "전체 선택" vs "전체선택"). */ +export const ARIA_LABELS = { + selectAll: "전체 선택", + sort: "정렬", +} as const; + +/** Shared Feedback copy — per-screen titles stay local; only the copy that + * repeats across screens lives here. */ +export const FEEDBACK_TEXT = { + refreshRetry: "새로고침 후 다시 시도해 주세요.", +} as const; + export const QUERY_KEYS = { teamsTree: "teamsTree", users: "users", diff --git a/frontend/src/constants/errorConstants.ts b/frontend/src/constants/errorConstants.ts new file mode 100644 index 0000000..2e5d32b --- /dev/null +++ b/frontend/src/constants/errorConstants.ts @@ -0,0 +1,46 @@ +import { ERROR_CODES } from "@/constants/apiConstants"; + +/** + * Backend error code → user-facing Korean copy, shared by every screen that + * surfaces the shared error envelope (parseErrorCode). The same code can read + * differently per flow (e.g. USER_NOT_FOUND during add vs batch), so maps are + * grouped by context rather than merged into one — pick the map that matches + * the flow. For unmapped codes each call site picks its own fallback: the + * generic retry copy, or the raw backend code itself where that diagnostic + * detail is worth showing (member removal / user delete failure modals). + */ + +/** Duplicate-name copy — shared by the server reason map and the client-side + duplicate check in the create/rename team modals (must stay identical). */ +export const TEAM_NAME_DUPLICATE_TEXT = + "같은 상위 팀에 동일한 이름이 이미 있습니다."; + +/** Team CRUD failures (SC-06/07 — create · rename · delete). */ +export const TEAM_REASON: Record = { + [ERROR_CODES.TEAM_NAME_DUPLICATE]: TEAM_NAME_DUPLICATE_TEXT, + [ERROR_CODES.TEAM_NAME_INVALID]: "팀 이름 형식이 올바르지 않습니다.", + [ERROR_CODES.TEAM_HAS_CHILDREN]: "하위 팀이 있어 삭제할 수 없습니다.", +}; + +/** Per-target failure reasons from the batch endpoints (bulk role change, + membership removal, user delete) — listed in MemberBatchFailureModal. */ +export const BATCH_REASON: Record = { + [ERROR_CODES.USER_NOT_FOUND]: "사용자를 찾을 수 없습니다", + [ERROR_CODES.NOT_TEAM_MEMBER]: "팀 멤버가 아닙니다", + [ERROR_CODES.TEAM_NOT_FOUND]: "팀을 찾을 수 없습니다", +}; + +/** Generic retry copy for an unmapped batch code (e.g. a transient + INTERNAL). Used by the role-change flows; the removal/delete failure + modals instead surface the raw code as a diagnostic hint. */ +export const BATCH_REASON_FALLBACK = "처리에 실패했습니다. 다시 시도해 주세요."; + +/** Add-member flow failures (SC-06 팀에 멤버 추가) — the add context words + the same codes differently (USER_NOT_FOUND = unregistered account). */ +export const ADD_MEMBER_REASON: Record = { + [ERROR_CODES.ALREADY_TEAM_MEMBER]: "이미 초대된 사용자입니다.", + [ERROR_CODES.USER_NOT_FOUND]: "등록되지 않은 계정입니다.", + [ERROR_CODES.CANNOT_INVITE_ADMIN]: "콘솔 관리자 계정은 추가할 수 없습니다.", + [ERROR_CODES.MAIL_UPSTREAM_ERROR]: + "초대 코드 전송에 실패했습니다. 다시 시도해 주세요.", +}; diff --git a/frontend/src/constants/noticeConstants.ts b/frontend/src/constants/noticeConstants.ts new file mode 100644 index 0000000..f3ef8b5 --- /dev/null +++ b/frontend/src/constants/noticeConstants.ts @@ -0,0 +1,64 @@ +import { MODAL_TITLES } from "@/constants/commonConstants"; + +/** + * showNotice copy grouped per flow — {title, success, failure, ...} so a + * flow's wording lives in one place instead of inline at each call site. + * Titles reuse MODAL_TITLES where the notice reports the outcome of that + * modal's action; flows without a matching modal title keep their own. + * Keys beyond success/failure are code-specific bodies (e.g. alreadyMember + * for ALREADY_TEAM_MEMBER) picked by the call site's error handling. + */ +export const NOTICE_TEXT = { + resendInvitation: { + title: "초대 코드 재전송", + success: "초대 코드를 재전송했습니다.", + failure: "초대 코드 재전송에 실패했습니다. 다시 시도해 주세요.", + /** Per-account reason row in the batch-failure modal. */ + failedReason: "재전송 실패", + }, + addMembership: { + title: "팀 추가", + success: "팀에 추가되었습니다.", + alreadyMember: "이미 소속된 팀입니다.", + failure: "팀 추가에 실패했습니다. 다시 시도해 주세요.", + }, + /* Role-change and remove-failure results render INSIDE + RoleChangeConfirmModal/MembershipRemoveModal (SC-06 E-1/E-2) — only + the full-success removal toast goes through showNotice. */ + removeMembership: { + title: MODAL_TITLES.removeMembership, + success: "멤버십이 제거되었습니다.", + }, + deactivateSession: { + title: MODAL_TITLES.deactivateSession, + success: "세션을 비활성화했습니다.", + alreadyExpired: "이미 만료된 세션입니다.", + failure: "세션 비활성화에 실패했습니다. 다시 시도해 주세요.", + }, + cancelInvitation: { + title: MODAL_TITLES.cancelInvitation, + success: "초대를 취소했습니다.", + nothingToCancel: "취소할 초대가 없습니다.", + failure: "초대 취소에 실패했습니다. 다시 시도해 주세요.", + }, + createTeam: { + title: "팀 생성", + success: "팀이 생성되었습니다.", + }, + renameTeam: { + title: MODAL_TITLES.renameTeam, + success: "팀 이름이 변경되었습니다.", + }, + deleteTeam: { + title: "팀 삭제", + success: "팀이 삭제되었습니다.", + }, + addTeamMember: { + title: "멤버 추가", + success: "멤버를 추가했습니다.", + }, + deleteMember: { + title: "멤버 삭제", + success: "멤버를 삭제했습니다.", + }, +} as const; diff --git a/frontend/src/constants/styleConstants.ts b/frontend/src/constants/styleConstants.ts index fad01a1..9858dce 100644 --- a/frontend/src/constants/styleConstants.ts +++ b/frontend/src/constants/styleConstants.ts @@ -3,6 +3,12 @@ * Visual values are translated from UIKIT modules/rune-ui-buttons and * modules/rune-admin-kit CSS — UIKIT is the design source of truth. */ +import type { TMemberStatus } from "@/types/commonTypes"; +import type { TInvitationStatus } from "@/types/teamTypes"; +import type { TWorkspaceStatus } from "@/types/workspaceTypes"; + +/** Status → chip/badge presentation (label + text color). */ +type TStatusStyle = { label: string; color: string }; /* Form controls embed w-full: the parent container constrains width. Metrics are UIKIT values normalized to even px (project rule). */ @@ -79,18 +85,34 @@ export const BADGE_TONE_VAR = { neutral: "bg-muted-foreground/12 text-muted-foreground", } as const; -/* Session chips — the only status a list view shows. */ +/* Shared modal building blocks — the ModalLayout children every confirm + modal composes. One source so the copies can't drift (the body gap had + already split into gap-4 vs gap-5 before this was centralized). */ +export const MODAL_STYLE_VAR = { + /* Centered single-line message (alert/failure bodies). */ + message: "text-center text-base", + /* Vertical form/content stack. */ + body: "flex w-full flex-col gap-4", + /* Button row — one spacing for every confirm modal (the team/workspace + modals used to sit at gap-2 while the users flows used gap-4; unified + on gap-4, 2026-08-03). */ + footer: "flex w-full items-center gap-4", +} as const; + +/* Session chips — the only status a list view shows. The satisfies clause + keys this map to the status union: adding/renaming a status value is a + compile error here until the label map follows. */ export const MEMBER_STATUS_VAR = { online: { label: "온라인", color: "text-mint" }, offline: { label: "오프라인", color: "text-faint" }, -} as const; +} as const satisfies Record; /* Invitation-status labels — shown only in the member detail drawer. */ export const INVITATION_STATUS_VAR = { invite_pending: { label: "초대 수락 대기", color: "text-warning" }, invite_expired: { label: "초대 코드 만료", color: "text-faint" }, invite_redeemed: { label: "초대 코드 사용됨", color: "text-accent-blue" }, -} as const; +} as const satisfies Record; export const WORKSPACE_STATUS_VAR = { provisioning: { label: "생성 중", color: "text-warning" }, @@ -100,4 +122,4 @@ export const WORKSPACE_STATUS_VAR = { starting: { label: "재실행 중", color: "text-warning" }, deleting: { label: "삭제 중", color: "text-warning" }, error: { label: "사용 불가", color: "text-negative" }, -} as const; +} as const satisfies Record; diff --git a/frontend/src/constants/teamConstants.ts b/frontend/src/constants/teamConstants.ts new file mode 100644 index 0000000..ba95277 --- /dev/null +++ b/frontend/src/constants/teamConstants.ts @@ -0,0 +1,15 @@ +import { TEAM_MEMBER_ROLE } from "@/constants/apiConstants"; +import type { TDropdownOption } from "@/types/commonTypes"; + +/** Team name rule: digits, Hangul, Latin letters, and - _ only. */ +export const TEAM_NAME_PATTERN = /^[0-9A-Za-z가-힣_-]+$/; + +export const TEAM_NAME_RULE_TEXT = + "숫자·한글·영어와 - _ 만 사용할 수 있습니다."; + +/** Grantable member roles (Admin is console-account only — API §0). */ +export const ROLE_OPTIONS: TDropdownOption[] = [ + { 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 }, +]; diff --git a/frontend/src/components/users/memberStatusMap.ts b/frontend/src/constants/userConstants.ts similarity index 71% rename from frontend/src/components/users/memberStatusMap.ts rename to frontend/src/constants/userConstants.ts index 2b384f0..93475b1 100644 --- a/frontend/src/components/users/memberStatusMap.ts +++ b/frontend/src/constants/userConstants.ts @@ -1,9 +1,10 @@ +import { SESSION_STATUS } from "@/constants/apiConstants"; import type { TMemberStatus } from "@/types/commonTypes"; import type { TSessionStatus } from "@/types/teamTypes"; /** API session status → MemberStatus chip state. Identity today, but kept as a seam so the chip vocabulary can diverge from the wire later. */ export const CHIP_STATUS: Record = { - online: "online", - offline: "offline", + [SESSION_STATUS.online]: "online", + [SESSION_STATUS.offline]: "offline", }; diff --git a/frontend/src/hooks/__tests__/useBatchFailureModal.test.ts b/frontend/src/hooks/__tests__/useBatchFailureModal.test.ts new file mode 100644 index 0000000..d6b5319 --- /dev/null +++ b/frontend/src/hooks/__tests__/useBatchFailureModal.test.ts @@ -0,0 +1,55 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { + toBatchFailureRows, + useBatchFailureModal, +} from "@/hooks/useBatchFailureModal"; +import { BATCH_REASON_FALLBACK } from "@/constants/errorConstants"; + +describe("useBatchFailureModal", () => { + it("starts closed and opens with the given rows", () => { + const { result } = renderHook(() => useBatchFailureModal()); + expect(result.current.batchFailures).toBeNull(); + act(() => + result.current.showBatchFailures([{ account: "a", reason: "r" }]), + ); + expect(result.current.batchFailures).toEqual([ + { account: "a", reason: "r" }, + ]); + act(() => result.current.closeBatchFailures()); + expect(result.current.batchFailures).toBeNull(); + }); +}); + +describe("toBatchFailureRows", () => { + const failed = [ + { id: "u1", code: "USER_NOT_FOUND", message: "x" }, + { id: "u2", code: "INTERNAL", message: "y" }, + ]; + + it("maps known codes through BATCH_REASON and labels via labelOf", () => { + const rows = toBatchFailureRows( + failed, + (id) => `acct-${id}`, + () => BATCH_REASON_FALLBACK, + ); + expect(rows[0]).toEqual({ + account: "acct-u1", + reason: "사용자를 찾을 수 없습니다", + }); + expect(rows[1]).toEqual({ + account: "acct-u2", + reason: BATCH_REASON_FALLBACK, + }); + }); + + it("supports the raw-code fallback used by removal/delete flows", () => { + const rows = toBatchFailureRows( + failed, + (id) => id, + (code) => code, + ); + expect(rows[1].reason).toBe("INTERNAL"); + }); +}); diff --git a/frontend/src/hooks/__tests__/usePageScopedSelection.test.ts b/frontend/src/hooks/__tests__/usePageScopedSelection.test.ts new file mode 100644 index 0000000..2b267e5 --- /dev/null +++ b/frontend/src/hooks/__tests__/usePageScopedSelection.test.ts @@ -0,0 +1,44 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { usePageScopedSelection } from "@/hooks/usePageScopedSelection"; + +describe("usePageScopedSelection", () => { + it("toggles single ids on and off", () => { + const { result } = renderHook(() => usePageScopedSelection()); + act(() => result.current.toggleOne("a", true)); + act(() => result.current.toggleOne("b", true)); + expect(result.current.selectedIds).toEqual(new Set(["a", "b"])); + act(() => result.current.toggleOne("a", false)); + expect(result.current.selectedIds).toEqual(new Set(["b"])); + }); + + it("toggleAll adds and removes only the given ids", () => { + const { result } = renderHook(() => usePageScopedSelection()); + act(() => result.current.toggleOne("keep", true)); + act(() => result.current.toggleAll(["a", "b"], true)); + expect(result.current.selectedIds).toEqual(new Set(["keep", "a", "b"])); + act(() => result.current.toggleAll(["a", "b"], false)); + expect(result.current.selectedIds).toEqual(new Set(["keep"])); + }); + + it("clearSelection empties the set", () => { + const { result } = renderHook(() => usePageScopedSelection()); + act(() => result.current.toggleAll(["a", "b"], true)); + act(() => result.current.clearSelection()); + expect(result.current.selectedIds.size).toBe(0); + }); + + it("setSelectedIds supports batch-result reconciliation", () => { + const { result } = renderHook(() => usePageScopedSelection()); + act(() => result.current.toggleAll(["ok", "failed"], true)); + act(() => + result.current.setSelectedIds((prev) => { + const next = new Set(prev); + next.delete("ok"); + return next; + }), + ); + expect(result.current.selectedIds).toEqual(new Set(["failed"])); + }); +}); diff --git a/frontend/src/hooks/__tests__/useServerPagination.test.ts b/frontend/src/hooks/__tests__/useServerPagination.test.ts new file mode 100644 index 0000000..29edb2a --- /dev/null +++ b/frontend/src/hooks/__tests__/useServerPagination.test.ts @@ -0,0 +1,53 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { useServerPagination } from "@/hooks/useServerPagination"; + +describe("useServerPagination", () => { + it("starts on page 1 with one page until a total arrives", () => { + const { result } = renderHook(() => useServerPagination(10)); + expect(result.current.page).toBe(1); + expect(result.current.totalPages).toBe(1); + }); + + it("derives totalPages from the reported total", () => { + const { result } = renderHook(() => useServerPagination(10)); + act(() => result.current.syncTotal(35)); + expect(result.current.totalPages).toBe(4); + expect(result.current.page).toBe(1); + }); + + it("clamps the request page when the range shrinks", () => { + const { result } = renderHook(() => useServerPagination(10)); + act(() => result.current.syncTotal(50)); + act(() => result.current.setPage(5)); + expect(result.current.page).toBe(5); + /* A sort/filter change or deletion shrinks the result set. */ + act(() => result.current.syncTotal(21)); + expect(result.current.totalPages).toBe(3); + expect(result.current.page).toBe(3); + }); + + it("never exposes a page beyond totalPages even before the correction", () => { + const { result } = renderHook(() => useServerPagination(10)); + act(() => result.current.setPage(9)); + /* totalPages still 1 — the returned page must stay in range so the + query never asks for an out-of-range slice. */ + expect(result.current.page).toBe(1); + }); + + it("resetPage returns to page 1", () => { + const { result } = renderHook(() => useServerPagination(10)); + act(() => result.current.syncTotal(50)); + act(() => result.current.setPage(4)); + act(() => result.current.resetPage()); + expect(result.current.page).toBe(1); + }); + + it("treats an empty result as a single page", () => { + const { result } = renderHook(() => useServerPagination(10)); + act(() => result.current.syncTotal(0)); + expect(result.current.totalPages).toBe(1); + expect(result.current.page).toBe(1); + }); +}); diff --git a/frontend/src/hooks/mutations/useUpdateMutation.ts b/frontend/src/hooks/mutations/useUpdateMutation.ts index 25e8ce3..d6a72ed 100644 --- a/frontend/src/hooks/mutations/useUpdateMutation.ts +++ b/frontend/src/hooks/mutations/useUpdateMutation.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { postSystemUpdate } from "@/api/updateAPIs"; +import { SYSTEM_UPDATE_STATE } from "@/constants/apiConstants"; import { QUERY_KEYS } from "@/constants/commonConstants"; import type { TSystemUpdateStatus } from "@/types/updateTypes"; @@ -21,7 +22,7 @@ export const useUpdateMutation = () => { ? { ...current, targetVersion: version, - state: "queued", + state: SYSTEM_UPDATE_STATE.queued, } : current, ); diff --git a/frontend/src/hooks/queries/useUpdateQuery.ts b/frontend/src/hooks/queries/useUpdateQuery.ts index 0beb0b6..3fcb826 100644 --- a/frontend/src/hooks/queries/useUpdateQuery.ts +++ b/frontend/src/hooks/queries/useUpdateQuery.ts @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { getSystemUpdate } from "@/api/updateAPIs"; +import { SYSTEM_UPDATE_STATE } from "@/constants/apiConstants"; import { QUERY_KEYS } from "@/constants/commonConstants"; import type { TSystemUpdateStatus } from "@/types/updateTypes"; @@ -8,7 +9,7 @@ const ACTIVE_POLL_MS = 2000; const IDLE_POLL_MS = 60 * 60 * 1000; export const isSystemUpdateActive = (state: TSystemUpdateStatus["state"]) => - state === "queued" || state === "running"; + state === SYSTEM_UPDATE_STATE.queued || state === SYSTEM_UPDATE_STATE.running; /** * Checks for a release without disturbing the app when GitHub or the local diff --git a/frontend/src/hooks/queries/useWorkspaceQuery.ts b/frontend/src/hooks/queries/useWorkspaceQuery.ts index 01506ab..a275a22 100644 --- a/frontend/src/hooks/queries/useWorkspaceQuery.ts +++ b/frontend/src/hooks/queries/useWorkspaceQuery.ts @@ -1,22 +1,23 @@ import { useQuery } from "@tanstack/react-query"; import { getWorkspace } from "@/api/workspaceAPIs"; +import { WORKSPACE_STATUS } from "@/constants/apiConstants"; import { QUERY_KEYS } from "@/constants/commonConstants"; import type { - TWorkspaceStatus, TWorkspace, + TWorkspaceStatus, TWorkspaceWire, -} from "@/types/commonTypes"; +} from "@/types/workspaceTypes"; /** How often to re-poll GET /workspace while a phase is mid-transition. */ const POLL_MS = 10000; /** Phases mid-transition — the query keeps polling while the workspace sits here. */ export const isTransitionalStatus = (status: TWorkspaceStatus): boolean => - status === "provisioning" || - status === "stopping" || - status === "starting" || - status === "deleting"; + status === WORKSPACE_STATUS.provisioning || + status === WORKSPACE_STATUS.stopping || + status === WORKSPACE_STATUS.starting || + status === WORKSPACE_STATUS.deleting; /** * useWorkspaceQuery reads the singular workspace (SC-02). A 404 means "no diff --git a/frontend/src/hooks/useBatchFailureModal.ts b/frontend/src/hooks/useBatchFailureModal.ts new file mode 100644 index 0000000..28d378b --- /dev/null +++ b/frontend/src/hooks/useBatchFailureModal.ts @@ -0,0 +1,43 @@ +import { useState } from "react"; + +import { BATCH_REASON } from "@/constants/errorConstants"; +import type { TBatchResult } from "@/types/teamTypes"; + +/** One row of MemberBatchFailureModal: target label + failure copy. */ +export type TBatchFailureRow = { account: string; reason: string }; + +/** + * useBatchFailureModal owns the partial-failure surface shared by the + * batch endpoints (bulk role change, membership removal, user delete): + * non-null rows open MemberBatchFailureModal listing exactly what failed + * and why (API design — partial success is not an error). + */ +export const useBatchFailureModal = () => { + const [batchFailures, setBatchFailures] = useState( + null, + ); + + const closeBatchFailures = () => setBatchFailures(null); + + return { + batchFailures, + showBatchFailures: setBatchFailures, + closeBatchFailures, + }; +}; + +/** + * Maps a batch result's failures onto modal rows: a per-target label plus + * the shared BATCH_REASON copy. Unmapped codes fall back to whatever the + * caller chooses — the generic retry copy (role-change flows) or the raw + * backend code as a diagnostic hint (removal/delete flows). + */ +export const toBatchFailureRows = ( + failed: TBatchResult["failed"], + labelOf: (id: string) => string, + fallbackFor: (code: string) => string, +): TBatchFailureRow[] => + failed.map((f) => ({ + account: labelOf(f.id), + reason: BATCH_REASON[f.code] ?? fallbackFor(f.code), + })); diff --git a/frontend/src/hooks/useMembershipDrafts.ts b/frontend/src/hooks/useMembershipDrafts.ts new file mode 100644 index 0000000..3407273 --- /dev/null +++ b/frontend/src/hooks/useMembershipDrafts.ts @@ -0,0 +1,240 @@ +import { useState } from "react"; + +import { + toBatchFailureRows, + useBatchFailureModal, +} from "@/hooks/useBatchFailureModal"; +import { usePageScopedSelection } from "@/hooks/usePageScopedSelection"; +import { parseErrorCode } from "@/api/parseError"; +import { useNoticeStore } from "@/state/store/noticeStore"; +import { buildTeamOptions } from "@/utils/buildTeamOptions"; +import { getTeamDescendantIds } from "@/utils/teamHierarchy"; +import { ERROR_CODES } from "@/constants/apiConstants"; +import { BATCH_REASON_FALLBACK } from "@/constants/errorConstants"; +import { NOTICE_TEXT } from "@/constants/noticeConstants"; +import type { TBatchResult, TTeamTree } from "@/types/teamTypes"; +import type { TUserListItem } from "@/types/userTypes"; + +/** One membership row as rendered: server truth (baseRole) with the + staged edits (role pick, checkbox) applied on top. */ +export type TMembershipDraft = { + teamId: string; + teamName: string; + baseRole: string; + role: string; + checked: boolean; +}; + +interface UseMembershipDraftsOptions { + user: TUserListItem; + /** Real team tree (GET /teams/tree) — add picker + sub-team notice. */ + teams: TTeamTree; + onUpdateRoles: ( + changes: { teamId: string; role: string }[], + ) => Promise; + onRemoveMemberships: (teamIds: string[]) => Promise; + onAddMembership: (teamId: string, role: string) => Promise; +} + +/** + * useMembershipDrafts owns the SC-13 membership machine. Server truth + * (user.memberships) flows straight from props — never copied into + * state — so the fresher GET /users/{id} payload and every post-mutation + * refetch render immediately. Only the user's own edits are staged (role + * picks + checkbox selection), re-applied as a diff on top of whatever + * the server currently says. The confirm flows reconcile batch results: + * succeeded targets un-stage (the refetch delivers their new truth), + * failed ones stay staged/checked for a retry and surface in the + * batch-failure modal. + */ +export const useMembershipDrafts = ({ + user, + teams, + onUpdateRoles, + onRemoveMemberships, + onAddMembership, +}: UseMembershipDraftsOptions) => { + const [pendingRoles, setPendingRoles] = useState>( + new Map(), + ); + const { + selectedIds: checkedIds, + toggleOne: setChecked, + toggleAll: setAllChecked, + setSelectedIds: setCheckedIds, + } = usePageScopedSelection(); + const { batchFailures, showBatchFailures, closeBatchFailures } = + useBatchFailureModal(); + const showNotice = useNoticeStore((state) => state.showNotice); + + const memberships: TMembershipDraft[] = user.memberships.map((m) => ({ + teamId: m.teamId, + teamName: m.teamName, + baseRole: m.role, + role: pendingRoles.get(m.teamId) ?? m.role, + checked: checkedIds.has(m.teamId), + })); + + /* A staged pick equal to the (possibly refetched) server role is a + no-op and drops out of `changes` on its own. */ + const changes = memberships.filter((m) => m.role !== m.baseRole); + const selected = memberships.filter((m) => m.checked); + const allChecked = + memberships.length > 0 && memberships.every((m) => m.checked); + + /* Sub-team retention notice (SC-14 no.2): a selected team has a + descendant team whose membership stays after this removal. */ + const remainingIds = memberships + .filter((m) => !m.checked) + .map((m) => m.teamId); + const subteamNotice = selected.some((m) => + getTeamDescendantIds(teams, m.teamId).some((id) => + remainingIds.includes(id), + ), + ); + + /* Failure rows are labeled by team name — the drawer's batch targets + are this one user's memberships. */ + const teamNameOf = (teamId: string) => + memberships.find((m) => m.teamId === teamId)?.teamName ?? teamId; + + const stageRole = (teamId: string, role: string) => + setPendingRoles((prev) => new Map(prev).set(teamId, role)); + const resetStaged = () => setPendingRoles(new Map()); + + /* ── [팀 추가하기] picker row (SC-13 no.2) ─────────────────────── */ + const [addOpen, setAddOpen] = useState(false); + const [addTeamId, setAddTeamId] = useState(""); + const [addRole, setAddRole] = useState(""); + const [adding, setAdding] = useState(false); + + /* Teams the user already belongs to stay out of the add picker. + Depth indent stripped — the narrow drawer dropdown can't fit + deep-tree indentation. */ + const joinedIds = new Set(memberships.map((m) => m.teamId)); + const addableTeams = buildTeamOptions(teams) + .filter((o) => !joinedIds.has(o.value)) + .map(({ value, label }) => ({ value, label })); + + const resetAdd = () => { + setAddOpen(false); + setAddTeamId(""); + setAddRole(""); + }; + const toggleAddRow = () => (addOpen ? resetAdd() : setAddOpen(true)); + + const handleAdd = async () => { + setAdding(true); + try { + /* The mutation invalidates the user detail/list queries — the new + row arrives with the refetch, so nothing is mirrored locally. */ + await onAddMembership(addTeamId, addRole); + showNotice( + NOTICE_TEXT.addMembership.title, + NOTICE_TEXT.addMembership.success, + "info", + ); + resetAdd(); + } catch (err) { + const code = err instanceof Response ? await parseErrorCode(err) : ""; + showNotice( + NOTICE_TEXT.addMembership.title, + code === ERROR_CODES.ALREADY_TEAM_MEMBER + ? NOTICE_TEXT.addMembership.alreadyMember + : NOTICE_TEXT.addMembership.failure, + "error", + ); + } finally { + setAdding(false); + } + }; + + /* ── confirm flows (RoleChangeConfirmModal / MembershipRemoveModal) ── */ + const confirmRoleChanges = async () => { + const changedIds = changes.map((m) => m.teamId); + const result = await onUpdateRoles( + changes.map((m) => ({ teamId: m.teamId, role: m.role })), + ); + const failedIds = new Set(result.failed.map((f) => f.id)); + /* Applied roles come back with the invalidation refetch — drop their + staged picks and keep only the failed ones staged for a retry. */ + setPendingRoles((prev) => { + const next = new Map(prev); + for (const teamId of changedIds) { + if (!failedIds.has(teamId)) next.delete(teamId); + } + return next; + }); + if (result.failed.length > 0) { + showBatchFailures( + toBatchFailureRows( + result.failed, + teamNameOf, + () => BATCH_REASON_FALLBACK, + ), + ); + } + }; + + const confirmRemovals = async () => { + const removedIds = selected.map((m) => m.teamId); + const result = await onRemoveMemberships(removedIds); + const failedIds = new Set(result.failed.map((f) => f.id)); + /* Removed rows drop out with the invalidation refetch — clear their + staged edits; failed rows keep their check for a retry. */ + setCheckedIds((prev) => { + const next = new Set(prev); + for (const teamId of removedIds) { + if (!failedIds.has(teamId)) next.delete(teamId); + } + return next; + }); + setPendingRoles((prev) => { + const next = new Map(prev); + for (const teamId of removedIds) { + if (!failedIds.has(teamId)) next.delete(teamId); + } + return next; + }); + if (result.failed.length === 0) { + showNotice( + NOTICE_TEXT.removeMembership.title, + NOTICE_TEXT.removeMembership.success, + "success", + ); + } else { + showBatchFailures( + toBatchFailureRows( + result.failed, + teamNameOf, + () => BATCH_REASON_FALLBACK, + ), + ); + } + }; + + return { + memberships, + changes, + selected, + allChecked, + subteamNotice, + stageRole, + setChecked, + setAllChecked, + resetStaged, + addOpen, + addTeamId, + addRole, + adding, + addableTeams, + setAddTeamId, + setAddRole, + toggleAddRow, + handleAdd, + confirmRoleChanges, + confirmRemovals, + batchFailures, + closeBatchFailures, + }; +}; diff --git a/frontend/src/hooks/usePageScopedSelection.ts b/frontend/src/hooks/usePageScopedSelection.ts new file mode 100644 index 0000000..dbd17c9 --- /dev/null +++ b/frontend/src/hooks/usePageScopedSelection.ts @@ -0,0 +1,38 @@ +import { useState } from "react"; + +/** + * usePageScopedSelection owns a checkbox column's Set-of-ids selection + * (users page, team member table, drawer membership rows). + * + * "Page-scoped" is a caller contract: whatever changes the visible rows + * (page move, filter change, team switch) should call clearSelection so a + * checked row never rides along into a bulk action taken on a different + * slice. setSelectedIds is exposed for batch-result reconciliation — + * dropping succeeded targets while failed ones stay selected for a retry. + */ +export const usePageScopedSelection = () => { + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const toggleOne = (id: string, selected: boolean) => + setSelectedIds((prev) => { + const next = new Set(prev); + if (selected) next.add(id); + else next.delete(id); + return next; + }); + + /** Header select-all: add/remove the given (visible) ids in one shot. */ + const toggleAll = (ids: string[], selected: boolean) => + setSelectedIds((prev) => { + const next = new Set(prev); + ids.forEach((id) => { + if (selected) next.add(id); + else next.delete(id); + }); + return next; + }); + + const clearSelection = () => setSelectedIds(new Set()); + + return { selectedIds, toggleOne, toggleAll, clearSelection, setSelectedIds }; +}; diff --git a/frontend/src/hooks/useServerPagination.ts b/frontend/src/hooks/useServerPagination.ts new file mode 100644 index 0000000..9efab16 --- /dev/null +++ b/frontend/src/hooks/useServerPagination.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useState } from "react"; + +import { DEFAULT_PAGE_SIZE } from "@/constants/commonConstants"; + +/** + * useServerPagination owns the page state for a server-paged table. + * + * totalPages tracks the last response's total (kept as state, not derived, + * so a page/sort transition under keepPreviousData never flashes an interim + * value), and the returned `page` is clamped against it BEFORE the query + * call — an out-of-range request never fires. When a response shrinks the + * range (filter/sort change, deletions emptying the last page), the stored + * page is corrected so Pagination and later renders resume from a valid + * value instead of the stale, too-high one. + * + * Wiring: pass `page` to the list query, then report each response's total + * back with one effect — `useEffect(() => syncTotal(total), [total, + * syncTotal])`. + */ +export const useServerPagination = (pageSize: number = DEFAULT_PAGE_SIZE) => { + const [rawPage, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const page = Math.min(rawPage, totalPages); + + const syncTotal = useCallback( + (total: number) => { + const next = Math.max(1, Math.ceil(total / pageSize)); + setTotalPages(next); + setPage((prev) => Math.min(prev, next)); + }, + [pageSize], + ); + + const resetPage = useCallback(() => setPage(1), []); + + return { page, totalPages, setPage, resetPage, syncTotal, pageSize }; +}; + +/** Companion one-liner so callers don't hand-roll the report-back effect. */ +export const useSyncPaginationTotal = ( + syncTotal: (total: number) => void, + total: number, +) => { + useEffect(() => { + syncTotal(total); + }, [syncTotal, total]); +}; diff --git a/frontend/src/hooks/useStagedRoleEdits.ts b/frontend/src/hooks/useStagedRoleEdits.ts new file mode 100644 index 0000000..0ad4f57 --- /dev/null +++ b/frontend/src/hooks/useStagedRoleEdits.ts @@ -0,0 +1,78 @@ +import { useState } from "react"; + +import type { TTeamMemberRole } from "@/types/teamTypes"; + +/** + * useStagedRoleEdits owns the SC-06 staged role-edit machine: dropdown + * picks collect in pendingRoles and only apply on [변경사항 업데이트]; + * savedRoles is the committed baseline shown until the invalidation + * refetch delivers the server truth (the list query keeps previous data + * visible during the refetch). + */ +export const useStagedRoleEdits = () => { + const [pendingRoles, setPendingRoles] = useState< + Map + >(new Map()); + const [savedRoles, setSavedRoles] = useState>( + new Map(), + ); + + /** Committed role for a member — the staged baseline or the wire value. */ + const baseRole = (userId: string, fallback: TTeamMemberRole) => + savedRoles.get(userId) ?? fallback; + + /** Stage a dropdown pick; picking the base value back un-stages it. */ + const stageRole = ( + userId: string, + fallback: TTeamMemberRole, + nextRole: string, + ) => + setPendingRoles((prev) => { + const next = new Map(prev); + if (nextRole === baseRole(userId, fallback)) next.delete(userId); + else next.set(userId, nextRole as TTeamMemberRole); + return next; + }); + + /** [변경사항 초기화] — staged picks drop; the committed baseline stays. */ + const resetStaged = () => setPendingRoles(new Map()); + + /** Team switch — nothing staged or committed may leak across teams. */ + const resetAll = () => { + setPendingRoles(new Map()); + setSavedRoles(new Map()); + }; + + /** Full batch success — commit every staged pick into the baseline. */ + const applyAll = () => { + setSavedRoles((prev) => new Map([...prev, ...pendingRoles])); + setPendingRoles(new Map()); + }; + + /** Partial batch failure — commit only what succeeded and keep the + failed entries staged so the user can retry them. */ + const reconcileBatch = (failedIds: Set) => { + setSavedRoles( + (prev) => + new Map([ + ...prev, + ...[...pendingRoles.entries()].filter( + ([userId]) => !failedIds.has(userId), + ), + ]), + ); + setPendingRoles( + (prev) => new Map([...prev].filter(([userId]) => failedIds.has(userId))), + ); + }; + + return { + pendingRoles, + baseRole, + stageRole, + resetStaged, + resetAll, + applyAll, + reconcileBatch, + }; +}; diff --git a/frontend/src/hooks/useTeamCrud.ts b/frontend/src/hooks/useTeamCrud.ts new file mode 100644 index 0000000..440aec3 --- /dev/null +++ b/frontend/src/hooks/useTeamCrud.ts @@ -0,0 +1,118 @@ +import { useState } from "react"; + +import { + useCreateTeamMutation, + useDeleteTeamMutation, + useRenameTeamMutation, +} from "@/hooks/mutations/useTeamMutations"; +import { parseErrorCode } from "@/api/parseError"; +import { useNoticeStore } from "@/state/store/noticeStore"; +import { TEAM_REASON } from "@/constants/errorConstants"; +import { NOTICE_TEXT } from "@/constants/noticeConstants"; + +interface UseTeamCrudOptions { + /** Rename/delete target — pass "" when only the create flow is used + (the mutations are lazy, so an unused id never fires). */ + teamId: string; + /** Close the owning modal after a successful mutation. */ + onDone: () => void; + /** Post-delete hand-off (SC-08 — reselect another root team). */ + onDeleted?: () => void; +} + +/** + * useTeamCrud owns the team create/rename/delete orchestration shared by + * TreeDetailView (SC-07~09) and TeamsPage's empty-state create (SC-06 B): + * one TEAM_REASON error mapping into the modals' inline error, one + * success-notice wiring. teamError is reset on every attempt; callers + * clear it when opening/closing a modal so a stale error never leaks + * into a fresh one. + */ +export const useTeamCrud = ({ + teamId, + onDone, + onDeleted, +}: UseTeamCrudOptions) => { + const [teamError, setTeamError] = useState(null); + const createTeam = useCreateTeamMutation(); + const renameTeam = useRenameTeamMutation(teamId); + const deleteTeam = useDeleteTeamMutation(teamId); + const showNotice = useNoticeStore((state) => state.showNotice); + + const clearTeamError = () => setTeamError(null); + + const handleCreate = (name: string, parentId: string | null) => { + setTeamError(null); + createTeam.mutate( + { name, parentId }, + { + onSuccess: () => { + onDone(); + showNotice( + NOTICE_TEXT.createTeam.title, + NOTICE_TEXT.createTeam.success, + "success", + ); + }, + onError: async (res) => { + const code = await parseErrorCode(res); + setTeamError(TEAM_REASON[code] ?? "팀 생성에 실패했습니다."); + }, + }, + ); + }; + + const handleRename = (name: string) => { + setTeamError(null); + renameTeam.mutate( + { name }, + { + onSuccess: () => { + onDone(); + showNotice( + NOTICE_TEXT.renameTeam.title, + NOTICE_TEXT.renameTeam.success, + "success", + ); + }, + onError: async (res) => { + const code = await parseErrorCode(res); + setTeamError(TEAM_REASON[code] ?? "이름 변경에 실패했습니다."); + }, + }, + ); + }; + + const handleDelete = ( + action: "purge" | "transfer", + targetTeamId?: string, + ) => { + setTeamError(null); + deleteTeam.mutate( + { memoryAction: action, targetTeamId }, + { + onSuccess: () => { + onDone(); + showNotice( + NOTICE_TEXT.deleteTeam.title, + NOTICE_TEXT.deleteTeam.success, + "success", + onDeleted, + ); + }, + onError: async (res) => { + const code = await parseErrorCode(res); + setTeamError(TEAM_REASON[code] ?? "팀 삭제에 실패했습니다."); + }, + }, + ); + }; + + return { + teamError, + clearTeamError, + handleCreate, + handleRename, + handleDelete, + }; +}; diff --git a/frontend/src/hooks/useUserBatchActions.ts b/frontend/src/hooks/useUserBatchActions.ts new file mode 100644 index 0000000..536a866 --- /dev/null +++ b/frontend/src/hooks/useUserBatchActions.ts @@ -0,0 +1,149 @@ +import type { Dispatch, SetStateAction } from "react"; + +import { + useDeleteUsers, + useInviteMutation, + useResendInvitation, +} from "@/hooks/mutations/useInvitationMutations"; +import { + toBatchFailureRows, + useBatchFailureModal, +} from "@/hooks/useBatchFailureModal"; +import { parseErrorCode } from "@/api/parseError"; +import { useNoticeStore } from "@/state/store/noticeStore"; +import { ERROR_CODES } from "@/constants/apiConstants"; +import { NOTICE_TEXT } from "@/constants/noticeConstants"; +import type { TTeamMemberRole } from "@/types/teamTypes"; +import type { + TInvitePayload, + TInviteResult, + TUserListItem, +} from "@/types/userTypes"; + +interface UseUserBatchActionsOptions { + /** Selection reconciliation — deleted targets drop out, failed ones + stay selected for a retry. */ + setSelectedIds: Dispatch>>; + /** Called with the ids that were actually deleted (drawer close-out). */ + onDeleted: (deletedIds: string[]) => void; +} + +/** + * useUserBatchActions owns the SC-11 bulk flows — invite (SC-12), invite- + * code resend, and batch delete (SC-15) — including their notice/batch- + * failure surfaces. Pure orchestration over the invitation mutations; the + * page supplies selection reconciliation and the drawer close-out. + */ +export const useUserBatchActions = ({ + setSelectedIds, + onDeleted, +}: UseUserBatchActionsOptions) => { + const invite = useInviteMutation(); + const resend = useResendInvitation(); + const deleteUsersMutation = useDeleteUsers(); + const { batchFailures, showBatchFailures, closeBatchFailures } = + useBatchFailureModal(); + const showNotice = useNoticeStore((state) => state.showNotice); + + /** POST /invitations — server judges duplicates and target states; only + the staged team/role sets are sent (buildInvitePreview's sub-team + expansion is display-only, the server performs the real expansion). */ + const inviteMember = async ( + payload: TInvitePayload, + ): Promise => { + try { + await invite.mutateAsync({ + account: payload.email, + username: payload.username, + memberships: payload.sets.map((set) => ({ + teamId: set.teamId, + role: set.role as TTeamMemberRole, + })), + }); + return "success"; + } catch (err) { + if (err instanceof Response) { + const code = await parseErrorCode(err); + return code === ERROR_CODES.ALREADY_TEAM_MEMBER + ? "duplicate-account" + : "error"; + } + return "error"; + } + }; + + /** POST /invitations/resend for one account (drawer action). */ + const resendCode = (userId: string) => resend.mutateAsync(userId); + + /** POST /invitations/resend (per target) — status never changes (D10). + Selection stays intact on partial failure so the user can retry. */ + const resendCodes = async (targets: TUserListItem[]) => { + const results = await Promise.allSettled( + targets.map((u) => resend.mutateAsync(u.userId)), + ); + const failed = targets.filter((_, i) => results[i].status === "rejected"); + if (failed.length === 0) { + showNotice( + NOTICE_TEXT.resendInvitation.title, + NOTICE_TEXT.resendInvitation.success, + "info", + ); + return; + } + showBatchFailures( + failed.map((u) => ({ + account: u.account, + reason: NOTICE_TEXT.resendInvitation.failedReason, + })), + ); + }; + + /** DELETE /users (batch) — memberships, session token, and unused + invite codes go together (D13). Full success clears the targets + from selection and closes the drawer if it pointed at one of + them; partial failure shows the failure modal (account + reason) + and leaves the still-failed ids selected for retry. Throws only + on full failure, so MemberDeleteModal/the drawer's onDeleteMember + contract (resolve unless every target failed) is unaffected. */ + const deleteMembers = async (targets: TUserListItem[]) => { + const userIds = targets.map((u) => u.userId); + const result = await deleteUsersMutation.mutateAsync(userIds); + const failedIds = new Set(result.failed.map((f) => f.id)); + const succeededIds = userIds.filter((id) => !failedIds.has(id)); + + setSelectedIds((prev) => { + const next = new Set(prev); + succeededIds.forEach((id) => next.delete(id)); + return next; + }); + onDeleted(succeededIds); + + if (result.failed.length === 0) { + showNotice( + NOTICE_TEXT.deleteMember.title, + NOTICE_TEXT.deleteMember.success, + "info", + ); + return; + } + if (succeededIds.length === 0) { + throw new Error("delete failed for every target"); + } + showBatchFailures( + toBatchFailureRows( + result.failed, + (id) => targets.find((u) => u.userId === id)?.account ?? id, + (code) => code, + ), + ); + }; + + return { + inviteMember, + resendCode, + resendCodes, + deleteMembers, + batchFailures, + closeBatchFailures, + }; +}; diff --git a/frontend/src/pages/SessionsPage.tsx b/frontend/src/pages/SessionsPage.tsx index 84097a9..f474884 100644 --- a/frontend/src/pages/SessionsPage.tsx +++ b/frontend/src/pages/SessionsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import Button from "@/components/elements/Button"; import Dropdown from "@/components/elements/Dropdown"; @@ -6,14 +6,27 @@ import Feedback from "@/components/elements/Feedback"; import Pagination from "@/components/elements/Pagination"; import Table from "@/components/table/Table"; import TableCell from "@/components/table/TableCell"; +import TableEmptyRow from "@/components/table/TableEmptyRow"; import TableFoot from "@/components/table/TableFoot"; import TableHead from "@/components/table/TableHead"; import TableHeaderCell from "@/components/table/TableHeaderCell"; +import TableLoadingRow from "@/components/table/TableLoadingRow"; import TableRow from "@/components/table/TableRow"; import { useInvitationHistoryQuery } from "@/hooks/queries/useInvitationHistoryQuery"; +import { + useServerPagination, + useSyncPaginationTotal, +} from "@/hooks/useServerPagination"; import { cn } from "@/utils/cn"; import { formatDateTime } from "@/utils/formatDate"; -import { BTN_TEXT } from "@/constants/commonConstants"; +import { + ARIA_LABELS, + BTN_TEXT, + DEFAULT_PAGE_SIZE, + FEEDBACK_TEXT, + PAGE_TITLES, + TABLE_HEADERS, +} from "@/constants/commonConstants"; import type { TDropdownOption } from "@/types/commonTypes"; const styles = { @@ -26,14 +39,11 @@ const styles = { sort query params (console API design §6). No status filter or issuance button: issuance lives in user/team management. */ const SORT_OPTIONS: TDropdownOption[] = [ - { value: "username", label: "멤버 이름" }, + { value: "username", label: TABLE_HEADERS.memberName }, { value: "issued_at", label: "최근 발급 시간" }, - { value: "last_access", label: "최근 접속 시간" }, + { value: "last_access", label: TABLE_HEADERS.lastAccess }, ]; -/* 10 rows per page, fixed (SC-16 no.4) — the ?size=10 query param. */ -const PAGE_SIZE = 10; - /** * SessionsPage is the session management screen (SC-16): the token * issuance/access history table (state A) with a 3-way sort and fixed @@ -44,37 +54,24 @@ const PAGE_SIZE = 10; */ const SessionsPage = () => { const [sort, setSort] = useState("last_access"); - const [page, setPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - const currentPage = Math.min(page, totalPages); - const historyQuery = useInvitationHistoryQuery(sort, currentPage, PAGE_SIZE); + const { page, totalPages, setPage, resetPage, syncTotal } = + useServerPagination(); + const historyQuery = useInvitationHistoryQuery(sort, page, DEFAULT_PAGE_SIZE); const rows = historyQuery.data?.items ?? []; const total = historyQuery.data?.total ?? 0; - - /* totalPages tracks the last response's total (a page/sort transition - keeps the previous value via keepPreviousData until the new page - resolves); currentPage clamps against it before the query call - above, so the request itself is always in range. This effect only - corrects the stored `page` once totalPages shrinks (e.g. a sort - change reduces the result count), so Pagination and later renders - resume from a valid value instead of the stale, too-high one. */ - useEffect(() => { - const nextTotalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); - setTotalPages(nextTotalPages); - if (page > nextTotalPages) setPage(nextTotalPages); - }, [total, page]); + useSyncPaginationTotal(syncTotal, total); /* Sort change resets to page 1 (SC-16 no.4). */ const changeSort = (value: string) => { setSort(value); - setPage(1); + resetPage(); }; /* ── SC-16 state B — 조회 실패 ──────────────────────────────────── */ if (historyQuery.isError) { return ( - + { default left-aligned 92px row. */ className="flex min-h-45 flex-col items-center justify-center text-center" title="이력 정보를 불러올 수 없습니다." - description="새로고침 후 다시 시도해 주세요." + description={FEEDBACK_TEXT.refreshRetry} action={ { /* ── SC-16 state A — 기본 ───────────────────────────────────────── */ return ( - + { value={sort} onChange={changeSort} size="sm" - ariaLabel="정렬" + ariaLabel={ARIA_LABELS.sort} className="w-40" /> } foot={ @@ -135,30 +132,20 @@ const SessionsPage = () => { {/* Fixed column widths — auto layout would resize per page's content and shift the headers while paginating. */} - 사용자 - 발급 시간 - 최근 접속 시간 + + {TABLE_HEADERS.user} + + + {TABLE_HEADERS.issuedAt} + + + {TABLE_HEADERS.lastAccess} + - {historyQuery.isPending && ( - - - 불러오는 중… - - - )} + {historyQuery.isPending && } {!historyQuery.isPending && total === 0 && ( - - - 이력이 없습니다. - - + 이력이 없습니다. )} {rows.map((row) => ( /* Reissues are separate rows (D11) — username alone is not diff --git a/frontend/src/pages/TeamsPage.tsx b/frontend/src/pages/TeamsPage.tsx index 7ed5b80..a1e78b4 100644 --- a/frontend/src/pages/TeamsPage.tsx +++ b/frontend/src/pages/TeamsPage.tsx @@ -7,18 +7,14 @@ import SearchInput from "@/components/elements/SearchInput"; import CreateTeamModal from "@/components/teams/CreateTeamModal"; import OrgChart from "@/components/teams/OrgChart"; import TreeDetailView from "@/components/teams/TreeDetailView"; -import { useCreateTeamMutation } from "@/hooks/mutations/useTeamMutations"; import { useTeamsTreeQuery } from "@/hooks/queries/useTeamsTreeQuery"; -import { parseErrorCode } from "@/api/parseError"; +import { useTeamCrud } from "@/hooks/useTeamCrud"; import { cn } from "@/utils/cn"; -import { BTN_TEXT } from "@/constants/commonConstants"; -import { useNoticeStore } from "@/stores/noticeStore"; - -/** Create-team error codes → SC-07 copy (shared with TreeDetailView). */ -const CREATE_TEAM_REASON: Record = { - TEAM_NAME_DUPLICATE: "같은 상위 팀에 동일한 이름이 이미 있습니다.", - TEAM_NAME_INVALID: "팀 이름 형식이 올바르지 않습니다.", -}; +import { + BTN_TEXT, + FEEDBACK_TEXT, + PAGE_TITLES, +} from "@/constants/commonConstants"; const feedbackPanel = "m-6 flex min-h-[340px] flex-col items-center justify-center gap-3 text-center"; @@ -57,28 +53,17 @@ const TeamsPage = () => { /* SC-06 state B (팀 0개) create action — the tree panel's [새 팀 만들기] is gone when there are no teams, so the empty panel owns the create - flow (same mutation/error mapping as TreeDetailView's SC-07). */ + flow (same mutation/error mapping as TreeDetailView's SC-07, via the + shared useTeamCrud hook). */ const [createOpen, setCreateOpen] = useState(false); - const [createError, setCreateError] = useState(null); - const createTeam = useCreateTeamMutation(); - const showNotice = useNoticeStore((s) => s.showNotice); - - const handleCreate = (name: string, parentId: string | null) => { - setCreateError(null); - createTeam.mutate( - { name, parentId }, - { - onSuccess: () => { - setCreateOpen(false); - showNotice("팀 생성", "팀이 생성되었습니다.", "success"); - }, - onError: async (res) => { - const code = await parseErrorCode(res); - setCreateError(CREATE_TEAM_REASON[code] ?? "팀 생성에 실패했습니다."); - }, - }, - ); - }; + const { + teamError: createError, + clearTeamError, + handleCreate, + } = useTeamCrud({ + teamId: "", + onDone: () => setCreateOpen(false), + }); /* 트리·상세 is the entry view (its first top-level team auto-selected); 조직도 is reached by the view toggle. */ @@ -120,18 +105,18 @@ const TeamsPage = () => { if (isPending) { return ( - + ); } if (isError) { return ( - + { } return ( - + { btnColor="mintFilled" className="w-fit" handleClick={() => { - setCreateError(null); + clearTeamError(); setCreateOpen(true); }} /> diff --git a/frontend/src/pages/UITestPage.tsx b/frontend/src/pages/UITestPage.tsx index 6b4af32..6b80250 100644 --- a/frontend/src/pages/UITestPage.tsx +++ b/frontend/src/pages/UITestPage.tsx @@ -1,6 +1,5 @@ import { Fragment, useEffect, useState } from "react"; -import MembershipRow from "@/components/drawer/MembershipRow"; import Badge from "@/components/elements/Badge"; import Button from "@/components/elements/Button"; import Checkbox from "@/components/elements/Checkbox"; @@ -26,6 +25,8 @@ import TableRow from "@/components/table/TableRow"; import TableToolbar from "@/components/table/TableToolbar"; import TeamTree from "@/components/tree/TeamTree"; import TeamTreeFooter from "@/components/tree/TeamTreeFooter"; +import MembershipRow from "@/components/users/MembershipRow"; +import { useToastStore } from "@/state/store/toastStore"; import { cn } from "@/utils/cn"; import { BTN_TEXT } from "@/constants/commonConstants"; import { @@ -34,14 +35,11 @@ import { MEMBER_STATUS_VAR, WORKSPACE_STATUS_VAR, } from "@/constants/styleConstants"; -import type { - TMemberStatus, - TTeamNode, - TWorkspaceStatus, -} from "@/types/commonTypes"; +import { ROLE_OPTIONS } from "@/constants/teamConstants"; +import type { TMemberStatus } from "@/types/commonTypes"; import type { TBTNColor } from "@/types/styleTypes"; -import type { TInvitationStatus } from "@/types/teamTypes"; -import { useToastStore } from "@/stores/toastStore"; +import type { TInvitationStatus, TTeamViewNode } from "@/types/teamTypes"; +import type { TWorkspaceStatus } from "@/types/workspaceTypes"; type TUITestModal = "alert" | "confirm" | "wide" | "scroll" | null; @@ -56,12 +54,6 @@ const BTN_THEMES: { color: TBTNColor; role: string; text: string }[] = [ { color: "redOutline", role: "outline · danger", text: "멤버 삭제" }, ]; -const ROLE_OPTIONS = [ - { value: "edit", label: "edit" }, - { value: "write", label: "write" }, - { value: "read", label: "read" }, -]; - const TEAM_OPTIONS = [ { value: "platform", label: "플랫폼팀" }, { value: "fe", label: "프론트엔드", depth: 1 }, @@ -119,7 +111,7 @@ const SESSION_ROWS = [ { account: "a@corp.com", issuedAt: "2026-07-05 18:20", connectedAt: "" }, ]; -const TEAM_FIXTURE: TTeamNode[] = [ +const TEAM_FIXTURE: TTeamViewNode[] = [ { id: "platform", name: "Platform", @@ -207,7 +199,9 @@ const UITestPage = () => { const [tableSearch, setTableSearch] = useState(""); const [tablePage, setTablePage] = useState(1); const [treeQuery, setTreeQuery] = useState(""); - const [treeSelected, setTreeSelected] = useState(TEAM_FIXTURE[0]); + const [treeSelected, setTreeSelected] = useState( + TEAM_FIXTURE[0], + ); const [drawerOpen, setDrawerOpen] = useState(false); const [memberships, setMemberships] = useState( MEMBERSHIP_FIXTURE.map((m) => ({ ...m, role: m.baseRole })), diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index 0801305..4845a19 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -1,30 +1,22 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import Button from "@/components/elements/Button"; import Checkbox from "@/components/elements/Checkbox"; -import Dropdown from "@/components/elements/Dropdown"; import Feedback from "@/components/elements/Feedback"; -import MemberStatus from "@/components/elements/MemberStatus"; import Pagination from "@/components/elements/Pagination"; -import SearchInput from "@/components/elements/SearchInput"; import Table from "@/components/table/Table"; -import TableCell from "@/components/table/TableCell"; +import TableEmptyRow from "@/components/table/TableEmptyRow"; import TableFoot from "@/components/table/TableFoot"; import TableHead from "@/components/table/TableHead"; import TableHeaderCell from "@/components/table/TableHeaderCell"; -import TableRow from "@/components/table/TableRow"; +import TableLoadingRow from "@/components/table/TableLoadingRow"; import MemberBatchFailureModal from "@/components/teams/MemberBatchFailureModal"; -import { buildTeamOptions } from "@/components/teams/teamOptions"; import InviteMemberModal from "@/components/users/InviteMemberModal"; import MemberDeleteModal from "@/components/users/MemberDeleteModal"; import MemberDetailDrawer from "@/components/users/MemberDetailDrawer"; -import { CHIP_STATUS } from "@/components/users/memberStatusMap"; -import { - useCancelInvitation, - useDeleteUsers, - useInviteMutation, - useResendInvitation, -} from "@/hooks/mutations/useInvitationMutations"; +import UserRow from "@/components/users/UserRow"; +import UsersToolbar from "@/components/users/UsersToolbar"; +import { useCancelInvitation } from "@/hooks/mutations/useInvitationMutations"; import { useAddUserMembership, useBulkUserRoleChange, @@ -35,40 +27,40 @@ import { useTeamsTreeQuery } from "@/hooks/queries/useTeamsTreeQuery"; import { useUserQuery } from "@/hooks/queries/useUserQuery"; import { useUsersQuery } from "@/hooks/queries/useUsersQuery"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; -import { parseErrorCode } from "@/api/parseError"; -import { BTN_TEXT } from "@/constants/commonConstants"; +import { usePageScopedSelection } from "@/hooks/usePageScopedSelection"; +import { + useServerPagination, + useSyncPaginationTotal, +} from "@/hooks/useServerPagination"; +import { useUserBatchActions } from "@/hooks/useUserBatchActions"; +import { buildTeamOptions } from "@/utils/buildTeamOptions"; +import { SESSION_STATUS } from "@/constants/apiConstants"; +import { + ARIA_LABELS, + BTN_TEXT, + DEFAULT_PAGE_SIZE, + FEEDBACK_TEXT, + PAGE_TITLES, + TABLE_HEADERS, +} from "@/constants/commonConstants"; import type { TDropdownOption } from "@/types/commonTypes"; import type { TTeamMemberRole, TTeamTree } from "@/types/teamTypes"; -import type { - TInvitePayload, - TInviteResult, - TUserListItem, -} from "@/types/userTypes"; -import { useNoticeStore } from "@/stores/noticeStore"; const styles = { page: "flex flex-col gap-3.5 p-4", - /* Wide enough for typical names at the 40% column; anything longer - (up to the 50-char username cap) truncates with an ellipsis and - keeps the full name in the title tooltip. */ - usernameCell: "max-w-[400px] truncate", - overflowChip: - "border-border text-faint ml-1.5 rounded-full border px-2 text-xs", }; /* Filter/sort option sets (SC-11 no.2–3). "all" stands in for 전체. The list shows only the session axis, so the filter matches it. */ const STATUS_OPTIONS: TDropdownOption[] = [ { value: "all", label: "전체" }, - { value: "online", label: "온라인" }, - { value: "offline", label: "오프라인" }, + { value: SESSION_STATUS.online, label: "온라인" }, + { value: SESSION_STATUS.offline, label: "오프라인" }, ]; /* Depth indent stripped — the 150px filter trigger can't fit deep-tree indentation (it forces horizontal scrolling in the menu); teams list - flush left in tree order and long names truncate with an ellipsis. - Computed in-component (buildTeamOptions depends on the real teams - query result — no static dummy list anymore). */ + flush left in tree order and long names truncate with an ellipsis. */ const buildGroupOptions = (teams: TTeamTree): TDropdownOption[] => [ { value: "all", label: "전체" }, ...buildTeamOptions(teams).map(({ value, label }) => ({ value, label })), @@ -76,50 +68,31 @@ const buildGroupOptions = (teams: TTeamTree): TDropdownOption[] => [ const SORT_OPTIONS: TDropdownOption[] = [ { value: "last_invited", label: "최근 초대 코드 발송" }, - { value: "username", label: "멤버 이름" }, + { value: "username", label: TABLE_HEADERS.memberName }, ]; -/** First membership as "team · role"; the rest collapse into "+n". */ -const membershipSummary = (user: TUserListItem) => { - const [first, ...rest] = user.memberships; - return first - ? { summary: `${first.teamName} · ${first.role}`, extra: rest.length } - : { summary: "—", extra: 0 }; -}; - -/* 10 rows per page — caps the table height inside one screen; also the - ?size=10 GET /users query param. */ -const PAGE_SIZE = 10; - -/** Batch-delete failure reasons shown by account (DELETE /users). */ -const BATCH_REASON: Record = { - USER_NOT_FOUND: "사용자를 찾을 수 없습니다", -}; - /** * UsersPage is the user management screen (SC-11): cross-team user * list with search/filters/sort, bulk actions, and pagination, plus * the invite modal (SC-12), member detail drawer (SC-13), and delete * confirm (SC-15). The list is driven by GET /users (useUsersQuery) — * search/status/team/sort/page all become query params, and the - * server returns the already filtered/sorted/paged rows. The drawer's - * detail (GET /users/{id}), role/membership batch, session deactivate, - * invite/resend/cancel, and delete mutations are all wired to the API. + * server returns the already filtered/sorted/paged rows. Bulk flows + * (invite/resend/delete) live in useUserBatchActions; the drawer's + * membership machine lives in useMembershipDrafts. */ const UsersPage = () => { const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [groupFilter, setGroupFilter] = useState("all"); const [sort, setSort] = useState("last_invited"); - const [selectedIds, setSelectedIds] = useState>(new Set()); - const [page, setPage] = useState(1); const [inviteOpen, setInviteOpen] = useState(false); const [drawerUserId, setDrawerUserId] = useState(null); const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); - const [batchFailures, setBatchFailures] = useState< - { account: string; reason: string }[] | null - >(null); - const showNotice = useNoticeStore((state) => state.showNotice); + const { selectedIds, toggleOne, toggleAll, clearSelection, setSelectedIds } = + usePageScopedSelection(); + const { page, totalPages, setPage, resetPage, syncTotal } = + useServerPagination(); const { data: teams } = useTeamsTreeQuery(); const detailQuery = useUserQuery(drawerUserId ?? ""); @@ -127,12 +100,25 @@ const UsersPage = () => { const removeMemberships = useRemoveUserMemberships(drawerUserId ?? ""); const addMembership = useAddUserMembership(drawerUserId ?? ""); const deactivateSession = useDeactivateUserSession(drawerUserId ?? ""); - const invite = useInviteMutation(); - const resend = useResendInvitation(); const cancel = useCancelInvitation(); - const deleteUsersMutation = useDeleteUsers(); const groupOptions = buildGroupOptions(teams ?? []); + const { + inviteMember, + resendCode, + resendCodes, + deleteMembers, + batchFailures, + closeBatchFailures, + } = useUserBatchActions({ + setSelectedIds, + onDeleted: (deletedIds) => { + if (drawerUserId && deletedIds.includes(drawerUserId)) { + setDrawerUserId(null); + } + }, + }); + const debouncedSearch = useDebouncedValue(search, 300); const usersQuery = useUsersQuery({ search: debouncedSearch.trim(), @@ -140,17 +126,11 @@ const UsersPage = () => { teamId: groupFilter, sort, page, - size: PAGE_SIZE, + size: DEFAULT_PAGE_SIZE, }); const users = usersQuery.data?.items ?? []; const total = usersQuery.data?.total ?? 0; - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); - const currentPage = Math.min(page, totalPages); - - /* keep the requested page within range so the query never asks for an out-of-range page */ - useEffect(() => { - if (page > totalPages) setPage(totalPages); - }, [page, totalPages]); + useSyncPaginationTotal(syncTotal, total); /* A search/filter is active whenever it would narrow the server-side result — distinguishes "no members at all" (state B) from "no @@ -170,124 +150,29 @@ const UsersPage = () => { (setter: (value: T) => void) => (value: T) => { setter(value); - setPage(1); - setSelectedIds(new Set()); + resetPage(); + clearSelection(); }; /* Moving to another page clears the selection too — checks are page-scoped, and a checked row on the old page shouldn't ride along into a bulk action taken on a different page. */ const goToPage = (next: number) => { setPage(next); - setSelectedIds(new Set()); + clearSelection(); }; /* Select-all is page-scoped. */ const allSelected = users.length > 0 && users.every((u) => selectedIds.has(u.userId)); - const toggleAll = (checked: boolean) => - setSelectedIds((prev) => { - const next = new Set(prev); - users.forEach((u) => - checked ? next.add(u.userId) : next.delete(u.userId), - ); - return next; - }); - - const toggleOne = (userId: string, checked: boolean) => - setSelectedIds((prev) => { - const next = new Set(prev); - if (checked) next.add(userId); - else next.delete(userId); - return next; - }); - - /** POST /invitations — server judges duplicates and target states; only - the staged team/role sets are sent (buildInvitePreview's sub-team - expansion is display-only, the server performs the real expansion). */ - const inviteMember = async ( - payload: TInvitePayload, - ): Promise => { - try { - await invite.mutateAsync({ - account: payload.email, - username: payload.username, - memberships: payload.sets.map((set) => ({ - teamId: set.teamId, - role: set.role as TTeamMemberRole, - })), - }); - return "success"; - } catch (err) { - if (err instanceof Response) { - const code = await parseErrorCode(err); - return code === "ALREADY_TEAM_MEMBER" ? "duplicate-account" : "error"; - } - return "error"; - } - }; - - /** POST /invitations/resend (per target) — status never changes (D10). - Selection stays intact on partial failure so the user can retry. */ - const resendCodes = async (targets: TUserListItem[]) => { - const results = await Promise.allSettled( - targets.map((u) => resend.mutateAsync(u.userId)), - ); - const failed = targets.filter((_, i) => results[i].status === "rejected"); - if (failed.length === 0) { - showNotice("초대 코드 재전송", "초대 코드를 재전송했습니다.", "info"); - return; - } - setBatchFailures( - failed.map((u) => ({ account: u.account, reason: "재전송 실패" })), - ); - }; - - /** DELETE /users (batch) — memberships, session token, and unused - invite codes go together (D13). Full success clears the targets - from selection and closes the drawer if it pointed at one of - them; partial failure shows the failure modal (account + reason) - and leaves the still-failed ids selected for retry. Throws only - on full failure, so MemberDeleteModal/the drawer's onDeleteMember - contract (resolve unless every target failed) is unaffected. */ - const deleteMembers = async (targets: TUserListItem[]) => { - const userIds = targets.map((u) => u.userId); - const result = await deleteUsersMutation.mutateAsync(userIds); - const failedIds = new Set(result.failed.map((f) => f.id)); - const succeededIds = userIds.filter((id) => !failedIds.has(id)); - - setSelectedIds((prev) => { - const next = new Set(prev); - succeededIds.forEach((id) => next.delete(id)); - return next; - }); - if (drawerUserId && succeededIds.includes(drawerUserId)) { - setDrawerUserId(null); - } - - if (result.failed.length === 0) { - showNotice("멤버 삭제", "멤버를 삭제했습니다.", "info"); - return; - } - if (succeededIds.length === 0) { - throw new Error("delete failed for every target"); - } - setBatchFailures( - result.failed.map((f) => ({ - account: targets.find((u) => u.userId === f.id)?.account ?? f.id, - reason: BATCH_REASON[f.code] ?? f.code, - })), - ); - }; - /* ── SC-11 state C — 조회 실패 ──────────────────────────────────── */ if (usersQuery.isError) { return ( - + { all hidden) ─── */ if (!usersQuery.isPending && total === 0 && !hasActiveFilter) { return ( - + { } return ( - + { pagination never shifts the layout. */ scrollClassName="min-h-[526px]" toolbar={ - - - - - {/* filter/order dropdown */} - - - 정렬 기준 - - - - 멤버 상태 - - - - 팀 - - - - - - {/* Actions — second row, left-aligned (SC-11 no.4–6) */} - - resendCodes(selectedUsers)} - /> - setBulkDeleteOpen(true)} - /> - setInviteOpen(true)} - /> - - - + resendCodes(selectedUsers)} + onOpenBulkDelete={() => setBulkDeleteOpen(true)} + onOpenInvite={() => setInviteOpen(true)} + /> } foot={ @@ -435,77 +261,41 @@ const UsersPage = () => { + toggleAll( + users.map((u) => u.userId), + checked, + ) + } + ariaLabel={ARIA_LABELS.selectAll} /> {/* Fixed column widths — auto layout would resize per page's content and shift the headers while paginating. */} - 멤버 이름 - 멤버 상태 - 팀 (권한) + + {TABLE_HEADERS.memberName} + + + {TABLE_HEADERS.memberStatus} + + + {TABLE_HEADERS.teamWithRole} + - {usersQuery.isPending && ( - - - 불러오는 중… - - - )} + {usersQuery.isPending && } {!usersQuery.isPending && users.length === 0 && ( - - - 검색 결과가 없습니다. - - + 검색 결과가 없습니다. )} - {users.map((user) => { - const { summary, extra } = membershipSummary(user); - return ( - setDrawerUserId(user.userId)} - > - {/* Checkbox clicks must not open the drawer (SC-11 no.8) */} - - e.stopPropagation()}> - toggleOne(user.userId, checked)} - ariaLabel={`${user.account} 선택`} - /> - - - - {user.username} - - - - - - {summary} - {extra > 0 && ( - `${m.teamName} · ${m.role}`) - .join(", ")} - > - +{extra} - - )} - - - ); - })} + {users.map((user) => ( + toggleOne(user.userId, checked)} + onOpen={() => setDrawerUserId(user.userId)} + /> + ))} @@ -543,7 +333,7 @@ const UsersPage = () => { await deactivateSession.mutateAsync(); }} onResendCode={async () => { - await resend.mutateAsync(drawerUser.userId); + await resendCode(drawerUser.userId); }} onCancelInvitation={async () => { await cancel.mutateAsync(drawerUser.userId); @@ -570,7 +360,7 @@ const UsersPage = () => { {batchFailures && ( setBatchFailures(null)} + onClose={closeBatchFailures} /> )} diff --git a/frontend/src/pages/WorkspacePage.tsx b/frontend/src/pages/WorkspacePage.tsx index 7108f89..c66eeee 100644 --- a/frontend/src/pages/WorkspacePage.tsx +++ b/frontend/src/pages/WorkspacePage.tsx @@ -8,8 +8,9 @@ import { isTransitionalStatus, useWorkspaceQuery, } from "@/hooks/queries/useWorkspaceQuery"; -import { PATH_LIST } from "@/constants/commonConstants"; -import { useWorkspaceStore } from "@/stores/workspaceStore"; +import { useWorkspaceStore } from "@/state/store/workspaceStore"; +import { WORKSPACE_STATUS } from "@/constants/apiConstants"; +import { PAGE_TITLES, PATH_LIST } from "@/constants/commonConstants"; const panelClass = "m-6 flex min-h-[340px] flex-col items-center justify-center gap-3 text-center"; @@ -41,7 +42,7 @@ const WorkspacePage = () => { const [createdHere, setCreatedHere] = useState(false); useEffect(() => { - if (workspace?.status === "running" && createdHere) { + if (workspace?.status === WORKSPACE_STATUS.running && createdHere) { setCreatedHere(false); openModal(); } @@ -62,7 +63,7 @@ const WorkspacePage = () => { const exists = workspace != null; const transitional = exists && isTransitionalStatus(workspace.status); - if (isLoading) return ; + if (isLoading) return ; /* A workspace exists → go to the console. The one exception is our own create still provisioning: stay and keep the spinner until it runs. */ @@ -84,7 +85,7 @@ const WorkspacePage = () => { transitional; return ( - + {creating ? ( { "page", ); /* username asc — a@corp.com's row ("a 사용자") leads the first page. */ - expect(await screen.findByText(usernameOf("a@corp.com"))).toBeInTheDocument(); + expect( + await screen.findByText(usernameOf("a@corp.com")), + ).toBeInTheDocument(); }); it("shows the fixed page-size footer", async () => { diff --git a/frontend/src/pages/__tests__/UsersPage.test.tsx b/frontend/src/pages/__tests__/UsersPage.test.tsx index 0d2d464..67db614 100644 --- a/frontend/src/pages/__tests__/UsersPage.test.tsx +++ b/frontend/src/pages/__tests__/UsersPage.test.tsx @@ -8,9 +8,9 @@ import UsersPage from "@/pages/UsersPage"; import * as invitationAPIs from "@/api/invitationAPIs"; import * as teamAPIs from "@/api/teamAPIs"; import * as userAPIs from "@/api/userAPIs"; +import { useNoticeStore } from "@/state/store/noticeStore"; import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants"; import type { TUserListItem } from "@/types/userTypes"; -import { useNoticeStore } from "@/stores/noticeStore"; const jsonRes = (body: unknown) => ({ ok: true, json: async () => body }) as unknown as Response; @@ -182,7 +182,10 @@ describe("UsersPage", () => { total: 12, // > PAGE_SIZE → a second page exists page: 1, size: 10, - items: [user("u_1", "k@corp.com", "김철수"), user("u_2", "m@corp.com", "박미영")], + items: [ + user("u_1", "k@corp.com", "김철수"), + user("u_2", "m@corp.com", "박미영"), + ], }); const typer = userEvent.setup(); renderPage(); @@ -269,10 +272,7 @@ describe("UsersPage", () => { screen.getByPlaceholderText("user@corp.com"), "new@corp.com", ); - await typer.type( - screen.getByLabelText("사용자 이름 (username)"), - "김신입", - ); + await typer.type(screen.getByLabelText("사용자 이름 (username)"), "김신입"); await typer.click(screen.getByRole("button", { name: "세트 1 팀" })); await typer.click(screen.getByRole("option", { name: "백엔드" })); await typer.click(screen.getByRole("button", { name: "세트 1 role" })); diff --git a/frontend/src/pages/__tests__/WorkspacePage.test.tsx b/frontend/src/pages/__tests__/WorkspacePage.test.tsx index 70a873f..0e0601f 100644 --- a/frontend/src/pages/__tests__/WorkspacePage.test.tsx +++ b/frontend/src/pages/__tests__/WorkspacePage.test.tsx @@ -3,8 +3,8 @@ import { render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import WorkspacePage from "@/pages/WorkspacePage"; -import type { TWorkspace } from "@/types/commonTypes"; -import { useWorkspaceStore } from "@/stores/workspaceStore"; +import { useWorkspaceStore } from "@/state/store/workspaceStore"; +import type { TWorkspace } from "@/types/workspaceTypes"; /* Server state is mocked; the page renders only while no workspace exists (query → null), which is exactly the post-teardown handoff situation. */ @@ -70,8 +70,6 @@ describe("WorkspacePage", () => { expect( screen.getByText(/워크스페이스를 생성하는 중입니다/), ).toBeInTheDocument(); - expect( - screen.queryByText("생성된 워크스페이스가 없습니다."), - ).toBeNull(); + expect(screen.queryByText("생성된 워크스페이스가 없습니다.")).toBeNull(); }); }); 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/stores/__tests__/noticeStore.test.ts b/frontend/src/state/store/__tests__/noticeStore.test.ts similarity index 96% rename from frontend/src/stores/__tests__/noticeStore.test.ts rename to frontend/src/state/store/__tests__/noticeStore.test.ts index ddf01c3..c62862d 100644 --- a/frontend/src/stores/__tests__/noticeStore.test.ts +++ b/frontend/src/state/store/__tests__/noticeStore.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useNoticeStore } from "@/stores/noticeStore"; +import { useNoticeStore } from "@/state/store/noticeStore"; describe("noticeStore", () => { beforeEach(() => { diff --git a/frontend/src/stores/__tests__/workspaceStore.test.ts b/frontend/src/state/store/__tests__/workspaceStore.test.ts similarity index 94% rename from frontend/src/stores/__tests__/workspaceStore.test.ts rename to frontend/src/state/store/__tests__/workspaceStore.test.ts index 3c2ded8..81f089e 100644 --- a/frontend/src/stores/__tests__/workspaceStore.test.ts +++ b/frontend/src/state/store/__tests__/workspaceStore.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { useWorkspaceStore } from "@/stores/workspaceStore"; +import { useWorkspaceStore } from "@/state/store/workspaceStore"; const reset = () => useWorkspaceStore.setState({ modalOpen: false, deleteConfirmOpen: 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/stores/noticeStore.ts b/frontend/src/state/store/noticeStore.ts similarity index 100% rename from frontend/src/stores/noticeStore.ts rename to frontend/src/state/store/noticeStore.ts diff --git a/frontend/src/stores/toastStore.ts b/frontend/src/state/store/toastStore.ts similarity index 100% rename from frontend/src/stores/toastStore.ts rename to frontend/src/state/store/toastStore.ts diff --git a/frontend/src/stores/workspaceStore.ts b/frontend/src/state/store/workspaceStore.ts similarity index 100% rename from frontend/src/stores/workspaceStore.ts rename to frontend/src/state/store/workspaceStore.ts diff --git a/frontend/src/types/commonTypes.ts b/frontend/src/types/commonTypes.ts index 6250152..4085a6d 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; @@ -15,64 +10,5 @@ export type TDropdownOption = { /** Session chip state — the only status a list view renders. */ export type TMemberStatus = "online" | "offline"; -/** - * rune workspace lifecycle phase (wireframe SC-03 badge; console API - * `phase`). `provisioning` is the transient state right after create, - * before the endpoint/row count exist. - */ -export type TWorkspaceStatus = - | "provisioning" - | "running" - | "stopping" - | "stopped" - | "starting" - | "deleting" - | "error"; - -/** - * rune workspace record surfaced in the console (wireframe SC-02 state D), - * mapped from the API `GET /workspace` body. The workspace name is never - * exposed — it is a hash-like random value stored DB-side only. endpoint and - * rowCount are null until the workspace finishes provisioning. - */ -export type TWorkspace = { - status: TWorkspaceStatus; - endpoint: string | null; - rowCount: number | null; - /** - * The workspace exists in the cloud but was created by a different console - * install than this one (a reinstall minted a fresh team_secret), so its - * stored data is encrypted under a key we no longer hold and it can only be - * deleted + recreated. Absent/false on a healthy workspace. - */ - orphaned: boolean; - /** - * The data-plane credential expired and a background reconnect cannot - * re-bootstrap it — the user must drive a reconnect (POST /workspace). The - * cloud workspace itself is healthy; only the local engine link is stale. - * Mutually exclusive with orphaned (recreate supersedes reconnect). - */ - reconnectRequired: boolean; -}; - -/** Wire shape of `GET /workspace` (console API design 2026-07-13, §Workspace). */ -export type TWorkspaceWire = { - phase: TWorkspaceStatus; - endpointUrl: string | null; - rows: number | null; - /** true when the workspace no longer matches this console (reinstall). */ - orphaned?: boolean; - /** true when the data-plane credential expired and needs a user-driven reconnect. */ - reconnect?: boolean; -}; - -/** Recursive team-tree node (UIKIT AdminTeamNode, wireframe SC-06). */ -export type TTeamNode = { - id: string; - name: string; - members: number; - children?: TTeamNode[]; -}; - /** Toast tone — semantic colors are state, not decoration. */ export type TToastTone = "info" | "success" | "error"; diff --git a/frontend/src/types/teamTypes.ts b/frontend/src/types/teamTypes.ts index 41de255..1c0e96b 100644 --- a/frontend/src/types/teamTypes.ts +++ b/frontend/src/types/teamTypes.ts @@ -1,3 +1,9 @@ +import type { + INVITATION_STATUS, + SESSION_STATUS, + TEAM_MEMBER_ROLE, +} from "@/constants/apiConstants"; + export type TTeamNode = { id: string; name: string; @@ -9,15 +15,27 @@ export type TTeamNode = { export type TTeamTree = TTeamNode[]; -/** Grantable member role (Admin is console-account only — API §0). */ -export type TTeamMemberRole = "edit" | "write" | "read"; +/** Recursive team-tree node the tree/org views consume (UIKIT + AdminTeamNode, wireframe SC-06) — built client-side from the flat + TTeamTree. Distinct from TTeamNode, the flat wire row above. */ +export type TTeamViewNode = { + id: string; + name: string; + members: number; + children?: TTeamViewNode[]; +}; + +/** Grantable member role — derived from TEAM_MEMBER_ROLE (single source). */ +export type TTeamMemberRole = + (typeof TEAM_MEMBER_ROLE)[keyof typeof TEAM_MEMBER_ROLE]; -/** Invitation-code lifecycle status on the wire (common contract). */ +/** Invitation-code lifecycle status — derived from INVITATION_STATUS. */ export type TInvitationStatus = - "invite_pending" | "invite_expired" | "invite_redeemed"; + (typeof INVITATION_STATUS)[keyof typeof INVITATION_STATUS]; -/** Session-token liveness on the wire (common contract). */ -export type TSessionStatus = "online" | "offline"; +/** Session-token liveness — derived from SESSION_STATUS. */ +export type TSessionStatus = + (typeof SESSION_STATUS)[keyof typeof SESSION_STATUS]; /** GET /teams/{id} detail. */ export type TTeamDetail = { diff --git a/frontend/src/types/updateTypes.ts b/frontend/src/types/updateTypes.ts index 95c6ea7..501551a 100644 --- a/frontend/src/types/updateTypes.ts +++ b/frontend/src/types/updateTypes.ts @@ -1,6 +1,9 @@ -/** Lifecycle reported by the privileged rune-console update agent. */ +import type { SYSTEM_UPDATE_STATE } from "@/constants/apiConstants"; + +/** Lifecycle reported by the privileged rune-console update agent — + derived from SYSTEM_UPDATE_STATE (single source). */ export type TSystemUpdateState = - "idle" | "queued" | "running" | "failed" | "succeeded"; + (typeof SYSTEM_UPDATE_STATE)[keyof typeof SYSTEM_UPDATE_STATE]; /** Wire contract for GET /api/v1/system/update. */ export type TSystemUpdateStatus = { diff --git a/frontend/src/types/workspaceTypes.ts b/frontend/src/types/workspaceTypes.ts new file mode 100644 index 0000000..d495628 --- /dev/null +++ b/frontend/src/types/workspaceTypes.ts @@ -0,0 +1,47 @@ +import type { WORKSPACE_STATUS } from "@/constants/apiConstants"; + +/** + * rune workspace lifecycle phase (wireframe SC-03 badge; console API + * `phase`). `provisioning` is the transient state right after create, + * before the endpoint/row count exist. Derived from WORKSPACE_STATUS + * (single source). + */ +export type TWorkspaceStatus = + (typeof WORKSPACE_STATUS)[keyof typeof WORKSPACE_STATUS]; + +/** + * rune workspace record surfaced in the console (wireframe SC-02 state D), + * mapped from the API `GET /workspace` body. The workspace name is never + * exposed — it is a hash-like random value stored DB-side only. endpoint and + * rowCount are null until the workspace finishes provisioning. + */ +export type TWorkspace = { + status: TWorkspaceStatus; + endpoint: string | null; + rowCount: number | null; + /** + * The workspace exists in the cloud but was created by a different console + * install than this one (a reinstall minted a fresh team_secret), so its + * stored data is encrypted under a key we no longer hold and it can only be + * deleted + recreated. Absent/false on a healthy workspace. + */ + orphaned: boolean; + /** + * The data-plane credential expired and a background reconnect cannot + * re-bootstrap it — the user must drive a reconnect (POST /workspace). The + * cloud workspace itself is healthy; only the local engine link is stale. + * Mutually exclusive with orphaned (recreate supersedes reconnect). + */ + reconnectRequired: boolean; +}; + +/** Wire shape of `GET /workspace` (console API design 2026-07-13, §Workspace). */ +export type TWorkspaceWire = { + phase: TWorkspaceStatus; + endpointUrl: string | null; + rows: number | null; + /** true when the workspace no longer matches this console (reinstall). */ + orphaned?: boolean; + /** true when the data-plane credential expired and needs a user-driven reconnect. */ + reconnect?: boolean; +}; diff --git a/frontend/src/components/teams/__tests__/teamHierarchy.test.ts b/frontend/src/utils/__tests__/teamHierarchy.test.ts similarity index 93% rename from frontend/src/components/teams/__tests__/teamHierarchy.test.ts rename to frontend/src/utils/__tests__/teamHierarchy.test.ts index 937ce51..547386a 100644 --- a/frontend/src/components/teams/__tests__/teamHierarchy.test.ts +++ b/frontend/src/utils/__tests__/teamHierarchy.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - getTeamDescendantIds, - getTeamName, -} from "@/components/teams/teamHierarchy"; +import { getTeamDescendantIds, getTeamName } from "@/utils/teamHierarchy"; import type { TTeamTree } from "@/types/teamTypes"; /** Minimal 3-node chain: t_1 root → t_2 child → t_3 grandchild. */ diff --git a/frontend/src/components/teams/teamOptions.ts b/frontend/src/utils/buildTeamOptions.ts similarity index 59% rename from frontend/src/components/teams/teamOptions.ts rename to frontend/src/utils/buildTeamOptions.ts index 2415780..0342397 100644 --- a/frontend/src/components/teams/teamOptions.ts +++ b/frontend/src/utils/buildTeamOptions.ts @@ -1,19 +1,6 @@ import type { TDropdownOption } from "@/types/commonTypes"; import type { TTeamTree } from "@/types/teamTypes"; -/** Team name rule: digits, Hangul, Latin letters, and - _ only. */ -export const TEAM_NAME_PATTERN = /^[0-9A-Za-z가-힣_-]+$/; - -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" }, -]; - /** All teams in tree order with depth indent (for team-picker dropdowns). Pure function over the real `teams` query result — used by the team CRUD modals (create/rename/delete) and the Users page pickers. */ diff --git a/frontend/src/utils/email.ts b/frontend/src/utils/email.ts new file mode 100644 index 0000000..6087511 --- /dev/null +++ b/frontend/src/utils/email.ts @@ -0,0 +1,7 @@ +/** Email (account) field rules — shared by the invite form (SC-12) and the + * team add-member form (SC-06), mirroring the username.ts convention of + * keeping a field's pattern and its validation copy together. */ + +export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export const EMAIL_FORMAT_ERROR = "올바른 이메일 형식이 아닙니다."; diff --git a/frontend/src/utils/formatDate.ts b/frontend/src/utils/formatDate.ts index 2dea4a6..091991c 100644 --- a/frontend/src/utils/formatDate.ts +++ b/frontend/src/utils/formatDate.ts @@ -6,19 +6,23 @@ const KST_TIME_ZONE = "Asia/Seoul"; +/* Constructed once at module load — Intl.DateTimeFormat construction is + one of the costlier Intl operations and these run in every table cell + on every render; the options never change. */ +const KST_FORMATTER = new Intl.DateTimeFormat("en-US", { + timeZone: KST_TIME_ZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", +}); + /** Break an ISO instant into zero-padded KST calendar parts. */ const kstParts = (iso: string): Record => { - const formatter = new Intl.DateTimeFormat("en-US", { - timeZone: KST_TIME_ZONE, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hourCycle: "h23", - }); const parts: Record = {}; - for (const { type, value } of formatter.formatToParts(new Date(iso))) { + for (const { type, value } of KST_FORMATTER.formatToParts(new Date(iso))) { parts[type] = value; } return parts; diff --git a/frontend/src/components/users/invitePreview.ts b/frontend/src/utils/invitePreview.ts similarity index 95% rename from frontend/src/components/users/invitePreview.ts rename to frontend/src/utils/invitePreview.ts index 65e4027..c6d2832 100644 --- a/frontend/src/components/users/invitePreview.ts +++ b/frontend/src/utils/invitePreview.ts @@ -1,7 +1,4 @@ -import { - getTeamDescendantIds, - getTeamName, -} from "@/components/teams/teamHierarchy"; +import { getTeamDescendantIds, getTeamName } from "@/utils/teamHierarchy"; import type { TTeamTree } from "@/types/teamTypes"; import type { TInviteSet } from "@/types/userTypes"; diff --git a/frontend/src/utils/teamHierarchy.ts b/frontend/src/utils/teamHierarchy.ts new file mode 100644 index 0000000..1f6f410 --- /dev/null +++ b/frontend/src/utils/teamHierarchy.ts @@ -0,0 +1,70 @@ +import type { TTeamTree, TTeamViewNode } from "@/types/teamTypes"; + +/** + * Team-tree lookups over a flat `TTeamTree` — shared by the invite preview + * (SC-12 no.3) and the membership-removal sub-team notice (SC-14 no.2). + * Pure functions over the tree passed in (from `useTeamsTreeQuery`); trees + * are small, so no memoized id-map is kept at module scope. + */ + +/** Team name for `teamId`, or the id itself if the team is unknown. */ +export const getTeamName = (teams: TTeamTree, teamId: string): string => + teams.find((team) => team.id === teamId)?.name ?? teamId; + +/** All descendant ids of a team, in depth-first tree order. */ +export const getTeamDescendantIds = ( + teams: TTeamTree, + teamId: string, +): string[] => + (teams.find((team) => team.id === teamId)?.childrenIds ?? []).flatMap( + (childId) => [childId, ...getTeamDescendantIds(teams, childId)], + ); + +/** + * GET /teams/tree returns flat nodes — the client builds the recursive + * TTeamViewNode shape the TeamTree component consumes (API design §3). + * Single pass over a children index (not a filter per parent), so the + * build stays linear in team count. Callers memoize per teams array. + */ +export const buildTeamNodes = (teams: TTeamTree): TTeamViewNode[] => { + const childrenOf = new Map(); + for (const team of teams) { + const siblings = childrenOf.get(team.parentId); + if (siblings) siblings.push(team); + else childrenOf.set(team.parentId, [team]); + } + const build = (parentId: string | null): TTeamViewNode[] => + (childrenOf.get(parentId) ?? []).map((team) => ({ + id: team.id, + name: team.name, + members: team.memberCount, + children: team.childCount > 0 ? build(team.id) : undefined, + })); + return build(null); +}; + +/** Depth-first lookup in a built view-node tree. */ +export const findTeamNode = ( + nodes: TTeamViewNode[], + id: string, +): TTeamViewNode | undefined => + nodes.reduce( + (found, node) => + found ?? (node.id === id ? node : findTeamNode(node.children ?? [], id)), + undefined, + ); + +/** Ancestor ids of a team — expanded so a selection handed off from + the org chart is actually visible in the tree. */ +export const ancestorIds = ( + flatById: Map, + teamId: string, +): string[] => { + const ids: string[] = []; + let parentId = flatById.get(teamId)?.parentId; + while (parentId) { + ids.push(parentId); + parentId = flatById.get(parentId)?.parentId; + } + return ids; +};