diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index f3be9b1..2e0418a 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { Navigate, Outlet, Route, Routes, useNavigate } from 'react-router-dom'; import { useAuthStore } from './store/authStore'; +import { landingPathFor } from './lib/roleTrack'; import { LoginPage } from './screens/login/LoginPage'; import { OtpSetupPage } from './screens/login/OtpSetupPage'; import { BoothLayoutEditorPage } from './screens/floorplan/BoothLayoutEditorPage'; @@ -99,12 +100,16 @@ function RequireAuth({ children }: { children: React.ReactNode }) { } /** - * 루트/미매칭 분기 — 인증=홈 리다이렉트, 미인증=SCR-T0 3-트랙 공개 관문 직접 렌더 + * 루트/미매칭 분기 — 인증=역할별 랜딩 리다이렉트, 미인증=SCR-T0 3-트랙 공개 관문 직접 렌더 * (design.md §3C-0: 미인증 `/`는 대외 유입 관문. 2026-07-12 소유자 지적 반영). + * 인증 시 랜딩은 completeLogin 과 동일 규칙(plan_role_routing §2-1) — ops·business=/home(회귀 0), + * admin→/admin, agency→/contractor/dashboard, visitor→/visitor. 로그인 직후·`/`·catch-all 일관. */ function RootGate() { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); - if (isAuthenticated) return ; + const user = useAuthStore((s) => s.user); + const workspaces = useAuthStore((s) => s.workspaces); + if (isAuthenticated) return ; return ; } diff --git a/src/frontend/src/components/layout/AppShell.tsx b/src/frontend/src/components/layout/AppShell.tsx index 9ef9f8e..9345619 100644 --- a/src/frontend/src/components/layout/AppShell.tsx +++ b/src/frontend/src/components/layout/AppShell.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useAuthStore } from '../../store/authStore'; +import type { Track } from '../../lib/roleTrack'; import { useThemeStore } from '../../store/themeStore'; import { useMdiStore } from '../../store/mdiStore'; import { useUiStore, isMobileViewport } from '../../store/uiStore'; @@ -55,6 +56,8 @@ interface NavItem { scoped?: boolean; /** true → 클릭 불가한 소그룹 구분 헤더(시스템관리 20항목 정리용). */ section?: boolean; + /** 노출 허용 트랙(미지정=전 트랙). plan §2-3 항목 단위 필터(예: 플로어플랜=agency 숨김). */ + tracks?: Track[]; } interface NavGroupDef { id: string; @@ -64,6 +67,17 @@ interface NavGroupDef { items: NavItem[]; /** 관리자 전용 그룹 — useIsAdmin() 시에만 노출. */ admin?: boolean; + /** 노출 허용 트랙(미지정=전 트랙). plan §2-3 그룹 노출 매트릭스. */ + tracks?: Track[]; +} + +/** plan §2-3 노출 매트릭스 — 트랙 상수(가독성용). system 은 admin 플래그로 별도 게이트. */ +const BIZ_OPS_ADMIN: Track[] = ['business', 'ops', 'admin']; +const WORK_TRACKS: Track[] = ['business', 'ops', 'agency', 'admin']; + +/** tracks 미지정(전 트랙 허용) 또는 현재 트랙 포함 시 노출. */ +function trackAllows(tracks: Track[] | undefined, active: Track): boolean { + return !tracks || tracks.includes(active); } /** 홈 — 로그인 후 랜딩(단일 최상위 링크, 아코디언 밖). */ @@ -80,6 +94,7 @@ const GROUPS: NavGroupDef[] = [ label: '전시 운영', labelKey: 'shell.menu.groups.ops', Icon: IconOperations, + tracks: BIZ_OPS_ADMIN, // agency·visitor ✕ items: [ { key: 'dashboard', label: '대시보드', labelKey: 'shell.menu.items.dashboard', Icon: IconDashboard, scoped: true }, { key: 'exhibitors', label: '참가업체 관리', labelKey: 'shell.menu.items.exhibitors', Icon: IconExhibitors, scoped: true }, @@ -94,8 +109,10 @@ const GROUPS: NavGroupDef[] = [ label: '설계·시공', labelKey: 'shell.menu.groups.design', Icon: IconFloorplan, + tracks: WORK_TRACKS, // business·ops·admin ● + agency 부분(항목 단위) items: [ - { key: 'floorplan', label: '플로어플랜 스튜디오', labelKey: 'shell.menu.items.floorplan', Icon: IconFloorplan, scoped: true }, + // 플로어플랜 편집은 agency 숨김(주최자·홀매니저·관리자 전용). + { key: 'floorplan', label: '플로어플랜 스튜디오', labelKey: 'shell.menu.items.floorplan', Icon: IconFloorplan, scoped: true, tracks: BIZ_OPS_ADMIN }, { key: 'documents', label: '서류·마일스톤', labelKey: 'shell.menu.items.documents', Icon: IconDocument, to: '/docs' }, { key: 'auctions', label: '공사 옥션', labelKey: 'shell.menu.items.auctions', Icon: IconSettlement, to: '/auctions' }, { key: 'contractor', label: '수주 부스', labelKey: 'shell.menu.items.contractor', Icon: IconExhibitors, to: '/contractor/dashboard' }, @@ -107,6 +124,7 @@ const GROUPS: NavGroupDef[] = [ label: '관람객·마케팅', labelKey: 'shell.menu.groups.visitor', Icon: IconUsers, + tracks: BIZ_OPS_ADMIN, // agency·visitor ✕ items: [ { key: 'visitors', label: '관람객', labelKey: 'shell.menu.items.visitors', Icon: IconUsers, to: '/visitors' }, { key: 'checkin', label: '체크인 데스크', labelKey: 'shell.menu.items.checkin', Icon: IconCheckCircle, to: '/visitors/checkin' }, @@ -121,6 +139,7 @@ const GROUPS: NavGroupDef[] = [ label: '정산·분석', labelKey: 'shell.menu.groups.finance', Icon: IconAnalytics, + tracks: BIZ_OPS_ADMIN, // agency·visitor ✕ items: [ { key: 'settlement', label: '정산', labelKey: 'shell.menu.items.settlement', Icon: IconSettlement, to: '/settlement' }, { key: 'analytics', label: '경영분석', labelKey: 'shell.menu.items.analytics', Icon: IconAnalytics, to: '/analytics' }, @@ -131,6 +150,7 @@ const GROUPS: NavGroupDef[] = [ label: '업무 공통', labelKey: 'shell.menu.groups.work', Icon: IconDocument, + tracks: WORK_TRACKS, // 전 업무 트랙 ● (visitor ✕) items: [ { key: 'worklog', label: '업무일지', labelKey: 'shell.menu.items.worklog', Icon: IconDocument, to: '/work/worklog' }, { key: 'work-schedule', label: '일정', labelKey: 'shell.menu.items.workSchedule', Icon: IconCalendar, to: '/work/schedule' }, @@ -232,6 +252,7 @@ export function AppShell() { const openTab = useMdiStore((s) => s.openTab); const resolvedEventId = useResolvedEventId(); const isAdmin = useIsAdmin(); + const activeTrack = useAuthStore((s) => s.activeTrack()); const isDark = themeResolved === 'dark'; const { t } = useTranslation(); @@ -274,7 +295,10 @@ export function AppShell() { closeOnMobile(); } - const visibleGroups = GROUPS.filter((g) => !g.admin || isAdmin); + // 관리자 그룹은 isAdmin 게이트 유지 + 트랙 노출 매트릭스(plan §2-3). 미지정 그룹=전 트랙. + const visibleGroups = GROUPS.filter( + (g) => (!g.admin || isAdmin) && trackAllows(g.tracks, activeTrack), + ); return (
@@ -343,7 +367,9 @@ export function AppShell() { {expanded && (
    - {group.items.map((item) => + {group.items + .filter((item) => trackAllows(item.tracks, activeTrack)) + .map((item) => item.section ? ( // 소그룹 구분 헤더(클릭 불가) — 시스템관리 20항목 정리(WISE 구분선 패턴).