feat: Phase 4-1 테넌트 관리 백엔드 구현

- TenantService 생성 (CRUD, 통계, 복원/영구삭제)
- API Controller 구현 (HTMX 요청 감지, HTML/JSON 이중 응답)
- FormRequest 검증 (StoreTenantRequest, UpdateTenantRequest)
- Tenant 모델 확장 (17개 필드, 관계 설정, accessor)
- Department, Menu, Role 모델 복사 (admin → mng)
- Web Controller 수정 (index/create/edit 화면)
- MIGRATION_PLAN.md 작성 (HTMX + API 아키텍처)
This commit is contained in:
2025-11-21 14:46:13 +09:00
parent 70c2978b44
commit 575e9df431
13 changed files with 1698 additions and 35 deletions

View File

@@ -0,0 +1,206 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreTenantRequest;
use App\Http\Requests\UpdateTenantRequest;
use App\Services\TenantService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TenantController extends Controller
{
public function __construct(
private readonly TenantService $tenantService
) {}
/**
* 테넌트 목록 조회
*/
public function index(Request $request): JsonResponse
{
$tenants = $this->tenantService->getTenants(
$request->all(),
$request->integer('per_page', 15)
);
// HTMX 요청 시 HTML 반환
if ($request->header('HX-Request')) {
return response()->json([
'html' => view('tenants.partials.table', compact('tenants'))->render(),
]);
}
// 일반 요청 시 JSON 반환
return response()->json([
'success' => true,
'data' => $tenants->items(),
'meta' => [
'current_page' => $tenants->currentPage(),
'last_page' => $tenants->lastPage(),
'per_page' => $tenants->perPage(),
'total' => $tenants->total(),
],
]);
}
/**
* 테넌트 생성
*/
public function store(StoreTenantRequest $request): JsonResponse
{
$tenant = $this->tenantService->createTenant($request->validated());
// HTMX 요청 시 성공 메시지와 리다이렉트 헤더 반환
if ($request->header('HX-Request')) {
return response()->json([
'success' => true,
'message' => '테넌트가 생성되었습니다.',
'redirect' => route('tenants.index'),
]);
}
return response()->json([
'success' => true,
'message' => '테넌트가 생성되었습니다.',
'data' => $tenant,
], 201);
}
/**
* 특정 테넌트 조회
*/
public function show(Request $request, int $id): JsonResponse
{
$tenant = $this->tenantService->getTenantById($id, true);
if (!$tenant) {
return response()->json([
'success' => false,
'message' => '테넌트를 찾을 수 없습니다.',
], 404);
}
// HTMX 요청 시 HTML 반환
if ($request->header('HX-Request')) {
return response()->json([
'html' => view('tenants.partials.detail', compact('tenant'))->render(),
]);
}
return response()->json([
'success' => true,
'data' => $tenant,
]);
}
/**
* 테넌트 수정
*/
public function update(UpdateTenantRequest $request, int $id): JsonResponse
{
$this->tenantService->updateTenant($id, $request->validated());
// HTMX 요청 시 성공 메시지와 리다이렉트 헤더 반환
if ($request->header('HX-Request')) {
return response()->json([
'success' => true,
'message' => '테넌트가 수정되었습니다.',
'redirect' => route('tenants.index'),
]);
}
return response()->json([
'success' => true,
'message' => '테넌트가 수정되었습니다.',
]);
}
/**
* 테넌트 삭제 (Soft Delete)
*/
public function destroy(Request $request, int $id): JsonResponse
{
$this->tenantService->deleteTenant($id);
// HTMX 요청 시 테이블 행 제거 트리거
if ($request->header('HX-Request')) {
return response()->json([
'success' => true,
'message' => '테넌트가 삭제되었습니다.',
'action' => 'remove',
]);
}
return response()->json([
'success' => true,
'message' => '테넌트가 삭제되었습니다.',
]);
}
/**
* 테넌트 복원
*/
public function restore(Request $request, int $id): JsonResponse
{
$this->tenantService->restoreTenant($id);
// HTMX 요청 시 테이블 새로고침 트리거
if ($request->header('HX-Request')) {
return response()->json([
'success' => true,
'message' => '테넌트가 복원되었습니다.',
'action' => 'refresh',
]);
}
return response()->json([
'success' => true,
'message' => '테넌트가 복원되었습니다.',
]);
}
/**
* 테넌트 영구 삭제 (슈퍼관리자 전용)
*/
public function forceDestroy(Request $request, int $id): JsonResponse
{
// 슈퍼관리자 권한 체크
if (!auth()->user()?->is_super_admin) {
return response()->json([
'success' => false,
'message' => '권한이 없습니다.',
], 403);
}
$this->tenantService->forceDeleteTenant($id);
// HTMX 요청 시 테이블 행 제거 트리거
if ($request->header('HX-Request')) {
return response()->json([
'success' => true,
'message' => '테넌트가 영구 삭제되었습니다.',
'action' => 'remove',
]);
}
return response()->json([
'success' => true,
'message' => '테넌트가 영구 삭제되었습니다.',
]);
}
/**
* 테넌트 통계 조회
*/
public function stats(Request $request): JsonResponse
{
$stats = $this->tenantService->getTenantStats();
return response()->json([
'success' => true,
'data' => $stats,
]);
}
}

View File

