Files
sam-manage/docs/MNG_PROJECT_PLAN.md
hskwon 76c8a94e4f docs: MNG 프로젝트 문서 정비
- 개발 단계별 문서 추가 (00_OVERVIEW ~ 06_PHASE)
- 기술 표준 문서 추가 (99_TECHNICAL_STANDARDS)
- 개발 프로세스 및 패턴 문서 추가
  - API_FLOW_TESTER_DESIGN, DEV_PROCESS
  - HTMX_API_PATTERN, LAYOUT_PATTERN
  - SETUP_GUIDE, MNG_PROJECT_PLAN
- 프로젝트 관리 문서 추가 (project-management/)
- INDEX.md, MNG_CRITICAL_RULES.md 업데이트
2025-11-30 21:04:19 +09:00

27 KiB

MNG 프로젝트 개발 계획서

📋 프로젝트 개요

목적

  • 문제점: 기존 admin/ (Filament v4)은 AI 없이 수정이 어려움
  • 목표: 수정 용이한 Plain Laravel 기반 관리자 패널 구축
  • 도메인: mng.sam.kr
  • 철학: 단순함 > 복잡함, AI 없이도 수정 가능한 직관적 코드

핵심 전략

┌─────────────────────────────────────────────┐
│  MNG (mng.sam.kr)                          │
│  ┌──────────────┐     ┌─────────────────┐  │
│  │ Web Routes   │────▶│ Blade + HTMX    │  │ ← DaisyUI (심플)
│  │ (세션 인증)   │     │ (수정 용이)      │  │
│  └──────────────┘     └─────────────────┘  │
│  ┌──────────────┐     ┌─────────────────┐  │
│  │ API Routes   │────▶│ Admin API       │  │ ← 처음부터 분리
│  │ (토큰 인증)   │     │ (관리자 전용)    │  │
│  └──────────────┘     └─────────────────┘  │
│         ↓                                   │
│  ┌──────────────────────────────────────┐  │
│  │ Service Layer (비즈니스 로직)         │  │ ← admin/ 복사
│  └──────────────────────────────────────┘  │
│         ↓                                   │
│  ┌──────────────────────────────────────┐  │
│  │ Models (admin/ 복사, Filament 제거)  │  │
│  └──────────────────────────────────────┘  │
└─────────────────┬───────────────────────────┘
                  ↓
    ┌─────────────────────────────┐
    │ MySQL 8.0 (공유 DB)          │
    │ - admin/ (점차 deprecated)  │
    │ - api/ (외부 API)           │
    │ - mng/ (새 관리자) ← 최종   │
    └─────────────────────────────┘

설계 원칙

  1. 단순성: 복잡한 추상화 금지, 인라인 코드 허용
  2. 수정 용이성: AI 없이도 Blade 템플릿 수정 가능
  3. 코드 재사용: admin/ 모델/서비스 복사 후 간소화
  4. DB 공유: 기존 테이블 최대한 활용

🏗️ 아키텍처 설계

1. 디렉토리 구조

SAM/
├── admin/          # Filament (점차 deprecated)
├── api/            # 외부 클라이언트 API
├── mng/            # ⭐ 운영 관리자 패널 (NEW)
│   ├── app/
│   │   ├── Http/
│   │   │   ├── Controllers/
│   │   │   │   ├── Web/           # Blade 컨트롤러 (단순)
│   │   │   │   │   ├── Auth/
│   │   │   │   │   ├── Dashboard/
│   │   │   │   │   ├── User/
│   │   │   │   │   └── Product/
│   │   │   │   └── Api/           # Admin API (향후)
│   │   │   │       └── Admin/
│   │   │   ├── Requests/          # FormRequest (필수)
│   │   │   └── Middleware/
│   │   ├── Services/              # admin/ 복사 후 간소화
│   │   ├── Models/                # admin/ 복사, Filament 코드 제거
│   │   └── Traits/
│   │       ├── BelongsToTenant.php   # admin/에서 복사
│   │       └── HasAuditLog.php       # admin/에서 복사
│   ├── routes/
│   │   ├── web.php                # Blade 라우트
│   │   └── api.php                # Admin API (/api/admin/*)
│   ├── resources/
│   │   └── views/
│   │       ├── layouts/
│   │       │   ├── app.blade.php     # 단순 레이아웃
│   │       │   └── guest.blade.php
│   │       ├── auth/              # 로그인 화면
│   │       ├── dashboard/         # 대시보드
│   │       ├── users/             # 사용자 관리
│   │       └── products/          # 제품 관리
│   ├── database/
│   │   └── migrations/
│   │       └── # 관리자 전용: admin_*
│   │       └── # 통계 전용: stat_*
│   ├── tests/
│   │   └── Feature/
│   └── .env
├── docker/
│   └── nginx/
│       └── mng.sam.kr.conf
└── claudedocs/
    └── mng/
        ├── MNG_PROJECT_PLAN.md    # 이 문서
        ├── API_SPEC.md            # API 명세
        └── PROGRESS.md            # 진행 상황

