From 540f4281cc61355d903ad6ed01adb195c341a612 Mon Sep 17 00:00:00 2001 From: choibk Date: Sat, 24 Jan 2026 10:54:41 +0900 Subject: [PATCH] Refactor: Modular architecture, CCTV fix, License sync, UI improvements --- .cursorrules | 23 ++ docs/MODULAR_ARCHITECTURE_GUIDE.md | 97 +++++++ server/routes/system.js | 28 +- src/core/types.ts | 33 +++ src/main.tsx | 4 +- src/modules/asset/module.tsx | 26 ++ src/modules/cctv/module.tsx | 11 + src/modules/cctv/pages/MonitoringPage.tsx | 2 +- src/modules/production/module.tsx | 11 + .../production/pages/ProductionPage.tsx | 10 + src/platform/App.tsx | 61 +++++ src/platform/ModuleLoader.tsx | 67 +++++ src/platform/pages/BasicSettingsPage.tsx | 54 +++- src/platform/styles/global.css | 98 +++++++ src/shared/ui/Components.css | 52 ++-- src/widgets/layout/MainLayout.css | 32 +-- src/widgets/layout/MainLayout.tsx | 249 +++++++++--------- 17 files changed, 664 insertions(+), 194 deletions(-) create mode 100644 .cursorrules create mode 100644 docs/MODULAR_ARCHITECTURE_GUIDE.md create mode 100644 src/core/types.ts create mode 100644 src/modules/asset/module.tsx create mode 100644 src/modules/cctv/module.tsx create mode 100644 src/modules/production/module.tsx create mode 100644 src/modules/production/pages/ProductionPage.tsx create mode 100644 src/platform/App.tsx create mode 100644 src/platform/ModuleLoader.tsx create mode 100644 src/platform/styles/global.css diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..c62ceb0 --- /dev/null +++ b/.cursorrules @@ -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 속성을 통해 접근 권한을 선언해야 한다. diff --git a/docs/MODULAR_ARCHITECTURE_GUIDE.md b/docs/MODULAR_ARCHITECTURE_GUIDE.md new file mode 100644 index 0000000..6746736 --- /dev/null +++ b/docs/MODULAR_ARCHITECTURE_GUIDE.md @@ -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 제공. diff --git a/server/routes/system.js b/server/routes/system.js index b6a1ec5..17a4fb0 100644 --- a/server/routes/system.js +++ b/server/routes/system.js @@ -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 } diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..8c5858e --- /dev/null +++ b/src/core/types.ts @@ -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[]; +} diff --git a/src/main.tsx b/src/main.tsx index d5e482c..bf35ed5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( diff --git a/src/modules/asset/module.tsx b/src/modules/asset/module.tsx new file mode 100644 index 0000000..f94431c --- /dev/null +++ b/src/modules/asset/module.tsx @@ -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: , label: '대시보드', group: '기본' }, + { path: '/list', element: , label: '자산 목록', group: '기본' }, + { path: '/register', element: , label: '자산 등록', group: '기본' }, + { path: '/settings', element: , label: '자산 설정', group: '기본' }, + { path: '/detail/:assetId', element: }, + + { path: '/facilities', element: , label: '시설물 관리', position: 'top' }, + { path: '/tools', element: , label: '공구 관리', position: 'top' }, + { path: '/general', element: , label: '일반 자산', position: 'top' }, + { path: '/consumables', element: , label: '소모품 관리', position: 'top' }, + { path: '/instruments', element: , label: '계측기 관리', position: 'top' }, + { path: '/vehicles', element: , label: '차량 관리', position: 'top' }, + ], + requiredRoles: ['admin', 'manager'] +}; diff --git a/src/modules/cctv/module.tsx b/src/modules/cctv/module.tsx new file mode 100644 index 0000000..2c92cb2 --- /dev/null +++ b/src/modules/cctv/module.tsx @@ -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: , label: '실시간 관제' }, + ], + requiredRoles: ['admin', 'operator'] +}; diff --git a/src/modules/cctv/pages/MonitoringPage.tsx b/src/modules/cctv/pages/MonitoringPage.tsx index b079250..2246285 100644 --- a/src/modules/cctv/pages/MonitoringPage.tsx +++ b/src/modules/cctv/pages/MonitoringPage.tsx @@ -234,7 +234,7 @@ export function MonitoringPage() {

{user?.role === 'admin' && ( + {/* Dynamic Modules Injection */} + {modulesList.map((mod) => { + const moduleKey = mod.moduleName.split('-')[0]; + if (!isModuleActive(moduleKey)) return null; - {expandedModules.includes('smart_asset') && ( -
- - - 대시보드 - + // 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 ( +
+ {hasSubMenu ? ( + <> + + {isExpanded && ( +
+ {(() => { + const groupedRoutes: Record = {}; + 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 => ( + + {route.icon || } + {route.label} + + ))} + + {Object.entries(groupedRoutes).map(([groupName, routes]) => ( +
+
+ {groupName} +
+ {routes.map(route => ( + + {route.icon || } + {route.label} + + ))} +
+ ))} + + ); + })()} +
+ )} + + ) : ( location.pathname.includes(path)) ? 'active' : ''}`} + to={`${mod.basePath}${mod.routes[0]?.path || ''}`} + className={`module-header ${location.pathname.startsWith(mod.basePath) ? 'active' : ''}`} + style={{ textDecoration: 'none' }} > - - 자산 현황 +
+
- - - 설정 - -
- )} -
- )} - - {/* Module: Production Management */} - {isModuleActive('production') && ( -
- - - {expandedModules.includes('production') && ( -
- - - 대시보드 - -
- )} -
- )} - - {/* Module: Monitoring */} - {isModuleActive('monitoring') && ( -
- -
-
- -
- )} + )} +
+ ); + })}
@@ -158,67 +181,35 @@ export function MainLayout() {
- {/* Dynamic Tabs based on current route */}
- {/* 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 => ( + + {route.label} + + )); + })()} + + {/* Legacy manual override check (can be removed if Asset Settings is fully migrated to route config) */} {location.pathname.includes('/asset/settings') && ( <> - - 기본 설정 - - - 카테고리 관리 - - - 설치 위치 - - - 자산 상태 - - - 정비 구분 - + {/* 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')) && ( - <> - 전체 조회 - 설비 자산 - 공구 관리 - 계측기 관리 - 차량/운반 - 일반 자산 - 소모품 관리 - - )} -
-
- {/* Future: Notifications, Search */}