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:
206
app/Http/Controllers/Api/Admin/TenantController.php
Normal file
206
app/Http/Controllers/Api/Admin/TenantController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
95
app/Http/Requests/StoreTenantRequest.php
Normal file
95
app/Http/Requests/StoreTenantRequest.php
Normal 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' => '관리자 메모',
|
||||
];
|
||||
}
|
||||
}
|
||||
103
app/Http/Requests/UpdateTenantRequest.php
Normal file
103
app/Http/Requests/UpdateTenantRequest.php
Normal 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' => '관리자 메모',
|
||||
];
|
||||
}
|
||||
}
|
||||
64
app/Models/Commons/Menu.php
Normal file
64
app/Models/Commons/Menu.php
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
43
app/Models/Permissions/Role.php
Normal file
43
app/Models/Permissions/Role.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
73
app/Models/Tenants/Department.php
Normal file
73
app/Models/Tenants/Department.php
Normal 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');
|
||||
}
|
||||
}
|
||||
133
app/Models/Tenants/Tenant.php
Normal file
133
app/Models/Tenants/Tenant.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
149
app/Services/TenantService.php
Normal file
149
app/Services/TenantService.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
243
docs/INDEX.md
243
docs/INDEX.md
@@ -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
557
docs/MIGRATION_PLAN.md
Normal 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 테넌트 관리 구현 시작
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user