Phase 6(MapLibre+deck.gl), CSS 리팩토링, RBAC, 10탭 API 전환 등 현재 시스템 상태를 정확히 반영하여 모든 문서를 처음부터 재작성. - README.md: 기술 스택(MapLibre+deck.gl), 빌드, 구조, 스킬 갱신 - CLAUDE.md: CSS @layer, RBAC, HTTP 정책, 백엔드 모듈 반영 - docs/README.md: 아키텍처 상세 (3-Layer, 인증, 권한, CSS) - docs/DEVELOPMENT-GUIDE.md: 워크플로우 전체 흐름 + 실전 예시 - docs/INSTALL_GUIDE.md: 온라인/오프라인 설치 매뉴얼 - docs/COMMON-GUIDE.md: 공통 로직 9개 섹션 (인증~CSS) - docs/MENU-TAB-GUIDE.md: 새 탭 추가 5단계 + 예시 - docs/CRUD-API-GUIDE.md: End-to-End CRUD API 패턴 - docs/MOCK-TO-API-GUIDE.md: Mock→API 전환 10단계 프로세스 - docs/_backup_20260301/: 기존 문서 백업 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
673 lines
19 KiB
Markdown
673 lines
19 KiB
Markdown
# 새 메뉴 탭 추가 가이드
|
|
|
|
새로운 메뉴 탭을 추가하는 전체 절차를 5단계로 설명한다.
|
|
board 탭을 기준 템플릿으로 사용하며, 각 단계별 실제 코드 예시를 제공한다.
|
|
|
|
> **소요 시간**: 약 20~30분 (기본 CRUD 탭 기준)
|
|
|
|
---
|
|
|
|
## 메뉴 시스템 아키텍처
|
|
|
|
```
|
|
[DB] AUTH_SETTING (menu.config JSON)
|
|
|
|
|
v GET /api/menus
|
|
[Backend] settingsService.ts (DEFAULT_MENU_CONFIG, VALID_MENU_IDS)
|
|
|
|
|
v API
|
|
[Frontend] menuStore.ts --> TopBar.tsx (탭 렌더링, enabled && hasPermission 필터링)
|
|
--> App.tsx (renderView 라우팅)
|
|
```
|
|
|
|
- **DB**가 메뉴 정의의 단일 소스 (id, label, icon, enabled, order)
|
|
- **TopBar**는 `enabled && hasPermission` 조건으로 탭을 필터링하고 `order` 순 정렬
|
|
- **App.tsx**의 `renderView`가 탭 ID에 따라 뷰 컴포넌트를 매핑
|
|
- **admin** 탭은 메뉴 관리 대상에서 제외 (TopBar에서 별도 아이콘 버튼으로 접근)
|
|
|
|
---
|
|
|
|
## 수정 파일 요약
|
|
|
|
| 단계 | 파일 | 작업 |
|
|
|------|------|------|
|
|
| **Step 1** | `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` | 뷰 컴포넌트 생성 |
|
|
| | `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` | API 서비스 생성 |
|
|
| | `frontend/src/tabs/{탭명}/index.ts` | re-export |
|
|
| **Step 2** | `frontend/src/common/types/navigation.ts` | MainTab 타입 추가 |
|
|
| | `frontend/src/App.tsx` | import + renderView case 추가 |
|
|
| | `frontend/src/common/hooks/useSubMenu.ts` | 서브메뉴 설정 (서브탭이 있는 경우) |
|
|
| **Step 3** | `frontend/src/common/constants/featureIds.ts` | FEATURE_ID 등록 |
|
|
| **Step 4** | `backend/src/{도메인}/{domain}Router.ts` | 라우터 생성 |
|
|
| | `backend/src/{도메인}/{domain}Service.ts` | 서비스 생성 |
|
|
| **Step 5** | `backend/src/server.ts` | 라우트 등록 |
|
|
| | `backend/src/settings/settingsService.ts` | DEFAULT_MENU_CONFIG 추가 |
|
|
| | `database/auth_init.sql` | menu.config 초기 JSON 추가 |
|
|
| | `database/migration/NNN_{domain}.sql` | DB 마이그레이션 |
|
|
|
|
---
|
|
|
|
## Step 1: 프론트엔드 탭 패키지 생성
|
|
|
|
### 1-1. 디렉토리 구조
|
|
|
|
```
|
|
frontend/src/tabs/{탭명}/
|
|
components/
|
|
{TabName}View.tsx # 메인 뷰 컴포넌트
|
|
services/
|
|
{tabName}Api.ts # API 서비스
|
|
index.ts # re-export
|
|
```
|
|
|
|
### 1-2. 뷰 컴포넌트 (보일러플레이트)
|
|
|
|
서브탭이 **없는** 간단한 탭:
|
|
|
|
```tsx
|
|
// frontend/src/tabs/monitoring/components/MonitoringView.tsx
|
|
|
|
export function MonitoringView() {
|
|
return (
|
|
<div className="flex flex-1 overflow-hidden">
|
|
<div className="flex-1 relative overflow-hidden">
|
|
<div className="flex flex-col h-full bg-bg-0">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
|
|
<div className="text-sm font-bold text-text-1">실시간 모니터링</div>
|
|
</div>
|
|
|
|
{/* 본문 */}
|
|
<div className="flex-1 overflow-auto px-8 py-6">
|
|
<p className="text-text-3 text-sm">준비 중입니다.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
서브탭이 **있는** 탭 (board 패턴):
|
|
|
|
```tsx
|
|
// frontend/src/tabs/monitoring/components/MonitoringView.tsx
|
|
|
|
import { useSubMenu } from '@common/hooks/useSubMenu';
|
|
|
|
export function MonitoringView() {
|
|
const { activeSubTab } = useSubMenu('monitoring');
|
|
|
|
const renderContent = () => {
|
|
switch (activeSubTab) {
|
|
case 'dashboard':
|
|
return <div>대시보드 컨텐츠</div>;
|
|
case 'alerts':
|
|
return <div>알림 컨텐츠</div>;
|
|
default:
|
|
return <div>준비 중입니다.</div>;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-1 overflow-hidden">
|
|
<div className="flex-1 relative overflow-hidden">
|
|
{renderContent()}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 1-3. API 서비스 (보일러플레이트)
|
|
|
|
```ts
|
|
// frontend/src/tabs/monitoring/services/monitoringApi.ts
|
|
|
|
import { api } from '@common/services/api';
|
|
|
|
// ============================================================
|
|
// 인터페이스
|
|
// ============================================================
|
|
|
|
export interface MonitoringItem {
|
|
sn: number;
|
|
title: string;
|
|
status: string;
|
|
regDtm: string;
|
|
}
|
|
|
|
export interface MonitoringListResponse {
|
|
items: MonitoringItem[];
|
|
totalCount: number;
|
|
page: number;
|
|
size: number;
|
|
}
|
|
|
|
export interface MonitoringListParams {
|
|
search?: string;
|
|
page?: number;
|
|
size?: number;
|
|
}
|
|
|
|
export interface CreateMonitoringInput {
|
|
title: string;
|
|
status?: string;
|
|
}
|
|
|
|
// ============================================================
|
|
// API 함수
|
|
// ============================================================
|
|
|
|
export async function fetchMonitoringList(
|
|
params?: MonitoringListParams,
|
|
): Promise<MonitoringListResponse> {
|
|
const response = await api.get<MonitoringListResponse>('/monitoring', { params });
|
|
return response.data;
|
|
}
|
|
|
|
export async function fetchMonitoringDetail(sn: number): Promise<MonitoringItem> {
|
|
const response = await api.get<MonitoringItem>(`/monitoring/${sn}`);
|
|
return response.data;
|
|
}
|
|
|
|
export async function createMonitoring(input: CreateMonitoringInput): Promise<{ sn: number }> {
|
|
const response = await api.post<{ sn: number }>('/monitoring', input);
|
|
return response.data;
|
|
}
|
|
```
|
|
|
|
### 1-4. index.ts (re-export)
|
|
|
|
```ts
|
|
// frontend/src/tabs/monitoring/index.ts
|
|
|
|
export { MonitoringView } from './components/MonitoringView';
|
|
```
|
|
|
|
> **참고**: 기존 탭의 index.ts 패턴과 동일하다. 모든 탭은 index.ts에서 메인 뷰만 export한다.
|
|
|
|
---
|
|
|
|
## Step 2: navigation.ts에 MainTab 추가 + App.tsx 라우팅
|
|
|
|
### 2-1. MainTab 타입에 ID 추가
|
|
|
|
```ts
|
|
// frontend/src/common/types/navigation.ts
|
|
|
|
// Before
|
|
export type MainTab = 'prediction' | 'hns' | 'rescue' | ... | 'admin';
|
|
|
|
// After (새 탭 ID를 admin 앞에 추가)
|
|
export type MainTab = 'prediction' | 'hns' | 'rescue' | ... | 'monitoring' | 'admin';
|
|
```
|
|
|
|
### 2-2. App.tsx에 import + renderView case 추가
|
|
|
|
```tsx
|
|
// frontend/src/App.tsx
|
|
|
|
// 1. import 추가
|
|
import { MonitoringView } from '@tabs/monitoring';
|
|
|
|
// 2. renderView switch에 case 추가
|
|
const renderView = () => {
|
|
switch (activeMainTab) {
|
|
// ... 기존 case들 ...
|
|
case 'monitoring':
|
|
return <MonitoringView />;
|
|
// admin은 항상 마지막
|
|
case 'admin':
|
|
return <AdminView />;
|
|
default:
|
|
return <div className="flex items-center justify-center h-full text-text-3">준비 중입니다...</div>;
|
|
}
|
|
};
|
|
```
|
|
|
|
### 2-3. 서브메뉴 설정 (서브탭이 있는 경우)
|
|
|
|
서브탭이 있다면 `useSubMenu.ts`에 3곳을 수정한다:
|
|
|
|
```ts
|
|
// frontend/src/common/hooks/useSubMenu.ts
|
|
|
|
// 1. subMenuConfigs 에 서브탭 배열 추가
|
|
const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
|
|
// ... 기존 설정 ...
|
|
monitoring: [
|
|
{ id: 'dashboard', label: '대시보드', icon: '📊' },
|
|
{ id: 'alerts', label: '알림 관리', icon: '🔔' },
|
|
],
|
|
};
|
|
|
|
// 2. subMenuState 에 기본 서브탭 추가
|
|
const subMenuState: Record<MainTab, string> = {
|
|
// ... 기존 상태 ...
|
|
monitoring: 'dashboard',
|
|
};
|
|
```
|
|
|
|
서브탭이 **없으면** `null`과 빈 문자열을 설정한다:
|
|
|
|
```ts
|
|
monitoring: null, // subMenuConfigs
|
|
monitoring: '', // subMenuState
|
|
```
|
|
|
|
---
|
|
|
|
## Step 3: featureIds.ts에 FEATURE_ID 등록
|
|
|
|
FEATURE_ID는 RBAC 권한 검사와 감사 로그에 사용된다.
|
|
형식: `'{메인탭}:{서브탭}'`
|
|
|
|
```ts
|
|
// frontend/src/common/constants/featureIds.ts
|
|
|
|
export const FEATURE_IDS = {
|
|
// ... 기존 항목 ...
|
|
|
|
// monitoring
|
|
'monitoring:dashboard': '모니터링 대시보드',
|
|
'monitoring:alerts': '알림 관리',
|
|
} as const;
|
|
```
|
|
|
|
> **동기화 필수**: 여기에 등록한 키는 백엔드의 `AUTH_PERM.RSRC_CD`와 일치해야 한다.
|
|
> 서브탭이 없는 탭은 `'{탭명}:main'` 형태로 하나만 등록한다.
|
|
|
|
---
|
|
|
|
## Step 4: 백엔드 모듈 생성
|
|
|
|
### 4-1. 디렉토리 구조
|
|
|
|
```
|
|
backend/src/{도메인}/
|
|
{domain}Router.ts # Express 라우터 (요청 파싱, 응답 포맷)
|
|
{domain}Service.ts # 비즈니스 로직 + DB 쿼리
|
|
```
|
|
|
|
### 4-2. 라우터 (보일러플레이트)
|
|
|
|
```ts
|
|
// backend/src/monitoring/monitoringRouter.ts
|
|
|
|
import { Router } from 'express';
|
|
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
|
import { AuthError } from '../auth/authService.js';
|
|
import { listItems, getItem, createItem } from './monitoringService.js';
|
|
|
|
const router = Router();
|
|
|
|
// GET /api/monitoring -- 목록 조회
|
|
router.get('/', requireAuth, requirePermission('monitoring', 'READ'), async (req, res) => {
|
|
try {
|
|
const { search, page, size } = req.query;
|
|
const result = await listItems({
|
|
search: search as string | undefined,
|
|
page: page ? parseInt(page as string, 10) : undefined,
|
|
size: size ? parseInt(size as string, 10) : undefined,
|
|
});
|
|
res.json(result);
|
|
} catch (err) {
|
|
console.error('[monitoring] 목록 조회 오류:', err);
|
|
res.status(500).json({ error: '목록 조회 중 오류가 발생했습니다.' });
|
|
}
|
|
});
|
|
|
|
// GET /api/monitoring/:sn -- 상세 조회
|
|
router.get('/:sn', requireAuth, requirePermission('monitoring', 'READ'), async (req, res) => {
|
|
try {
|
|
const sn = parseInt(req.params.sn as string, 10);
|
|
if (isNaN(sn)) {
|
|
res.status(400).json({ error: '유효하지 않은 번호입니다.' });
|
|
return;
|
|
}
|
|
const item = await getItem(sn);
|
|
res.json(item);
|
|
} catch (err) {
|
|
if (err instanceof AuthError) {
|
|
res.status(err.status).json({ error: err.message });
|
|
return;
|
|
}
|
|
console.error('[monitoring] 상세 조회 오류:', err);
|
|
res.status(500).json({ error: '조회 중 오류가 발생했습니다.' });
|
|
}
|
|
});
|
|
|
|
// POST /api/monitoring -- 등록
|
|
router.post('/', requireAuth, requirePermission('monitoring', 'CREATE'), async (req, res) => {
|
|
try {
|
|
const { title, status } = req.body;
|
|
if (!title) {
|
|
res.status(400).json({ error: '제목은 필수입니다.' });
|
|
return;
|
|
}
|
|
const result = await createItem({
|
|
title,
|
|
status,
|
|
authorId: req.user!.sub,
|
|
});
|
|
res.status(201).json(result);
|
|
} catch (err) {
|
|
if (err instanceof AuthError) {
|
|
res.status(err.status).json({ error: err.message });
|
|
return;
|
|
}
|
|
console.error('[monitoring] 등록 오류:', err);
|
|
res.status(500).json({ error: '등록 중 오류가 발생했습니다.' });
|
|
}
|
|
});
|
|
|
|
export default router;
|
|
```
|
|
|
|
### 4-3. 서비스 (보일러플레이트)
|
|
|
|
```ts
|
|
// backend/src/monitoring/monitoringService.ts
|
|
|
|
import { wingPool } from '../db/wingDb.js';
|
|
import { AuthError } from '../auth/authService.js';
|
|
|
|
// ============================================================
|
|
// 인터페이스
|
|
// ============================================================
|
|
|
|
interface MonitoringItem {
|
|
sn: number;
|
|
title: string;
|
|
status: string;
|
|
authorId: string;
|
|
regDtm: string;
|
|
}
|
|
|
|
interface ListItemsInput {
|
|
search?: string;
|
|
page?: number;
|
|
size?: number;
|
|
}
|
|
|
|
interface ListItemsResult {
|
|
items: MonitoringItem[];
|
|
totalCount: number;
|
|
page: number;
|
|
size: number;
|
|
}
|
|
|
|
interface CreateItemInput {
|
|
title: string;
|
|
status?: string;
|
|
authorId: string;
|
|
}
|
|
|
|
// ============================================================
|
|
// CRUD 함수
|
|
// ============================================================
|
|
|
|
export async function listItems(input: ListItemsInput): Promise<ListItemsResult> {
|
|
const page = input.page && input.page > 0 ? input.page : 1;
|
|
const size = input.size && input.size > 0 ? Math.min(input.size, 100) : 20;
|
|
const offset = (page - 1) * size;
|
|
|
|
let whereClause = "WHERE USE_YN = 'Y'";
|
|
const params: (string | number)[] = [];
|
|
let paramIdx = 1;
|
|
|
|
if (input.search) {
|
|
whereClause += ` AND TITLE ILIKE $${paramIdx}`;
|
|
params.push(`%${input.search}%`);
|
|
paramIdx++;
|
|
}
|
|
|
|
// 전체 건수
|
|
const countResult = await wingPool.query(
|
|
`SELECT COUNT(*) as cnt FROM MONITORING ${whereClause}`,
|
|
params,
|
|
);
|
|
const totalCount = parseInt(countResult.rows[0].cnt, 10);
|
|
|
|
// 목록
|
|
const listParams = [...params, size, offset];
|
|
const listResult = await wingPool.query(
|
|
`SELECT SN, TITLE, STATUS, AUTHOR_ID, REG_DTM
|
|
FROM MONITORING
|
|
${whereClause}
|
|
ORDER BY REG_DTM DESC
|
|
LIMIT $${paramIdx++} OFFSET $${paramIdx}`,
|
|
listParams,
|
|
);
|
|
|
|
const items: MonitoringItem[] = listResult.rows.map((r: Record<string, unknown>) => ({
|
|
sn: r.sn as number,
|
|
title: r.title as string,
|
|
status: r.status as string,
|
|
authorId: r.author_id as string,
|
|
regDtm: r.reg_dtm as string,
|
|
}));
|
|
|
|
return { items, totalCount, page, size };
|
|
}
|
|
|
|
export async function getItem(sn: number): Promise<MonitoringItem> {
|
|
const result = await wingPool.query(
|
|
`SELECT SN, TITLE, STATUS, AUTHOR_ID, REG_DTM
|
|
FROM MONITORING
|
|
WHERE SN = $1 AND USE_YN = 'Y'`,
|
|
[sn],
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
throw new AuthError('데이터를 찾을 수 없습니다.', 404);
|
|
}
|
|
|
|
const r = result.rows[0];
|
|
return {
|
|
sn: r.sn,
|
|
title: r.title,
|
|
status: r.status,
|
|
authorId: r.author_id,
|
|
regDtm: r.reg_dtm,
|
|
};
|
|
}
|
|
|
|
export async function createItem(input: CreateItemInput): Promise<{ sn: number }> {
|
|
if (!input.title || input.title.trim().length === 0) {
|
|
throw new AuthError('제목은 필수입니다.', 400);
|
|
}
|
|
|
|
const result = await wingPool.query(
|
|
`INSERT INTO MONITORING (TITLE, STATUS, AUTHOR_ID)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING SN`,
|
|
[input.title.trim(), input.status || 'ACTIVE', input.authorId],
|
|
);
|
|
|
|
return { sn: result.rows[0].sn };
|
|
}
|
|
```
|
|
|
|
### 주요 패턴 요약
|
|
|
|
| 항목 | 패턴 |
|
|
|------|------|
|
|
| DB Pool | `wingPool` (wing DB) 또는 `authPool` (wing_auth DB) |
|
|
| 에러 처리 | `AuthError(message, status)` 활용 |
|
|
| 논리 삭제 | `USE_YN = 'Y'/'N'` 컬럼 사용, DELETE 대신 UPDATE |
|
|
| 페이징 | `LIMIT $N OFFSET $M`, 기본 size 20, 최대 100 |
|
|
| 인증 | `requireAuth` (JWT 검증) + `requirePermission(resource, operation)` |
|
|
| 작성자 | `req.user!.sub` (JWT payload에서 USER_ID 추출) |
|
|
|
|
---
|
|
|
|
## Step 5: server.ts 라우트 등록 + DB 마이그레이션
|
|
|
|
### 5-1. server.ts에 라우트 등록
|
|
|
|
```ts
|
|
// backend/src/server.ts
|
|
|
|
// 1. import 추가
|
|
import monitoringRouter from './monitoring/monitoringRouter.js';
|
|
|
|
// 2. 업무 API 라우트 등록 (기존 라우트 아래에)
|
|
app.use('/api/monitoring', monitoringRouter);
|
|
```
|
|
|
|
> **참고**: import 경로에 `.js` 확장자가 필요하다 (TypeScript ESM 빌드).
|
|
|
|
### 5-2. DEFAULT_MENU_CONFIG에 메뉴 항목 추가
|
|
|
|
```ts
|
|
// backend/src/settings/settingsService.ts
|
|
|
|
const DEFAULT_MENU_CONFIG: MenuConfigItem[] = [
|
|
// ... 기존 10개 메뉴 ...
|
|
{ id: 'incidents', label: '통합조회', icon: '🔍', enabled: true, order: 10 },
|
|
{ id: 'monitoring', label: '실시간 모니터링', icon: '📡', enabled: true, order: 11 },
|
|
];
|
|
```
|
|
|
|
### 5-3. auth_init.sql에 menu.config 초기 JSON 추가
|
|
|
|
```sql
|
|
-- database/auth_init.sql 의 menu.config INSERT 문에 새 항목 추가
|
|
-- (신규 설치 시에만 적용. 기존 운영 DB는 관리자 UI에서 관리)
|
|
```
|
|
|
|
### 5-4. DB 마이그레이션 작성
|
|
|
|
```sql
|
|
-- database/migration/017_monitoring.sql
|
|
|
|
-- ============================================================
|
|
-- 마이그레이션 017: 모니터링 (MONITORING)
|
|
-- ============================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS MONITORING (
|
|
SN SERIAL PRIMARY KEY,
|
|
TITLE VARCHAR(200) NOT NULL,
|
|
STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
|
AUTHOR_ID UUID NOT NULL,
|
|
USE_YN CHAR(1) NOT NULL DEFAULT 'Y',
|
|
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
MDFCN_DTM TIMESTAMPTZ,
|
|
|
|
CONSTRAINT FK_MONITORING_AUTHOR FOREIGN KEY (AUTHOR_ID)
|
|
REFERENCES auth.AUTH_USER(USER_ID),
|
|
CONSTRAINT CK_MONITORING_USE CHECK (USE_YN IN ('Y','N'))
|
|
);
|
|
|
|
COMMENT ON TABLE MONITORING IS '모니터링';
|
|
COMMENT ON COLUMN MONITORING.USE_YN IS '사용여부 (N=논리삭제)';
|
|
|
|
CREATE INDEX IF NOT EXISTS IDX_MONITORING_REG_DTM ON MONITORING(REG_DTM DESC);
|
|
```
|
|
|
|
> 마이그레이션 파일 네이밍: `NNN_{도메인}.sql` (NNN은 다음 순번).
|
|
> 자세한 마이그레이션 패턴은 `CRUD-API-GUIDE.md`를 참고한다.
|
|
|
|
---
|
|
|
|
## 실전 예시: "monitoring" 탭 추가 전체 흐름
|
|
|
|
### 1단계: 프론트엔드 파일 생성
|
|
|
|
```bash
|
|
mkdir -p frontend/src/tabs/monitoring/components
|
|
mkdir -p frontend/src/tabs/monitoring/services
|
|
```
|
|
|
|
- `frontend/src/tabs/monitoring/components/MonitoringView.tsx` 생성
|
|
- `frontend/src/tabs/monitoring/services/monitoringApi.ts` 생성
|
|
- `frontend/src/tabs/monitoring/index.ts` 생성
|
|
|
|
### 2단계: 프론트엔드 기존 파일 수정
|
|
|
|
```diff
|
|
--- frontend/src/common/types/navigation.ts
|
|
+ export type MainTab = '...' | 'monitoring' | 'admin';
|
|
|
|
--- frontend/src/App.tsx
|
|
+ import { MonitoringView } from '@tabs/monitoring';
|
|
// renderView switch 내:
|
|
+ case 'monitoring':
|
|
+ return <MonitoringView />;
|
|
|
|
--- frontend/src/common/hooks/useSubMenu.ts
|
|
// subMenuConfigs:
|
|
+ monitoring: null,
|
|
// subMenuState:
|
|
+ monitoring: '',
|
|
```
|
|
|
|
### 3단계: FEATURE_ID 등록
|
|
|
|
```diff
|
|
--- frontend/src/common/constants/featureIds.ts
|
|
+ // monitoring
|
|
+ 'monitoring:main': '모니터링',
|
|
```
|
|
|
|
### 4단계: 백엔드 파일 생성
|
|
|
|
- `backend/src/monitoring/monitoringRouter.ts` 생성
|
|
- `backend/src/monitoring/monitoringService.ts` 생성
|
|
|
|
### 5단계: 백엔드 기존 파일 수정 + DB
|
|
|
|
```diff
|
|
--- backend/src/server.ts
|
|
+ import monitoringRouter from './monitoring/monitoringRouter.js';
|
|
+ app.use('/api/monitoring', monitoringRouter);
|
|
|
|
--- backend/src/settings/settingsService.ts
|
|
+ { id: 'monitoring', label: '실시간 모니터링', icon: '📡', enabled: true, order: 11 },
|
|
```
|
|
|
|
- `database/migration/017_monitoring.sql` 생성
|
|
- `database/auth_init.sql` 의 menu.config JSON에 항목 추가
|
|
|
|
### 6단계: 검증
|
|
|
|
```bash
|
|
cd frontend && npx tsc --noEmit # TypeScript 컴파일 검증
|
|
cd frontend && npx eslint . # ESLint 검증
|
|
cd backend && npx tsc --noEmit # 백엔드 컴파일 검증
|
|
```
|
|
|
|
---
|
|
|
|
## 체크리스트
|
|
|
|
### 프론트엔드
|
|
- [ ] `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` 생성
|
|
- [ ] `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` 생성
|
|
- [ ] `frontend/src/tabs/{탭명}/index.ts` re-export 생성
|
|
- [ ] `navigation.ts` MainTab 타입에 새 ID 추가
|
|
- [ ] `App.tsx` import + renderView switch case 추가
|
|
- [ ] `useSubMenu.ts` subMenuConfigs + subMenuState 추가 (서브탭 있는 경우)
|
|
- [ ] `featureIds.ts` FEATURE_ID 등록
|
|
- [ ] `npx tsc --noEmit` 통과
|
|
- [ ] `npx eslint .` 통과
|
|
|
|
### 백엔드
|
|
- [ ] `backend/src/{도메인}/{domain}Router.ts` 생성
|
|
- [ ] `backend/src/{도메인}/{domain}Service.ts` 생성
|
|
- [ ] `server.ts` import + `app.use()` 등록
|
|
- [ ] `settingsService.ts` DEFAULT_MENU_CONFIG에 항목 추가
|
|
- [ ] `npx tsc --noEmit` 통과
|
|
|
|
### DB
|
|
- [ ] `database/migration/NNN_{domain}.sql` 마이그레이션 작성
|
|
- [ ] `database/auth_init.sql` menu.config 초기 JSON 업데이트
|
|
- [ ] SQL 실행 검증
|
|
|
|
### 배포 후
|
|
- [ ] 관리자 로그인 -> 메뉴 관리에서 새 메뉴 표시 확인
|
|
- [ ] 메뉴 활성화/비활성화 토글 동작 확인
|
|
- [ ] 권한 미부여 사용자에게 메뉴가 보이지 않는지 확인
|