From 575e9df4312e9792f4abff12cdaf20751e709d7e Mon Sep 17 00:00:00 2001 From: hskwon Date: Fri, 21 Nov 2025 14:46:13 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=204-1=20=ED=85=8C=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EA=B4=80=EB=A6=AC=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 아키텍처) --- .../Api/Admin/TenantController.php | 206 +++++++ app/Http/Controllers/TenantController.php | 38 +- app/Http/Requests/StoreTenantRequest.php | 95 +++ app/Http/Requests/UpdateTenantRequest.php | 103 ++++ app/Models/Commons/Menu.php | 64 ++ app/Models/Permissions/Role.php | 43 ++ app/Models/Tenant.php | 27 - app/Models/Tenants/Department.php | 73 +++ app/Models/Tenants/Tenant.php | 133 +++++ app/Services/TenantService.php | 149 +++++ docs/INDEX.md | 243 +++++++- docs/MIGRATION_PLAN.md | 557 ++++++++++++++++++ resources/views/dashboard/index.blade.php | 2 +- 13 files changed, 1698 insertions(+), 35 deletions(-) create mode 100644 app/Http/Controllers/Api/Admin/TenantController.php create mode 100644 app/Http/Requests/StoreTenantRequest.php create mode 100644 app/Http/Requests/UpdateTenantRequest.php create mode 100644 app/Models/Commons/Menu.php create mode 100644 app/Models/Permissions/Role.php delete mode 100644 app/Models/Tenant.php create mode 100644 app/Models/Tenants/Department.php create mode 100644 app/Models/Tenants/Tenant.php create mode 100644 app/Services/TenantService.php create mode 100644 docs/MIGRATION_PLAN.md diff --git a/app/Http/Controllers/Api/Admin/TenantController.php b/app/Http/Controllers/Api/Admin/TenantController.php new file mode 100644 index 00000000..84ea1c8c --- /dev/null +++ b/app/Http/Controllers/Api/Admin/TenantController.php @@ -0,0 +1,206 @@ +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, + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/TenantController.php b/app/Http/Controllers/TenantController.php index a9e2c711..953a07ec 100644 --- a/app/Http/Controllers/TenantController.php +++ b/app/Http/Controllers/TenantController.php @@ -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) { diff --git a/app/Http/Requests/StoreTenantRequest.php b/app/Http/Requests/StoreTenantRequest.php new file mode 100644 index 00000000..838f8d37 --- /dev/null +++ b/app/Http/Requests/StoreTenantRequest.php @@ -0,0 +1,95 @@ +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' => '관리자 메모', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/UpdateTenantRequest.php b/app/Http/Requests/UpdateTenantRequest.php new file mode 100644 index 00000000..8c5ad504 --- /dev/null +++ b/app/Http/Requests/UpdateTenantRequest.php @@ -0,0 +1,103 @@ +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' => '관리자 메모', + ]; + } +} \ No newline at end of file diff --git a/app/Models/Commons/Menu.php b/app/Models/Commons/Menu.php new file mode 100644 index 00000000..5e93534a --- /dev/null +++ b/app/Models/Commons/Menu.php @@ -0,0 +1,64 @@ +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); + } + }); + } +} diff --git a/app/Models/Permissions/Role.php b/app/Models/Permissions/Role.php new file mode 100644 index 00000000..a0192b3a --- /dev/null +++ b/app/Models/Permissions/Role.php @@ -0,0 +1,43 @@ +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); + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php deleted file mode 100644 index ba5ce67e..00000000 --- a/app/Models/Tenant.php +++ /dev/null @@ -1,27 +0,0 @@ -whereNull('deleted_at'); - } -} diff --git a/app/Models/Tenants/Department.php b/app/Models/Tenants/Department.php new file mode 100644 index 00000000..ebb33f9f --- /dev/null +++ b/app/Models/Tenants/Department.php @@ -0,0 +1,73 @@ + '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'); + } +} diff --git a/app/Models/Tenants/Tenant.php b/app/Models/Tenants/Tenant.php new file mode 100644 index 00000000..fbb79c00 --- /dev/null +++ b/app/Models/Tenants/Tenant.php @@ -0,0 +1,133 @@ + '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, + }; + } +} diff --git a/app/Services/TenantService.php b/app/Services/TenantService.php new file mode 100644 index 00000000..beea804b --- /dev/null +++ b/app/Services/TenantService.php @@ -0,0 +1,149 @@ +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(), + ]; + } +} \ No newline at end of file diff --git a/docs/INDEX.md b/docs/INDEX.md index 96d3e913..38dbb2b8 100644 --- a/docs/INDEX.md +++ b/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 호출 --}} +
+ + +
+ +{{-- ❌ BAD: 과도한 추상화 --}} + + +{{-- ❌ BAD: Alpine.js 사용 (제거됨) --}} +
...
+``` + +### 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 준비 중 \ No newline at end of file diff --git a/docs/MIGRATION_PLAN.md b/docs/MIGRATION_PLAN.md new file mode 100644 index 00000000..59d33590 --- /dev/null +++ b/docs/MIGRATION_PLAN.md @@ -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') +
+ {{-- 헤더 --}} +
+

테넌트 관리

+ 테넌트 추가 +
+ + {{-- 검색/필터 --}} +
+
+
+
+ + + +
+
+
+
+ + {{-- 테이블 영역 (HTMX로 로드) --}} +
+
+ +
+
+
+@endsection +``` + +### 6. 부분 템플릿 생성 (HTMX 응답) + +```blade +{{-- resources/views/tenants/partials/table.blade.php --}} +
+
+
+ + + + + + + + + + + + @foreach($tenants as $tenant) + + + + + + + + @endforeach + +
ID회사명이메일상태작업
{{ $tenant->id }}{{ $tenant->company_name }}{{ $tenant->email }} + + {{ $tenant->tenant_st_code }} + + + 수정 + +
+
+ + {{-- 페이징 (HTMX) --}} +
+ @if($tenants->hasPages()) +
+ @for($page = 1; $page <= $tenants->lastPage(); $page++) + + @endfor +
+ @endif +
+
+
+``` + +--- + +## 📋 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 +
+ +
+``` + +### 2. 검색/필터 (Submit) +```blade +
+ + +
+``` + +### 3. 생성 (POST) +```blade +
+ + +
+``` + +### 4. 수정 (PUT) +```blade +
+ + +
+``` + +### 5. 삭제 (DELETE) +```blade + +``` + +### 6. 탭 전환 +```blade +
+ + +
+ +
+ +
+``` + +--- + +## 📊 진행 상황 체크리스트 + +### 전체 진행률 +- [ ] 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 테넌트 관리 구현 시작 \ No newline at end of file diff --git a/resources/views/dashboard/index.blade.php b/resources/views/dashboard/index.blade.php index d3369068..d695433a 100644 --- a/resources/views/dashboard/index.blade.php +++ b/resources/views/dashboard/index.blade.php @@ -8,7 +8,7 @@ @include('partials.tenant-selector') -
+

환영합니다!

{{ auth()->user()->name ?? 'User' }}님, MNG 관리자 패널에 로그인하셨습니다.