diff --git a/src/app/App.jsx b/src/app/App.jsx index 25665ad..8833bef 100644 --- a/src/app/App.jsx +++ b/src/app/App.jsx @@ -1,14 +1,53 @@ // src/app/App.jsx import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { AuthProvider, useAuth } from './providers/AuthProvider' -import MainLayout from '../layouts/MainLayout' +import AppLayout from '../layouts/AppLayout' import AuthLayout from '../layouts/AuthLayout' +import Placeholder from './Placeholder' import Login from '../auth/pages/Login' import Home from '../auth/pages/Home' import Admin from '../auth/pages/Admin' import ProductsPage from '../features/products/ProductsPage' import DriverDailyStatPage from '../features/crm/DriverDailyStatPage' import { RequireAuth, RequireRole } from '../auth/authGuard' +import { NAV } from '../lib/navConfig' + +// 실제 구현된 페이지만 여기에 등록. 나머지 메뉴는 자동으로 Placeholder로 채워진다. +// key는 navConfig item의 `to`(절대경로)와 일치시킨다. +const REAL_PAGES = { + '/crm/driver-daily-stat': , + '/opr/products': , +} + +// navConfig.NAV 를 펼쳐 라우트를 생성한다. +// - element: REAL_PAGES에 있으면 실제 페이지, 없으면 Placeholder +// - 모듈에 role이 있으면 해당 라우트를 로 감싼다. +function buildModuleRoutes() { + return NAV.map((mod) => { + const seg = mod.base.replace(/^\//, '') // '/hcm' -> 'hcm' + const first = mod.items[0] + const firstSeg = first ? first.to.replace(mod.base + '/', '') : '' // '/hcm/employees' -> 'employees' + + const children = mod.items.map((it) => { + const childSeg = it.to.replace(mod.base + '/', '') // 모듈 기준 상대경로 + const page = REAL_PAGES[it.to] ?? + const element = mod.role + ? {page} + : page + return + }) + + // /hcm 처럼 모듈 base로 오면 첫 서브메뉴로 보낸다. + // → 경로가 /hcm/* 로 유지되어 사이드바가 뜨고 탑 탭이 활성화된다. + // (빈 '모듈 홈' 화면을 원하면 아래 index element를 로 바꾸면 됨) + return ( + + {first && } />} + {children} + + ) + }) +} function AppRoutes() { const { user } = useAuth() @@ -28,15 +67,16 @@ function AppRoutes() { path="/" element={ - + } > } /> - } /> - } /> - {/* Admin-only route: front-end guard for UX; enforce the role on the server too. */} + {/* navConfig 기반 자동 생성 라우트 (HR/ACC/CS/OPR/SYS/CONFIG) */} + {buildModuleRoutes()} + + {/* navConfig에 없지만 유지하는 관리자 전용 라우트 */} } /> + + {/* 알 수 없는 경로는 홈으로 */} + } /> ) diff --git a/src/app/Placeholder.jsx b/src/app/Placeholder.jsx new file mode 100644 index 0000000..07d16a4 --- /dev/null +++ b/src/app/Placeholder.jsx @@ -0,0 +1,11 @@ +// 아직 구현되지 않은 메뉴를 위한 임시 페이지. +// navConfig의 메뉴를 모두 클릭 가능하게(404 없이) 만들어 두기 위한 스캐폴드. +// 실제 페이지가 생기면 App.jsx의 REAL_PAGES에 등록하면 이 자리를 대체한다. +export default function Placeholder({ title }) { + return ( +
+

{title}

+

준비 중인 화면입니다. (페이지 컴포넌트를 연결하세요)

