Refactor: Modular architecture, CCTV fix, License sync, UI improvements

This commit is contained in:
choibk 2026-01-24 10:54:41 +09:00
parent 59e876c838
commit 540f4281cc
17 changed files with 664 additions and 194 deletions

23
.cursorrules Normal file
View 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 속성을 통해 접근 권한을 선언해야 한다.

View 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 제공.

View File

@ -119,13 +119,20 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
const serverSubscriberId = subRows.length > 0 ? subRows[0].setting_value : null; const serverSubscriberId = subRows.length > 0 ? subRows[0].setting_value : null;
if (!serverSubscriberId) { 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) { // Allow 'dev' type to bypass strict subscriber check, but log it
return res.status(403).json({ if (result.type === 'dev') {
error: `License Subscriber Mismatch. Key is for '${result.subscriberId}', but Server is '${serverSubscriberId}'.` 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 // 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) { if (current.length > 0 && current[0].is_active && current[0].license_key) {
const old = current[0]; const old = current[0];
const historySql = ` const historySql = `
INSERT INTO license_history (module_code, license_key, license_type, subscriber_id, activated_at) INSERT INTO license_history (module_code, license_key, license_type, subscriber_id, activated_at)
VALUES (?, ?, ?, ?, NOW()) VALUES (?, ?, ?, ?, NOW())
`; `;
await db.query(historySql, [old.code, old.license_key, old.license_type, old.subscriber_id]); 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 }); await axios.post(`${licenseManagerUrl}/licenses/activate`, { licenseKey });
console.log(`✅ Synced activation status for key: ${licenseKey.substring(0, 20)}...`); console.log(`✅ Synced activation status for key: ${licenseKey.substring(0, 20)}...`);
} catch (syncErr) { } 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 // We don't fail the whole activation if sync fails, but we log it
} }

33
src/core/types.ts Normal file
View 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[];
}

View File

@ -1,7 +1,7 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './app/index.css' import './platform/styles/global.css'
import App from './app/App.tsx' import App from './platform/App.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

View 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']
};

View 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']
};

View File

@ -234,7 +234,7 @@ export function MonitoringPage() {
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold flex items-center gap-2"> <h1 className="text-2xl font-bold flex items-center gap-2">
<Video className="text-blue-600" /> <Video className="text-blue-600" />
CCTV CCTV <span className="text-xs text-slate-400 font-normal">({user?.role})</span>
</h1> </h1>
{user?.role === 'admin' && ( {user?.role === 'admin' && (
<button <button

View 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']
};

View 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
View 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;

View 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;

View File

@ -59,19 +59,49 @@ export function BasicSettingsPage() {
</h2> </h2>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"> ()</label> <label className="block text-sm font-medium text-slate-700 mb-1"> </label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-2 flex-wrap text-sm text-slate-600">
<Input <div className="flex items-center gap-1">
type="number" <Input
min="5" type="number"
max="1440" min="0"
className="w-32" max="24"
value={settings.session_timeout} className="!w-auto !mb-0"
onChange={e => setSettings({ ...settings, session_timeout: parseInt(e.target.value) })} style={{ width: '70px', textAlign: 'center' }}
/> value={Math.floor((settings.session_timeout || 10) / 60)}
<span className="text-slate-500 text-sm"> .</span> 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> </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>
</div> </div>
</Card> </Card>

View 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);
}

View File

@ -12,7 +12,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-weight: 500; font-weight: 500;
border-radius: var(--radius-md); border-radius: var(--sokuree-radius-md);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
border: 1px solid transparent; border: 1px solid transparent;
@ -21,53 +21,55 @@
} }
.ui-btn:focus-visible { .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 */ /* Variants */
.ui-btn-primary { .ui-btn-primary {
background-color: var(--color-bg-sidebar); background-color: var(--sokuree-brand-primary);
/* Slate 800 */
color: white; color: white;
} }
.ui-btn-primary:hover { .ui-btn-primary:hover {
background-color: #0f172a; background-color: #0284c7;
/* Slate 900 */ /* Sky 600 - slightly darker than brand primary */
} }
.ui-btn-secondary { .ui-btn-secondary {
background-color: white; background-color: white;
color: var(--color-text-primary); color: var(--sokuree-text-primary);
border-color: var(--color-border); border-color: var(--sokuree-border-color);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
} }
.ui-btn-secondary:hover { .ui-btn-secondary:hover {
background-color: #f8fafc; background-color: #f8fafc;
/* Slate 50 */ /* Slate 50 */
border-color: #cbd5e1;
/* Slate 300 */
color: var(--sokuree-brand-primary);
/* Use brand color on hover for secondary */
} }
.ui-btn-danger { .ui-btn-danger {
background-color: #dc2626; background-color: #ef4444;
/* Red 600 */ /* Red 500 */
color: white; color: white;
} }
.ui-btn-danger:hover { .ui-btn-danger:hover {
background-color: #b91c1c; background-color: #dc2626;
/* Red 700 */ /* Red 600 */
} }
.ui-btn-ghost { .ui-btn-ghost {
background-color: transparent; background-color: transparent;
color: var(--color-text-secondary); color: var(--sokuree-text-secondary);
} }
.ui-btn-ghost:hover { .ui-btn-ghost:hover {
background-color: #f1f5f9; background-color: var(--sokuree-bg-main);
/* Slate 100 */ color: var(--sokuree-text-primary);
color: var(--color-text-primary);
} }
/* Sizes */ /* Sizes */
@ -99,7 +101,7 @@
font-size: 1rem; font-size: 1rem;
/* Base size */ /* Base size */
font-weight: 600; font-weight: 600;
color: var(--color-text-primary); color: var(--sokuree-text-primary);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@ -113,7 +115,7 @@
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
left: 1rem; left: 1rem;
color: var(--color-text-secondary); color: var(--sokuree-text-muted);
pointer-events: none; pointer-events: none;
display: flex; display: flex;
align-items: center; align-items: center;
@ -126,9 +128,9 @@
width: 100%; width: 100%;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background-color: white; background-color: white;
border: 1px solid var(--color-border); border: 1px solid var(--sokuree-border-color);
border-radius: var(--radius-md); border-radius: var(--sokuree-radius-md);
color: var(--color-text-primary); color: var(--sokuree-text-primary);
font-size: 1rem; font-size: 1rem;
/* Clear readable text */ /* Clear readable text */
line-height: 1.5; line-height: 1.5;
@ -139,15 +141,15 @@
.ui-input:focus, .ui-input:focus,
.ui-select:focus { .ui-select:focus {
outline: none; 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); box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.15);
/* Ring effect */ /* Ring effect based on brand primary */
} }
.ui-input:disabled, .ui-input:disabled,
.ui-select:disabled { .ui-select:disabled {
background-color: #f8fafc; background-color: var(--sokuree-bg-main);
color: #94a3b8; color: var(--sokuree-text-muted);
cursor: not-allowed; cursor: not-allowed;
} }

