diff --git a/app/Http/Controllers/Api/Admin/DepartmentController.php b/app/Http/Controllers/Api/Admin/DepartmentController.php index ffde11ad..a0980b64 100644 --- a/app/Http/Controllers/Api/Admin/DepartmentController.php +++ b/app/Http/Controllers/Api/Admin/DepartmentController.php @@ -154,4 +154,58 @@ public function destroy(Request $request, int $id): JsonResponse 'message' => '부서가 삭제되었습니다.', ]); } + + /** + * 부서 복원 + */ + public function restore(Request $request, int $id): JsonResponse + { + $result = $this->departmentService->restoreDepartment($id); + + if (! $result) { + return response()->json([ + 'success' => false, + 'message' => '부서 복원에 실패했습니다.', + ], 400); + } + + if ($request->header('HX-Request')) { + return response()->json([ + 'success' => true, + 'message' => '부서가 복원되었습니다.', + ]); + } + + return response()->json([ + 'success' => true, + 'message' => '부서가 복원되었습니다.', + ]); + } + + /** + * 부서 영구 삭제 + */ + public function forceDelete(Request $request, int $id): JsonResponse + { + $result = $this->departmentService->forceDeleteDepartment($id); + + if (! $result) { + return response()->json([ + 'success' => false, + 'message' => '부서 영구 삭제에 실패했습니다. (하위 부서가 존재할 수 있습니다)', + ], 400); + } + + if ($request->header('HX-Request')) { + return response()->json([ + 'success' => true, + 'message' => '부서가 영구 삭제되었습니다.', + ]); + } + + return response()->json([ + 'success' => true, + 'message' => '부서가 영구 삭제되었습니다.', + ]); + } } diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index da61a58d..760fbd20 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers; +use App\Models\Department; +use App\Models\Role; use App\Services\UserService; use Illuminate\Http\Request; use Illuminate\View\View; @@ -25,7 +27,13 @@ public function index(Request $request): View */ public function create(): View { - return view('users.create'); + $tenantId = session('selected_tenant_id'); + + // 역할/부서 목록 (테넌트별) + $roles = $tenantId ? Role::where('tenant_id', $tenantId)->orderBy('name')->get() : collect(); + $departments = $tenantId ? Department::where('tenant_id', $tenantId)->where('is_active', true)->orderBy('name')->get() : collect(); + + return view('users.create', compact('roles', 'departments')); } /** @@ -39,6 +47,16 @@ public function edit(int $id): View abort(404, '사용자를 찾을 수 없습니다.'); } - return view('users.edit', compact('user')); + $tenantId = session('selected_tenant_id'); + + // 역할/부서 목록 (테넌트별) + $roles = $tenantId ? Role::where('tenant_id', $tenantId)->orderBy('name')->get() : collect(); + $departments = $tenantId ? Department::where('tenant_id', $tenantId)->where('is_active', true)->orderBy('name')->get() : collect(); + + // 사용자의 현재 역할/부서 ID 목록 + $userRoleIds = $tenantId ? $user->userRoles()->where('tenant_id', $tenantId)->pluck('role_id')->toArray() : []; + $userDepartmentIds = $tenantId ? $user->departmentUsers()->where('tenant_id', $tenantId)->pluck('department_id')->toArray() : []; + + return view('users.edit', compact('user', 'roles', 'departments', 'userRoleIds', 'userDepartmentIds')); } } diff --git a/app/Http/Requests/StoreUserRequest.php b/app/Http/Requests/StoreUserRequest.php index 1b782f3b..e3e8d050 100644 --- a/app/Http/Requests/StoreUserRequest.php +++ b/app/Http/Requests/StoreUserRequest.php @@ -30,6 +30,10 @@ public function rules(): array 'role' => 'nullable|string|max:50', 'is_active' => 'nullable|boolean', 'is_super_admin' => 'nullable|boolean', + 'role_ids' => 'nullable|array', + 'role_ids.*' => 'integer|exists:roles,id', + 'department_ids' => 'nullable|array', + 'department_ids.*' => 'integer|exists:departments,id', ]; } diff --git a/app/Http/Requests/UpdateUserRequest.php b/app/Http/Requests/UpdateUserRequest.php index 8c45c124..52a6865f 100644 --- a/app/Http/Requests/UpdateUserRequest.php +++ b/app/Http/Requests/UpdateUserRequest.php @@ -43,6 +43,10 @@ public function rules(): array 'role' => 'nullable|string|max:50', 'is_active' => 'nullable|boolean', 'is_super_admin' => 'nullable|boolean', + 'role_ids' => 'nullable|array', + 'role_ids.*' => 'integer|exists:roles,id', + 'department_ids' => 'nullable|array', + 'department_ids.*' => 'integer|exists:departments,id', ]; } diff --git a/app/Models/Department.php b/app/Models/Department.php new file mode 100644 index 00000000..17866f41 --- /dev/null +++ b/app/Models/Department.php @@ -0,0 +1,74 @@ + 'integer', + 'parent_id' => 'integer', + 'is_active' => 'boolean', + 'sort_order' => 'integer', + ]; + + protected $hidden = [ + 'deleted_by', + 'deleted_at', + ]; + + /** + * 상위 부서 + */ + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + /** + * 하위 부서 + */ + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id'); + } + + /** + * 부서-사용자 매핑 (DepartmentUser pivot) + */ + public function departmentUsers(): HasMany + { + return $this->hasMany(DepartmentUser::class, 'department_id'); + } + + /** + * 부서에 속한 사용자들 (belongsToMany) + */ + public function users() + { + return $this->belongsToMany(User::class, 'department_user') + ->withTimestamps() + ->withPivot(['tenant_id', 'is_primary', 'joined_at', 'left_at']); + } +} diff --git a/app/Models/DepartmentUser.php b/app/Models/DepartmentUser.php new file mode 100644 index 00000000..abd30101 --- /dev/null +++ b/app/Models/DepartmentUser.php @@ -0,0 +1,51 @@ + 'integer', + 'department_id' => 'integer', + 'user_id' => 'integer', + 'is_primary' => 'boolean', + 'joined_at' => 'datetime', + 'left_at' => 'datetime', + ]; + + /** + * 부서 + */ + public function department(): BelongsTo + { + return $this->belongsTo(Department::class, 'department_id'); + } + + /** + * 사용자 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/Tenants/Department.php b/app/Models/Tenants/Department.php index 072ac917..73ca3061 100644 --- a/app/Models/Tenants/Department.php +++ b/app/Models/Tenants/Department.php @@ -9,12 +9,13 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\Permission\Traits\HasPermissions; use Spatie\Permission\Traits\HasRoles; class Department extends Model { - use HasPermissions, HasRoles, ModelTrait; // 부서도 권한/역할을 가짐 + use HasPermissions, HasRoles, ModelTrait, SoftDeletes; // 부서도 권한/역할을 가짐 protected $table = 'departments'; diff --git a/app/Models/User.php b/app/Models/User.php index 41936b20..247dbe30 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -86,4 +87,44 @@ public function currentTenant() return $this->tenants()->find($tenantId); } + + /** + * 관계: 사용자-역할 (user_roles 테이블, 테넌트별) + */ + public function userRoles(): HasMany + { + return $this->hasMany(UserRole::class); + } + + /** + * 관계: 사용자-부서 (department_user 테이블, 테넌트별) + */ + public function departmentUsers(): HasMany + { + return $this->hasMany(DepartmentUser::class); + } + + /** + * 특정 테넌트의 역할 목록 조회 + */ + public function getRolesForTenant(int $tenantId) + { + return $this->userRoles() + ->where('tenant_id', $tenantId) + ->with('role') + ->get() + ->pluck('role'); + } + + /** + * 특정 테넌트의 부서 목록 조회 + */ + public function getDepartmentsForTenant(int $tenantId) + { + return $this->departmentUsers() + ->where('tenant_id', $tenantId) + ->with('department') + ->get() + ->pluck('department'); + } } diff --git a/app/Models/UserRole.php b/app/Models/UserRole.php new file mode 100644 index 00000000..2adbd062 --- /dev/null +++ b/app/Models/UserRole.php @@ -0,0 +1,44 @@ + 'integer', + 'tenant_id' => 'integer', + 'role_id' => 'integer', + 'assigned_at' => 'datetime', + ]; + + /** + * 사용자 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * 역할 + */ + public function role(): BelongsTo + { + return $this->belongsTo(Role::class); + } +} diff --git a/app/Services/DepartmentService.php b/app/Services/DepartmentService.php index 3195a6a8..b555c6e9 100644 --- a/app/Services/DepartmentService.php +++ b/app/Services/DepartmentService.php @@ -15,13 +15,22 @@ public function getDepartments(array $filters = [], int $perPage = 15): LengthAw { $tenantId = session('selected_tenant_id'); - $query = Department::query()->with('parent'); + $query = Department::query()->withTrashed()->with('parent'); // Tenant 필터링 (선택된 경우에만) if ($tenantId) { $query->where('tenant_id', $tenantId); } + // Soft Delete 필터 + if (isset($filters['trashed'])) { + if ($filters['trashed'] === 'only') { + $query->onlyTrashed(); + } elseif ($filters['trashed'] === 'with') { + $query->withTrashed(); + } + } + // 검색 필터 if (! empty($filters['search'])) { $search = $filters['search']; @@ -134,6 +143,55 @@ public function deleteDepartment(int $id): bool return $department->delete(); } + /** + * 부서 복원 + */ + public function restoreDepartment(int $id): bool + { + $tenantId = session('selected_tenant_id'); + + $query = Department::onlyTrashed(); + + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } + + $department = $query->find($id); + + if (! $department) { + return false; + } + + return $department->restore(); + } + + /** + * 부서 영구 삭제 + */ + public function forceDeleteDepartment(int $id): bool + { + $tenantId = session('selected_tenant_id'); + + $query = Department::withTrashed(); + + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } + + $department = $query->find($id); + + if (! $department) { + return false; + } + + // 하위 부서가 있는 경우 삭제 불가 + if ($department->children()->count() > 0) { + return false; + } + + return $department->forceDelete(); + } + /** * 부서 코드 중복 체크 */ diff --git a/app/Services/UserService.php b/app/Services/UserService.php index 92996c4c..96e7004f 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -2,7 +2,9 @@ namespace App\Services; +use App\Models\DepartmentUser; use App\Models\User; +use App\Models\UserRole; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Hash; @@ -87,6 +89,13 @@ public function createUser(array $data): User 'is_default' => true, 'joined_at' => now(), ]); + + // 역할/부서 동기화 + $roleIds = $data['role_ids'] ?? []; + $departmentIds = $data['department_ids'] ?? []; + + $this->syncRoles($user, $tenantId, $roleIds); + $this->syncDepartments($user, $tenantId, $departmentIds); } return $user; @@ -102,6 +111,8 @@ public function updateUser(int $id, array $data): bool return false; } + $tenantId = session('selected_tenant_id'); + // 비밀번호가 입력된 경우만 업데이트 if (! empty($data['password'])) { $data['password'] = Hash::make($data['password']); @@ -115,9 +126,67 @@ public function updateUser(int $id, array $data): bool // 수정자 정보 $data['updated_by'] = auth()->id(); + // 역할/부서 동기화 (테넌트가 선택된 경우) + if ($tenantId) { + $roleIds = $data['role_ids'] ?? []; + $departmentIds = $data['department_ids'] ?? []; + + $this->syncRoles($user, $tenantId, $roleIds); + $this->syncDepartments($user, $tenantId, $departmentIds); + } + + // role_ids, department_ids는 User 모델의 fillable이 아니므로 제거 + unset($data['role_ids'], $data['department_ids']); + return $user->update($data); } + /** + * 사용자 역할 동기화 (특정 테넌트) + */ + public function syncRoles(User $user, int $tenantId, array $roleIds): void + { + // 기존 역할 삭제 (해당 테넌트만) - forceDelete로 실제 삭제 + UserRole::withTrashed() + ->where('user_id', $user->id) + ->where('tenant_id', $tenantId) + ->forceDelete(); + + // 새 역할 추가 + foreach ($roleIds as $roleId) { + UserRole::create([ + 'user_id' => $user->id, + 'tenant_id' => $tenantId, + 'role_id' => $roleId, + 'assigned_at' => now(), + ]); + } + } + + /** + * 사용자 부서 동기화 (특정 테넌트) + */ + public function syncDepartments(User $user, int $tenantId, array $departmentIds): void + { + // 기존 부서 삭제 (해당 테넌트만) - forceDelete로 실제 삭제 + DepartmentUser::withTrashed() + ->where('user_id', $user->id) + ->where('tenant_id', $tenantId) + ->forceDelete(); + + // 새 부서 추가 (첫 번째를 primary로) + foreach ($departmentIds as $index => $departmentId) { + DepartmentUser::create([ + 'user_id' => $user->id, + 'tenant_id' => $tenantId, + 'department_id' => $departmentId, + 'is_primary' => $index === 0, + 'joined_at' => now(), + 'created_by' => auth()->id(), + ]); + } + } + /** * 사용자 삭제 (Soft Delete) */ diff --git a/resources/views/departments/index.blade.php b/resources/views/departments/index.blade.php index 082af5e6..36fe03c1 100644 --- a/resources/views/departments/index.blade.php +++ b/resources/views/departments/index.blade.php @@ -23,7 +23,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc -