Refactor: Modular architecture, CCTV fix, License sync, UI improvements
This commit is contained in:
parent
59e876c838
commit
540f4281cc
23
.cursorrules
Normal file
23
.cursorrules
Normal file
@ -0,0 +1,23 @@
|
||||
모든 답변은 한국어로 제시,
|
||||
제공되는 md 파일도 한국어로 제공,
|
||||
git push 등은 요청이 있을 때 만 수행,
|
||||
|
||||
📘 SOKUREE 플랫폼 개발 규칙 (Architecture Rules)
|
||||
[원칙: 느슨한 결합(Loose Coupling) 및 높은 응집도(High Cohesion)]
|
||||
플랫폼(Platform)의 최소 범위: 플랫폼은 전체 레이아웃(Header, Sidebar), 인증(AuthContext), 그리고 공통 테마(CSS Variables)만 관리한다. 모듈의 내부 비즈니스 로직에 깊게 관여하지 않는다.
|
||||
모듈(Module)의 독립성: 모든 비즈니스 기능은 src/modules/[module-name]/ 폴더 내에 응집되어야 한다. 모듈은 독립적인 Route 정보를 가져야 하며, 플랫폼과는 정의된 인터페이스로만 소통한다.
|
||||
[디렉토리 구조 상세 및 역할]
|
||||
src/core/: 플랫폼 엔진의 인터페이스 및 공통 타입 정의 (types.ts).
|
||||
src/platform/: 시스템의 뼈대 구현 (App.tsx, ModuleLoader.tsx, 공통 스타일).
|
||||
src/modules/[name]/: 개별 비즈니스 영역. module.tsx 파일이 모듈의 진입점(Entry Point)이 된다.
|
||||
shared/: 여러 모듈에서 공통으로 재사용하는 순수 유틸리티나 무상태(Stateless) 컴포넌트.
|
||||
[인터페이스 준수 사항]
|
||||
모든 모듈은 src/core/types.ts에 정의된 IModuleDefinition을 구현해야 한다.
|
||||
모든 모듈은 자신의 기능을 routes 배열로 정의하고, 플랫폼의 App.tsx에 등록함으로써 기능을 주입(Injection)한다.
|
||||
플랫폼 코드 내의 Routes에 모듈별 경로를 하드코딩하지 않으며, 반드시 ModuleLoader를 통해 동적으로 로드한다.
|
||||
[스타일링 및 디자인 시스템 규칙]
|
||||
CSS Variables 사용 강제: 색상, 간격, 둥근 모서리 등 브랜드 요소는 직접 값을 쓰지 않고 반드시 플랫폼이 제공하는 --sokuree-* 변수를 사용한다.
|
||||
예: color: var(--sokuree-brand-primary);, padding: var(--sokuree-spacing-md);
|
||||
기술 스택 자유도: 모듈 내부적으로는 Tailwind, CSS Modules, Styled-components 등을 자유롭게 선택할 수 있으나, 플랫폼의 디자인 토큰은 항상 준수해야 한다.
|
||||
[권한 및 보안]
|
||||
모듈은 requiredRoles 속성을 통해 접근 권한을 선언해야 한다.
|
||||
97
docs/MODULAR_ARCHITECTURE_GUIDE.md
Normal file
97
docs/MODULAR_ARCHITECTURE_GUIDE.md
Normal file
@ -0,0 +1,97 @@
|
||||
# SOKUREE 플랫폼 모듈화 아키텍처 가이드 (Modular Architecture Guide)
|
||||
|
||||
본 문서는 SOKUREE 플랫폼의 "느슨한 결합(Loose Coupling)"과 "높은 응집도(High Cohesion)" 원칙을 실현하기 위한 모듈화 아키텍처 설계 및 개발 가이드를 제공합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 아키텍처 설계 원칙
|
||||
|
||||
### 1.1 Minimal Platform Scope (최소 플랫폼 범위)
|
||||
플랫폼(Core/Platform)은 전체 시스템의 뼈대만 관리하며, 각 비즈니스 모듈의 내부 구현에는 관여하지 않습니다.
|
||||
- **플랫폼 역할**: 전체 레이아웃(헤더, 사이드바), 인증 및 세션 관리, 동적 모듈 로딩, 공통 디자인 토큰 제공.
|
||||
- **모듈 역할**: 비즈니스 로직, 독립적인 UI 구성, 자체 라우팅 정보 정의.
|
||||
|
||||
### 1.2 Inversion of Control (제어의 역전)
|
||||
플랫폼이 모듈을 직접 import 하여 배치하는 대신, 모듈이 자신의 정보를 플랫폼에 주입(Injection)하는 방식으로 동작합니다. 이를 통해 새로운 모듈 추가 시 플랫폼 코드 수정을 최소화합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 폴더 구조 (Project Structure)
|
||||
|
||||
```text
|
||||
src/
|
||||
├── core/ # 플랫폼의 뇌 (핵심 비즈니스 규칙 및 인터페이스)
|
||||
│ └── types.ts # IModuleDefinition 등 공통 타입 정의
|
||||
├── platform/ # 플랫폼의 뼈대 (UI 레이아웃 및 인프라)
|
||||
│ ├── App.tsx # 메인 앱 엔트리 및 레이아웃 구성
|
||||
│ ├── ModuleLoader.tsx # 모듈 라우트를 동적으로 결합하는 컴포넌트
|
||||
│ └── styles/
|
||||
│ └── global.css # CSS Variables (Design Tokens) 선언
|
||||
├── modules/ # 비즈니스 모듈 (독립적인 영역)
|
||||
│ ├── asset/ # 자산 관리 모듈
|
||||
│ ├── cctv/ # CCTV 모듈
|
||||
│ └── ...
|
||||
└── shared/ # 공통 유틸리티 및 컴포넌트 (선택 사항)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 핵심 인터페이스 (Core Interfaces)
|
||||
|
||||
### 3.1 IModuleDefinition
|
||||
모듈이 플랫폼에 등록되기 위해 반드시 준수해야 하는 규격입니다.
|
||||
|
||||
```typescript
|
||||
export interface IModuleDefinition {
|
||||
moduleName: string; // 모듈 식별자 (Global Unique)
|
||||
basePath: string; // 모듈의 기본 URL 경로 (예: '/asset')
|
||||
routes: IModuleRoute[]; // 모듈 내부의 세부 라우팅 정보
|
||||
requiredRoles?: string[]; // (Optional) 접근을 위한 권한 정보
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 IModuleRoute
|
||||
모듈 내 개별 페이지 및 메뉴 구성을 위한 정보입니다.
|
||||
|
||||
```typescript
|
||||
export interface IModuleRoute {
|
||||
path: string; // basePath 이후의 상대 경로
|
||||
element: ReactNode; // 렌더링할 컴포넌트
|
||||
label?: string; // 메뉴에 표시될 이름
|
||||
icon?: ReactNode; // 메뉴 아이콘
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 디자인 시스템 (Design Tokens)
|
||||
|
||||
플랫폼은 모듈의 스타일 라이브러리 선택권(Tailwind, CSS-in-JS 등)을 존중하면서도 통일성을 유지하기 위해 **CSS Variables**를 사용합니다.
|
||||
|
||||
- **위치**: `src/platform/styles/global.css`
|
||||
- **사용 방법**:
|
||||
```css
|
||||
/* 모듈 내부 CSS */
|
||||
.my-component {
|
||||
background-color: var(--sokuree-brand-primary);
|
||||
padding: var(--sokuree-spacing-md);
|
||||
border-radius: var(--sokuree-radius-md);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 새 모듈 추가 방법 (Quick Start)
|
||||
|
||||
1. **모듈 정의 생성**: `src/modules/[moduleName]/index.ts` (또는 유사한 위치)에서 `IModuleDefinition` 객체를 내보냅니다.
|
||||
2. **라우트 정의**: 모듈 내부에서 사용할 페이지들을 `routes` 배열에 추가합니다.
|
||||
3. **플랫폼 등록**: `src/platform/App.tsx`의 `sampleModules` (또는 실제 서버에서 받아온 모듈 리스트)에 위 객체를 추가합니다.
|
||||
4. **확인**: 사이드바 메뉴에 자동으로 노출되며 해당 경로 접근 시 정상 렌더링되는지 확인합니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 향후 고려 사항
|
||||
|
||||
- **Dynamic Import**: 모듈이 많아질 경우 `React.lazy`를 통한 코드 분할(Code Splitting) 적용.
|
||||
- **Error Boundary**: 특정 모듈의 런타임 에러가 플랫폼 전체로 전파되지 않도록 `ModuleLoader` 내부에 에러 처리 로직 강화.
|
||||
- **Shared Context**: 플랫폼이 제공하는 사용자 정보 등을 모듈이 쉽게 꺼내 쓸 수 있는 Hook 제공.
|
||||
@ -119,13 +119,20 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
|
||||
const serverSubscriberId = subRows.length > 0 ? subRows[0].setting_value : null;
|
||||
|
||||
if (!serverSubscriberId) {
|
||||
return res.status(400).json({ error: 'Server Subscriber ID is not set. Please configure it in settings first.' });
|
||||
return res.status(400).json({ error: '서버 구독자 ID가 설정되지 않았습니다. [서버 환경 설정]에서 먼저 설정해주세요.' });
|
||||
}
|
||||
|
||||
if (result.subscriberId !== serverSubscriberId) {
|
||||
return res.status(403).json({
|
||||
error: `License Subscriber Mismatch. Key is for '${result.subscriberId}', but Server is '${serverSubscriberId}'.`
|
||||
});
|
||||
// Allow 'dev' type to bypass strict subscriber check, but log it
|
||||
if (result.type === 'dev') {
|
||||
if (result.subscriberId !== serverSubscriberId) {
|
||||
console.warn(`⚠️ Dev License used: Subscriber ID mismatch allowed. Key: ${result.subscriberId}, Server: ${serverSubscriberId}`);
|
||||
}
|
||||
} else {
|
||||
if (result.subscriberId !== serverSubscriberId) {
|
||||
return res.status(403).json({
|
||||
error: `구독자 ID 불일치: 라이선스 키는 [${result.subscriberId}] 전용이지만, 현재 서버는 [${serverSubscriberId}]로 설정되어 있습니다.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Archive Current License if exists
|
||||
@ -133,9 +140,9 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
|
||||
if (current.length > 0 && current[0].is_active && current[0].license_key) {
|
||||
const old = current[0];
|
||||
const historySql = `
|
||||
INSERT INTO license_history (module_code, license_key, license_type, subscriber_id, activated_at)
|
||||
VALUES (?, ?, ?, ?, NOW())
|
||||
`;
|
||||
INSERT INTO license_history (module_code, license_key, license_type, subscriber_id, activated_at)
|
||||
VALUES (?, ?, ?, ?, NOW())
|
||||
`;
|
||||
await db.query(historySql, [old.code, old.license_key, old.license_type, old.subscriber_id]);
|
||||
}
|
||||
|
||||
@ -166,7 +173,10 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
|
||||
await axios.post(`${licenseManagerUrl}/licenses/activate`, { licenseKey });
|
||||
console.log(`✅ Synced activation status for key: ${licenseKey.substring(0, 20)}...`);
|
||||
} catch (syncErr) {
|
||||
console.error('⚠️ Failed to sync status with License Manager:', syncErr.message);
|
||||
const errorDetail = syncErr.response ?
|
||||
`Status: ${syncErr.response.status}, Data: ${JSON.stringify(syncErr.response.data)}` :
|
||||
syncErr.message;
|
||||
console.error(`⚠️ Failed to sync status with License Manager: ${errorDetail}`);
|
||||
// We don't fail the whole activation if sync fails, but we log it
|
||||
}
|
||||
|
||||
|
||||
33
src/core/types.ts
Normal file
33
src/core/types.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* 紐⑤뱢 ??媛쒕퀎 ?쇱슦???뺤쓽
|
||||
*/
|
||||
export interface IModuleRoute {
|
||||
path: string;
|
||||
element: ReactNode;
|
||||
label?: string; // 메뉴 표시 레이블
|
||||
icon?: ReactNode; // 메뉴 아이콘
|
||||
group?: string; // 메뉴 그룹 (옵션)
|
||||
position?: 'sidebar' | 'top'; // 메뉴 위치 (기본값: sidebar)
|
||||
}
|
||||
|
||||
/**
|
||||
* SOKUREE ?뚮옯??紐⑤뱢 ?뺤쓽 ?명꽣?섏씠??
|
||||
* 紐⑤뱺 紐⑤뱢? ???명꽣?섏씠?ㅻ? 以?섑븯???뚮옯?쇱뿉 ?깅줉?섏뼱????
|
||||
*/
|
||||
export interface IModuleDefinition {
|
||||
moduleName: string; // 紐⑤뱢 ?앸퀎??(?? 'asset-management')
|
||||
basePath: string; // 紐⑤뱢??湲곕낯 寃쎈줈 (?? '/asset')
|
||||
routes: IModuleRoute[]; // 紐⑤뱢 ?대? ?쇱슦???뺣낫
|
||||
requiredRoles?: string[]; // 紐⑤뱢 ?묎렐???꾪븳 ?꾩슂 沅뚰븳 (Optional)
|
||||
description?: string; // 紐⑤뱢 ?ㅻ챸
|
||||
}
|
||||
|
||||
/**
|
||||
* ?뚮옯???덉씠?꾩썐 援ъ꽦???꾪븳 ???
|
||||
*/
|
||||
export interface IPlatformLayoutProps {
|
||||
children: ReactNode;
|
||||
modules: IModuleDefinition[];
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './app/index.css'
|
||||
import App from './app/App.tsx'
|
||||
import './platform/styles/global.css'
|
||||
import App from './platform/App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
||||
26
src/modules/asset/module.tsx
Normal file
26
src/modules/asset/module.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import type { IModuleDefinition } from '../../core/types';
|
||||
import { DashboardPage } from './pages/DashboardPage';
|
||||
import { AssetListPage } from './pages/AssetListPage';
|
||||
import { AssetRegisterPage } from './pages/AssetRegisterPage';
|
||||
import { AssetSettingsPage } from './pages/AssetSettingsPage';
|
||||
import { AssetDetailPage } from './pages/AssetDetailPage';
|
||||
|
||||
export const assetModule: IModuleDefinition = {
|
||||
moduleName: 'asset-management',
|
||||
basePath: '/asset',
|
||||
routes: [
|
||||
{ path: '/dashboard', element: <DashboardPage />, label: '대시보드', group: '기본' },
|
||||
{ path: '/list', element: <AssetListPage />, label: '자산 목록', group: '기본' },
|
||||
{ path: '/register', element: <AssetRegisterPage />, label: '자산 등록', group: '기본' },
|
||||
{ path: '/settings', element: <AssetSettingsPage />, label: '자산 설정', group: '기본' },
|
||||
{ path: '/detail/:assetId', element: <AssetDetailPage /> },
|
||||
|
||||
{ path: '/facilities', element: <AssetListPage />, label: '시설물 관리', position: 'top' },
|
||||
{ path: '/tools', element: <AssetListPage />, label: '공구 관리', position: 'top' },
|
||||
{ path: '/general', element: <AssetListPage />, label: '일반 자산', position: 'top' },
|
||||
{ path: '/consumables', element: <AssetListPage />, label: '소모품 관리', position: 'top' },
|
||||
{ path: '/instruments', element: <AssetListPage />, label: '계측기 관리', position: 'top' },
|
||||
{ path: '/vehicles', element: <AssetListPage />, label: '차량 관리', position: 'top' },
|
||||
],
|
||||
requiredRoles: ['admin', 'manager']
|
||||
};
|
||||
11
src/modules/cctv/module.tsx
Normal file
11
src/modules/cctv/module.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import type { IModuleDefinition } from '../../core/types';
|
||||
import { MonitoringPage } from './pages/MonitoringPage';
|
||||
|
||||
export const cctvModule: IModuleDefinition = {
|
||||
moduleName: 'monitoring-cctv',
|
||||
basePath: '/monitoring',
|
||||
routes: [
|
||||
{ path: '/live', element: <MonitoringPage />, label: '실시간 관제' },
|
||||
],
|
||||
requiredRoles: ['admin', 'operator']
|
||||
};
|
||||
@ -234,7 +234,7 @@ export function MonitoringPage() {
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Video className="text-blue-600" />
|
||||
CCTV
|
||||
CCTV <span className="text-xs text-slate-400 font-normal">({user?.role})</span>
|
||||
</h1>
|
||||
{user?.role === 'admin' && (
|
||||
<button
|
||||
|
||||
11
src/modules/production/module.tsx
Normal file
11
src/modules/production/module.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import type { IModuleDefinition } from '../../core/types';
|
||||
import { ProductionPage } from './pages/ProductionPage';
|
||||
|
||||
export const productionModule: IModuleDefinition = {
|
||||
moduleName: 'production-management',
|
||||
basePath: '/production',
|
||||
routes: [
|
||||
{ path: '/dashboard', element: <ProductionPage />, label: '생산 현황' },
|
||||
],
|
||||
requiredRoles: ['admin', 'manager']
|
||||
};
|
||||
10
src/modules/production/pages/ProductionPage.tsx
Normal file
10
src/modules/production/pages/ProductionPage.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
export function ProductionPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">생산 관리</h1>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p>생산 관리 대시보드입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
src/platform/App.tsx
Normal file
61
src/platform/App.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from '../shared/auth/AuthContext';
|
||||
import { SystemProvider } from '../shared/context/SystemContext';
|
||||
import { AuthGuard } from '../shared/auth/AuthGuard';
|
||||
import { MainLayout } from '../widgets/layout/MainLayout';
|
||||
import { LoginPage } from '../pages/auth/LoginPage';
|
||||
|
||||
// Modules
|
||||
import { assetModule } from '../modules/asset/module';
|
||||
import { cctvModule } from '../modules/cctv/module';
|
||||
import { productionModule } from '../modules/production/module';
|
||||
import ModuleLoader from './ModuleLoader';
|
||||
|
||||
// Platform / System Pages
|
||||
import { UserManagementPage } from './pages/UserManagementPage';
|
||||
import { BasicSettingsPage } from './pages/BasicSettingsPage';
|
||||
import { LicensePage } from '../system/pages/LicensePage';
|
||||
|
||||
import './styles/global.css';
|
||||
|
||||
const modules = [assetModule, cctvModule, productionModule];
|
||||
|
||||
export const App = () => {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SystemProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Protected Routes */}
|
||||
<Route element={
|
||||
<AuthGuard>
|
||||
<MainLayout modulesList={modules} />
|
||||
</AuthGuard>
|
||||
}>
|
||||
{/* Dynamic Module Routes */}
|
||||
<Route path="/*" element={<ModuleLoader modules={modules} />} />
|
||||
|
||||
{/* Navigation Fallback within Layout */}
|
||||
<Route index element={<Navigate to="/asset/dashboard" replace />} />
|
||||
|
||||
{/* Platform Admin Routes (Could also be moved to an Admin module later) */}
|
||||
<Route path="/admin/users" element={<UserManagementPage />} />
|
||||
<Route path="/admin/settings" element={<BasicSettingsPage />} />
|
||||
<Route path="/admin/license" element={<LicensePage />} />
|
||||
<Route path="/admin" element={<Navigate to="/admin/settings" replace />} />
|
||||
</Route>
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</SystemProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
67
src/platform/ModuleLoader.tsx
Normal file
67
src/platform/ModuleLoader.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import type { IModuleDefinition } from '../core/types';
|
||||
import { useAuth } from '../shared/auth/AuthContext';
|
||||
import { useSystem } from '../shared/context/SystemContext';
|
||||
|
||||
interface ModuleLoaderProps {
|
||||
modules: IModuleDefinition[];
|
||||
}
|
||||
|
||||
/**
|
||||
* ModuleLoader Component
|
||||
*
|
||||
* ?깅줉??紐⑤뱺 紐⑤뱢???쇱슦???뺣낫瑜?湲곕컲?쇰줈
|
||||
* react-router-dom??Routes 援ъ꽦???숈쟻?쇰줈 ?앹꽦?⑸땲??
|
||||
* ?먰븳 紐⑤뱢 ?쒖꽦???곹깭 諛??ъ슜??沅뚰븳??寃利앺빀?덈떎.
|
||||
*/
|
||||
export const ModuleLoader = ({ modules }: ModuleLoaderProps) => {
|
||||
const { user } = useAuth();
|
||||
const { modules: activeModules, isLoading } = useSystem();
|
||||
|
||||
if (isLoading) {
|
||||
return <div style={{ padding: '2rem', textAlign: 'center' }}>紐⑤뱢 濡쒕뵫 以?..</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{modules.map((module) => {
|
||||
// 1. 紐⑤뱢 ?쒖꽦???щ? 泥댄겕 (SystemContext 湲곕컲)
|
||||
// moduleName??mapping 肄붾뱶? ?ㅻ? ???덉쑝誘濡?留ㅽ븨 濡쒖쭅???꾩슂?????덉쓬
|
||||
// ?ш린?쒕뒗 ?덉떆濡?moduleName???욌떒???깆쓣 ?ъ슜?섍굅??吏곸젒 留ㅽ븨?쒕떎怨?媛??
|
||||
const moduleKey = module.moduleName.split('-')[0]; // 'asset-management' -> 'asset'
|
||||
const isActive = activeModules[moduleKey]?.active;
|
||||
|
||||
if (!isActive) return null;
|
||||
|
||||
// 2. 沅뚰븳 泥댄겕 (Role 湲곕컲)
|
||||
if (module.requiredRoles && user && !module.requiredRoles.includes(user.role)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Route key={module.moduleName} path={module.basePath.startsWith('/') ? module.basePath.substring(1) : module.basePath}>
|
||||
{/* 紐⑤뱢??媛??쒕툕 ?쇱슦??*/}
|
||||
{module.routes.map((route, index) => (
|
||||
<Route
|
||||
key={`${module.moduleName}-route-${index}`}
|
||||
path={route.path.startsWith('/') ? route.path.substring(1) : route.path}
|
||||
element={route.element}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 紐⑤뱢??湲곕낯 寃쎈줈(basePath) ?묎렐 ??泥?踰덉㎏ ?쇱슦?몃줈 由щ떎?대젆??*/}
|
||||
{module.routes.length > 0 && (
|
||||
<Route index element={<Navigate to={`${module.basePath}${module.routes[0].path}`} replace />} />
|
||||
)}
|
||||
</Route>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ?꾨Т 紐⑤뱢??留ㅼ묶?섏? ?딆쓣 寃쎌슦 硫붿씤?쇰줈 */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModuleLoader;
|
||||
|
||||
@ -59,19 +59,49 @@ export function BasicSettingsPage() {
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">세션 타임아웃 (분)</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
type="number"
|
||||
min="5"
|
||||
max="1440"
|
||||
className="w-32"
|
||||
value={settings.session_timeout}
|
||||
onChange={e => setSettings({ ...settings, session_timeout: parseInt(e.target.value) })}
|
||||
/>
|
||||
<span className="text-slate-500 text-sm">분 동안 활동이 없으면 자동으로 로그아웃됩니다.</span>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">세션 타임아웃</label>
|
||||
<div className="flex items-center gap-2 flex-wrap text-sm text-slate-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="24"
|
||||
className="!w-auto !mb-0"
|
||||
style={{ width: '70px', textAlign: 'center' }}
|
||||
value={Math.floor((settings.session_timeout || 10) / 60)}
|
||||
onChange={e => {
|
||||
const newHours = parseInt(e.target.value) || 0;
|
||||
const currentTotal = settings.session_timeout || 10;
|
||||
const currentMinutes = currentTotal % 60;
|
||||
setSettings({ ...settings, session_timeout: (newHours * 60) + currentMinutes });
|
||||
}}
|
||||
placeholder="0"
|
||||
/>
|
||||
<span className="mr-2 whitespace-nowrap text-slate-700 font-medium">시간</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
className="!w-auto !mb-0"
|
||||
style={{ width: '70px', textAlign: 'center' }}
|
||||
value={(settings.session_timeout || 10) % 60}
|
||||
onChange={e => {
|
||||
const newMinutes = parseInt(e.target.value) || 0;
|
||||
const currentTotal = settings.session_timeout || 10;
|
||||
const currentHours = Math.floor(currentTotal / 60);
|
||||
setSettings({ ...settings, session_timeout: (currentHours * 60) + newMinutes });
|
||||
}}
|
||||
placeholder="10"
|
||||
/>
|
||||
<span className="whitespace-nowrap text-slate-700 font-medium">분</span>
|
||||
</div>
|
||||
<span className="text-slate-500 whitespace-nowrap ml-1">동안 활동이 없으면 자동으로 로그아웃됩니다.</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">(최소 5분 ~ 최대 24시간)</p>
|
||||
<p className="text-xs text-slate-400 mt-1 pl-1">
|
||||
( 총 {settings.session_timeout || 10}분 / 최소 5분 ~ 최대 24시간 )
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
98
src/platform/styles/global.css
Normal file
98
src/platform/styles/global.css
Normal file
@ -0,0 +1,98 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/**
|
||||
* SOKUREE Platform Global Design Tokens
|
||||
*/
|
||||
:root {
|
||||
/* Core Colors - Brand Identity */
|
||||
--sokuree-brand-primary: #0EA5E9;
|
||||
--sokuree-brand-secondary: #0F172A;
|
||||
--sokuree-brand-accent: #E2E8F0;
|
||||
|
||||
/* Neutral Colors - Foundations */
|
||||
--sokuree-bg-main: #F1F5F9;
|
||||
--sokuree-bg-sidebar: #1E293B;
|
||||
--sokuree-bg-card: #ffffff;
|
||||
|
||||
--sokuree-text-primary: #0F172A;
|
||||
--sokuree-text-secondary: #64748B;
|
||||
--sokuree-text-muted: #64748B;
|
||||
--sokuree-text-inverse: #F8FAFC;
|
||||
|
||||
/* Borders & Dividers */
|
||||
--sokuree-border-color: #E2E8F0;
|
||||
--sokuree-radius-sm: 0.25rem;
|
||||
--sokuree-radius-md: 0.375rem;
|
||||
--sokuree-radius-lg: 0.5rem;
|
||||
|
||||
/* Spacing System */
|
||||
--sokuree-spacing-xs: 0.25rem;
|
||||
--sokuree-spacing-sm: 0.5rem;
|
||||
--sokuree-spacing-md: 1rem;
|
||||
--sokuree-spacing-lg: 1.5rem;
|
||||
--sokuree-spacing-xl: 2rem;
|
||||
|
||||
/* Layout Dimensions */
|
||||
--sokuree-header-height: 64px;
|
||||
--sokuree-sidebar-width: 260px;
|
||||
|
||||
/* Transition */
|
||||
--transition-base: all 0.2s ease-in-out;
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--sokuree-bg-main);
|
||||
color: var(--sokuree-text-primary);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* SOKUREE Minimal Utilities */
|
||||
.sokuree-card {
|
||||
background-color: var(--sokuree-bg-card);
|
||||
border: 1px solid var(--sokuree-border-color);
|
||||
border-radius: var(--sokuree-radius-md);
|
||||
padding: var(--sokuree-spacing-md);
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
@ -12,7 +12,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--sokuree-radius-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
border: 1px solid transparent;
|
||||
@ -21,53 +21,55 @@
|
||||
}
|
||||
|
||||
.ui-btn:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--color-brand-accent), 0 0 0 4px var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 2px var(--sokuree-brand-accent), 0 0 0 4px var(--sokuree-brand-primary);
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.ui-btn-primary {
|
||||
background-color: var(--color-bg-sidebar);
|
||||
/* Slate 800 */
|
||||
background-color: var(--sokuree-brand-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ui-btn-primary:hover {
|
||||
background-color: #0f172a;
|
||||
/* Slate 900 */
|
||||
background-color: #0284c7;
|
||||
/* Sky 600 - slightly darker than brand primary */
|
||||
}
|
||||
|
||||
.ui-btn-secondary {
|
||||
background-color: white;
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border);
|
||||
color: var(--sokuree-text-primary);
|
||||
border-color: var(--sokuree-border-color);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.ui-btn-secondary:hover {
|
||||
background-color: #f8fafc;
|
||||
/* Slate 50 */
|
||||
border-color: #cbd5e1;
|
||||
/* Slate 300 */
|
||||
color: var(--sokuree-brand-primary);
|
||||
/* Use brand color on hover for secondary */
|
||||
}
|
||||
|
||||
.ui-btn-danger {
|
||||
background-color: #dc2626;
|
||||
/* Red 600 */
|
||||
background-color: #ef4444;
|
||||
/* Red 500 */
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ui-btn-danger:hover {
|
||||
background-color: #b91c1c;
|
||||
/* Red 700 */
|
||||
background-color: #dc2626;
|
||||
/* Red 600 */
|
||||
}
|
||||
|
||||
.ui-btn-ghost {
|
||||
background-color: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--sokuree-text-secondary);
|
||||
}
|
||||
|
||||
.ui-btn-ghost:hover {
|
||||
background-color: #f1f5f9;
|
||||
/* Slate 100 */
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--sokuree-bg-main);
|
||||
color: var(--sokuree-text-primary);
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
@ -99,7 +101,7 @@
|
||||
font-size: 1rem;
|
||||
/* Base size */
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
color: var(--sokuree-text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@ -113,7 +115,7 @@
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--sokuree-text-muted);
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -126,9 +128,9 @@
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: white;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--sokuree-border-color);
|
||||
border-radius: var(--sokuree-radius-md);
|
||||
color: var(--sokuree-text-primary);
|
||||
font-size: 1rem;
|
||||
/* Clear readable text */
|
||||
line-height: 1.5;
|
||||
@ -139,15 +141,15 @@
|
||||
.ui-input:focus,
|
||||
.ui-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-primary);
|
||||
border-color: var(--sokuree-brand-primary);
|
||||
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.15);
|
||||
/* Ring effect */
|
||||
/* Ring effect based on brand primary */
|
||||
}
|
||||
|
||||
.ui-input:disabled,
|
||||
.ui-select:disabled {
|
||||
background-color: #f8fafc;
|
||||
color: #94a3b8;
|
||||
background-color: var(--sokuree-bg-main);
|
||||
color: var(--sokuree-text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
@ -3,15 +3,15 @@
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background-color: var(--color-bg-base);
|
||||
background-color: var(--sokuree-bg-main);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background-color: var(--color-bg-sidebar);
|
||||
color: var(--color-text-inverse);
|
||||
background-color: var(--sokuree-bg-sidebar);
|
||||
color: var(--sokuree-text-inverse);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
@ -65,7 +65,7 @@
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
transition: var(--transition-base);
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--sokuree-radius-md);
|
||||
}
|
||||
|
||||
.module-header:hover {
|
||||
@ -98,7 +98,7 @@
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.75rem 0.6rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--sokuree-radius-md);
|
||||
color: #94a3b8;
|
||||
transition: var(--transition-base);
|
||||
font-weight: 500;
|
||||
@ -111,7 +111,7 @@
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background-color: var(--color-brand-primary);
|
||||
background-color: var(--sokuree-brand-primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
@ -135,7 +135,7 @@
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-brand-primary);
|
||||
background-color: var(--sokuree-brand-primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -163,7 +163,7 @@
|
||||
.logout-btn {
|
||||
color: #94a3b8;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--sokuree-radius-sm);
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
@ -178,20 +178,20 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-base);
|
||||
background-color: var(--sokuree-bg-main);
|
||||
}
|
||||
|
||||
/* Top Header - White Theme */
|
||||
.top-header {
|
||||
height: 64px;
|
||||
background-color: var(--color-bg-surface);
|
||||
background-color: var(--sokuree-bg-card);
|
||||
/* White Header */
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--sokuree-border-color);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
/* Tabs at bottom */
|
||||
padding: 0 2rem;
|
||||
color: var(--color-text-primary);
|
||||
color: var(--sokuree-text-primary);
|
||||
}
|
||||
|
||||
.header-tabs {
|
||||
@ -202,7 +202,7 @@
|
||||
.tab-item {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--sokuree-text-secondary);
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.95rem;
|
||||
@ -214,16 +214,16 @@
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
color: var(--color-brand-primary);
|
||||
color: var(--sokuree-brand-primary);
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: var(--color-brand-primary);
|
||||
color: var(--sokuree-brand-primary);
|
||||
font-weight: 700;
|
||||
border-bottom-color: var(--color-brand-primary);
|
||||
border-bottom-color: var(--sokuree-brand-primary);
|
||||
/* Blue underline */
|
||||
}
|
||||
|
||||
|
||||
@ -2,14 +2,19 @@ import { useState } from 'react';
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../shared/auth/AuthContext';
|
||||
import { useSystem } from '../../shared/context/SystemContext';
|
||||
import { LayoutDashboard, Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Factory, Video, Shield } from 'lucide-react';
|
||||
import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield } from 'lucide-react';
|
||||
import type { IModuleDefinition } from '../../core/types';
|
||||
import './MainLayout.css';
|
||||
|
||||
export function MainLayout() {
|
||||
interface MainLayoutProps {
|
||||
modulesList: IModuleDefinition[];
|
||||
}
|
||||
|
||||
export function MainLayout({ modulesList }: MainLayoutProps) {
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuth();
|
||||
const { modules } = useSystem();
|
||||
const [expandedModules, setExpandedModules] = useState<string[]>(['smart_asset']);
|
||||
const [expandedModules, setExpandedModules] = useState<string[]>(['asset-management']);
|
||||
|
||||
const toggleModule = (moduleId: string) => {
|
||||
setExpandedModules(prev =>
|
||||
@ -19,7 +24,6 @@ export function MainLayout() {
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to check if a module is active (default/fallback handling can be done here or in context)
|
||||
const isModuleActive = (code: string) => modules[code]?.active;
|
||||
|
||||
return (
|
||||
@ -33,7 +37,7 @@ export function MainLayout() {
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
{/* Module: System Management */}
|
||||
{/* Module: System Management (Platform Core) */}
|
||||
{user?.role === 'admin' && (
|
||||
<div className="module-group">
|
||||
<button
|
||||
@ -66,78 +70,97 @@ export function MainLayout() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Module: Asset Management (Renamed from Smart Asset) */}
|
||||
{isModuleActive('asset') && (
|
||||
<div className="module-group">
|
||||
<button
|
||||
className={`module-header ${expandedModules.includes('smart_asset') ? 'active' : ''}`}
|
||||
onClick={() => toggleModule('smart_asset')}
|
||||
>
|
||||
<div className="module-title">
|
||||
<Layers size={18} />
|
||||
<span>자산 관리</span>
|
||||
</div>
|
||||
{expandedModules.includes('smart_asset') ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
{/* Dynamic Modules Injection */}
|
||||
{modulesList.map((mod) => {
|
||||
const moduleKey = mod.moduleName.split('-')[0];
|
||||
if (!isModuleActive(moduleKey)) return null;
|
||||
|
||||
{expandedModules.includes('smart_asset') && (
|
||||
<div className="module-items">
|
||||
<Link to="/asset/dashboard" className={`nav-item ${location.pathname.includes('dashboard') ? 'active' : ''}`}>
|
||||
<LayoutDashboard size={18} />
|
||||
<span>대시보드</span>
|
||||
</Link>
|
||||
// Check roles
|
||||
if (mod.requiredRoles && user && !mod.requiredRoles.includes(user.role)) return null;
|
||||
|
||||
const hasSubMenu = mod.routes.filter(r => r.label).length > 1;
|
||||
const isExpanded = expandedModules.includes(mod.moduleName);
|
||||
|
||||
return (
|
||||
<div key={mod.moduleName} className="module-group">
|
||||
{hasSubMenu ? (
|
||||
<>
|
||||
<button
|
||||
className={`module-header ${isExpanded ? 'active' : ''}`}
|
||||
onClick={() => toggleModule(mod.moduleName)}
|
||||
>
|
||||
<div className="module-title">
|
||||
<Layers size={18} />
|
||||
<span>{mod.moduleName.split('-')[0].charAt(0).toUpperCase() + mod.moduleName.split('-')[0].slice(1)} 관리</span>
|
||||
</div>
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="module-items">
|
||||
{(() => {
|
||||
const groupedRoutes: Record<string, typeof mod.routes> = {};
|
||||
const ungroupedRoutes: typeof mod.routes = [];
|
||||
|
||||
mod.routes.filter(r => r.label && (!r.position || r.position === 'sidebar')).forEach(route => {
|
||||
if (route.group) {
|
||||
if (!groupedRoutes[route.group]) groupedRoutes[route.group] = [];
|
||||
groupedRoutes[route.group].push(route);
|
||||
} else {
|
||||
ungroupedRoutes.push(route);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{ungroupedRoutes.map(route => (
|
||||
<Link
|
||||
key={`${mod.moduleName}-${route.path}`}
|
||||
to={`${mod.basePath}${route.path}`}
|
||||
className={`nav-item ${location.pathname.startsWith(`${mod.basePath}${route.path}`) ? 'active' : ''}`}
|
||||
>
|
||||
{route.icon || <Box size={18} />}
|
||||
<span>{route.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{Object.entries(groupedRoutes).map(([groupName, routes]) => (
|
||||
<div key={groupName} className="menu-group-section">
|
||||
<div className="menu-group-label text-xs font-bold text-slate-500 uppercase px-3 py-2 mt-2 mb-1">
|
||||
{groupName}
|
||||
</div>
|
||||
{routes.map(route => (
|
||||
<Link
|
||||
key={`${mod.moduleName}-${route.path}`}
|
||||
to={`${mod.basePath}${route.path}`}
|
||||
className={`nav-item ${location.pathname.startsWith(`${mod.basePath}${route.path}`) ? 'active' : ''}`}
|
||||
>
|
||||
{route.icon || <Box size={18} />}
|
||||
<span>{route.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
to="/asset/list"
|
||||
className={`nav-item ${['/asset/list', '/asset/facilities', '/asset/tools', '/asset/general', '/asset/consumables'].some(path => location.pathname.includes(path)) ? 'active' : ''}`}
|
||||
to={`${mod.basePath}${mod.routes[0]?.path || ''}`}
|
||||
className={`module-header ${location.pathname.startsWith(mod.basePath) ? 'active' : ''}`}
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<Box size={18} />
|
||||
<span>자산 현황</span>
|
||||
<div className="module-title">
|
||||
<Video size={18} />
|
||||
<span>{mod.moduleName.split('-')[0].toUpperCase()}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<Link to="/asset/settings" className={`nav-item ${location.pathname.includes('settings') ? 'active' : ''}`}>
|
||||
<Settings size={18} />
|
||||
<span>설정</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Module: Production Management */}
|
||||
{isModuleActive('production') && (
|
||||
<div className="module-group">
|
||||
<button
|
||||
className={`module-header ${expandedModules.includes('production') ? 'active' : ''}`}
|
||||
onClick={() => toggleModule('production')}
|
||||
>
|
||||
<div className="module-title">
|
||||
<Factory size={18} />
|
||||
<span>생산 관리</span>
|
||||
</div>
|
||||
{expandedModules.includes('production') ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
|
||||
{expandedModules.includes('production') && (
|
||||
<div className="module-items">
|
||||
<Link to="/production/dashboard" className={`nav-item ${location.pathname.includes('/production/dashboard') ? 'active' : ''}`}>
|
||||
<LayoutDashboard size={18} />
|
||||
<span>대시보드</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Module: Monitoring */}
|
||||
{isModuleActive('monitoring') && (
|
||||
<div className="module-group">
|
||||
<Link to="/monitoring" className={`module-header ${location.pathname.includes('/monitoring') ? 'active' : ''}`}>
|
||||
<div className="module-title">
|
||||
<Video size={18} />
|
||||
<span>CCTV</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
@ -158,67 +181,35 @@ export function MainLayout() {
|
||||
|
||||
<main className="main-content">
|
||||
<header className="top-header">
|
||||
{/* Dynamic Tabs based on current route */}
|
||||
<div className="header-tabs">
|
||||
{/* Settings Tabs */}
|
||||
{/* Dynamic Tabs based on Active Module 'top' routes */}
|
||||
{(() => {
|
||||
// Find active module based on current path
|
||||
const activeModule = modulesList.find(m => location.pathname.startsWith(m.basePath));
|
||||
if (!activeModule) return null;
|
||||
|
||||
const topRoutes = activeModule.routes.filter(r => r.position === 'top');
|
||||
|
||||
return topRoutes.map(route => (
|
||||
<Link
|
||||
key={`${activeModule.moduleName}-top-${route.path}`}
|
||||
to={`${activeModule.basePath}${route.path}`}
|
||||
className={`tab-item ${location.pathname.startsWith(`${activeModule.basePath}${route.path}`) ? 'active' : ''}`}
|
||||
>
|
||||
{route.label}
|
||||
</Link>
|
||||
));
|
||||
})()}
|
||||
|
||||
{/* Legacy manual override check (can be removed if Asset Settings is fully migrated to route config) */}
|
||||
{location.pathname.includes('/asset/settings') && (
|
||||
<>
|
||||
<Link
|
||||
to="/asset/settings?tab=basic"
|
||||
className={`tab-item ${(!location.search.includes('tab=') || location.search.includes('tab=basic')) ? 'active' : ''}`}
|
||||
>
|
||||
기본 설정
|
||||
</Link>
|
||||
<Link
|
||||
to="/asset/settings?tab=category"
|
||||
className={`tab-item ${location.search.includes('tab=category') ? 'active' : ''}`}
|
||||
>
|
||||
카테고리 관리
|
||||
</Link>
|
||||
<Link
|
||||
to="/asset/settings?tab=location"
|
||||
className={`tab-item ${location.search.includes('tab=location') ? 'active' : ''}`}
|
||||
>
|
||||
설치 위치
|
||||
</Link>
|
||||
<Link
|
||||
to="/asset/settings?tab=status"
|
||||
className={`tab-item ${location.search.includes('tab=status') ? 'active' : ''}`}
|
||||
>
|
||||
자산 상태
|
||||
</Link>
|
||||
<Link
|
||||
to="/asset/settings?tab=maintenance"
|
||||
className={`tab-item ${location.search.includes('tab=maintenance') ? 'active' : ''}`}
|
||||
>
|
||||
정비 구분
|
||||
</Link>
|
||||
{/* Keeping this just in case, but ideally should be managed via route config now?
|
||||
Actually settings tabs are usually sub-pages of settings, not top-level module nav.
|
||||
Leaving for safety.
|
||||
*/}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Asset Management Tabs (Visible for facilities, tools, general, consumables) */}
|
||||
{(location.pathname.startsWith('/asset/list') ||
|
||||
location.pathname.startsWith('/asset/facilities') ||
|
||||
location.pathname.startsWith('/asset/tools') ||
|
||||
location.pathname.startsWith('/asset/general') ||
|
||||
location.pathname.startsWith('/asset/consumables') ||
|
||||
location.pathname.startsWith('/asset/instruments') ||
|
||||
location.pathname.startsWith('/asset/vehicles') ||
|
||||
location.pathname.startsWith('/asset/register') ||
|
||||
location.pathname.startsWith('/asset/detail')) && (
|
||||
<>
|
||||
<Link to="/asset/list" className={`tab-item ${location.pathname === '/asset/list' ? 'active' : ''}`}>전체 조회</Link>
|
||||
<Link to="/asset/facilities" className={`tab-item ${location.pathname.includes('/facilities') ? 'active' : ''}`}>설비 자산</Link>
|
||||
<Link to="/asset/tools" className={`tab-item ${location.pathname.includes('/tools') ? 'active' : ''}`}>공구 관리</Link>
|
||||
<Link to="/asset/instruments" className={`tab-item ${location.pathname.includes('/instruments') ? 'active' : ''}`}>계측기 관리</Link>
|
||||
<Link to="/asset/vehicles" className={`tab-item ${location.pathname.includes('/vehicles') ? 'active' : ''}`}>차량/운반</Link>
|
||||
<Link to="/asset/general" className={`tab-item ${location.pathname.includes('/general') ? 'active' : ''}`}>일반 자산</Link>
|
||||
<Link to="/asset/consumables" className={`tab-item ${location.pathname.includes('/consumables') ? 'active' : ''}`}>소모품 관리</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
{/* Future: Notifications, Search */}
|
||||
</div>
|
||||
</header>
|
||||
<div className="content-area">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user