+
+ ) +} diff --git a/src/features/crm/DistanceStrip.jsx b/src/features/crm/DistanceStrip.jsx index a370c7d..8aebffb 100644 --- a/src/features/crm/DistanceStrip.jsx +++ b/src/features/crm/DistanceStrip.jsx @@ -116,8 +116,11 @@ export default function DistanceStrip({ rows }) { ] const lineH = 16 const tw = 176, th = 24 + rows.length * lineH - const tx = Math.min(Math.max(d.mx - tw / 2, 6), W - tw - 6) - const ty = Math.max(6, CY - R - th - 8) + // 마커를 가리지 않게 옆에 배치 (오른쪽 우선, 공간 없으면 왼쪽) + let tx = d.mx + R + 12 + if (tx + tw > W - 6) tx = d.mx - R - 12 - tw + tx = Math.min(Math.max(tx, 6), W - tw - 6) + const ty = Math.min(Math.max(CY - th / 2, 6), H - th - 6) return ( canSee(m, role)) + + const handleLogout = async () => { + try { + await fetchWithCred(authUrl('/auth/logout'), { method: 'POST' }) + } catch (e) { + // 서버 에러가 나도 프런트는 로그아웃 처리한다. + console.warn('Logout request failed', e) + } finally { + logout() + navigate('/login', { replace: true }) + } + } + + return ( +
+
+ GREEN FIELD + + + +
+ + {[user?.firstName, user?.lastName].filter(Boolean).join(' ') || user?.username || 'User'} + + +
+
+ +
+ +
+
+
+ ) +} diff --git a/src/layouts/MainLayout.jsx b/src/layouts/MainLayout.jsx deleted file mode 100644 index adcfbac..0000000 --- a/src/layouts/MainLayout.jsx +++ /dev/null @@ -1,29 +0,0 @@ -// src/layouts/MainLayout.jsx -import { Outlet, Link } from 'react-router-dom' -import TopBar from './TopBar' -import { useAuth } from '../app/providers/AuthProvider' - -export default function MainLayout() { - const { role } = useAuth() - - return ( -
- - -
- - -
- -
-
-
- ) -} diff --git a/src/layouts/Sidebar.jsx b/src/layouts/Sidebar.jsx new file mode 100644 index 0000000..7e9d000 --- /dev/null +++ b/src/layouts/Sidebar.jsx @@ -0,0 +1,63 @@ +import { useState } from 'react' +import { NavLink } from 'react-router-dom' +import { NAV, canSee } from '../lib/navConfig' +import { useAuth } from '../app/providers/AuthProvider' + +function Icon({ name }) { + const d = { + chart: 'M4 19V5M4 19h16M8 16v-5M12 16V8M16 16v-3', + users: 'M9 11a3 3 0 100-6 3 3 0 000 6zM4 19a5 5 0 0110 0M17 11a3 3 0 000-6', + truck: 'M3 7h11v8H3zM14 10h3l3 3v2h-6zM7 18a2 2 0 100-4 2 2 0 000 4zM18 18a2 2 0 100-4 2 2 0 000 4z', + wallet: 'M3 8a1 1 0 011-1h15v11a1 1 0 01-1 1H4a1 1 0 01-1-1zM16 12h.01', + clipboard: 'M9 4h6v3H9zM7 5H5v15h14V5h-2', + clock: 'M12 21a9 9 0 100-18 9 9 0 000 18zM12 7v5l3 2', + book: 'M5 4h12a1 1 0 011 1v15l-7-3-7 3V5a1 1 0 011-1z', + box: 'M21 8l-9-5-9 5 9 5 9-5zM3 8v8l9 5M21 8v8l-9 5', + route: 'M6 19a2 2 0 100-4 2 2 0 000 4zM18 9a2 2 0 100-4 2 2 0 000 4zM8 17h6a3 3 0 003-3V9', + shield: 'M12 3l8 3v6c0 4-3.5 7-8 9-4.5-2-8-5-8-9V6l8-3z', + list: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01', + history: 'M3 12a9 9 0 109-9 9 9 0 00-7.5 4M3 4v4h4M12 8v4l3 2', + gear: 'M12 15a3 3 0 100-6 3 3 0 000 6zM19 12a7 7 0 00-.1-1l2-1.6-2-3.4-2.4 1a7 7 0 00-1.7-1l-.4-2.5H9.6L9.2 6a7 7 0 00-1.7 1l-2.4-1-2 3.4 2 1.6a7 7 0 000 2l-2 1.6 2 3.4 2.4-1a7 7 0 001.7 1l.4 2.5h4.8l.4-2.5a7 7 0 001.7-1l2.4 1 2-3.4-2-1.6a7 7 0 00.1-1z', + plug: 'M9 2v6M15 2v6M7 8h10v3a5 5 0 01-10 0V8zM12 16v6', + }[name] || 'M5 12h14' + return ( + + ) +} + +export default function Sidebar({ moduleKey }) { + const [collapsed, setCollapsed] = useState(false) + const { role } = useAuth() + + const mod = NAV.find((m) => m.key === moduleKey) + if (!mod) return null + + // 모듈은 보이지만 일부 서브메뉴만 권한이 걸린 경우도 필터. + const items = mod.items.filter((it) => canSee(it, role)) + + return ( + + ) +} diff --git a/src/layouts/TopBar.jsx b/src/layouts/TopBar.jsx deleted file mode 100644 index 299286e..0000000 --- a/src/layouts/TopBar.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useNavigate } from 'react-router-dom' -import { useAuth } from '../app/providers/AuthProvider' -import { fetchWithCred } from '../lib/api' -import { authUrl } from '../lib/endpoints' - -export default function TopBar() { - const { user, logout } = useAuth() - const navigate = useNavigate() - - const handleLogout = async () => { - try { - await fetchWithCred( - authUrl('/auth/logout'), - { method: 'POST' } - ) - } catch (e) { - // 서버 에러 나도 프론트는 로그아웃 처리 - console.warn('Logout request failed', e) - } finally { - logout() - navigate('/login', { replace: true }) - } - } - - return ( -
-
GREEN FIELD
- - - -
- - {user?.firstName} {user?.lastName} - - -
-
- ) -} diff --git a/src/lib/navConfig.js b/src/lib/navConfig.js new file mode 100644 index 0000000..126f817 --- /dev/null +++ b/src/lib/navConfig.js @@ -0,0 +1,70 @@ +// navConfig.js +// 메뉴의 단일 소스(single source of truth). +// 상단 메인메뉴(모듈) + 각 모듈의 사이드바 서브메뉴를 한 곳에서 정의한다. +// +// key : 모듈 식별자 (활성 모듈 판별에 사용) +// label : 상단 탭에 보이는 이름 +// base : 이 모듈에 속하는 경로의 prefix. pathname.startsWith(base)로 활성 판별. +// role : (선택) 이 모듈을 볼 수 있는 권한. 생략하면 누구나 노출. +// 단일 문자열 'admin' 또는 배열 ['admin','manager'] 모두 가능. +// items : 사이드바 서브메뉴. 각 item도 선택적으로 role을 가질 수 있다. +// +// ⚠️ role은 어디까지나 UX용(메뉴 숨김)일 뿐, 실제 접근 통제는 서버(Spring Security)와 +// App.jsx의 이 담당한다. 프런트 메뉴만으로 보안을 신뢰하지 말 것. + +export const NAV = [ + { + key: 'hcm', label: 'HR', base: '/hcm', items: [ + { label: '직원 관리', to: '/hcm/employees', icon: 'users' }, + { label: '근태 관리', to: '/hcm/attendance', icon: 'clock' }, + { label: '급여', to: '/hcm/payroll', icon: 'wallet' }, + ], + }, + { + key: 'acc', label: 'ACC', base: '/acc', items: [ + { label: '정산', to: '/acc/settlements', icon: 'wallet' }, + { label: '매출/매입 원장', to: '/acc/ledger', icon: 'book' }, + { label: '세금계산서', to: '/acc/invoices', icon: 'clipboard' }, + ], + }, + { + key: 'crm', label: 'CS', base: '/crm', items: [ + { label: '드라이버 운행 현황', to: '/crm/driver-daily-stat', icon: 'chart' }, + { label: '고객 관리', to: '/crm/customers', icon: 'users' }, + { label: '일일 오더', to: '/crm/daily-orders', icon: 'clipboard' }, + ], + }, + { + key: 'opr', label: 'OPR', base: '/opr', items: [ + { label: '상품(Products)', to: '/opr/products', icon: 'box' }, + { label: '차량 관리', to: '/opr/vehicles', icon: 'truck' }, + { label: '배차', to: '/opr/dispatch', icon: 'route' }, + ], + }, + { + key: 'sys', label: 'SYS', base: '/sys', role: 'admin', items: [ + { label: '사용자/권한', to: '/sys/users', icon: 'shield' }, + { label: '공통 코드', to: '/sys/codes', icon: 'list' }, + { label: '감사 로그', to: '/sys/audit', icon: 'history' }, + ], + }, + { + key: 'config', label: 'CONFIG', base: '/config', role: 'admin', items: [ + { label: '환경설정', to: '/config/settings', icon: 'gear' }, + { label: '연동 관리', to: '/config/integrations', icon: 'plug' }, + ], + }, +] + +// 권한 헬퍼: entry(모듈 또는 item)의 role과 사용자 role을 비교. +// role이 없으면 항상 true(공개). +export function canSee(entry, userRole) { + if (!entry?.role) return true + const allowed = Array.isArray(entry.role) ? entry.role : [entry.role] + return allowed.includes(userRole) +} + +// 현재 경로(pathname)에 해당하는 모듈 key를 찾는다. 없으면 null. +export function moduleKeyFor(pathname) { + return NAV.find((m) => pathname.startsWith(m.base))?.key ?? null +}