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: 과도한 추상화 --}} +| ID | +회사명 | +이메일 | +상태 | +작업 | +
|---|---|---|---|---|
| {{ $tenant->id }} | +{{ $tenant->company_name }} | +{{ $tenant->email }} | ++ + {{ $tenant->tenant_st_code }} + + | ++ 수정 + + | +
{{ auth()->user()->name ?? 'User' }}님, MNG 관리자 패널에 로그인하셨습니다.