feat: [rd] 조직도 관리 화면 추가

- SortableJS 기반 drag & drop 부서 배치 UI
- 미배치 직원 패널 + 부서 트리 (3단계 계층 지원)
- 직원 배치/해제 API 엔드포인트
- 실시간 저장 및 인원수 표시
This commit is contained in:
김보곤
2026-03-06 19:34:52 +09:00
parent ebb10b5c47
commit 774a35e097
3 changed files with 469 additions and 1 deletions

View File

@@ -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]);
}
/**
* 중대재해처벌법 실무 점검
*/

View File

@@ -0,0 +1,351 @@
@extends('layouts.app')
@section('title', '조직도 관리')
@section('content')
<div x-data="orgChart()" x-init="init()">
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
<i class="ri-organization-chart text-purple-600"></i>
조직도 관리
</h1>
<div class="flex items-center gap-3">
<span class="text-sm text-gray-500" x-text="`전체 ${totalEmployees}명 | 배치 ${assignedCount}명 | 미배치 ${unassignedCount}명`"></span>
<a href="{{ route('rd.index') }}" class="bg-white hover:bg-gray-100 text-gray-700 px-4 py-2 rounded-lg border transition text-sm">
<i class="ri-arrow-left-line"></i> 돌아가기
</a>
</div>
</div>
<!-- 안내 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg px-4 py-3 mb-6 text-sm text-blue-700 flex items-start gap-2">
<i class="ri-information-line text-lg shrink-0 mt-0.5"></i>
<span>직원 카드를 드래그하여 부서에 배치하거나, 미배치 영역으로 이동할 있습니다. 변경은 즉시 저장됩니다.</span>
</div>
<div class="flex gap-6" style="min-height: calc(100vh - 240px);">
<!-- 좌측: 미배치 직원 패널 -->
<div class="shrink-0" style="width: 280px;">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 sticky" style="top: 80px;">
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50 rounded-t-lg">
<h2 class="text-sm font-semibold text-gray-700 flex items-center gap-2">
<i class="ri-user-unfollow-line text-orange-500"></i>
미배치 직원
<span class="ml-auto bg-orange-100 text-orange-600 text-xs font-bold px-2 py-0.5 rounded-full" x-text="unassignedCount"></span>
</h2>
</div>
<!-- 검색 -->
<div class="px-3 py-2 border-b border-gray-100">
<input type="text" x-model="searchUnassigned" placeholder="이름 검색..."
class="w-full px-3 py-1.5 border border-gray-200 rounded text-sm focus:outline-none focus:ring-1 focus:ring-purple-400">
</div>
<!-- 미배치 직원 목록 (드롭존) -->
<div id="unassigned-zone" class="p-2 overflow-y-auto" style="max-height: calc(100vh - 380px); min-height: 100px;"
data-department-id="">
<template x-for="emp in filteredUnassigned" :key="emp.id">
<div class="employee-card bg-white border border-gray-200 rounded-lg px-3 py-2 mb-2 cursor-grab hover:shadow-md hover:border-purple-300 transition flex items-center gap-2"
:data-employee-id="emp.id">
<div class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0"
:style="`background-color: ${getAvatarColor(emp.display_name)}`"
x-text="emp.display_name?.charAt(0) || '?'"></div>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-800 truncate" x-text="emp.display_name"></p>
<p class="text-xs text-gray-400 truncate" x-text="emp.position_label || ''"></p>
</div>
</div>
</template>
<div x-show="filteredUnassigned.length === 0" class="text-center text-gray-400 text-xs py-6">
<i class="ri-user-smile-line text-2xl block mb-1"></i>
미배치 직원 없음
</div>
</div>
</div>
</div>
<!-- 우측: 조직도 트리 -->
<div class="flex-1 min-w-0">
<!-- 최상위 부서 카드들 -->
<template x-for="dept in rootDepartments" :key="dept.id">
<div class="mb-4">
<div x-data="{ open: true }">
<!-- 부서 헤더 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div class="flex items-center gap-2 px-4 py-3 bg-gradient-to-r from-purple-50 to-indigo-50 border-b border-gray-200 cursor-pointer select-none"
@click="open = !open">
<i class="ri-arrow-down-s-line transition-transform text-gray-400" :class="!open && '-rotate-90'"></i>
<i class="ri-building-2-line text-purple-600"></i>
<span class="font-semibold text-gray-800" x-text="dept.name"></span>
<span class="text-xs text-gray-400 ml-1" x-text="dept.code ? `(${dept.code})` : ''"></span>
<span class="ml-auto bg-purple-100 text-purple-700 text-xs font-bold px-2 py-0.5 rounded-full"
x-text="getDeptEmployeeCount(dept.id) + '명'"></span>
</div>
<!-- 부서 직원 드롭존 -->
<div x-show="open" x-collapse>
<div class="dept-drop-zone p-3 min-h-[60px]" :data-department-id="dept.id">
<template x-for="emp in getDeptEmployees(dept.id)" :key="emp.id">
<div class="employee-card inline-flex items-center gap-2 bg-white border border-gray-200 rounded-lg px-3 py-2 mr-2 mb-2 cursor-grab hover:shadow-md hover:border-purple-300 transition"
:data-employee-id="emp.id">
<div class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0"
:style="`background-color: ${getAvatarColor(emp.display_name)}`"
x-text="emp.display_name?.charAt(0) || '?'"></div>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-800 whitespace-nowrap" x-text="emp.display_name"></p>
<p class="text-xs text-gray-400 whitespace-nowrap" x-text="emp.position_label || ''"></p>
</div>
<button class="ml-1 text-gray-300 hover:text-red-500 transition shrink-0" title="미배치로 이동"
@click.stop="unassignEmployee(emp.id)">
<i class="ri-close-line"></i>
</button>
</div>
</template>
<div x-show="getDeptEmployees(dept.id).length === 0" class="text-center text-gray-300 text-xs py-4">
직원을 여기로 드래그하세요
</div>
</div>
<!-- 하위 부서 -->
<template x-for="child in getChildDepartments(dept.id)" :key="child.id">
<div class="border-t border-gray-100">
<div x-data="{ childOpen: true }">
<div class="flex items-center gap-2 px-6 py-2.5 bg-gray-50 border-b border-gray-100 cursor-pointer select-none"
@click="childOpen = !childOpen">
<i class="ri-arrow-down-s-line transition-transform text-gray-400 text-sm" :class="!childOpen && '-rotate-90'"></i>
<i class="ri-git-branch-line text-indigo-400 text-sm"></i>
<span class="text-sm font-medium text-gray-700" x-text="child.name"></span>
<span class="text-xs text-gray-400" x-text="child.code ? `(${child.code})` : ''"></span>
<span class="ml-auto bg-indigo-50 text-indigo-600 text-xs font-bold px-2 py-0.5 rounded-full"
x-text="getDeptEmployeeCount(child.id) + '명'"></span>
</div>
<div x-show="childOpen" x-collapse>
<div class="dept-drop-zone px-6 py-3 min-h-[50px]" :data-department-id="child.id">
<template x-for="emp in getDeptEmployees(child.id)" :key="emp.id">
<div class="employee-card inline-flex items-center gap-2 bg-white border border-gray-200 rounded-lg px-3 py-2 mr-2 mb-2 cursor-grab hover:shadow-md hover:border-purple-300 transition"
:data-employee-id="emp.id">
<div class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0"
:style="`background-color: ${getAvatarColor(emp.display_name)}`"
x-text="emp.display_name?.charAt(0) || '?'"></div>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-800 whitespace-nowrap" x-text="emp.display_name"></p>
<p class="text-xs text-gray-400 whitespace-nowrap" x-text="emp.position_label || ''"></p>
</div>
<button class="ml-1 text-gray-300 hover:text-red-500 transition shrink-0" title="미배치로 이동"
@click.stop="unassignEmployee(emp.id)">
<i class="ri-close-line"></i>
</button>
</div>
</template>
<div x-show="getDeptEmployees(child.id).length === 0" class="text-center text-gray-300 text-xs py-3">
직원을 여기로 드래그하세요
</div>
</div>
<!-- 3단계 하위 부서 -->
<template x-for="grandchild in getChildDepartments(child.id)" :key="grandchild.id">
<div class="border-t border-gray-50">
<div class="flex items-center gap-2 px-10 py-2 bg-gray-50/50 border-b border-gray-50">
<i class="ri-corner-down-right-line text-gray-300 text-sm"></i>
<span class="text-sm text-gray-600" x-text="grandchild.name"></span>
<span class="ml-auto bg-gray-100 text-gray-500 text-xs font-bold px-2 py-0.5 rounded-full"
x-text="getDeptEmployeeCount(grandchild.id) + '명'"></span>
</div>
<div class="dept-drop-zone px-10 py-2 min-h-[40px]" :data-department-id="grandchild.id">
<template x-for="emp in getDeptEmployees(grandchild.id)" :key="emp.id">
<div class="employee-card inline-flex items-center gap-2 bg-white border border-gray-200 rounded-lg px-3 py-1.5 mr-2 mb-2 cursor-grab hover:shadow-md hover:border-purple-300 transition"
:data-employee-id="emp.id">
<div class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0"
:style="`background-color: ${getAvatarColor(emp.display_name)}`"
x-text="emp.display_name?.charAt(0) || '?'"></div>
<span class="text-sm text-gray-800 whitespace-nowrap" x-text="emp.display_name"></span>
<button class="text-gray-300 hover:text-red-500 transition shrink-0" title="미배치로 이동"
@click.stop="unassignEmployee(emp.id)">
<i class="ri-close-line text-sm"></i>
</button>
</div>
</template>
<div x-show="getDeptEmployees(grandchild.id).length === 0" class="text-center text-gray-300 text-xs py-2">
드래그하여 배치
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<div x-show="rootDepartments.length === 0" class="bg-white rounded-lg shadow-sm p-12 text-center text-gray-400">
<i class="ri-building-2-line text-4xl block mb-2"></i>
<p>등록된 부서가 없습니다.</p>
<p class="text-sm mt-1">먼저 부서를 등록해주세요.</p>
</div>
</div>
</div>
<!-- 저장 토스트 -->
<div x-show="saving" x-transition
class="fixed bottom-6 right-6 bg-gray-800 text-white px-4 py-2 rounded-lg shadow-lg text-sm flex items-center gap-2 z-50">
<i class="ri-loader-4-line animate-spin"></i> 저장 ...
</div>
</div>
@endsection
@push('scripts')
<script>
function orgChart() {
return {
departments: @json($departments),
employees: @json($employees->map(fn($e) => [
'id' => $e->id,
'user_id' => $e->user_id,
'department_id' => $e->department_id,
'display_name' => $e->display_name ?? $e->user?->name ?? '(이름없음)',
'position_label' => $e->position_label,
])),
searchUnassigned: '',
saving: false,
sortables: [],
get totalEmployees() { return this.employees.length; },
get assignedCount() { return this.employees.filter(e => e.department_id).length; },
get unassignedCount() { return this.employees.filter(e => !e.department_id).length; },
get unassignedEmployees() { return this.employees.filter(e => !e.department_id); },
get filteredUnassigned() {
if (!this.searchUnassigned) return this.unassignedEmployees;
const s = this.searchUnassigned.toLowerCase();
return this.unassignedEmployees.filter(e => e.display_name?.toLowerCase().includes(s));
},
get rootDepartments() {
return this.departments.filter(d => !d.parent_id);
},
getChildDepartments(parentId) {
return this.departments.filter(d => d.parent_id === parentId);
},
getDeptEmployees(deptId) {
return this.employees.filter(e => e.department_id === deptId);
},
getDeptEmployeeCount(deptId) {
// 해당 부서 + 하위 부서의 직원 수 합산
let count = this.employees.filter(e => e.department_id === deptId).length;
const children = this.departments.filter(d => d.parent_id === deptId);
for (const child of children) {
count += this.getDeptEmployeeCount(child.id);
}
return count;
},
getAvatarColor(name) {
if (!name) return '#9CA3AF';
const colors = ['#6366F1','#8B5CF6','#EC4899','#F59E0B','#10B981','#3B82F6','#EF4444','#14B8A6','#F97316','#6D28D9'];
let hash = 0;
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return colors[Math.abs(hash) % colors.length];
},
init() {
this.$nextTick(() => this.initSortable());
},
initSortable() {
// 미배치 영역
const unassignedEl = document.getElementById('unassigned-zone');
if (unassignedEl) {
this.sortables.push(new Sortable(unassignedEl, {
group: 'org-chart',
animation: 150,
ghostClass: 'opacity-40',
dragClass: 'shadow-lg',
onAdd: (evt) => this.handleDrop(evt),
}));
}
// 부서 드롭존 - MutationObserver로 동적 생성 대응
this.observeAndInitDropZones();
},
observeAndInitDropZones() {
const initZones = () => {
document.querySelectorAll('.dept-drop-zone').forEach(el => {
if (el._sortableInitialized) return;
el._sortableInitialized = true;
this.sortables.push(new Sortable(el, {
group: 'org-chart',
animation: 150,
ghostClass: 'opacity-40',
dragClass: 'shadow-lg',
onAdd: (evt) => this.handleDrop(evt),
}));
});
};
initZones();
// Alpine이 DOM을 업데이트할 때 다시 초기화
const observer = new MutationObserver(() => {
setTimeout(initZones, 100);
});
observer.observe(document.querySelector('[x-data]'), { childList: true, subtree: true });
},
handleDrop(evt) {
const employeeId = parseInt(evt.item.dataset.employeeId);
const targetDeptId = evt.to.dataset.departmentId;
const newDeptId = targetDeptId ? parseInt(targetDeptId) : null;
// 클라이언트 상태 업데이트
const emp = this.employees.find(e => e.id === employeeId);
if (emp) {
emp.department_id = newDeptId;
}
// 서버에 저장
this.saveAssignment(employeeId, newDeptId);
},
async saveAssignment(employeeId, departmentId) {
this.saving = true;
try {
const url = departmentId
? '{{ route("rd.org-chart.assign") }}'
: '{{ route("rd.org-chart.unassign") }}';
const body = departmentId
? { employee_id: employeeId, department_id: departmentId }
: { employee_id: employeeId };
await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: JSON.stringify(body),
});
} catch (e) {
console.error('조직도 저장 실패:', e);
} finally {
this.saving = false;
}
},
async unassignEmployee(empId) {
const emp = this.employees.find(e => e.id === empId);
if (emp) {
emp.department_id = null;
await this.saveAssignment(empId, null);
}
},
};
}
</script>
@endpush

View File

@@ -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');