레이아웃 변경

This commit is contained in:
Hyojin Ahn 2026-06-19 15:17:54 -04:00
parent cc714be650
commit ed8a0c5777
9 changed files with 315 additions and 82 deletions

View File

@ -1,14 +1,53 @@
// src/app/App.jsx // src/app/App.jsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './providers/AuthProvider' import { AuthProvider, useAuth } from './providers/AuthProvider'
import MainLayout from '../layouts/MainLayout' import AppLayout from '../layouts/AppLayout'
import AuthLayout from '../layouts/AuthLayout' import AuthLayout from '../layouts/AuthLayout'
import Placeholder from './Placeholder'
import Login from '../auth/pages/Login' import Login from '../auth/pages/Login'
import Home from '../auth/pages/Home' import Home from '../auth/pages/Home'
import Admin from '../auth/pages/Admin' import Admin from '../auth/pages/Admin'
import ProductsPage from '../features/products/ProductsPage' import ProductsPage from '../features/products/ProductsPage'
import DriverDailyStatPage from '../features/crm/DriverDailyStatPage' import DriverDailyStatPage from '../features/crm/DriverDailyStatPage'
import { RequireAuth, RequireRole } from '../auth/authGuard' 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() { function AppRoutes() {
const { user } = useAuth() const { user } = useAuth()
@ -28,15 +67,16 @@ function AppRoutes() {
path="/" path="/"
element={ element={
<RequireAuth> <RequireAuth>
<MainLayout /> <AppLayout />
</RequireAuth> </RequireAuth>
} }
> >
<Route index element={<Home />} /> <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 <Route
path="admin" path="admin"
element={ element={
@ -45,6 +85,9 @@ function AppRoutes() {
</RequireRole> </RequireRole>
} }
/> />
{/* 알 수 없는 경로는 홈으로 */}
<Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
</Routes> </Routes>
) )

11
src/app/Placeholder.jsx Normal file
View File

@ -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>
)
}

View File

@ -116,8 +116,11 @@ export default function DistanceStrip({ rows }) {
] ]
const lineH = 16 const lineH = 16
const tw = 176, th = 24 + rows.length * lineH 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 ( return (
<g pointerEvents="none"> <g pointerEvents="none">
<rect x={tx} y={ty} width={tw} height={th} rx="8" <rect x={tx} y={ty} width={tw} height={th} rx="8"

59
src/layouts/AppLayout.css Normal file
View File

@ -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; }

59
src/layouts/AppLayout.jsx Normal file
View File

@ -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>
)
}

View File

@ -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>
)
}

63
src/layouts/Sidebar.jsx Normal file
View File

@ -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>
)
}

View File

@ -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>
)
}

70
src/lib/navConfig.js Normal file
View File

@ -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
}