2. 기술 스택 (확정)

레이어 기술 버전 비고
백엔드 Laravel 12.x PHP 8.4+
인증 Sanctum 4.x 세션 + 토큰
DB MySQL 8.0 admin, api와 공유
프론트엔드 Blade + HTMX 1.x 단순, 수정 용이
CSS Tailwind CSS 3.x 기존과 통일
UI 컴포넌트 DaisyUI 4.x 심플, 클래스 기반
아이콘 Heroicons - Tailwind 친화적
문서화 L5-Swagger - Admin API 전용
테스트 PHPUnit - Feature Test

3. DB 테이블 명명 규칙 (변경)

기존 테이블 재사용 (마이그레이션 없음)

✅ users, roles, departments
✅ products, materials, bom_items
✅ menus, menu_role
✅ audit_logs, categories, files
✅ tenants

관리자 전용 테이블 (admin_* 접두사)

// database/migrations/2025_01_20_create_admin_settings_table.php
Schema::create('admin_settings', function (Blueprint $table) {
    $table->id();
    $table->string('key')->unique();
    $table->text('value')->nullable();
    $table->string('type')->default('string'); // string, json, boolean
    $table->timestamps();
});

// 예시 테이블
- admin_settings        # 관리자 설정
- admin_logs           # 관리자 작업 로그
- admin_preferences    # 관리자 개인 설정

통계 테이블 (stat_* 접두사)

// database/migrations/2025_01_20_create_stat_daily_sales_table.php
Schema::create('stat_daily_sales', function (Blueprint $table) {
    $table->id();
    $table->date('date');
    $table->decimal('total_amount', 15, 2);
    $table->integer('order_count');
    $table->timestamps();

    $table->unique('date');
});

// 예시 테이블
- stat_daily_sales     # 일별 매출 통계
- stat_inventory       # 재고 통계
- stat_user_activity   # 사용자 활동 통계

4. 모델/서비스 복사 전략

admin/ → mng/ 복사 프로세스

