From 3fccd7414cbc06fdcfbdb5a34a54c2ba304e29c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 6 Mar 2026 19:34:52 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[rd]=20=EC=A1=B0=EC=A7=81=EB=8F=84=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=99=94=EB=A9=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SortableJS 기반 drag & drop 부서 배치 UI - 미배치 직원 패널 + 부서 트리 (3단계 계층 지원) - 직원 배치/해제 API 엔드포인트 - 실시간 저장 및 인원수 표시 --- app/Http/Controllers/RdController.php | 111 ++++++++ resources/views/rd/org-chart.blade.php | 351 +++++++++++++++++++++++++ routes/web.php | 8 +- 3 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 resources/views/rd/org-chart.blade.php diff --git a/app/Http/Controllers/RdController.php b/app/Http/Controllers/RdController.php index 721693d9..2d5ddee5 100644 --- a/app/Http/Controllers/RdController.php +++ b/app/Http/Controllers/RdController.php @@ -2,8 +2,11 @@ namespace App\Http\Controllers; +use App\Models\HR\Employee; use App\Models\Rd\AiQuotation; +use App\Models\Tenants\Department; use App\Services\Rd\AiQuotationService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -28,6 +31,114 @@ public function index(Request $request): View|\Illuminate\Http\Response return view('rd.index', compact('dashboard', 'statuses')); } + /** + * 조직도 관리 + */ + public function orgChart(Request $request): View|\Illuminate\Http\Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('rd.org-chart')); + } + + $tenantId = session('selected_tenant_id'); + + // 부서 트리 (parent_id=null이 최상위) + $departments = Department::where('tenant_id', $tenantId) + ->where('is_active', true) + ->orderBy('sort_order') + ->orderBy('name') + ->get(); + + // 전체 직원 (활성 상태) + $employees = Employee::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('employee_status', 'active') + ->with(['user', 'department']) + ->orderBy('display_name') + ->get(); + + // 미배치 직원 (department_id가 null) + $unassigned = $employees->whereNull('department_id'); + // 배치된 직원을 부서별로 그룹핑 + $assignedByDept = $employees->whereNotNull('department_id')->groupBy('department_id'); + + return view('rd.org-chart', compact('departments', 'employees', 'unassigned', 'assignedByDept')); + } + + /** + * 조직도 - 직원 부서 배치 + */ + public function orgChartAssign(Request $request): JsonResponse + { + $request->validate([ + 'employee_id' => 'required|integer', + 'department_id' => 'required|integer', + ]); + + $tenantId = session('selected_tenant_id'); + $employee = Employee::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('id', $request->employee_id) + ->first(); + + if (! $employee) { + return response()->json(['success' => false, 'message' => '직원을 찾을 수 없습니다.'], 404); + } + + $employee->department_id = $request->department_id; + $employee->save(); + + return response()->json(['success' => true]); + } + + /** + * 조직도 - 직원 부서 해제 (미배치로 이동) + */ + public function orgChartUnassign(Request $request): JsonResponse + { + $request->validate([ + 'employee_id' => 'required|integer', + ]); + + $tenantId = session('selected_tenant_id'); + $employee = Employee::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('id', $request->employee_id) + ->first(); + + if (! $employee) { + return response()->json(['success' => false, 'message' => '직원을 찾을 수 없습니다.'], 404); + } + + $employee->department_id = null; + $employee->save(); + + return response()->json(['success' => true]); + } + + /** + * 조직도 - 부서 내 직원 순서/이동 일괄 처리 + */ + public function orgChartReorder(Request $request): JsonResponse + { + $request->validate([ + 'moves' => 'required|array', + 'moves.*.employee_id' => 'required|integer', + 'moves.*.department_id' => 'nullable|integer', + ]); + + $tenantId = session('selected_tenant_id'); + + foreach ($request->moves as $move) { + Employee::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('id', $move['employee_id']) + ->update(['department_id' => $move['department_id']]); + } + + return response()->json(['success' => true]); + } + /** * 중대재해처벌법 실무 점검 */ diff --git a/resources/views/rd/org-chart.blade.php b/resources/views/rd/org-chart.blade.php new file mode 100644 index 00000000..ec96d422 --- /dev/null +++ b/resources/views/rd/org-chart.blade.php @@ -0,0 +1,351 @@ +@extends('layouts.app') + +@section('title', '조직도 관리') + +@section('content') +
+ +
+

+ + 조직도 관리 +

+ +
+ + +
+ + 직원 카드를 드래그하여 부서에 배치하거나, 미배치 영역으로 이동할 수 있습니다. 변경은 즉시 저장됩니다. +
+ +
+ +
+
+
+

+ + 미배치 직원 + +

+
+ +
+ +
+ +
+ +
+ + 미배치 직원 없음 +
+
+
+
+ + +
+ + + +
+ +

등록된 부서가 없습니다.

+

먼저 부서를 등록해주세요.

+
+
+
+ + +
+ 저장 중... +
+
+@endsection + +@push('scripts') + +@endpush diff --git a/routes/web.php b/routes/web.php index 4336ca95..a84e588a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,8 +13,8 @@ use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\BoardController; use App\Http\Controllers\CategoryController; -use App\Http\Controllers\ChinaTech\BigTechController; use App\Http\Controllers\CategorySyncController; +use App\Http\Controllers\ChinaTech\BigTechController; use App\Http\Controllers\ClaudeCode\CoworkController as ClaudeCodeCoworkController; use App\Http\Controllers\ClaudeCode\NewsController as ClaudeCodeNewsController; use App\Http\Controllers\ClaudeCode\PricingController as ClaudeCodePricingController; @@ -387,6 +387,12 @@ Route::get('/ai-quotation/{id}/edit', [RdController::class, 'editQuotation'])->name('ai-quotation.edit'); Route::get('/ai-quotation/{id}', [RdController::class, 'showQuotation'])->name('ai-quotation.show'); + // 조직도 관리 + Route::get('/org-chart', [RdController::class, 'orgChart'])->name('org-chart'); + Route::post('/org-chart/assign', [RdController::class, 'orgChartAssign'])->name('org-chart.assign'); + Route::post('/org-chart/unassign', [RdController::class, 'orgChartUnassign'])->name('org-chart.unassign'); + Route::post('/org-chart/reorder', [RdController::class, 'orgChartReorder'])->name('org-chart.reorder'); + // 중대재해처벌법 실무 점검 Route::get('/safety-audit', [RdController::class, 'safetyAudit'])->name('safety-audit');