레이아웃 변경
This commit is contained in:
parent
cc714be650
commit
ed8a0c5777
|
|
@ -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': <DriverDailyStatPage />,
|
||||
'/opr/products': <ProductsPage />,
|
||||
}
|
||||
|
||||
// navConfig.NAV 를 펼쳐 라우트를 생성한다.
|
||||
// - element: REAL_PAGES에 있으면 실제 페이지, 없으면 Placeholder
|
||||
// - 모듈에 role이 있으면 해당 라우트를 <RequireRole>로 감싼다.
|
||||
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] ?? <Placeholder title={it.label} />
|
||||
const element = mod.role
|
||||
? <RequireRole role={mod.role}>{page}</RequireRole>
|
||||
: page
|
||||
return <Route key={it.to} path={childSeg} element={element} />
|
||||
})
|
||||
|
||||
// /hcm 처럼 모듈 base로 오면 첫 서브메뉴로 보낸다.
|
||||
// → 경로가 /hcm/* 로 유지되어 사이드바가 뜨고 탑 탭이 활성화된다.
|
||||
// (빈 '모듈 홈' 화면을 원하면 아래 index element를 <Placeholder.../>로 바꾸면 됨)
|
||||
return (
|
||||
<Route key={mod.key} path={seg}>
|
||||
{first && <Route index element={<Navigate to={firstSeg} replace />} />}
|
||||
{children}
|
||||
</Route>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { user } = useAuth()
|
||||
|
|
@ -28,15 +67,16 @@ function AppRoutes() {
|
|||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<MainLayout />
|
||||
<AppLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="products" element={<ProductsPage />} />
|
||||
<Route path="crm/driver-daily-stat" element={<DriverDailyStatPage />} />
|
||||
|
||||
{/* Admin-only route: front-end guard for UX; enforce the role on the server too. */}
|
||||
{/* navConfig 기반 자동 생성 라우트 (HR/ACC/CS/OPR/SYS/CONFIG) */}
|
||||
{buildModuleRoutes()}
|
||||
|
||||
{/* navConfig에 없지만 유지하는 관리자 전용 라우트 */}
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
|
|
@ -45,6 +85,9 @@ function AppRoutes() {
|
|||
</RequireRole>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 알 수 없는 경로는 홈으로 */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
// 아직 구현되지 않은 메뉴를 위한 임시 페이지.
|
||||
// navConfig의 메뉴를 모두 클릭 가능하게(404 없이) 만들어 두기 위한 스캐폴드.
|
||||
// 실제 페이지가 생기면 App.jsx의 REAL_PAGES에 등록하면 이 자리를 대체한다.
|
||||
export default function Placeholder({ title }) {
|
||||
return (
|
||||
<div style={{ padding: 8 }}>
|
||||
<h2 style={{ margin: '0 0 8px' }}>{title}</h2>
|
||||
<p style={{ color: '#6b7280' }}>준비 중인 화면입니다. (페이지 컴포넌트를 연결하세요)</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<g pointerEvents="none">
|
||||
<rect x={tx} y={ty} width={tw} height={th} rx="8"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
.app { display: flex; flex-direction: column; min-height: 100vh; }
|
||||
|
||||
.app-top {
|
||||
display: flex; align-items: center; gap: 24px;
|
||||
height: 52px; padding: 0 20px;
|
||||
background: #2e7d32; color: #fff; /* 현재 톤 유지하려면 #4CAF50 */
|
||||
}
|
||||
.app-brand { font-weight: 700; letter-spacing: .3px; }
|
||||
.topnav { display: flex; gap: 4px; }
|
||||
.topnav-item {
|
||||
padding: 6px 14px; border-radius: 6px; font-size: 14px;
|
||||
color: #eafff0; text-decoration: none; opacity: .85;
|
||||
}
|
||||
.topnav-item:hover { background: rgba(255,255,255,.12); opacity: 1; }
|
||||
.topnav-item.active { background: rgba(255,255,255,.2); opacity: 1; font-weight: 600; }
|
||||
.app-user { margin-left: auto; display: flex; align-items: center; gap: 12px; font-size: 14px; }
|
||||
.app-logout {
|
||||
height: 28px; padding: 0 10px; font-size: 13px;
|
||||
border: 1px solid rgba(255,255,255,.5); border-radius: 6px;
|
||||
background: transparent; color: #fff; cursor: pointer;
|
||||
}
|
||||
|
||||
.app-body { display: flex; flex: 1; min-height: 0; }
|
||||
.app-main { flex: 1; min-width: 0; padding: 20px 24px; background: #fafbfa; }
|
||||
|
||||
.sb {
|
||||
flex: 0 0 220px; width: 220px; background: #fff;
|
||||
border-right: 1px solid #eceef0; display: flex; flex-direction: column;
|
||||
transition: flex-basis .15s ease, width .15s ease;
|
||||
}
|
||||
.sb--collapsed { flex-basis: 56px; width: 56px; }
|
||||
.sb-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
height: 44px; padding: 0 8px 0 14px; border-bottom: 1px solid #f0f2f0;
|
||||
}
|
||||
.sb--collapsed .sb-head { justify-content: center; padding: 0; }
|
||||
.sb-title { font-size: 13px; font-weight: 600; color: #5b6b60; }
|
||||
.sb-toggle {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border: none; background: transparent;
|
||||
color: #8a948c; cursor: pointer; border-radius: 6px;
|
||||
}
|
||||
.sb-toggle:hover { background: #f2f4f2; color: #5b6b60; }
|
||||
.sb-nav { padding: 8px 0; display: flex; flex-direction: column; }
|
||||
.sb-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
height: 40px; margin: 2px 8px; padding: 0 12px;
|
||||
color: #3a463e; text-decoration: none; border-radius: 8px;
|
||||
white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
.sb-item:hover { background: #f6faf7; }
|
||||
.sb-item.active { background: #eef3ef; color: #2e7d32; font-weight: 600; }
|
||||
.sb-ico { flex: 0 0 20px; display: inline-flex; justify-content: center; color: #5b6b60; }
|
||||
.sb-item.active .sb-ico { color: #2e7d32; }
|
||||
.sb--collapsed .sb-item { justify-content: center; padding: 0; }
|
||||
/* 브랜드를 홈 링크로 사용 */
|
||||
.app-brand { color: #fff; text-decoration: none; }
|
||||
.app-brand:hover { opacity: .9; }
|
||||
.app-username { font-weight: 500; }
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import Sidebar from './Sidebar'
|
||||
import { NAV, canSee, moduleKeyFor } from '../lib/navConfig'
|
||||
import { useAuth } from '../app/providers/AuthProvider'
|
||||
import { fetchWithCred } from '../lib/api'
|
||||
import { authUrl } from '../lib/endpoints'
|
||||
import './AppLayout.css'
|
||||
|
||||
export default function AppLayout() {
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { user, role, logout } = useAuth()
|
||||
|
||||
// 현재 경로가 속한 모듈 → 사이드바가 어떤 서브메뉴를 보여줄지 결정.
|
||||
const activeKey = moduleKeyFor(pathname)
|
||||
|
||||
// 권한에 맞는 상단 탭만 노출 (SYS/CONFIG 등은 admin에게만).
|
||||
const modules = NAV.filter((m) => 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 (
|
||||
<div className="app">
|
||||
<header className="app-top">
|
||||
<NavLink to="/" end className="app-brand">GREEN FIELD</NavLink>
|
||||
|
||||
<nav className="topnav">
|
||||
{modules.map((m) => (
|
||||
<NavLink key={m.key} to={m.base} className="topnav-item">
|
||||
{m.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="app-user">
|
||||
<span className="app-username">
|
||||
{[user?.firstName, user?.lastName].filter(Boolean).join(' ') || user?.username || 'User'}
|
||||
</span>
|
||||
<button className="app-logout" onClick={handleLogout}>Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="app-body">
|
||||
<Sidebar moduleKey={activeKey} />
|
||||
<main className="app-main"><Outlet /></main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="layout">
|
||||
<TopBar />
|
||||
|
||||
<div className="body">
|
||||
<aside className="sidebar">
|
||||
<ul>
|
||||
<li><Link to="/">Home</Link></li>
|
||||
<li><Link to="/crm/driver-daily-stat">Dashboard</Link></li>
|
||||
{/* Admin link is shown only to admins (UX); the route + server also enforce it. */}
|
||||
{role === 'admin' && <li><Link to="/admin">Admin</Link></li>}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main className="content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d={d} /></svg>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<aside className={`sb ${collapsed ? 'sb--collapsed' : ''}`}>
|
||||
<div className="sb-head">
|
||||
{!collapsed && <span className="sb-title">{mod.label}</span>}
|
||||
<button className="sb-toggle" onClick={() => setCollapsed((c) => !c)}
|
||||
aria-label={collapsed ? '메뉴 펼치기' : '메뉴 접기'}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ transform: collapsed ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}>
|
||||
<path d="M15 6l-6 6 6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav className="sb-nav">
|
||||
{items.map((it) => (
|
||||
<NavLink key={it.to} to={it.to} className="sb-item"
|
||||
title={collapsed ? it.label : undefined}>
|
||||
<span className="sb-ico"><Icon name={it.icon} /></span>
|
||||
{!collapsed && <span className="sb-label">{it.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<header className="topbar">
|
||||
<div className="logo">GREEN FIELD</div>
|
||||
|
||||
<nav className="top-menu">
|
||||
<span>HR</span>
|
||||
<span>ACCOUNT</span>
|
||||
<span>CUSTOMER</span>
|
||||
<span>OPERATION</span>
|
||||
</nav>
|
||||
|
||||
<div className="user-info">
|
||||
<span className="user-name">
|
||||
{user?.firstName} {user?.lastName}
|
||||
</span>
|
||||
<button className="logout-btn" onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
|
@ -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의 <RequireRole>이 담당한다. 프런트 메뉴만으로 보안을 신뢰하지 말 것.
|
||||
|
||||
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
|
||||
}
|
||||
Loading…
Reference in New Issue