@@ -2,12 +2,48 @@
namespace App\Http\Controllers;
use App\Services\TenantService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class TenantController extends Controller
{
public function __construct(
private readonly TenantService $tenantService
) {}
/**
* 테넌트 전환
* 테넌트 목록 (Blade 화면)
*/
public function index(Request $request): View
{
return view('tenants.index');
}
/**
* 테넌트 생성 화면
*/
public function create(): View
{
return view('tenants.create');
}
/**
* 테넌트 수정 화면
*/
public function edit(int $id): View
{
$tenant = $this->tenantService->getTenantById($id);
if (!$tenant) {
abort(404, '테넌트를 찾을 수 없습니다.');
}
return view('tenants.edit', compact('tenant'));
}
/**
* 테넌트 전환 (기존 기능 유지)
*/
public function switch(Request $request)
{

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreTenantRequest extends FormRequest
{
/**
* 인증 확인
*/
public function authorize(): bool
{
return auth()->check();
}
/**
* 유효성 검증 규칙
*/
public function rules(): array
{
return [
// 기본 정보 (필수)
'company_name' => ['required', 'string', 'max:100'],
'code' => ['required', 'string', 'max:50', 'unique:tenants,code'],
'email' => ['nullable', 'email', 'max:100'],
'phone' => ['nullable', 'string', 'max:20'],
// 회사 정보
'business_num' => ['nullable', 'string', 'max:20'],
'corp_reg_no' => ['nullable', 'string', 'max:20'],
'ceo_name' => ['nullable', 'string', 'max:50'],
'address' => ['nullable', 'string', 'max:255'],
'homepage' => ['nullable', 'url', 'max:255'],
'fax' => ['nullable', 'string', 'max:20'],
// 구독 정보
'tenant_st_code' => ['required', 'string', 'in:trial,active,suspended,expired'],
'billing_tp_code' => ['nullable', 'string', 'in:monthly,yearly,free'],
'max_users' => ['nullable', 'integer', 'min:1'],
'trial_ends_at' => ['nullable', 'date'],
'expires_at' => ['nullable', 'date'],
'last_paid_at' => ['nullable', 'date'],
// 관리 메모
'admin_memo' => ['nullable', 'string'],
];
}
/**
* 유효성 검증 메시지
*/
public function messages(): array
{
return [
'company_name.required' => '회사명은 필수입니다.',
'company_name.max' => '회사명은 최대 100자까지 입력 가능합니다.',
'code.required' => '테넌트 코드는 필수입니다.',
'code.unique' => '이미 사용 중인 테넌트 코드입니다.',
'email.email' => '올바른 이메일 형식이 아닙니다.',
'homepage.url' => '올바른 URL 형식이 아닙니다.',
'tenant_st_code.required' => '상태는 필수입니다.',
'tenant_st_code.in' => '올바른 상태를 선택해주세요.',
'billing_tp_code.in' => '올바른 결제 유형을 선택해주세요.',
'max_users.integer' => '최대 사용자 수는 숫자여야 합니다.',
'max_users.min' => '최대 사용자 수는 최소 1명 이상이어야 합니다.',
];
}
/**
* 필드명 한글화
*/
public function attributes(): array
{
return [
'company_name' => '회사명',
'code' => '테넌트 코드',
'email' => '이메일',
'phone' => '전화번호',
'business_num' => '사업자등록번호',
'corp_reg_no' => '법인등록번호',
'ceo_name' => '대표자명',
'address' => '주소',
'homepage' => '홈페이지',
'fax' => '팩스',
'tenant_st_code' => '상태',
'billing_tp_code' => '결제 유형',
'max_users' => '최대 사용자 수',
'trial_ends_at' => '트라이얼 종료일',
'expires_at' => '구독 만료일',
'last_paid_at' => '마지막 결제일',
'admin_memo' => '관리자 메모',
];
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateTenantRequest extends FormRequest
{
/**
* 인증 확인
*/
public function authorize(): bool
{
return auth()->check();
}
/**
* 유효성 검증 규칙
*/
public function rules(): array
{
$tenantId = $this->route('tenant');
return [
// 기본 정보 (필수)
'company_name' => ['required', 'string', 'max:100'],
'code' => [
'required',
'string',
'max:50',
Rule::unique('tenants', 'code')->ignore($tenantId),
],
'email' => ['nullable', 'email', 'max:100'],
'phone' => ['nullable', 'string', 'max:20'],
// 회사 정보
'business_num' => ['nullable', 'string', 'max:20'],
'corp_reg_no' => ['nullable', 'string', 'max:20'],
'ceo_name' => ['nullable', 'string', 'max:50'],
'address' => ['nullable', 'string', 'max:255'],
'homepage' => ['nullable', 'url', 'max:255'],
'fax' => ['nullable', 'string', 'max:20'],
// 구독 정보
'tenant_st_code' => ['required', 'string', 'in:trial,active,suspended,expired'],
'billing_tp_code' => ['nullable', 'string', 'in:monthly,yearly,free'],
'max_users' => ['nullable', 'integer', 'min:1'],
'trial_ends_at' => ['nullable', 'date'],
'expires_at' => ['nullable', 'date'],
'last_paid_at' => ['nullable', 'date'],
// 관리 메모
'admin_memo' => ['nullable', 'string'],
];
}
/**
* 유효성 검증 메시지
*/
public function messages(): array
{
return [
'company_name.required' => '회사명은 필수입니다.',
'company_name.max' => '회사명은 최대 100자까지 입력 가능합니다.',
'code.required' => '테넌트 코드는 필수입니다.',
'code.unique' => '이미 사용 중인 테넌트 코드입니다.',
'email.email' => '올바른 이메일 형식이 아닙니다.',
'homepage.url' => '올바른 URL 형식이 아닙니다.',
'tenant_st_code.required' => '상태는 필수입니다.',
'tenant_st_code.in' => '올바른 상태를 선택해주세요.',
'billing_tp_code.in' => '올바른 결제 유형을 선택해주세요.',
'max_users.integer' => '최대 사용자 수는 숫자여야 합니다.',
'max_users.min' => '최대 사용자 수는 최소 1명 이상이어야 합니다.',
];
}
/**
* 필드명 한글화
*/
public function attributes(): array
{
return [
'company_name' => '회사명',
'code' => '테넌트 코드',
'email' => '이메일',
'phone' => '전화번호',
'business_num' => '사업자등록번호',
'corp_reg_no' => '법인등록번호',
'ceo_name' => '대표자명',
'address' => '주소',
'homepage' => '홈페이지',
'fax' => '팩스',
'tenant_st_code' => '상태',
'billing_tp_code' => '결제 유형',
'max_users' => '최대 사용자 수',
'trial_ends_at' => '트라이얼 종료일',
'expires_at' => '구독 만료일',
'last_paid_at' => '마지막 결제일',
'admin_memo' => '관리자 메모',
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Models\Commons;
use App\Models\Scopes\TenantScope;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @mixin IdeHelperMenu
*/
class Menu extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id', 'parent_id', 'name', 'url', 'is_active', 'sort_order',
'hidden', 'is_external', 'external_url', 'icon',
'created_by', 'updated_by', 'deleted_by',
];
protected $hidden = [
'created_by',
'updated_by',
'deleted_by',
'deleted_at',
];
public function parent()
{
return $this->belongsTo(Menu::class, 'parent_id');
}
public function children()
{
return $this->hasMany(Menu::class, 'parent_id');
}
public function tenant()
{
return $this->belongsTo(\App\Models\Tenants\Tenant::class, 'tenant_id');
}
/**
* 공유(NULL) + 현재 테넌트 모두 포함해서 조회
* (SoftDeletes 글로벌 스코프는 그대로 유지)
*/
public function scopeWithShared($query, ?int $tenantId = null)
{
$tenantId = $tenantId ?? app('tenant_id');
return $query
->withoutGlobalScope(TenantScope::class)
->where(function ($w) use ($tenantId) {
if (is_null($tenantId)) {
$w->whereNull('tenant_id');
} else {
$w->whereNull('tenant_id')->orWhere('tenant_id', $tenantId);
}
});
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Models\Permissions;
use App\Models\Commons\IdeHelperRole;
use App\Models\Members\UserRole;
use App\Models\Tenants\Tenant;
use Illuminate\Database\Eloquent\Model;
/**
* @mixin IdeHelperRole
*/
class Role extends Model
{
protected $fillable = [
'tenant_id', 'name', 'description',
];
public function menuPermissions()
{
return $this->hasMany(RoleMenuPermission::class, 'role_id');
}
public function permissions()
{
return $this->belongsToMany(
Permission::class,
'role_has_permissions',
'role_id',
'permission_id'
);
}
public function tenant()
{
return $this->belongsTo(Tenant::class);
}
public function userRoles()
{
return $this->hasMany(UserRole::class);
}
}

View File

@@ -1,27 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Tenant extends Model
{
use SoftDeletes;
protected $table = 'tenants';
protected $fillable = [
'company_name',
'code',
'tenant_st_code',
];
/**
* 활성 테넌트만 조회 (삭제되지 않은 모든 테넌트)
*/
public function scopeActive($query)
{
return $query->whereNull('deleted_at');
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Models\Tenants;
use App\Models\Members\User;
use App\Models\Permissions\PermissionOverride;
use App\Models\Tenants\Pivots\DepartmentUser;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Spatie\Permission\Traits\HasRoles;
class Department extends Model
{
use HasRoles, ModelTrait; // 부서도 권한/역할을 가짐
protected $table = 'departments';
protected $guarded = ['id'];
protected $casts = [
'tenant_id' => 'int',
'parent_id' => 'int',
'is_active' => 'bool',
'sort_order' => 'int',
];
protected $hidden = [
'deleted_by', 'deleted_at',
];
// 스파티 가드명(프로젝트 설정에 맞게 조정)
protected string $guard_name = 'web';
/** 테넌트 관계 */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_id');
}
/** 상위/하위 부서 */
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function children()
{
return $this->hasMany(self::class, 'parent_id');
}
/** 부서-사용자 N:N (추가 컬럼 포함 Pivot) */
public function users()
{
return $this->belongsToMany(User::class, 'department_user')
->using(DepartmentUser::class)
->withTimestamps()
->withPivot(['tenant_id', 'is_primary', 'joined_at', 'left_at']);
}
/** 부서의 권한 오버라이드(DENY/임시허용) */
public function permissionOverrides(): MorphMany
{
return $this->morphMany(PermissionOverride::class, 'model');
}
/** 부서-사용자 매핑 로우들(피벗 테이블의 레코드들) */
public function departmentUsers()
{
return $this->hasMany(DepartmentUser::class, 'department_id');
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Models\Tenants;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Tenant extends Model
{
use SoftDeletes;
protected $table = 'tenants';
protected $fillable = [
// 기본 정보
'company_name',
'code',
'email',
'phone',
// 회사 정보
'business_num',
'corp_reg_no',
'ceo_name',
'address',
'homepage',
'fax',
// 구독 정보
'tenant_st_code',
'billing_tp_code',
'max_users',
'trial_ends_at',
'expires_at',
'last_paid_at',
// 관리 메모
'admin_memo',
];
protected $casts = [
'max_users' => 'integer',
'trial_ends_at' => 'datetime',
'expires_at' => 'datetime',
'last_paid_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
];
/**
* 활성 테넌트만 조회 (삭제되지 않은 모든 테넌트)
*/
public function scopeActive($query)
{
return $query->whereNull('deleted_at');
}
/**
* 관계: 사용자
*/
public function users(): HasMany
{
return $this->hasMany(User::class, 'tenant_id');
}
/**
* 관계: 부서
*/
public function departments(): HasMany
{
return $this->hasMany(\App\Models\Tenants\Department::class, 'tenant_id');
}
/**
* 관계: 메뉴
*/
public function menus(): HasMany
{
return $this->hasMany(\App\Models\Commons\Menu::class, 'tenant_id');
}
/**
* 관계: 역할
*/
public function roles(): HasMany
{
return $this->hasMany(\App\Models\Permissions\Role::class, 'tenant_id');
}
/**
* 상태 배지 색상 (Blade 뷰에서 사용)
*/
public function getStatusBadgeColorAttribute(): string
{
return match($this->tenant_st_code) {
'active' => 'success',
'trial' => 'warning',
'suspended', 'expired' => 'error',
default => 'neutral',
};
}
/**
* 상태 한글명 (Blade 뷰에서 사용)
*/
public function getStatusLabelAttribute(): string
{
return match($this->tenant_st_code) {
'trial' => '트라이얼',
'active' => '활성',
'suspended' => '정지',
'expired' => '만료',
default => $this->tenant_st_code,
};
}
/**
* 결제 유형 한글명 (Blade 뷰에서 사용)
*/
public function getBillingTypeLabelAttribute(): ?string
{
if (!$this->billing_tp_code) {
return null;
}
return match($this->billing_tp_code) {
'monthly' => '월간',
'yearly' => '연간',
'free' => '무료',
default => $this->billing_tp_code,
};
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Services;
use App\Models\Tenants\Tenant;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
class TenantService
{
/**
* 테넌트 목록 조회 (페이지네이션)
*/
public function getTenants(array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$query = Tenant::query()
->withCount(['users', 'departments', 'menus', 'roles'])
->withTrashed();
// 검색 필터
if (!empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('company_name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
// 상태 필터
if (!empty($filters['tenant_st_code'])) {
$query->where('tenant_st_code', $filters['tenant_st_code']);
}
// Soft Delete 필터
if (isset($filters['trashed'])) {
if ($filters['trashed'] === 'only') {
$query->onlyTrashed();
} elseif ($filters['trashed'] === 'with') {
$query->withTrashed();
}
}
// 정렬
$sortBy = $filters['sort_by'] ?? 'id';
$sortDirection = $filters['sort_direction'] ?? 'desc';
$query->orderBy($sortBy, $sortDirection);
return $query->paginate($perPage);
}
/**
* 특정 테넌트 조회
*/
public function getTenantById(int $id, bool $withTrashed = false): ?Tenant
{
$query = Tenant::query()->withCount(['users', 'departments', 'menus', 'roles']);
if ($withTrashed) {
$query->withTrashed();
}
return $query->find($id);
}
/**
* 테넌트 생성
*/
public function createTenant(array $data): Tenant
{
return Tenant::create($data);
}
/**
* 테넌트 수정
*/
public function updateTenant(int $id, array $data): bool
{
$tenant = Tenant::findOrFail($id);
return $tenant->update($data);
}
/**
* 테넌트 삭제 (Soft Delete)
*/
public function deleteTenant(int $id): bool
{
$tenant = Tenant::findOrFail($id);
return $tenant->delete();
}
/**
* 테넌트 복원
*/
public function restoreTenant(int $id): bool
{
$tenant = Tenant::onlyTrashed()->findOrFail($id);
return $tenant->restore();
}
/**
* 테넌트 영구 삭제 (슈퍼관리자 전용)
*/
public function forceDeleteTenant(int $id): bool
{
$tenant = Tenant::withTrashed()->findOrFail($id);
return $tenant->forceDelete();
}
/**
* 활성 테넌트 목록 (드롭다운용)
*/
public function getActiveTenants(): Collection
{
return Tenant::query()
->active()
->orderBy('company_name')
->get(['id', 'company_name', 'code']);
}
/**
* 테넌트 코드 중복 체크
*/
public function isCodeExists(string $code, ?int $excludeId = null): bool
{
$query = Tenant::where('code', $code);
if ($excludeId) {
$query->where('id', '!=', $excludeId);
}
return $query->exists();
}
/**
* 테넌트 통계
*/
public function getTenantStats(): array
{
return [
'total' => Tenant::count(),
'active' => Tenant::where('tenant_st_code', 'active')->count(),
'trial' => Tenant::where('tenant_st_code', 'trial')->count(),
'suspended' => Tenant::where('tenant_st_code', 'suspended')->count(),
'expired' => Tenant::where('tenant_st_code', 'expired')->count(),
'trashed' => Tenant::onlyTrashed()->count(),
];
}
}

View File

@@ -1,22 +1,253 @@
# MNG 프로젝트 문서
> 📌 **MNG 관리자 패널 문서 (Laravel + DaisyUI)**
> 📌 **MNG 관리자 패널 문서 (Laravel + Blade + Tailwind)**
---
## 📋 프로젝트 개요
**MNG**는 Filament 의존성 없이 AI 없이도 수정 가능한 Plain Laravel 관리자 패널입니다.
**핵심 철학:**
- **단순함 > 복잡함** - 복잡한 추상화 제거
- **수정 용이성** - AI 없이도 Blade 템플릿 수정 가능
- **코드 재사용** - admin/ 모델/서비스 복사 후 간소화
**기술 스택:**
- Laravel 12 + PHP 8.4
- Blade + Tailwind CSS 3.x + HTMX
- Sanctum 인증 (세션 + 토큰)
- MySQL 8.0 (admin/api와 DB 공유)
**도메인:** `mng.sam.kr`
---
## 🚀 빠른 시작
### 개발 명령어
```bash
# 서버 실행
php artisan serve
# Vite (Tailwind CSS)
npm run dev
# 마이그레이션
php artisan migrate
# 코드 스타일
./vendor/bin/pint
```
### 로그인
- URL: `http://mng.sam.kr/login`
- 계정: admin/ 또는 api/와 동일한 users 테이블 사용
---
## 📖 개발 가이드
- 추후 추가 예정
### 현재 진행 상황
**완료된 Phase:**
-**Phase 1**: 인증 시스템 (AuthService, LoginController, User 모델)
-**Phase 2**: 레이아웃 구조 (sidebar + header, Pure Tailwind)
-**Phase 3**: 테넌트 선택 기능 (TenantController, ViewServiceProvider)
**진행 중 Phase:**
- 🔄 **Phase 4**: 시스템 관리 메뉴 (admin/ Filament 기능 이식)
- 테넌트 관리
- 사용자 관리
- 메뉴 관리
- 역할/부서/권한 관리
**계획된 Phase:**
-**Phase 5**: 제품/자재 관리
-**Phase 6**: BOM/카테고리 관리
### 프로젝트 문서
- **[CURRENT_WORKS.md](../CURRENT_WORKS.md)** - 현재 작업 진행 상황
- **[MIGRATION_PLAN.md](./MIGRATION_PLAN.md)** - Admin → MNG 마이그레이션 계획 (Phase 4)
- **[claudedocs/mng/MNG_PROJECT_PLAN.md](../../claudedocs/mng/MNG_PROJECT_PLAN.md)** - 전체 프로젝트 계획
- **[claudedocs/mng/DEV_PROCESS.md](../../claudedocs/mng/DEV_PROCESS.md)** - 개발 프로세스 (HTMX + API 방식)
- **[claudedocs/mng/SETUP_GUIDE.md](../../claudedocs/mng/SETUP_GUIDE.md)** - 초기 설정 가이드
---
## 🔍 분석 문서
## 🏗️ 아키텍처
- 추후 추가 예정
### 디렉토리 구조
```
mng/
├── app/
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── Auth/ # 인증 컨트롤러
│ │ │ └── TenantController.php
│ │ ├── Requests/ # FormRequest (검증)
│ │ └── Middleware/
│ ├── Services/ # 비즈니스 로직 (Service-First)
│ ├── Models/ # Eloquent 모델 (독립 운영)
│ │ ├── User.php
│ │ └── Tenant.php
│ └── Providers/
│ └── ViewServiceProvider.php
├── routes/
│ ├── web.php # Blade 라우트
│ └── api.php # Admin API (향후)
├── resources/
│ └── views/
│ ├── layouts/
│ │ └── app.blade.php # 마스터 레이아웃
│ ├── partials/
│ │ ├── sidebar.blade.php
│ │ ├── header.blade.php
│ │ └── tenant-selector.blade.php
│ ├── auth/ # 로그인 화면
│ └── dashboard/ # 대시보드
├── database/
│ └── migrations/
└── docs/ # 프로젝트 문서
└── INDEX.md # 이 문서
```
### 레이아웃 구조
```
┌─────────────────────────────────────┐
│ ┌──────┐ ┌─────────────────────┐ │
│ │ │ │ Header (64px) │ │
│ │ Side │ ├─────────────────────┤ │
│ │ bar │ │ │ │
│ │(256) │ │ Main Content │ │
│ │ │ │ │ │
│ └──────┘ └─────────────────────┘ │
└─────────────────────────────────────┘
```
**특징:**
- 좌측 사이드바: 256px 고정
- 상단 헤더: 64px 고정
- Pure Tailwind CSS (DaisyUI 제거됨 - oklch() 호환성 문제)
- HTMX 기반 인터랙션 (Alpine.js 제거)
- 반응형: 추후 모바일 대응 예정
---
## 🔄 Admin → MNG 마이그레이션 전략
### 모델 복사 프로세스
```bash
# 1. admin/ 모델 복사
cp -r admin/app/Models/* mng/app/Models/
# 2. Filament 코드 제거
# - form(), table(), getNavigationLabel() 등 제거
# - 순수 Eloquent 관계만 유지
# 3. Traits 복사
cp admin/app/Traits/BelongsToTenant.php mng/app/Traits/
cp admin/app/Traits/HasAuditLog.php mng/app/Traits/
```
### Blade + HTMX 작성 원칙
```blade
{{-- ✅ GOOD: HTMX로 API 호출 --}}
<form hx-post="/api/admin/users"
hx-target="#user-list"
hx-swap="beforeend">
<input type="text" name="name" class="input input-bordered" />
<button class="btn btn-primary">저장</button>
</form>
{{-- ❌ BAD: 과도한 추상화 --}}
<x-custom-card :config="$complexConfig" />
{{-- ❌ BAD: Alpine.js 사용 (제거됨) --}}
<div x-data="{ open: false }">...</div>
```
### API + Blade 구조
```
1. Blade 화면 (Web Routes)
└─ HTMX 호출 → /api/admin/users
2. API Controller
└─ HTMX 요청? → Blade 부분 HTML 반환
└─ 일반 요청? → JSON 반환
3. Service → Model → Database
```
---
## 📊 데이터베이스
### DB 공유 전략
- **기존 테이블 재사용**: users, roles, departments, products 등
- **관리자 전용 테이블**: `admin_*` 접두사
- **통계 테이블**: `stat_*` 접두사
### 모델 독립 운영
- admin/, api/, mng/ 각각 독립된 모델 사용
- 동일한 DB 테이블을 참조하지만 모델은 독립
- Filament 의존성 제거
---
## 🛡️ 품질 관리
### 코드 품질 체크리스트
```
□ Service-First (비즈니스 로직 → Service)
□ FormRequest (컨트롤러 검증 금지)
□ BelongsToTenant (multi-tenant 스코프)
□ i18n 키 (하드코딩 금지)
□ Soft Delete (deleted_at)
□ 감사 로그 (HasAuditLog trait)
□ Feature Test
□ Pint (코드 스타일)
```
### UI 수정 용이성 체크리스트
```
□ Tailwind 유틸리티 클래스 직접 사용 (추상화 최소)
□ Alpine.js 단순 인터랙션만
□ Blade 템플릿 2레벨 이하
□ 인라인 Tailwind 허용
□ AI 없이 수정 가능
```
---
## 🔗 관련 문서
- **[메인 CLAUDE.md](../CLAUDE.md)** - MNG 프로젝트 가이드
- **[CURRENT_WORKS.md](../CURRENT_WORKS.md)** - 현재 작업 이력
### SAM 공통 문서
- **[CLAUDE.md](../../CLAUDE.md)** - SAM 프로젝트 가이드
- **[docs/INDEX.md](../../docs/INDEX.md)** - 전체 문서 네비게이션
- **[docs/reference/architecture.md](../../docs/reference/architecture.md)** - SAM 아키텍처
- **[docs/specs/database-schema.md](../../docs/specs/database-schema.md)** - DB 스키마
### MNG 프로젝트 문서
- **[claudedocs/mng/MNG_PROJECT_PLAN.md](../../claudedocs/mng/MNG_PROJECT_PLAN.md)** - 프로젝트 전체 계획
- **[claudedocs/mng/DEV_PROCESS.md](../../claudedocs/mng/DEV_PROCESS.md)** - 개발 프로세스
- **[CURRENT_WORKS.md](../CURRENT_WORKS.md)** - 작업 진행 상황
---
## 📝 작업 이력
**2025-11-20 (수)**
- Phase 1: 인증 시스템 구현 (AuthService, LoginController)
- Phase 2: 레이아웃 구조화 (sidebar + header, Pure Tailwind)
- Phase 3: 테넌트 선택 기능 구현 (TenantController)
**2025-11-21 (목)**
- 문서 체계화 (INDEX.md 작성)
- 공통 문서 업데이트 (CLAUDE.md, docs/INDEX.md, architecture.md)
---
**최종 업데이트**: 2025-11-21
**버전**: 1.0
**상태**: Phase 3 완료, Phase 4 준비 중

557
docs/MIGRATION_PLAN.md Normal file
View File

@@ -0,0 +1,557 @@
# Admin → MNG 마이그레이션 계획
> 📌 **Admin 시스템 관리 메뉴 11개를 MNG로 마이그레이션**
**작성일**: 2025-11-21
**상태**: Phase 4 준비 중
---
## 📋 마이그레이션 개요
### 목표
Admin(Filament v4)의 시스템 관리 메뉴 11개를 MNG(Plain Laravel)로 이식하여 수정 용이한 관리자 패널 구축
### 핵심 전략
```
┌─────────────────────────────────────────┐
│ Blade 화면 (Web Routes) │
│ - 화면만 담당, 데이터 처리 없음 │
│ ↓ HTMX 호출 (hx-get, hx-post 등) │
│ ↓ │
│ API Routes (/api/admin/*) │
│ - 실제 데이터 CRUD 처리 │
│ - HTMX 요청 시 HTML 반환 │
│ - 일반 요청 시 JSON 반환 │
│ ↓ │
│ API Controller → Service → Model │
│ ↓ │
│ MySQL (admin/api와 DB 공유) │
└─────────────────────────────────────────┘
```
### 기술 스택
- **프론트엔드**: Blade + HTMX + DaisyUI + Tailwind CSS
- **백엔드**: Laravel 12 + PHP 8.4 + Sanctum
- **인터랙션**: HTMX (Alpine.js 제거)
---
## 🎯 마이그레이션 대상 (11개 메뉴)
### Admin 시스템 관리 메뉴 분석
| # | 메뉴명 | Resource 파일 | 모델 | 복잡도 | 우선순위 |
|---|--------|--------------|------|--------|---------|
| 1 | 테넌트 | `TenantResource.php` | `Tenant` | ⭐⭐ | 1 |
| 2 | 사용자 | `UserResource.php` | `User` | ⭐⭐⭐ | 2 |
| 3 | 메뉴 | `MenuResource.php` | `Menu` | ⭐⭐ | 3 |
| 4 | 역할 | `RoleResource.php` | `Role` | ⭐⭐ | 4 |
| 5 | 부서 | `DepartmentResource.php` | `Department` | ⭐⭐ | 5 |
| 6 | 권한 | `PermissionResource.php` | `Permission` | ⭐⭐ | 6 |
| 7 | 역할 권한 관리 | `RolePermissionsResource.php` | - | ⭐⭐⭐ | 7 |
| 8 | 부서 권한 관리 | `DepartmentPermissionsResource.php` | - | ⭐⭐⭐ | 8 |
| 9 | 개인 권한 관리 | `UserPermissionsResource.php` | - | ⭐⭐⭐ | 9 |
| 10 | 권한 분석 | `PermissionAnalysisResource.php` | - | ⭐⭐⭐⭐ | 10 |
| 11 | 삭제된 데이터 백업 | `ArchivedRecordResource.php` | `ArchivedRecord` | ⭐⭐⭐ | 11 |
**복잡도:**
- ⭐ 단순 CRUD
- ⭐⭐ CRUD + 관계
- ⭐⭐⭐ CRUD + 복잡한 관계 + 커스텀 UI
- ⭐⭐⭐⭐ 읽기 전용 + 복잡한 쿼리 + 매트릭스 UI
---
## 🏗️ 라우트 구조
### Web Routes (Blade 화면만)
```php
// routes/web.php
Route::middleware(['auth:sanctum'])->group(function () {
// 테넌트 관리
Route::get('/tenants', [TenantController::class, 'index'])->name('tenants.index');
Route::get('/tenants/create', [TenantController::class, 'create'])->name('tenants.create');
Route::get('/tenants/{tenant}/edit', [TenantController::class, 'edit'])->name('tenants.edit');
// 사용자 관리
Route::get('/users', [UserController::class, 'index'])->name('users.index');
Route::get('/users/create', [UserController::class, 'create'])->name('users.create');
Route::get('/users/{user}/edit', [UserController::class, 'edit'])->name('users.edit');
// ... 나머지 메뉴
});
```
### API Routes (실제 데이터 처리)
```php
// routes/api.php
Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () {
// 테넌트 API
Route::apiResource('tenants', Api\Admin\TenantController::class);
// 사용자 API
Route::apiResource('users', Api\Admin\UserController::class);
// 권한 관리 API (Custom)
Route::prefix('permissions')->group(function () {
Route::get('/role/{role}', [Api\Admin\RolePermissionController::class, 'show']);
Route::post('/role/{role}', [Api\Admin\RolePermissionController::class, 'update']);
Route::get('/department/{department}', [Api\Admin\DepartmentPermissionController::class, 'show']);
Route::post('/department/{department}', [Api\Admin\DepartmentPermissionController::class, 'update']);
Route::get('/user/{user}', [Api\Admin\UserPermissionController::class, 'show']);
Route::post('/user/{user}', [Api\Admin\UserPermissionController::class, 'update']);
Route::get('/analysis', [Api\Admin\PermissionAnalysisController::class, 'index']);
});
// ... 나머지 메뉴
});
```
---
## 📐 표준 개발 프로세스
### 1. 모델 복사 (Admin → MNG)
```bash
# 1. 모델 복사
cp admin/app/Models/Tenants/Tenant.php mng/app/Models/Tenant.php
cp admin/app/Models/Members/User.php mng/app/Models/User.php
# ...
# 2. Filament 코드 제거
# - form(), table(), getNavigationLabel() 등 제거
# - 순수 Eloquent 관계만 유지
# 3. Traits 복사
cp admin/app/Traits/BelongsToTenant.php mng/app/Traits/
cp admin/app/Traits/ModelTrait.php mng/app/Traits/
```
### 2. Service Layer 생성
```php
// mng/app/Services/TenantService.php
namespace App\Services;
use App\Models\Tenant;
use Illuminate\Pagination\LengthAwarePaginator;
class TenantService
{
/**
* 테넌트 목록 조회 (검색, 필터, 페이징)
*/
public function getTenants(array $filters = []): LengthAwarePaginator
{
$query = Tenant::query();
// 검색
if (!empty($filters['search'])) {
$query->where('company_name', 'like', "%{$filters['search']}%");
}
// 상태 필터
if (!empty($filters['status'])) {
$query->where('tenant_st_code', $filters['status']);
}
return $query->paginate(20);
}
/**
* 테넌트 생성
*/
public function createTenant(array $data): Tenant
{
return Tenant::create($data);
}
// update(), delete() ...
}
```
### 3. API Controller 생성
```php
// mng/app/Http/Controllers/Api/Admin/TenantController.php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\StoreTenantRequest;
use App\Services\TenantService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TenantController extends Controller
{
public function __construct(
private TenantService $tenantService
) {}
/**
* 테넌트 목록 (API)
* GET /api/admin/tenants
*/
public function index(Request $request): JsonResponse
{
$tenants = $this->tenantService->getTenants($request->all());
// HTMX 요청 시 HTML 반환
if ($request->header('HX-Request')) {
return response()->view('tenants.partials.table', compact('tenants'));
}
// 일반 요청 시 JSON 반환
return response()->json([
'success' => true,
'data' => $tenants->items(),
'meta' => [
'current_page' => $tenants->currentPage(),
'total' => $tenants->total(),
],
]);
}
/**
* 테넌트 생성 (API)
* POST /api/admin/tenants
*/
public function store(StoreTenantRequest $request): JsonResponse
{
$tenant = $this->tenantService->createTenant($request->validated());
return response()->json([
'success' => true,
'data' => $tenant,
'message' => 'tenants.created',
], 201);
}
// update(), destroy() ...
}
```
### 4. Web Controller 생성 (Blade 화면만)
```php
// mng/app/Http/Controllers/TenantController.php
namespace App\Http\Controllers;
class TenantController extends Controller
{
/**
* 테넌트 목록 화면
* GET /tenants
*/
public function index()
{
return view('tenants.index');
}
/**
* 테넌트 생성 화면
* GET /tenants/create
*/
public function create()
{
return view('tenants.create');
}
/**
* 테넌트 수정 화면
* GET /tenants/{tenant}/edit
*/
public function edit($id)
{
return view('tenants.edit', compact('id'));
}
}
```
### 5. Blade 뷰 생성 (HTMX 호출)
```blade
{{-- resources/views/tenants/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="{{ route('tenants.create') }}" class="btn btn-primary">테넌트 추가</a>
</div>
{{-- 검색/필터 --}}
<div class="card bg-white shadow-xl">
<div class="card-body">
<form hx-get="/api/admin/tenants"
hx-target="#tenant-table"
hx-trigger="submit">
<div class="grid grid-cols-3 gap-4">
<input type="text" name="search"
placeholder="회사명 검색"
class="input input-bordered" />
<select name="status" class="select select-bordered">
<option value="">전체 상태</option>
<option value="trial">트라이얼</option>
<option value="active">활성</option>
</select>
<button type="submit" class="btn btn-primary">검색</button>
</div>
</form>
</div>
</div>
{{-- 테이블 영역 (HTMX로 로드) --}}
<div id="tenant-table"
hx-get="/api/admin/tenants"
hx-trigger="load">
<div class="flex justify-center p-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
</div>
</div>
@endsection
```
### 6. 부분 템플릿 생성 (HTMX 응답)
```blade
{{-- resources/views/tenants/partials/table.blade.php --}}
<div class="card bg-white 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>
</tr>
</thead>
<tbody>
@foreach($tenants as $tenant)
<tr>
<td>{{ $tenant->id }}</td>
<td>{{ $tenant->company_name }}</td>
<td>{{ $tenant->email }}</td>
<td>
<span class="badge badge-success">
{{ $tenant->tenant_st_code }}
</span>
</td>
<td>
<a href="{{ route('tenants.edit', $tenant->id) }}"
class="btn btn-sm">수정</a>
<button hx-delete="/api/admin/tenants/{{ $tenant->id }}"
hx-confirm="정말 삭제하시겠습니까?"
hx-target="closest tr"
hx-swap="outerHTML swap:1s"
class="btn btn-sm btn-error">
삭제
</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{-- 페이징 (HTMX) --}}
<div class="flex justify-center mt-4">
@if($tenants->hasPages())
<div class="btn-group">
@for($page = 1; $page <= $tenants->lastPage(); $page++)
<button hx-get="/api/admin/tenants?page={{ $page }}"
hx-target="#tenant-table"
class="btn btn-sm {{ $page == $tenants->currentPage() ? 'btn-active' : '' }}">
{{ $page }}
</button>
@endfor
</div>
@endif
</div>
</div>
</div>
```
---
## 📋 Phase 4 세부 계획
### Phase 4-1: 테넌트 관리 (우선순위 1)
**예상 기간**: 2-3일
**구현 범위:**
- [x] 모델 복사 (Tenant.php)
- [ ] TenantService 생성
- [ ] API Controller (Api\Admin\TenantController)
- [ ] Web Controller (TenantController)
- [ ] FormRequest (StoreTenantRequest, UpdateTenantRequest)
- [ ] Blade 뷰 (index, create, edit)
- [ ] 부분 템플릿 (partials/table, partials/form)
- [ ] Feature Test
### Phase 4-2: 사용자 관리 (우선순위 2)
**예상 기간**: 3-4일
**구현 범위:**
- [ ] 모델 복사 (User.php)
- [ ] UserService 생성
- [ ] API Controller
- [ ] Web Controller
- [ ] FormRequest
- [ ] Blade 뷰
- [ ] **탭 UI** (HTMX로 구현)
- Tenants 탭
- Departments 탭
- Roles 탭
- Permissions 탭
- [ ] Feature Test
### Phase 4-3: 메뉴/역할/부서 관리 (우선순위 3-5)
**예상 기간**: 5-6일
**구현 범위:**
- [ ] Menu CRUD + 트리 구조 UI
- [ ] Role CRUD
- [ ] Department CRUD + 트리 구조 UI
### Phase 4-4: 권한 관리 (우선순위 6-9)
**예상 기간**: 7-8일
**구현 범위:**
- [ ] Permission CRUD
- [ ] 역할 권한 관리 (체크박스 매트릭스)
- [ ] 부서 권한 관리 (체크박스 매트릭스)
- [ ] 개인 권한 관리 (체크박스 매트릭스)
### Phase 4-5: 권한 분석/백업 (우선순위 10-11)
**예상 기간**: 3-4일
**구현 범위:**
- [ ] 권한 분석 (읽기 전용 매트릭스)
- [ ] 삭제 데이터 백업 조회/복원
---
## 🔧 HTMX 패턴 가이드
### 1. 목록 조회 (Load)
```blade
<div hx-get="/api/admin/users"
hx-trigger="load"
hx-target="this">
<span class="loading loading-spinner"></span>
</div>
```
### 2. 검색/필터 (Submit)
```blade
<form hx-get="/api/admin/users"
hx-target="#results"
hx-trigger="submit">
<input name="search" class="input input-bordered" />
<button class="btn btn-primary">검색</button>
</form>
```
### 3. 생성 (POST)
```blade
<form hx-post="/api/admin/users"
hx-target="#user-list"
hx-swap="beforeend">
<!-- 폼 필드 -->
<button class="btn btn-primary">저장</button>
</form>
```
### 4. 수정 (PUT)
```blade
<form hx-put="/api/admin/users/{{ $user->id }}"
hx-target="closest tr"
hx-swap="outerHTML">
<!-- 폼 필드 -->
<button class="btn btn-primary">수정</button>
</form>
```
### 5. 삭제 (DELETE)
```blade
<button hx-delete="/api/admin/users/{{ $user->id }}"
hx-confirm="정말 삭제하시겠습니까?"
hx-target="closest tr"
hx-swap="outerHTML swap:1s"
class="btn btn-error">
삭제
</button>
```
### 6. 탭 전환
```blade
<div class="tabs tabs-boxed">
<button class="tab tab-active"
hx-get="/api/admin/users/{{ $user->id }}/tenants"
hx-target="#tab-content">
테넌트
</button>
<button class="tab"
hx-get="/api/admin/users/{{ $user->id }}/roles"
hx-target="#tab-content">
역할
</button>
</div>
<div id="tab-content" class="mt-4">
<!-- HTMX로 로드된 탭 내용 -->
</div>
```
---
## 📊 진행 상황 체크리스트
### 전체 진행률
- [ ] Phase 4-1: 테넌트 관리 (0%)
- [ ] Phase 4-2: 사용자 관리 (0%)
- [ ] Phase 4-3: 메뉴/역할/부서 (0%)
- [ ] Phase 4-4: 권한 관리 (0%)
- [ ] Phase 4-5: 권한 분석/백업 (0%)
### 공통 작업
- [x] Admin 모델 분석 완료
- [x] 마이그레이션 계획 수립
- [ ] HTMX 환경 구축
- [ ] DaisyUI 컴포넌트 확정
- [ ] API 응답 형식 표준화
---
## 🔗 관련 문서
- **[MNG_PROJECT_PLAN.md](../../claudedocs/mng/MNG_PROJECT_PLAN.md)** - 전체 프로젝트 계획
- **[DEV_PROCESS.md](../../claudedocs/mng/DEV_PROCESS.md)** - 개발 프로세스 (HTMX 패턴)
- **[mng/docs/INDEX.md](./INDEX.md)** - MNG 프로젝트 문서
- **[CLAUDE.md](../../CLAUDE.md)** - SAM 프로젝트 가이드
---
**최종 업데이트**: 2025-11-21
**버전**: 1.0
**다음 단계**: Phase 4-1 테넌트 관리 구현 시작

View File

@@ -8,7 +8,7 @@
@include('partials.tenant-selector')
<!-- Welcome Card -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="bg-white rounded-lg shadow overflow-hidden mt-6">
<div class="p-6">
<h2 class="text-2xl font-bold text-gray-900 mb-2">환영합니다!</h2>
<p class="text-gray-600">{{ auth()->user()->name ?? 'User' }}, MNG 관리자 패널에 로그인하셨습니다.</p>