docs: Phase 2 레이아웃 변환 추적 업데이트
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# MNG 메뉴 시스템: DB 기반 동적 메뉴 전환 계획
|
# MNG 메뉴 시스템: DB 기반 동적 메뉴 전환 계획
|
||||||
|
|
||||||
> 작성일: 2025-12-16
|
> 작성일: 2025-12-16
|
||||||
> 수정일: 2025-12-16 (Laravel 12 미들웨어 등록 방식, 확장 테이블 설계 반영)
|
> 수정일: 2025-12-16 (Laravel 12 미들웨어, JSON options 컬럼 방식으로 변경)
|
||||||
> 목적: mng 사이드바를 DB 기반으로 전환하여 직원별 권한에 따라 메뉴 동적 표시
|
> 목적: mng 사이드바를 DB 기반으로 전환하여 직원별 권한에 따라 메뉴 동적 표시
|
||||||
> 선택: **Option A - DB 메뉴 기반**
|
> 선택: **Option A - DB 메뉴 기반**
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ 목표 mng 메뉴 시스템 │
|
│ 목표 mng 메뉴 시스템 │
|
||||||
├─────────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
│ DB (menus + menu_mng_extensions 테이블, tenant_id=1) │
|
│ DB (menus 테이블 + options JSON 컬럼, tenant_id=1) │
|
||||||
│ ├── 일반 메뉴 (역할/부서/개인 권한으로 제어) │
|
│ ├── 일반 메뉴 (역할/부서/개인 권한으로 제어) │
|
||||||
│ ├── 개발도구 메뉴 (슈퍼관리자 전용) │
|
│ ├── 개발도구 메뉴 (슈퍼관리자 전용) │
|
||||||
│ └── R&D Labs 메뉴 (슈퍼관리자 전용) │
|
│ └── R&D Labs 메뉴 (슈퍼관리자 전용) │
|
||||||
@@ -41,40 +41,54 @@
|
|||||||
└─────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.3 DB 테이블 구조
|
### 1.3 DB 설계 방식 비교
|
||||||
|
|
||||||
**menus 테이블** (기존 - 수정하지 않음)
|
| 방식 | 장점 | 단점 |
|
||||||
|
|------|------|------|
|
||||||
|
| **별도 테이블** | menus 완전 무수정 | JOIN 필요, 테이블 관리 |
|
||||||
|
| **JSON 컬럼** ✅ | JOIN 불필요, 유연한 확장, 범용 | menus 수정 (안전) |
|
||||||
|
|
||||||
|
**선택: JSON 컬럼 방식**
|
||||||
|
- nullable JSON 컬럼 추가는 기존 코드에 영향 없음
|
||||||
|
- Laravel의 JSON 캐스팅으로 편리한 사용
|
||||||
|
- 나중에 API, React에서도 활용 가능
|
||||||
|
|
||||||
|
### 1.4 DB 테이블 구조
|
||||||
|
|
||||||
|
**menus 테이블** (기존 + options 컬럼 추가)
|
||||||
```
|
```
|
||||||
id, tenant_id, parent_id, global_menu_id
|
id, tenant_id, parent_id, global_menu_id
|
||||||
name, url, icon, sort_order
|
name, url, icon, sort_order
|
||||||
is_active, hidden, is_customized, is_external, external_url
|
is_active, hidden, is_customized, is_external, external_url
|
||||||
|
options (JSON, nullable) ← 신규 추가
|
||||||
created_by, updated_by, deleted_by, created_at, updated_at, deleted_at
|
created_by, updated_by, deleted_by, created_at, updated_at, deleted_at
|
||||||
```
|
```
|
||||||
|
|
||||||
> ⚠️ **중요**: menus 테이블은 API, React 등 28개 이상 파일에서 사용 중이므로 직접 수정하지 않음
|
**options JSON 구조** (범용 설계)
|
||||||
|
```json
|
||||||
**menu_mng_extensions 테이블** (신규 - mng 전용 확장)
|
{
|
||||||
```sql
|
"route_name": "dashboard",
|
||||||
CREATE TABLE menu_mng_extensions (
|
"section": "main",
|
||||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
"menu_type": "normal",
|
||||||
menu_id BIGINT UNSIGNED NOT NULL, -- menus 테이블 FK
|
"requires_role": null,
|
||||||
menu_type ENUM('normal', 'dev_tool', 'lab') DEFAULT 'normal',
|
"blade_component": null,
|
||||||
route_name VARCHAR(100) NULL, -- Laravel 라우트 이름
|
"css_class": null,
|
||||||
section ENUM('main', 'dev_tools', 'labs') DEFAULT 'main',
|
"meta": {}
|
||||||
lab_tab CHAR(1) NULL, -- R&D Labs용: 's', 'a', 'm'
|
}
|
||||||
blade_component VARCHAR(100) NULL, -- 커스텀 Blade 컴포넌트
|
|
||||||
css_class VARCHAR(100) NULL, -- 추가 CSS 클래스
|
|
||||||
requires_super_admin BOOLEAN DEFAULT FALSE, -- 슈퍼관리자 전용
|
|
||||||
created_at TIMESTAMP NULL,
|
|
||||||
updated_at TIMESTAMP NULL,
|
|
||||||
|
|
||||||
FOREIGN KEY (menu_id) REFERENCES menus(id) ON DELETE CASCADE,
|
|
||||||
INDEX idx_menu_id (menu_id),
|
|
||||||
INDEX idx_section (section),
|
|
||||||
INDEX idx_menu_type (menu_type)
|
|
||||||
);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
| 필드 | 타입 | 설명 | 예시 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `route_name` | string | Laravel 라우트 이름 | `"pm.projects.index"` |
|
||||||
|
| `section` | string | 메뉴 섹션 위치 | `"main"`, `"tools"`, `"labs"` |
|
||||||
|
| `menu_type` | string | 메뉴 유형 | `"normal"`, `"tool"`, `"lab"` |
|
||||||
|
| `requires_role` | string | 필요 역할 | `"super_admin"`, `null` |
|
||||||
|
| `blade_component` | string | 커스텀 컴포넌트 | `"menus.custom-item"` |
|
||||||
|
| `css_class` | string | 추가 CSS 클래스 | `"text-red-500"` |
|
||||||
|
| `meta` | object | 앱별 추가 데이터 | `{"tab": "s"}` |
|
||||||
|
|
||||||
|
> **범용 설계 원칙**: mng 고유 필드는 `meta`에 저장, 공통 필드만 최상위에 배치
|
||||||
|
|
||||||
**permissions 테이블** (Spatie)
|
**permissions 테이블** (Spatie)
|
||||||
```
|
```
|
||||||
id, tenant_id, name, guard_name, created_at, updated_at
|
id, tenant_id, name, guard_name, created_at, updated_at
|
||||||
@@ -109,8 +123,8 @@ menu:{menu_id}.delete # 삭제 권한
|
|||||||
| 메뉴 유형 | 권한 처리 |
|
| 메뉴 유형 | 권한 처리 |
|
||||||
|-----------|----------|
|
|-----------|----------|
|
||||||
| 일반 메뉴 | 역할/부서/개인 권한으로 제어 |
|
| 일반 메뉴 | 역할/부서/개인 권한으로 제어 |
|
||||||
| 개발도구 | `is_super_admin` 체크 또는 특별 권한 |
|
| 개발도구 | `options.requires_role = "super_admin"` |
|
||||||
| R&D Labs | `is_super_admin` 체크 또는 특별 권한 |
|
| R&D Labs | `options.requires_role = "super_admin"` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -118,10 +132,10 @@ menu:{menu_id}.delete # 삭제 권한
|
|||||||
|
|
||||||
### Phase 1: DB 스키마 및 시딩 (1-2일)
|
### Phase 1: DB 스키마 및 시딩 (1-2일)
|
||||||
|
|
||||||
#### 3.1.1 확장 테이블 마이그레이션
|
#### 3.1.1 options 컬럼 마이그레이션
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// database/migrations/xxxx_create_menu_mng_extensions_table.php
|
// database/migrations/xxxx_add_options_to_menus_table.php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
@@ -132,69 +146,73 @@ return new class extends Migration
|
|||||||
{
|
{
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::create('menu_mng_extensions', function (Blueprint $table) {
|
Schema::table('menus', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->json('options')->nullable()
|
||||||
$table->foreignId('menu_id')
|
->after('external_url')
|
||||||
->constrained('menus')
|
->comment('확장 옵션 (JSON): route_name, section, menu_type, requires_role, meta 등');
|
||||||
->onDelete('cascade');
|
|
||||||
$table->enum('menu_type', ['normal', 'dev_tool', 'lab'])->default('normal');
|
|
||||||
$table->string('route_name', 100)->nullable()
|
|
||||||
->comment('Laravel 라우트 이름');
|
|
||||||
$table->enum('section', ['main', 'dev_tools', 'labs'])->default('main');
|
|
||||||
$table->char('lab_tab', 1)->nullable()
|
|
||||||
->comment('R&D Labs용: s, a, m');
|
|
||||||
$table->string('blade_component', 100)->nullable()
|
|
||||||
->comment('커스텀 Blade 컴포넌트');
|
|
||||||
$table->string('css_class', 100)->nullable()
|
|
||||||
->comment('추가 CSS 클래스');
|
|
||||||
$table->boolean('requires_super_admin')->default(false)
|
|
||||||
->comment('슈퍼관리자 전용 여부');
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->index('menu_id');
|
|
||||||
$table->index('section');
|
|
||||||
$table->index('menu_type');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down(): void
|
public function down(): void
|
||||||
{
|
{
|
||||||
Schema::dropIfExists('menu_mng_extensions');
|
Schema::table('menus', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('options');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3.1.2 확장 테이블 모델
|
#### 3.1.2 Menu 모델 수정 (API)
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// app/Models/MenuMngExtension.php
|
// api/app/Models/Commons/Menu.php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models\Commons;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
// ... 기존 코드
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class MenuMngExtension extends Model
|
class Menu extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'menu_id',
|
'tenant_id', 'parent_id', 'global_menu_id', 'name', 'url', 'is_active', 'sort_order',
|
||||||
'menu_type',
|
'hidden', 'is_customized', 'is_external', 'external_url', 'icon',
|
||||||
'route_name',
|
'options', // 추가
|
||||||
'section',
|
'created_by', 'updated_by', 'deleted_by',
|
||||||
'lab_tab',
|
|
||||||
'blade_component',
|
|
||||||
'css_class',
|
|
||||||
'requires_super_admin',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'requires_super_admin' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
|
'hidden' => 'boolean',
|
||||||
|
'is_customized' => 'boolean',
|
||||||
|
'is_external' => 'boolean',
|
||||||
|
'options' => 'array', // 추가
|
||||||
];
|
];
|
||||||
|
|
||||||
public function menu(): BelongsTo
|
// 헬퍼 메서드 (선택적)
|
||||||
|
public function getOption(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Menu::class);
|
return data_get($this->options, $key, $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRouteName(): ?string
|
||||||
|
{
|
||||||
|
return $this->getOption('route_name');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSection(): string
|
||||||
|
{
|
||||||
|
return $this->getOption('section', 'main');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMenuType(): string
|
||||||
|
{
|
||||||
|
return $this->getOption('menu_type', 'normal');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresRole(): ?string
|
||||||
|
{
|
||||||
|
return $this->getOption('requires_role');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -202,13 +220,12 @@ class MenuMngExtension extends Model
|
|||||||
#### 3.1.3 mng 메뉴 시더 생성
|
#### 3.1.3 mng 메뉴 시더 생성
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// database/seeders/MngMenuSeeder.php
|
// mng/database/seeders/MngMenuSeeder.php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Models\Menu;
|
use App\Models\Menu;
|
||||||
use App\Models\MenuMngExtension;
|
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
class MngMenuSeeder extends Seeder
|
class MngMenuSeeder extends Seeder
|
||||||
@@ -223,10 +240,10 @@ class MngMenuSeeder extends Seeder
|
|||||||
'name' => '대시보드',
|
'name' => '대시보드',
|
||||||
'url' => '/dashboard',
|
'url' => '/dashboard',
|
||||||
'icon' => 'home',
|
'icon' => 'home',
|
||||||
'extension' => [
|
'options' => [
|
||||||
'route_name' => 'dashboard',
|
'route_name' => 'dashboard',
|
||||||
'menu_type' => 'normal',
|
|
||||||
'section' => 'main',
|
'section' => 'main',
|
||||||
|
'menu_type' => 'normal',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -235,22 +252,25 @@ class MngMenuSeeder extends Seeder
|
|||||||
'name' => '프로젝트 관리',
|
'name' => '프로젝트 관리',
|
||||||
'url' => null,
|
'url' => null,
|
||||||
'icon' => 'folder',
|
'icon' => 'folder',
|
||||||
'extension' => ['menu_type' => 'normal', 'section' => 'main'],
|
'options' => [
|
||||||
|
'section' => 'main',
|
||||||
|
'menu_type' => 'normal',
|
||||||
|
],
|
||||||
'children' => [
|
'children' => [
|
||||||
[
|
[
|
||||||
'name' => '프로젝트 대시보드',
|
'name' => '프로젝트 대시보드',
|
||||||
'url' => '/project-management',
|
'url' => '/project-management',
|
||||||
'extension' => ['route_name' => 'pm.index'],
|
'options' => ['route_name' => 'pm.index'],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => '프로젝트',
|
'name' => '프로젝트',
|
||||||
'url' => '/project-management/projects',
|
'url' => '/project-management/projects',
|
||||||
'extension' => ['route_name' => 'pm.projects.index'],
|
'options' => ['route_name' => 'pm.projects.index'],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => '일일 스크럼',
|
'name' => '일일 스크럼',
|
||||||
'url' => '/daily-logs',
|
'url' => '/daily-logs',
|
||||||
'extension' => ['route_name' => 'daily-logs.index'],
|
'options' => ['route_name' => 'daily-logs.index'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -262,26 +282,26 @@ class MngMenuSeeder extends Seeder
|
|||||||
'name' => '개발 도구',
|
'name' => '개발 도구',
|
||||||
'url' => null,
|
'url' => null,
|
||||||
'icon' => 'cog',
|
'icon' => 'cog',
|
||||||
'extension' => [
|
'options' => [
|
||||||
'menu_type' => 'dev_tool',
|
'section' => 'tools',
|
||||||
'section' => 'dev_tools',
|
'menu_type' => 'tool',
|
||||||
'requires_super_admin' => true,
|
'requires_role' => 'super_admin',
|
||||||
],
|
],
|
||||||
'children' => [
|
'children' => [
|
||||||
[
|
[
|
||||||
'name' => 'API 플로우 테스터',
|
'name' => 'API 플로우 테스터',
|
||||||
'url' => '/dev-tools/flow-tester',
|
'url' => '/dev-tools/flow-tester',
|
||||||
'extension' => [
|
'options' => [
|
||||||
'route_name' => 'dev-tools.flow-tester.index',
|
'route_name' => 'dev-tools.flow-tester.index',
|
||||||
'requires_super_admin' => true,
|
'requires_role' => 'super_admin',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => 'API 요청 로그',
|
'name' => 'API 요청 로그',
|
||||||
'url' => '/dev-tools/api-logs',
|
'url' => '/dev-tools/api-logs',
|
||||||
'extension' => [
|
'options' => [
|
||||||
'route_name' => 'dev-tools.api-logs.index',
|
'route_name' => 'dev-tools.api-logs.index',
|
||||||
'requires_super_admin' => true,
|
'requires_role' => 'super_admin',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -292,13 +312,22 @@ class MngMenuSeeder extends Seeder
|
|||||||
'name' => 'R&D Labs',
|
'name' => 'R&D Labs',
|
||||||
'url' => null,
|
'url' => null,
|
||||||
'icon' => 'beaker',
|
'icon' => 'beaker',
|
||||||
'extension' => [
|
'options' => [
|
||||||
'menu_type' => 'lab',
|
|
||||||
'section' => 'labs',
|
'section' => 'labs',
|
||||||
'requires_super_admin' => true,
|
'menu_type' => 'lab',
|
||||||
|
'requires_role' => 'super_admin',
|
||||||
],
|
],
|
||||||
'children' => [
|
'children' => [
|
||||||
// S, A, M 탭 구조
|
// 하위 메뉴들 (meta에 앱별 데이터 저장 가능)
|
||||||
|
[
|
||||||
|
'name' => 'S Lab',
|
||||||
|
'url' => '/labs/s',
|
||||||
|
'options' => [
|
||||||
|
'route_name' => 'labs.s.index',
|
||||||
|
'requires_role' => 'super_admin',
|
||||||
|
'meta' => ['tab' => 's'],
|
||||||
|
],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@@ -309,9 +338,8 @@ class MngMenuSeeder extends Seeder
|
|||||||
private function seedMenus(int $tenantId, array $menus, ?int $parentId = null): void
|
private function seedMenus(int $tenantId, array $menus, ?int $parentId = null): void
|
||||||
{
|
{
|
||||||
foreach ($menus as $index => $menuData) {
|
foreach ($menus as $index => $menuData) {
|
||||||
$extension = $menuData['extension'] ?? [];
|
|
||||||
$children = $menuData['children'] ?? [];
|
$children = $menuData['children'] ?? [];
|
||||||
unset($menuData['extension'], $menuData['children']);
|
unset($menuData['children']);
|
||||||
|
|
||||||
// 메뉴 생성
|
// 메뉴 생성
|
||||||
$menu = Menu::create([
|
$menu = Menu::create([
|
||||||
@@ -322,16 +350,9 @@ class MngMenuSeeder extends Seeder
|
|||||||
'icon' => $menuData['icon'] ?? null,
|
'icon' => $menuData['icon'] ?? null,
|
||||||
'sort_order' => $index,
|
'sort_order' => $index,
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
|
'options' => $menuData['options'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 확장 데이터 생성
|
|
||||||
if (!empty($extension)) {
|
|
||||||
MenuMngExtension::create([
|
|
||||||
'menu_id' => $menu->id,
|
|
||||||
...$extension,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 자식 메뉴 재귀 처리
|
// 자식 메뉴 재귀 처리
|
||||||
if (!empty($children)) {
|
if (!empty($children)) {
|
||||||
$this->seedMenus($tenantId, $children, $menu->id);
|
$this->seedMenus($tenantId, $children, $menu->id);
|
||||||
@@ -346,7 +367,7 @@ class MngMenuSeeder extends Seeder
|
|||||||
#### 3.2.1 SidebarMenuService 생성
|
#### 3.2.1 SidebarMenuService 생성
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// app/Services/SidebarMenuService.php
|
// mng/app/Services/SidebarMenuService.php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
@@ -366,22 +387,11 @@ class SidebarMenuService
|
|||||||
$user = $user ?? auth()->user();
|
$user = $user ?? auth()->user();
|
||||||
$tenantId = session('selected_tenant_id', 1);
|
$tenantId = session('selected_tenant_id', 1);
|
||||||
|
|
||||||
// 1. 테넌트의 모든 활성 메뉴 + 확장 데이터 조회 (LEFT JOIN)
|
// 1. 테넌트의 모든 활성 메뉴 조회
|
||||||
$allMenus = Menu::where('menus.tenant_id', $tenantId)
|
$allMenus = Menu::where('tenant_id', $tenantId)
|
||||||
->where('menus.is_active', true)
|
->where('is_active', true)
|
||||||
->where('menus.hidden', false)
|
->where('hidden', false)
|
||||||
->leftJoin('menu_mng_extensions', 'menus.id', '=', 'menu_mng_extensions.menu_id')
|
->orderBy('sort_order')
|
||||||
->select(
|
|
||||||
'menus.*',
|
|
||||||
'menu_mng_extensions.menu_type',
|
|
||||||
'menu_mng_extensions.route_name',
|
|
||||||
'menu_mng_extensions.section',
|
|
||||||
'menu_mng_extensions.lab_tab',
|
|
||||||
'menu_mng_extensions.blade_component',
|
|
||||||
'menu_mng_extensions.css_class',
|
|
||||||
'menu_mng_extensions.requires_super_admin'
|
|
||||||
)
|
|
||||||
->orderBy('menus.sort_order')
|
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
// 2. 슈퍼관리자는 모든 메뉴 표시
|
// 2. 슈퍼관리자는 모든 메뉴 표시
|
||||||
@@ -392,16 +402,15 @@ class SidebarMenuService
|
|||||||
// 3. 일반 사용자: 권한 기반 필터링
|
// 3. 일반 사용자: 권한 기반 필터링
|
||||||
$permittedMenuIds = $this->getPermittedMenuIds($user, $tenantId);
|
$permittedMenuIds = $this->getPermittedMenuIds($user, $tenantId);
|
||||||
|
|
||||||
// 4. 개발도구/Labs 및 슈퍼관리자 전용 메뉴 제외
|
// 4. 역할 필요 메뉴 및 특수 메뉴 제외
|
||||||
$filteredMenus = $allMenus->filter(function ($menu) use ($permittedMenuIds) {
|
$filteredMenus = $allMenus->filter(function ($menu) use ($permittedMenuIds, $user) {
|
||||||
// 슈퍼관리자 전용 메뉴 제외
|
// requires_role 체크
|
||||||
if ($menu->requires_super_admin) {
|
$requiredRole = $menu->getOption('requires_role');
|
||||||
return false;
|
if ($requiredRole && !$this->hasRole($user, $requiredRole)) {
|
||||||
}
|
|
||||||
// 개발도구/Labs는 일반 사용자에게 표시 안함
|
|
||||||
if (in_array($menu->menu_type, ['dev_tool', 'lab'])) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 권한 체크
|
||||||
return in_array($menu->id, $permittedMenuIds);
|
return in_array($menu->id, $permittedMenuIds);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -409,19 +418,30 @@ class SidebarMenuService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 섹션별 메뉴 조회 (main, dev_tools, labs)
|
* 섹션별 메뉴 조회 (main, tools, labs)
|
||||||
*/
|
*/
|
||||||
public function getMenusBySection(?User $user = null): array
|
public function getMenusBySection(?User $user = null): array
|
||||||
{
|
{
|
||||||
$menuTree = $this->getUserMenuTree($user);
|
$menuTree = $this->getUserMenuTree($user);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'main' => $menuTree->filter(fn($m) => ($m->section ?? 'main') === 'main'),
|
'main' => $menuTree->filter(fn($m) => $m->getSection() === 'main')->values(),
|
||||||
'dev_tools' => $menuTree->filter(fn($m) => ($m->section ?? 'main') === 'dev_tools'),
|
'tools' => $menuTree->filter(fn($m) => $m->getSection() === 'tools')->values(),
|
||||||
'labs' => $menuTree->filter(fn($m) => ($m->section ?? 'main') === 'labs'),
|
'labs' => $menuTree->filter(fn($m) => $m->getSection() === 'labs')->values(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 역할 확인
|
||||||
|
*/
|
||||||
|
private function hasRole(User $user, string $role): bool
|
||||||
|
{
|
||||||
|
return match ($role) {
|
||||||
|
'super_admin' => $user->is_super_admin,
|
||||||
|
default => $user->hasRole($role),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사용자가 접근 가능한 메뉴 ID 목록 조회
|
* 사용자가 접근 가능한 메뉴 ID 목록 조회
|
||||||
* 우선순위: 개인 DENY > 개인 ALLOW > 부서 DENY > 부서 ALLOW > 역할
|
* 우선순위: 개인 DENY > 개인 ALLOW > 부서 DENY > 부서 ALLOW > 역할
|
||||||
@@ -444,18 +464,19 @@ class SidebarMenuService
|
|||||||
private function getRoleMenuPermissions(User $user, int $tenantId): array
|
private function getRoleMenuPermissions(User $user, int $tenantId): array
|
||||||
{
|
{
|
||||||
// menu:*.view 형식의 권한에서 메뉴 ID 추출
|
// menu:*.view 형식의 권한에서 메뉴 ID 추출
|
||||||
$permissions = $user->getPermissionsViaRoles()
|
return $user->getPermissionsViaRoles()
|
||||||
->filter(fn($p) => str_starts_with($p->name, 'menu:') && str_ends_with($p->name, '.view'))
|
->filter(fn($p) => str_starts_with($p->name, 'menu:') && str_ends_with($p->name, '.view'))
|
||||||
->pluck('name')
|
->pluck('name')
|
||||||
->map(fn($name) => (int) explode('.', explode(':', $name)[1])[0])
|
->map(fn($name) => (int) explode('.', explode(':', $name)[1])[0])
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
return $permissions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getDepartmentMenuPermissions(User $user, int $tenantId): array
|
private function getDepartmentMenuPermissions(User $user, int $tenantId): array
|
||||||
{
|
{
|
||||||
// 부서 권한 조회 로직 (department_permissions 테이블)
|
if (!$user->department_id) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return DB::table('department_permissions')
|
return DB::table('department_permissions')
|
||||||
->where('department_id', $user->department_id)
|
->where('department_id', $user->department_id)
|
||||||
->where('permission_id', 'LIKE', 'menu:%')
|
->where('permission_id', 'LIKE', 'menu:%')
|
||||||
@@ -468,7 +489,6 @@ class SidebarMenuService
|
|||||||
|
|
||||||
private function getUserMenuOverrides(User $user, int $tenantId): array
|
private function getUserMenuOverrides(User $user, int $tenantId): array
|
||||||
{
|
{
|
||||||
// 개인 권한 오버라이드 조회 (user_permission_overrides 테이블)
|
|
||||||
return DB::table('user_permission_overrides')
|
return DB::table('user_permission_overrides')
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
->where('permission_id', 'LIKE', 'menu:%')
|
->where('permission_id', 'LIKE', 'menu:%')
|
||||||
@@ -495,14 +515,14 @@ class SidebarMenuService
|
|||||||
if ($user[$menuId]) {
|
if ($user[$menuId]) {
|
||||||
$permitted[] = $menuId;
|
$permitted[] = $menuId;
|
||||||
}
|
}
|
||||||
continue; // 개인 설정이 있으면 다른 건 무시
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($dept[$menuId])) {
|
if (isset($dept[$menuId])) {
|
||||||
if ($dept[$menuId]) {
|
if ($dept[$menuId]) {
|
||||||
$permitted[] = $menuId;
|
$permitted[] = $menuId;
|
||||||
}
|
}
|
||||||
continue; // 부서 설정이 있으면 역할은 무시
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($menuId, $role)) {
|
if (in_array($menuId, $role)) {
|
||||||
@@ -560,7 +580,7 @@ resources/views/
|
|||||||
#### 3.3.3 ViewServiceProvider에서 메뉴 공유
|
#### 3.3.3 ViewServiceProvider에서 메뉴 공유
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// app/Providers/ViewServiceProvider.php
|
// mng/app/Providers/ViewServiceProvider.php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
@@ -579,7 +599,7 @@ class ViewServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
$view->with([
|
$view->with([
|
||||||
'mainMenus' => $menusBySection['main'],
|
'mainMenus' => $menusBySection['main'],
|
||||||
'devToolsMenus' => $menusBySection['dev_tools'],
|
'toolsMenus' => $menusBySection['tools'],
|
||||||
'labsMenus' => $menusBySection['labs'],
|
'labsMenus' => $menusBySection['labs'],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -592,13 +612,12 @@ class ViewServiceProvider extends ServiceProvider
|
|||||||
#### 3.4.1 메뉴 권한 체크 미들웨어
|
#### 3.4.1 메뉴 권한 체크 미들웨어
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// app/Http/Middleware/CheckMenuPermission.php
|
// mng/app/Http/Middleware/CheckMenuPermission.php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use App\Models\Menu;
|
use App\Models\Menu;
|
||||||
use App\Models\MenuMngExtension;
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -614,41 +633,42 @@ class CheckMenuPermission
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 라우트 이름으로 메뉴 찾기 (확장 테이블에서)
|
// 라우트 이름으로 메뉴 찾기 (options JSON에서)
|
||||||
$routeName = $request->route()->getName();
|
$routeName = $request->route()->getName();
|
||||||
$extension = MenuMngExtension::where('route_name', $routeName)->first();
|
$menu = Menu::where('tenant_id', session('selected_tenant_id', 1))
|
||||||
|
->whereJsonContains('options->route_name', $routeName)
|
||||||
|
->first();
|
||||||
|
|
||||||
if (!$extension) {
|
if (!$menu) {
|
||||||
return $next($request); // 메뉴 등록 안 된 라우트는 패스
|
return $next($request); // 메뉴 등록 안 된 라우트는 패스
|
||||||
}
|
}
|
||||||
|
|
||||||
// 슈퍼관리자 전용 메뉴 체크
|
// requires_role 체크
|
||||||
if ($extension->requires_super_admin) {
|
$requiredRole = $menu->getOption('requires_role');
|
||||||
abort(403, '슈퍼관리자만 접근 가능합니다.');
|
if ($requiredRole) {
|
||||||
|
if ($requiredRole === 'super_admin' && !$user->is_super_admin) {
|
||||||
|
abort(403, '슈퍼관리자만 접근 가능합니다.');
|
||||||
|
}
|
||||||
|
if ($requiredRole !== 'super_admin' && !$user->hasRole($requiredRole)) {
|
||||||
|
abort(403, '접근 권한이 없습니다.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$permissionName = $permission ?? "menu:{$extension->menu_id}.view";
|
$permissionName = $permission ?? "menu:{$menu->id}.view";
|
||||||
|
|
||||||
if (!$this->hasMenuPermission($user, $extension->menu_id, $permissionName)) {
|
if (!$user->can($permissionName)) {
|
||||||
abort(403, '접근 권한이 없습니다.');
|
abort(403, '접근 권한이 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function hasMenuPermission($user, int $menuId, string $permissionName): bool
|
|
||||||
{
|
|
||||||
// SidebarMenuService와 동일한 권한 체크 로직 사용
|
|
||||||
// 또는 Spatie Permission 직접 체크
|
|
||||||
return $user->can($permissionName);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3.4.2 미들웨어 등록 (Laravel 12 방식)
|
#### 3.4.2 미들웨어 등록 (Laravel 12 방식)
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// bootstrap/app.php
|
// mng/bootstrap/app.php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
@@ -660,7 +680,6 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
// 기존 미들웨어
|
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'hq.member' => \App\Http\Middleware\EnsureHQMember::class,
|
'hq.member' => \App\Http\Middleware\EnsureHQMember::class,
|
||||||
'super.admin' => \App\Http\Middleware\EnsureSuperAdmin::class,
|
'super.admin' => \App\Http\Middleware\EnsureSuperAdmin::class,
|
||||||
@@ -692,7 +711,7 @@ Route::middleware(['auth', 'hq.member', 'menu.permission'])->group(function () {
|
|||||||
|
|
||||||
```
|
```
|
||||||
Phase 1: 준비 (하드코딩 + DB 병행)
|
Phase 1: 준비 (하드코딩 + DB 병행)
|
||||||
├── DB에 menu_mng_extensions 테이블 생성
|
├── menus 테이블에 options 컬럼 추가
|
||||||
├── mng 메뉴 시딩
|
├── mng 메뉴 시딩
|
||||||
├── SidebarMenuService 개발
|
├── SidebarMenuService 개발
|
||||||
└── 기존 sidebar.blade.php 유지
|
└── 기존 sidebar.blade.php 유지
|
||||||
@@ -732,24 +751,24 @@ Phase 4: 안정화
|
|||||||
|
|
||||||
| 파일 | 설명 |
|
| 파일 | 설명 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `database/migrations/xxxx_create_menu_mng_extensions_table.php` | mng 확장 테이블 생성 |
|
| `api/database/migrations/xxxx_add_options_to_menus.php` | options 컬럼 추가 |
|
||||||
| `app/Models/MenuMngExtension.php` | 확장 테이블 모델 |
|
| `mng/database/seeders/MngMenuSeeder.php` | mng 메뉴 시더 |
|
||||||
| `database/seeders/MngMenuSeeder.php` | mng 메뉴 시더 |
|
| `mng/database/seeders/MngMenuPermissionSeeder.php` | mng 메뉴 권한 시더 |
|
||||||
| `database/seeders/MngMenuPermissionSeeder.php` | mng 메뉴 권한 시더 |
|
| `mng/app/Services/SidebarMenuService.php` | 사용자별 메뉴 조회 |
|
||||||
| `app/Services/SidebarMenuService.php` | 사용자별 메뉴 조회 |
|
| `mng/app/Http/Middleware/CheckMenuPermission.php` | 메뉴 권한 미들웨어 |
|
||||||
| `app/Http/Middleware/CheckMenuPermission.php` | 메뉴 권한 미들웨어 |
|
| `mng/app/Providers/ViewServiceProvider.php` | 뷰 컴포저 |
|
||||||
| `app/Providers/ViewServiceProvider.php` | 뷰 컴포저 |
|
| `mng/resources/views/partials/sidebar-dynamic.blade.php` | 동적 사이드바 |
|
||||||
| `resources/views/partials/sidebar-dynamic.blade.php` | 동적 사이드바 |
|
| `mng/resources/views/components/sidebar/*.blade.php` | 사이드바 컴포넌트들 |
|
||||||
| `resources/views/components/sidebar/*.blade.php` | 사이드바 컴포넌트들 |
|
|
||||||
|
|
||||||
### 수정
|
### 수정
|
||||||
|
|
||||||
| 파일 | 변경 내용 |
|
| 파일 | 변경 내용 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `bootstrap/app.php` | CheckMenuPermission 미들웨어 등록 |
|
| `api/app/Models/Commons/Menu.php` | options 캐스팅 + 헬퍼 메서드 |
|
||||||
| `config/app.php` | mng_dynamic_sidebar 설정 추가 |
|
| `mng/bootstrap/app.php` | CheckMenuPermission 미들웨어 등록 |
|
||||||
| `routes/web.php` | 미들웨어 적용 |
|
| `mng/config/app.php` | mng_dynamic_sidebar 설정 추가 |
|
||||||
| `resources/views/partials/sidebar.blade.php` | 조건부 렌더링 |
|
| `mng/routes/web.php` | 미들웨어 적용 |
|
||||||
|
| `mng/resources/views/partials/sidebar.blade.php` | 조건부 렌더링 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -776,9 +795,10 @@ Phase 4: 안정화
|
|||||||
|
|
||||||
### Phase 1 완료 조건
|
### Phase 1 완료 조건
|
||||||
|
|
||||||
- [ ] 마이그레이션 실행 성공 (menu_mng_extensions 테이블 생성)
|
- [ ] 마이그레이션 실행 성공 (options 컬럼 추가)
|
||||||
- [ ] mng 메뉴 시더 실행 성공
|
- [ ] mng 메뉴 시더 실행 성공
|
||||||
- [ ] DB에 모든 mng 메뉴 존재 확인
|
- [ ] DB에 모든 mng 메뉴 존재 확인
|
||||||
|
- [ ] 기존 API/React 영향 없음 확인
|
||||||
|
|
||||||
### Phase 2 완료 조건
|
### Phase 2 완료 조건
|
||||||
|
|
||||||
@@ -848,10 +868,11 @@ AuditLog::create([
|
|||||||
이 계획을 승인하시면 다음 순서로 진행합니다:
|
이 계획을 승인하시면 다음 순서로 진행합니다:
|
||||||
|
|
||||||
1. **현재 mng 메뉴 전체 목록 정리** (그룹/항목/라우트/아이콘)
|
1. **현재 mng 메뉴 전체 목록 정리** (그룹/항목/라우트/아이콘)
|
||||||
2. **마이그레이션 및 시더 작성** (menu_mng_extensions 테이블)
|
2. **마이그레이션 작성** (menus.options 컬럼)
|
||||||
3. **SidebarMenuService 개발**
|
3. **Menu 모델 수정** (options 캐스팅)
|
||||||
4. **동적 사이드바 컴포넌트 개발**
|
4. **SidebarMenuService 개발**
|
||||||
5. **권한 미들웨어 적용**
|
5. **동적 사이드바 컴포넌트 개발**
|
||||||
6. **테스트 및 전환**
|
6. **권한 미들웨어 적용**
|
||||||
|
7. **테스트 및 전환**
|
||||||
|
|
||||||
진행하시겠습니까?
|
진행하시겠습니까?
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 5130 → MNG 마이그레이션 작업 추적
|
# 5130 → MNG 마이그레이션 작업 추적
|
||||||
|
|
||||||
> **시작일**: 2025-12-16
|
> **시작일**: 2025-12-16
|
||||||
> **현재 상태**: ✅ Phase 1 완료
|
> **현재 상태**: ✅ Phase 2 레이아웃 변환 완료
|
||||||
> **마지막 업데이트**: 2025-12-16
|
> **마지막 업데이트**: 2025-12-16
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
| Phase | 상태 | 완료 | 전체 | 진행률 |
|
| Phase | 상태 | 완료 | 전체 | 진행률 |
|
||||||
|-------|------|------|------|--------|
|
|-------|------|------|------|--------|
|
||||||
| Phase 1: 레이아웃 변환 | ✅ 완료 | 13 | 13 | 100% |
|
| Phase 1: 레이아웃 변환 | ✅ 완료 | 13 | 13 | 100% |
|
||||||
| Phase 2: AI 기능 구현 | ⏳ 대기 | 0 | 10 | 0% |
|
| Phase 2: AI 기능 구현 | 🔄 레이아웃 완료 | 0 | 10 | 0% |
|
||||||
| Phase 3: Management 구현 | ⏳ 대기 | 0 | 11 | 0% |
|
| Phase 3: Management 구현 | ⏳ 대기 | 0 | 11 | 0% |
|
||||||
| Phase 4: Strategy placeholder | ⏳ 대기 | 0 | 3 | 0% |
|
| Phase 4: Strategy placeholder | ⏳ 대기 | 0 | 3 | 0% |
|
||||||
| **전체** | | **14** | **38** | **36.8%** |
|
| **전체** | | **14** | **38** | **36.8%** |
|
||||||
@@ -58,21 +58,21 @@
|
|||||||
## 📋 Phase 2: AI 기능 구현 (10개)
|
## 📋 Phase 2: AI 기능 구현 (10개)
|
||||||
|
|
||||||
> **작업 유형**: placeholder → 전체 구현
|
> **작업 유형**: placeholder → 전체 구현
|
||||||
> **예상 시간**: 각 2~8시간
|
> **레이아웃 변환**: ✅ 완료 (2025-12-16)
|
||||||
> **우선순위**: 🟡 중간
|
> **우선순위**: 🟡 중간
|
||||||
|
|
||||||
| # | 파일명 | 메뉴명 | 상태 | 작업일 | 비고 |
|
| # | 파일명 | 메뉴명 | 레이아웃 | 기능구현 | 비고 |
|
||||||
|---|--------|--------|:----:|--------|------|
|
|---|--------|--------|:----:|:----:|------|
|
||||||
| 1 | `web-recording.blade.php` | 웹 녹음 AI 요약 | ⬜ | - | |
|
| 1 | `web-recording.blade.php` | 웹 녹음 AI 요약 | ✅ | ⬜ | |
|
||||||
| 2 | `meeting-summary.blade.php` | 회의록 AI 요약 | ⬜ | - | |
|
| 2 | `meeting-summary.blade.php` | 회의록 AI 요약 | ✅ | ⬜ | |
|
||||||
| 3 | `work-memo-summary.blade.php` | 업무협의록 AI 요약 | ⬜ | - | |
|
| 3 | `work-memo-summary.blade.php` | 업무협의록 AI 요약 | ✅ | ⬜ | |
|
||||||
| 4 | `operator-chatbot.blade.php` | 운영자용 챗봇 | ⬜ | - | |
|
| 4 | `operator-chatbot.blade.php` | 운영자용 챗봇 | ✅ | ⬜ | |
|
||||||
| 5 | `vertex-rag.blade.php` | Vertex RAG 챗봇 | ⬜ | - | |
|
| 5 | `vertex-rag.blade.php` | Vertex RAG 챗봇 | ✅ | ⬜ | |
|
||||||
| 6 | `tenant-knowledge.blade.php` | 테넌트 지식 업로드 | ⬜ | - | |
|
| 6 | `tenant-knowledge.blade.php` | 테넌트 지식 업로드 | ✅ | ⬜ | |
|
||||||
| 7 | `tenant-chatbot.blade.php` | 테넌트 챗봇 | ⬜ | - | |
|
| 7 | `tenant-chatbot.blade.php` | 테넌트 챗봇 | ✅ | ⬜ | |
|
||||||
| 8 | `sam-ai-alarm.blade.php` | SAM AI 알람음 제작 | ⬜ | - | |
|
| 8 | `sam-ai-alarm.blade.php` | SAM AI 알람음 제작 | ✅ | ⬜ | |
|
||||||
| 9 | `gps-attendance.blade.php` | GPS 출퇴근 관리 | ⬜ | - | |
|
| 9 | `gps-attendance.blade.php` | GPS 출퇴근 관리 | ✅ | ⬜ | |
|
||||||
| 10 | `company-overview.blade.php` | 기업개황 조회 | ⬜ | - | |
|
| 10 | `company-overview.blade.php` | 기업개황 조회 | ✅ | ⬜ | |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -159,6 +159,7 @@
|
|||||||
|
|
||||||
| 날짜 | 내용 | 커밋 |
|
| 날짜 | 내용 | 커밋 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
|
| 2025-12-16 | Phase 2 레이아웃 변환 - 10개 AI 페이지 | mng: (pending) |
|
||||||
| 2025-12-16 | Phase 1 완료 - 13개 파일 레이아웃 변환 | mng: `27052af` |
|
| 2025-12-16 | Phase 1 완료 - 13개 파일 레이아웃 변환 | mng: `27052af` |
|
||||||
| 2025-12-16 | 작업 추적 문서 생성 | docs: `5e6508c` |
|
| 2025-12-16 | 작업 추적 문서 생성 | docs: `5e6508c` |
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user