View File

@ -3,15 +3,15 @@
display: flex; display: flex;
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
background-color: var(--color-bg-base); background-color: var(--sokuree-bg-main);
overflow: hidden; overflow: hidden;
} }
/* Sidebar */ /* Sidebar */
.sidebar { .sidebar {
width: 260px; width: 260px;
background-color: var(--color-bg-sidebar); background-color: var(--sokuree-bg-sidebar);
color: var(--color-text-inverse); color: var(--sokuree-text-inverse);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-right: 1px solid rgba(255, 255, 255, 0.05); border-right: 1px solid rgba(255, 255, 255, 0.05);
@ -65,7 +65,7 @@
font-weight: 600; font-weight: 600;
font-size: 0.9rem; font-size: 0.9rem;
transition: var(--transition-base); transition: var(--transition-base);
border-radius: var(--radius-md); border-radius: var(--sokuree-radius-md);
} }
.module-header:hover { .module-header:hover {
@ -98,7 +98,7 @@
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding: 0.6rem 0.75rem 0.6rem 1rem; padding: 0.6rem 0.75rem 0.6rem 1rem;
border-radius: var(--radius-md); border-radius: var(--sokuree-radius-md);
color: #94a3b8; color: #94a3b8;
transition: var(--transition-base); transition: var(--transition-base);
font-weight: 500; font-weight: 500;
@ -111,7 +111,7 @@
} }
.nav-item.active { .nav-item.active {
background-color: var(--color-brand-primary); background-color: var(--sokuree-brand-primary);
color: #fff; color: #fff;
font-weight: 600; 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); 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; width: 36px;
height: 36px; height: 36px;
border-radius: 50%; border-radius: 50%;
background-color: var(--color-brand-primary); background-color: var(--sokuree-brand-primary);
color: #fff; color: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
@ -163,7 +163,7 @@
.logout-btn { .logout-btn {
color: #94a3b8; color: #94a3b8;
padding: 0.5rem; padding: 0.5rem;
border-radius: var(--radius-sm); border-radius: var(--sokuree-radius-sm);
transition: var(--transition-base); transition: var(--transition-base);
} }
@ -178,20 +178,20 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
background-color: var(--color-bg-base); background-color: var(--sokuree-bg-main);
} }
/* Top Header - White Theme */ /* Top Header - White Theme */
.top-header { .top-header {
height: 64px; height: 64px;
background-color: var(--color-bg-surface); background-color: var(--sokuree-bg-card);
/* White Header */ /* White Header */
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--sokuree-border-color);
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
/* Tabs at bottom */ /* Tabs at bottom */
padding: 0 2rem; padding: 0 2rem;
color: var(--color-text-primary); color: var(--sokuree-text-primary);
} }
.header-tabs { .header-tabs {
@ -202,7 +202,7 @@
.tab-item { .tab-item {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
font-weight: 500; font-weight: 500;
color: var(--color-text-secondary); color: var(--sokuree-text-secondary);
border-bottom: 3px solid transparent; border-bottom: 3px solid transparent;
transition: all 0.2s; transition: all 0.2s;
font-size: 0.95rem; font-size: 0.95rem;
@ -214,16 +214,16 @@
} }
.tab-item:hover { .tab-item:hover {
color: var(--color-brand-primary); color: var(--sokuree-brand-primary);
background-color: rgba(0, 0, 0, 0.02); background-color: rgba(0, 0, 0, 0.02);
border-top-left-radius: 4px; border-top-left-radius: 4px;
border-top-right-radius: 4px; border-top-right-radius: 4px;
} }
.tab-item.active { .tab-item.active {
color: var(--color-brand-primary); color: var(--sokuree-brand-primary);
font-weight: 700; font-weight: 700;
border-bottom-color: var(--color-brand-primary); border-bottom-color: var(--sokuree-brand-primary);
/* Blue underline */ /* Blue underline */
} }

View File

@ -2,14 +2,19 @@ import { useState } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom'; import { Outlet, Link, useLocation } from 'react-router-dom';
import { useAuth } from '../../shared/auth/AuthContext'; import { useAuth } from '../../shared/auth/AuthContext';
import { useSystem } from '../../shared/context/SystemContext'; 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'; import './MainLayout.css';
export function MainLayout() { interface MainLayoutProps {
modulesList: IModuleDefinition[];
}
export function MainLayout({ modulesList }: MainLayoutProps) {
const location = useLocation(); const location = useLocation();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { modules } = useSystem(); const { modules } = useSystem();
const [expandedModules, setExpandedModules] = useState<string[]>(['smart_asset']); const [expandedModules, setExpandedModules] = useState<string[]>(['asset-management']);
const toggleModule = (moduleId: string) => { const toggleModule = (moduleId: string) => {
setExpandedModules(prev => 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; const isModuleActive = (code: string) => modules[code]?.active;
return ( return (
@ -33,7 +37,7 @@ export function MainLayout() {
</div> </div>
<nav className="sidebar-nav"> <nav className="sidebar-nav">
{/* Module: System Management */} {/* Module: System Management (Platform Core) */}
{user?.role === 'admin' && ( {user?.role === 'admin' && (
<div className="module-group"> <div className="module-group">
<button <button
@ -66,78 +70,97 @@ export function MainLayout() {
</div> </div>
)} )}
{/* Module: Asset Management (Renamed from Smart Asset) */} {/* Dynamic Modules Injection */}
{isModuleActive('asset') && ( {modulesList.map((mod) => {
<div className="module-group"> const moduleKey = mod.moduleName.split('-')[0];
<button if (!isModuleActive(moduleKey)) return null;
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>
{expandedModules.includes('smart_asset') && ( // Check roles
<div className="module-items"> if (mod.requiredRoles && user && !mod.requiredRoles.includes(user.role)) return null;
<Link to="/asset/dashboard" className={`nav-item ${location.pathname.includes('dashboard') ? 'active' : ''}`}>
<LayoutDashboard size={18} /> const hasSubMenu = mod.routes.filter(r => r.label).length > 1;
<span></span> const isExpanded = expandedModules.includes(mod.moduleName);
</Link>
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 <Link
to="/asset/list" to={`${mod.basePath}${mod.routes[0]?.path || ''}`}
className={`nav-item ${['/asset/list', '/asset/facilities', '/asset/tools', '/asset/general', '/asset/consumables'].some(path => location.pathname.includes(path)) ? 'active' : ''}`} className={`module-header ${location.pathname.startsWith(mod.basePath) ? 'active' : ''}`}
style={{ textDecoration: 'none' }}
> >
<Box size={18} /> <div className="module-title">
<span> </span> <Video size={18} />
<span>{mod.moduleName.split('-')[0].toUpperCase()}</span>
</div>
</Link> </Link>
<Link to="/asset/settings" className={`nav-item ${location.pathname.includes('settings') ? 'active' : ''}`}> )}
<Settings size={18} /> </div>
<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>
)}
</nav> </nav>
<div className="sidebar-footer"> <div className="sidebar-footer">
@ -158,67 +181,35 @@ export function MainLayout() {
<main className="main-content"> <main className="main-content">
<header className="top-header"> <header className="top-header">
{/* Dynamic Tabs based on current route */}
<div className="header-tabs"> <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') && ( {location.pathname.includes('/asset/settings') && (
<> <>
<Link {/* Keeping this just in case, but ideally should be managed via route config now?
to="/asset/settings?tab=basic" Actually settings tabs are usually sub-pages of settings, not top-level module nav.
className={`tab-item ${(!location.search.includes('tab=') || location.search.includes('tab=basic')) ? 'active' : ''}`} Leaving for safety.
> */}
</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>
</> </>
)} )}
{/* 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> </div>
</header> </header>
<div className="content-area"> <div className="content-area">