# 1. 모델 복사 (Filament 의존성 제거)
cp -r admin/app/Models/* mng/app/Models/
# Filament 관련 코드 제거 (getNavigationLabel, form, table 등)

# 2. Traits 복사 (그대로 사용)
cp admin/app/Traits/BelongsToTenant.php mng/app/Traits/
cp admin/app/Traits/HasAuditLog.php mng/app/Traits/

# 3. Services 복사 (있다면)
cp -r admin/app/Services/* mng/app/Services/
# 또는 신규 작성 (Service-First 원칙)

모델 예시 (Filament 제거)

// admin/app/Models/User.php (Before)
class User extends Authenticatable implements FilamentUser
{
    use BelongsToTenant, HasAuditLog;

    public static function form(Form $form): Form { ... }  // ❌ 제거
    public static function table(Table $table): Table { ... }  // ❌ 제거
    public function canAccessPanel(Panel $panel): bool { ... }  // ❌ 제거
}

// mng/app/Models/User.php (After)
class User extends Authenticatable
{
    use BelongsToTenant, HasAuditLog;

    protected $fillable = [
        'tenant_id', 'email', 'password', 'name',
        'role_id', 'department_id', 'is_active',
    ];

    // 순수 Eloquent 관계만 유지
    public function role() { return $this->belongsTo(Role::class); }
    public function department() { return $this->belongsTo(Department::class); }
}

🎨 UI 설계 원칙 (수정 용이성 최우선)

DaisyUI 사용 철학

{{-- ✅ GOOD: 단순하고 직관적 --}}
<button class="btn btn-primary">저장</button>
<div class="card bg-base-100 shadow-xl">
    <div class="card-body">
        <h2 class="card-title">제목</h2>
        <p>내용</p>
    </div>
</div>

{{-- ❌ BAD: 과도한 추상화 --}}
<x-custom-button variant="primary" size="large" />
<x-card-wrapper :config="$complexConfig" />

Blade 템플릿 구조 (2레벨 최대)

{{-- layouts/app.blade.php (레이아웃) --}}
<!DOCTYPE html>
<html data-theme="light">
<head>
    <title>{{ config('app.name') }}</title>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
    <div class="drawer lg:drawer-open">
        {{-- 사이드바 --}}
        <input id="drawer" type="checkbox" class="drawer-toggle" />
        <div class="drawer-side">
            <label for="drawer" class="drawer-overlay"></label>
            <ul class="menu p-4 w-64 bg-base-200">
                @foreach($menus as $menu)
                    <li><a href="{{ $menu->url }}">{{ __($menu->name) }}</a></li>
                @endforeach
            </ul>
        </div>

        {{-- 메인 컨텐츠 --}}
        <div class="drawer-content">
            <div class="navbar bg-base-100">
                <div class="flex-1">
                    <a class="btn btn-ghost normal-case text-xl">MNG</a>
                </div>
                <div class="flex-none">
                    <div class="dropdown dropdown-end">
                        <label tabindex="0" class="btn btn-ghost">
                            {{ auth()->user()->name }}
                        </label>
                        <ul class="menu dropdown-content">
                            <li><a href="/logout">로그아웃</a></li>
                        </ul>
                    </div>
                </div>
            </div>

            <main class="p-6">
                @yield('content')
            </main>
        </div>
    </div>
</body>
</html>

{{-- users/index.blade.php (페이지) --}}
@extends('layouts.app')

@section('content')
<div class="card bg-base-100 shadow-xl">
    <div class="card-body">
        <h2 class="card-title">사용자 목록</h2>

        {{-- Alpine.js 최소 사용 --}}
        <div x-data="{ search: '' }">
            <input x-model="search" type="text"
                   placeholder="검색..."
                   class="input input-bordered w-full" />
        </div>

        <div class="overflow-x-auto">
            <table class="table w-full">
                <thead>
                    <tr>
                        <th>이름</th>
                        <th>이메일</th>
                        <th>역할</th>
                        <th>작업</th>
                    </tr>
                </thead>
                <tbody>
                    @foreach($users as $user)
                    <tr>
                        <td>{{ $user->name }}</td>
                        <td>{{ $user->email }}</td>
                        <td>{{ $user->role->name }}</td>
                        <td>
                            <a href="/users/{{ $user->id }}/edit" class="btn btn-sm">수정</a>
                        </td>
                    </tr>
                    @endforeach
                </tbody>
            </table>
        </div>

        {{ $users->links() }} {{-- Pagination --}}
    </div>
</div>
@endsection

Alpine.js 사용 원칙 (최소화)

{{-- ✅ GOOD: 단순 인터랙션 --}}
<div x-data="{ open: false }">
    <button @click="open = !open" class="btn">메뉴 열기</button>
    <div x-show="open" class="dropdown-content">메뉴 내용</div>
</div>

{{-- ❌ BAD: 복잡한 로직 (서버에서 처리) --}}
<div x-data="complexDataFetching()">
    <div x-init="loadData()">...</div>
</div>

🚀 개발 로드맵

Phase 1: 인프라 구축 (1일)

체크리스트

  • Laravel 12 프로젝트 생성 (mng/)
    cd SAM
    composer create-project laravel/laravel mng
    cd mng
    
  • .env 환경 변수 설정
    APP_NAME=MNG
    APP_URL=http://mng.sam.kr
    DB_CONNECTION=pgsql
    DB_HOST=postgres
    DB_PORT=5432
    DB_DATABASE=sam_db
    DB_USERNAME=sam_user
    DB_PASSWORD=sam_password
    
  • Composer 패키지 설치
    composer require laravel/sanctum
    composer require darkaonline/l5-swagger
    composer require --dev laravel/pint
    
  • Tailwind + DaisyUI + HTMX 설정
    npm install -D tailwindcss daisyui @tailwindcss/forms
    npm install htmx.org
    
    // tailwind.config.js
    module.exports = {
      plugins: [require('daisyui')],
      daisyui: {
        themes: ['light', 'dark'],
      },
    }
    
  • Docker Nginx 설정 (mng.sam.kr)
    server {
        listen 80;
        server_name mng.sam.kr;
        root /var/www/mng/public;
        index index.php;
    
        location / {
            try_files $uri $uri/ /index.php?$query_string;
        }
    
        location ~ \.php$ {
            fastcgi_pass mng:9000;
            fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
            include fastcgi_params;
        }
    }
    
  • admin/ 모델 복사
    cp -r admin/app/Models/* mng/app/Models/
    cp -r admin/app/Traits/* mng/app/Traits/
    # Filament 관련 코드 제거 후 커밋
    

산출물

  • mng/ 디렉토리 (Git 독립 저장소)
  • DaisyUI + Alpine.js 환경
  • 복사된 모델 (Filament 제거)

Phase 2: 인증 시스템 (2일)

로그인 화면 (DaisyUI)

{{-- resources/views/auth/login.blade.php --}}
<!DOCTYPE html>
<html data-theme="light">
<head>
    <title>로그인 - MNG</title>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="bg-base-200">
    <div class="hero min-h-screen">
        <div class="hero-content flex-col">
            <div class="card w-96 bg-base-100 shadow-xl">
                <div class="card-body">
                    <h2 class="card-title justify-center mb-4">MNG 로그인</h2>

                    <form method="POST" action="/login">
                        @csrf

                        <div class="form-control">
                            <label class="label">
                                <span class="label-text">이메일</span>
                            </label>
                            <input type="email" name="email"
                                   placeholder="email@example.com"
                                   class="input input-bordered"
                                   required autofocus />
                        </div>

                        <div class="form-control mt-4">
                            <label class="label">
                                <span class="label-text">비밀번호</span>
                            </label>
                            <input type="password" name="password"
                                   class="input input-bordered"
                                   required />
                        </div>

                        @if ($errors->any())
                        <div class="alert alert-error mt-4">
                            {{ $errors->first() }}
                        </div>
                        @endif

                        <div class="form-control mt-6">
                            <button class="btn btn-primary">로그인</button>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</body>
</html>

AuthService (admin/ 참고)

// app/Services/AuthService.php
namespace App\Services;

use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;

class AuthService
{
    public function login(array $credentials): bool
    {
        return Auth::attempt($credentials);
    }

    public function logout(): void
    {
        Auth::logout();
    }

    public function createToken(array $credentials): ?string
    {
        $user = User::where('email', $credentials['email'])->first();

        if (!$user || !Hash::check($credentials['password'], $user->password)) {
            return null;
        }

        return $user->createToken('mng-token')->plainTextToken;
    }
}

체크리스트

  • LoginRequest (FormRequest)
  • AuthService 작성
  • Web 로그인 구현 (세션)
  • API 로그인 구현 (토큰)
  • BelongsToTenant 적용 확인
  • Feature Test 작성

Phase 3: 대시보드 (1-2일)

DaisyUI Drawer 레이아웃

{{-- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html data-theme="light">
<head>
    <title>{{ config('app.name') }}</title>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
    <div class="drawer lg:drawer-open">
        <input id="drawer" type="checkbox" class="drawer-toggle" />

        {{-- 메인 컨텐츠 --}}
        <div class="drawer-content flex flex-col">
            {{-- 네비게이션 바 --}}
            <div class="w-full navbar bg-base-300">
                <div class="flex-none lg:hidden">
                    <label for="drawer" class="btn btn-square btn-ghost">
                        <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-6 h-6 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
                    </label>
                </div>
                <div class="flex-1 px-2 mx-2">MNG</div>
                <div class="flex-none">
                    <div class="dropdown dropdown-end">
                        <label tabindex="0" class="btn btn-ghost">
                            {{ auth()->user()->name }}
                        </label>
                        <ul tabindex="0" class="menu menu-compact dropdown-content mt-3 p-2 shadow bg-base-100 rounded-box w-52">
                            <li><a href="/profile">프로필</a></li>
                            <li>
                                <form method="POST" action="/logout">
                                    @csrf
                                    <button type="submit">로그아웃</button>
                                </form>
                            </li>
                        </ul>
                    </div>
                </div>
            </div>

            {{-- 페이지 컨텐츠 --}}
            <main class="p-6 flex-1">
                @yield('content')
            </main>
        </div>

        {{-- 사이드바 --}}
        <div class="drawer-side">
            <label for="drawer" class="drawer-overlay"></label>
            <ul class="menu p-4 w-64 bg-base-200 text-base-content">
                @foreach($menus as $menu)
                    @if($menu->children->isEmpty())
                        <li>
                            <a href="{{ $menu->url }}"
                               class="{{ request()->is($menu->url) ? 'active' : '' }}">
                                {{ __($menu->name) }}
                            </a>
                        </li>
                    @else
                        <li>
                            <details>
                                <summary>{{ __($menu->name) }}</summary>
                                <ul>
                                    @foreach($menu->children as $child)
                                        <li><a href="{{ $child->url }}">{{ __($child->name) }}</a></li>
                                    @endforeach
                                </ul>
                            </details>
                        </li>
                    @endif
                @endforeach
            </ul>
        </div>
    </div>
</body>
</html>

대시보드 컨트롤러

// app/Http/Controllers/Web/DashboardController.php
namespace App\Http\Controllers\Web;

use App\Http\Controllers\Controller;
use App\Services\MenuService;

class DashboardController extends Controller
{
    public function __construct(
        private MenuService $menuService
    ) {}

    public function index()
    {
        $menus = $this->menuService->getMenusForUser(auth()->user());

        return view('dashboard.index', compact('menus'));
    }
}

체크리스트

  • 레이아웃 템플릿 (DaisyUI Drawer)
  • 메뉴 서비스 (MenuService)
  • 역할별 메뉴 필터링
  • 대시보드 메인 페이지

Phase 4: 핵심 기능 (주 단위)

4.1 사용자 관리 (3-5일)

{{-- resources/views/users/index.blade.php --}}
@extends('layouts.app')

@section('content')
<div class="space-y-4">
    {{-- 헤더 --}}
    <div class="flex justify-between items-center">
        <h1 class="text-2xl font-bold">사용자 관리</h1>
        <a href="/users/create" class="btn btn-primary">사용자 추가</a>
    </div>

    {{-- 검색/필터 --}}
    <div class="card bg-base-100 shadow-xl">
        <div class="card-body">
            <form method="GET" action="/users">
                <div class="grid grid-cols-3 gap-4">
                    <div class="form-control">
                        <input type="text" name="search"
                               placeholder="이름 또는 이메일"
                               class="input input-bordered"
                               value="{{ request('search') }}" />
                    </div>
                    <div class="form-control">
                        <select name="role_id" class="select select-bordered">
                            <option value="">전체 역할</option>
                            @foreach($roles as $role)
                                <option value="{{ $role->id }}"
                                        {{ request('role_id') == $role->id ? 'selected' : '' }}>
                                    {{ $role->name }}
                                </option>
                            @endforeach
                        </select>
                    </div>
                    <div class="form-control">
                        <button type="submit" class="btn btn-primary">검색</button>
                    </div>
                </div>
            </form>
        </div>
    </div>

    {{-- 테이블 --}}
    <div class="card bg-base-100 shadow-xl">
        <div class="card-body">
            <div class="overflow-x-auto">
                <table class="table w-full">
                    <thead>
                        <tr>
                            <th>ID</th>
                            <th>이름</th>
                            <th>이메일</th>
                            <th>역할</th>
                            <th>부서</th>
                            <th>상태</th>
                            <th>작업</th>
                        </tr>
                    </thead>
                    <tbody>
                        @foreach($users as $user)
                        <tr>
                            <td>{{ $user->id }}</td>
                            <td>{{ $user->name }}</td>
                            <td>{{ $user->email }}</td>
                            <td>{{ $user->role->name }}</td>
                            <td>{{ $user->department->name }}</td>
                            <td>
                                <span class="badge {{ $user->is_active ? 'badge-success' : 'badge-error' }}">
                                    {{ $user->is_active ? '활성' : '비활성' }}
                                </span>
                            </td>
                            <td>
                                <div class="btn-group">
                                    <a href="/users/{{ $user->id }}/edit" class="btn btn-sm">수정</a>
                                    <button class="btn btn-sm btn-error"
                                            onclick="confirmDelete({{ $user->id }})">삭제</button>
                                </div>
                            </td>
                        </tr>
                        @endforeach
                    </tbody>
                </table>
            </div>

            {{ $users->links() }}
        </div>
    </div>
</div>

<script>
function confirmDelete(userId) {
    if (confirm('정말 삭제하시겠습니까?')) {
        document.getElementById('delete-form-' + userId).submit();
    }
}
</script>
@endsection

체크리스트

  • 사용자 목록 (검색, 필터, 페이징)
  • 사용자 생성 (FormRequest)
  • 사용자 수정
  • 사용자 삭제 (Soft Delete)
  • Feature Test

📊 데이터베이스 전략

DB 테이블 전략 (최종)

✅ 기존 테이블 재사용 (마이그레이션 없음)
   - users, roles, departments
   - products, materials
   - menus, audit_logs

🆕 관리자 전용 (admin_*)
   - admin_settings
   - admin_logs
   - admin_preferences

📊 통계 (stat_*)
   - stat_daily_sales
   - stat_inventory
   - stat_user_activity

모델 관리 전략

초기 복사: admin/app/Models → mng/app/Models
Filament 제거: form(), table(), canAccessPanel() 등
이후 운영: mng/ 독립 (admin 점차 deprecated)

🛡️ 품질 관리

코드 품질 체크리스트

□ Service-First (비즈니스 로직 → Service)
□ FormRequest (컨트롤러 검증 금지)
□ BelongsToTenant (multi-tenant 스코프)
□ i18n 키 (하드코딩 금지)
□ Soft Delete (deleted_at)
□ 감사 로그 (HasAuditLog trait)
□ Feature Test
□ Pint (코드 스타일)

UI 수정 용이성 체크리스트

□ DaisyUI 클래스 직접 사용 (추상화 최소)
□ Alpine.js 단순 인터랙션만
□ Blade 템플릿 2레벨 이하
□ 인라인 Tailwind 허용
□ AI 없이 수정 가능

🎯 예상 타임라인

MVP (최소 기능 제품) - 2주

Day 1-2:   Phase 1 (인프라) + admin/ 모델 복사
Day 3-4:   Phase 2 (인증)
Day 5-6:   Phase 3 (대시보드)
Day 7-14:  Phase 4 (사용자, 역할, 제품 관리)

전체 기능 이식 - 4-6주

Week 3-4: 제품, 자재 관리
Week 5:   게시판, 통계
Week 6:   테스트, 최적화

📚 참고 문서


다음 단계

즉시 시작 가능

  • mng/ Laravel 프로젝트 생성
  • DaisyUI + Alpine.js 설치
  • admin/ 모델 복사 및 Filament 제거
  • 로그인 화면 구현

작성일: 2025-01-20 버전: 2.0 상태: 정책 반영 완료 변경사항:

  • 폴더명: adm2/mng/
  • UI: DaisyUI + Blade + Alpine.js 확정
  • DB: 기존 테이블 재사용, admin_*, stat_* 접두사
  • 모델: admin/ 복사 후 Filament 제거
  • 철학: 단순함, 수정 용이성 최우선