diff --git a/app/Http/Controllers/Api/Admin/DepartmentController.php b/app/Http/Controllers/Api/Admin/DepartmentController.php new file mode 100644 index 00000000..b5432b5c --- /dev/null +++ b/app/Http/Controllers/Api/Admin/DepartmentController.php @@ -0,0 +1,157 @@ +departmentService->getDepartments( + $request->all(), + $request->integer('per_page', 15) + ); + + // HTMX 요청 시 HTML 반환 + if ($request->header('HX-Request')) { + $html = view('departments.partials.table', compact('departments'))->render(); + + return response()->json([ + 'html' => $html, + ]); + } + + // 일반 요청 시 JSON 반환 + return response()->json([ + 'success' => true, + 'data' => $departments->items(), + 'meta' => [ + 'current_page' => $departments->currentPage(), + 'last_page' => $departments->lastPage(), + 'per_page' => $departments->perPage(), + 'total' => $departments->total(), + ], + ]); + } + + /** + * 부서 생성 + */ + public function store(StoreDepartmentRequest $request): JsonResponse + { + $department = $this->departmentService->createDepartment($request->validated()); + + // HTMX 요청 시 성공 메시지와 리다이렉트 헤더 반환 + if ($request->header('HX-Request')) { + return response()->json([ + 'success' => true, + 'message' => '부서가 생성되었습니다.', + 'redirect' => route('departments.index'), + ]); + } + + return response()->json([ + 'success' => true, + 'message' => '부서가 생성되었습니다.', + 'data' => $department, + ], 201); + } + + /** + * 특정 부서 조회 + */ + public function show(Request $request, int $id): JsonResponse + { + $department = $this->departmentService->getDepartmentById($id); + + if (!$department) { + return response()->json([ + 'success' => false, + 'message' => '부서를 찾을 수 없습니다.', + ], 404); + } + + // HTMX 요청 시 HTML 반환 + if ($request->header('HX-Request')) { + return response()->json([ + 'html' => view('departments.partials.detail', compact('department'))->render(), + ]); + } + + return response()->json([ + 'success' => true, + 'data' => $department, + ]); + } + + /** + * 부서 수정 + */ + public function update(UpdateDepartmentRequest $request, int $id): JsonResponse + { + $result = $this->departmentService->updateDepartment($id, $request->validated()); + + if (!$result) { + return response()->json([ + 'success' => false, + 'message' => '부서 수정에 실패했습니다.', + ], 400); + } + + // HTMX 요청 시 성공 메시지와 리다이렉트 헤더 반환 + if ($request->header('HX-Request')) { + return response()->json([ + 'success' => true, + 'message' => '부서가 수정되었습니다.', + 'redirect' => route('departments.index'), + ]); + } + + return response()->json([ + 'success' => true, + 'message' => '부서가 수정되었습니다.', + ]); + } + + /** + * 부서 삭제 (Soft Delete) + */ + public function destroy(Request $request, int $id): JsonResponse + { + $result = $this->departmentService->deleteDepartment($id); + + if (!$result) { + return response()->json([ + 'success' => false, + 'message' => '부서 삭제에 실패했습니다. (하위 부서가 존재할 수 있습니다)', + ], 400); + } + + // HTMX 요청 시 테이블 행 제거 트리거 + if ($request->header('HX-Request')) { + return response()->json([ + 'success' => true, + 'message' => '부서가 삭제되었습니다.', + 'action' => 'remove', + ]); + } + + return response()->json([ + 'success' => true, + 'message' => '부서가 삭제되었습니다.', + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/DepartmentController.php b/app/Http/Controllers/DepartmentController.php new file mode 100644 index 00000000..56b40eb6 --- /dev/null +++ b/app/Http/Controllers/DepartmentController.php @@ -0,0 +1,48 @@ +departmentService->getActiveDepartments(); + + return view('departments.create', compact('departments')); + } + + /** + * 부서 수정 화면 + */ + public function edit(int $id): View + { + $department = $this->departmentService->getDepartmentById($id); + + if (!$department) { + abort(404, '부서를 찾을 수 없습니다.'); + } + + $departments = $this->departmentService->getActiveDepartments(); + + return view('departments.edit', compact('department', 'departments')); + } +} \ No newline at end of file diff --git a/app/Http/Requests/StoreDepartmentRequest.php b/app/Http/Requests/StoreDepartmentRequest.php new file mode 100644 index 00000000..04789300 --- /dev/null +++ b/app/Http/Requests/StoreDepartmentRequest.php @@ -0,0 +1,72 @@ + [ + 'required', + 'string', + 'max:50', + Rule::unique('departments', 'code') + ->where('tenant_id', $tenantId), + ], + 'name' => 'required|string|max:100', + 'description' => 'nullable|string|max:500', + 'parent_id' => 'nullable|exists:departments,id', + 'is_active' => 'nullable|boolean', + 'sort_order' => 'nullable|integer|min:0', + ]; + } + + /** + * Get custom attributes for validator errors. + */ + public function attributes(): array + { + return [ + 'code' => '부서 코드', + 'name' => '부서명', + 'description' => '설명', + 'parent_id' => '상위 부서', + 'is_active' => '활성 상태', + 'sort_order' => '정렬 순서', + ]; + } + + /** + * Get the error messages for the defined validation rules. + */ + public function messages(): array + { + return [ + 'code.required' => '부서 코드는 필수입니다.', + 'code.unique' => '이미 존재하는 부서 코드입니다.', + 'code.max' => '부서 코드는 최대 50자까지 입력 가능합니다.', + 'name.required' => '부서명은 필수입니다.', + 'name.max' => '부서명은 최대 100자까지 입력 가능합니다.', + 'description.max' => '설명은 최대 500자까지 입력 가능합니다.', + 'parent_id.exists' => '유효하지 않은 상위 부서입니다.', + 'sort_order.min' => '정렬 순서는 0 이상이어야 합니다.', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/UpdateDepartmentRequest.php b/app/Http/Requests/UpdateDepartmentRequest.php new file mode 100644 index 00000000..64741e4a --- /dev/null +++ b/app/Http/Requests/UpdateDepartmentRequest.php @@ -0,0 +1,79 @@ +route('id'); // URL 파라미터에서 department ID 가져오기 + + return [ + 'code' => [ + 'required', + 'string', + 'max:50', + Rule::unique('departments', 'code') + ->where('tenant_id', $tenantId) + ->ignore($departmentId), + ], + 'name' => 'required|string|max:100', + 'description' => 'nullable|string|max:500', + 'parent_id' => [ + 'nullable', + 'exists:departments,id', + Rule::notIn([$departmentId]), // 자기 자신을 상위 부서로 설정 불가 + ], + 'is_active' => 'nullable|boolean', + 'sort_order' => 'nullable|integer|min:0', + ]; + } + + /** + * Get custom attributes for validator errors. + */ + public function attributes(): array + { + return [ + 'code' => '부서 코드', + 'name' => '부서명', + 'description' => '설명', + 'parent_id' => '상위 부서', + 'is_active' => '활성 상태', + 'sort_order' => '정렬 순서', + ]; + } + + /** + * Get the error messages for the defined validation rules. + */ + public function messages(): array + { + return [ + 'code.required' => '부서 코드는 필수입니다.', + 'code.unique' => '이미 존재하는 부서 코드입니다.', + 'code.max' => '부서 코드는 최대 50자까지 입력 가능합니다.', + 'name.required' => '부서명은 필수입니다.', + 'name.max' => '부서명은 최대 100자까지 입력 가능합니다.', + 'description.max' => '설명은 최대 500자까지 입력 가능합니다.', + 'parent_id.exists' => '유효하지 않은 상위 부서입니다.', + 'parent_id.not_in' => '자기 자신을 상위 부서로 설정할 수 없습니다.', + 'sort_order.min' => '정렬 순서는 0 이상이어야 합니다.', + ]; + } +} \ No newline at end of file diff --git a/app/Services/DepartmentService.php b/app/Services/DepartmentService.php new file mode 100644 index 00000000..1e3bb5cc --- /dev/null +++ b/app/Services/DepartmentService.php @@ -0,0 +1,191 @@ +with('parent'); + + // Tenant 필터링 (선택된 경우에만) + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } + + // 검색 필터 + if (!empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('code', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%"); + }); + } + + // 활성 상태 필터 + if (isset($filters['is_active'])) { + $query->where('is_active', $filters['is_active']); + } + + // 정렬 + $sortBy = $filters['sort_by'] ?? 'sort_order'; + $sortDirection = $filters['sort_direction'] ?? 'asc'; + $query->orderBy($sortBy, $sortDirection); + + return $query->paginate($perPage); + } + + /** + * 특정 부서 조회 + */ + public function getDepartmentById(int $id): ?Department + { + $tenantId = session('selected_tenant_id'); + + $query = Department::query()->with(['parent', 'children']); + + // Tenant 필터링 (선택된 경우에만) + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } + + return $query->find($id); + } + + /** + * 부서 생성 + */ + public function createDepartment(array $data): Department + { + $tenantId = session('selected_tenant_id'); + + $department = Department::create([ + 'tenant_id' => $tenantId, + 'parent_id' => $data['parent_id'] ?? null, + 'code' => $data['code'], + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'is_active' => $data['is_active'] ?? true, + 'sort_order' => $data['sort_order'] ?? 0, + 'created_by' => auth()->id(), + ]); + + return $department->fresh(['parent']); + } + + /** + * 부서 수정 + */ + public function updateDepartment(int $id, array $data): bool + { + $department = $this->getDepartmentById($id); + + if (!$department) { + return false; + } + + // 자기 자신을 상위 부서로 설정하는 것 방지 + if (isset($data['parent_id']) && $data['parent_id'] == $id) { + return false; + } + + $updated = $department->update([ + 'parent_id' => $data['parent_id'] ?? $department->parent_id, + 'code' => $data['code'] ?? $department->code, + 'name' => $data['name'] ?? $department->name, + 'description' => $data['description'] ?? $department->description, + 'is_active' => $data['is_active'] ?? $department->is_active, + 'sort_order' => $data['sort_order'] ?? $department->sort_order, + 'updated_by' => auth()->id(), + ]); + + return $updated; + } + + /** + * 부서 삭제 (Soft Delete) + */ + public function deleteDepartment(int $id): bool + { + $department = $this->getDepartmentById($id); + + if (!$department) { + return false; + } + + // 하위 부서가 있는 경우 삭제 불가 + if ($department->children()->count() > 0) { + return false; + } + + $department->deleted_by = auth()->id(); + $department->save(); + + return $department->delete(); + } + + /** + * 부서 코드 중복 체크 + */ + public function isCodeExists(string $code, ?int $excludeId = null): bool + { + $tenantId = session('selected_tenant_id'); + + $query = Department::where('tenant_id', $tenantId) + ->where('code', $code); + + if ($excludeId) { + $query->where('id', '!=', $excludeId); + } + + return $query->exists(); + } + + /** + * 활성 부서 목록 (드롭다운용) + */ + public function getActiveDepartments(): Collection + { + $tenantId = session('selected_tenant_id'); + + $query = Department::query()->where('is_active', true); + + // Tenant 필터링 (선택된 경우에만) + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } + + return $query->orderBy('sort_order')->orderBy('name')->get(['id', 'parent_id', 'code', 'name']); + } + + /** + * 부서 통계 + */ + public function getDepartmentStats(): array + { + $tenantId = session('selected_tenant_id'); + + $baseQuery = Department::query(); + + // Tenant 필터링 (선택된 경우에만) + if ($tenantId) { + $baseQuery->where('tenant_id', $tenantId); + } + + return [ + 'total' => (clone $baseQuery)->count(), + 'active' => (clone $baseQuery)->where('is_active', true)->count(), + 'inactive' => (clone $baseQuery)->where('is_active', false)->count(), + ]; + } +} \ No newline at end of file diff --git a/resources/views/departments/create.blade.php b/resources/views/departments/create.blade.php new file mode 100644 index 00000000..53c256ba --- /dev/null +++ b/resources/views/departments/create.blade.php @@ -0,0 +1,134 @@ +@extends('layouts.app') + +@section('title', '부서 생성') + +@section('content') +
+ +
+

🏢 부서 생성

+ + ← 목록으로 + +
+ + +
+
+ + +
+

기본 정보

+
+
+ + +

최대 50자까지 입력 가능합니다.

+
+
+ + +

최대 100자까지 입력 가능합니다.

+
+
+
+ + +
+

부서 설정

+
+
+ + +
+
+ + +

숫자가 작을수록 먼저 표시됩니다.

+
+
+
+ + +
+

추가 정보

+
+
+ + +

최대 500자까지 입력 가능합니다.

+
+
+ + +
+
+
+ + +
+ + 취소 + + +
+
+
+
+@endsection + +@push('scripts') + + +@endpush \ No newline at end of file diff --git a/resources/views/departments/edit.blade.php b/resources/views/departments/edit.blade.php new file mode 100644 index 00000000..28ee66a5 --- /dev/null +++ b/resources/views/departments/edit.blade.php @@ -0,0 +1,166 @@ +@extends('layouts.app') + +@section('title', '부서 수정') + +@section('content') +
+ +
+

🏢 부서 수정

+ + ← 목록으로 + +
+ + +
+
+ + + +
+

기본 정보

+
+
+ + +

최대 50자까지 입력 가능합니다.

+
+
+ + +

최대 100자까지 입력 가능합니다.

+
+
+
+ + +
+

부서 설정

+
+
+ + +

자기 자신은 선택할 수 없습니다.

+
+
+ + +

숫자가 작을수록 먼저 표시됩니다.

+
+
+
+ + +
+

추가 정보

+
+
+ + +

최대 500자까지 입력 가능합니다.

+
+
+ is_active) ? 'checked' : '' }} + class="h-4 w-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"> + +
+
+
+ + +
+

부서 정보

+
+
+ 생성일: + {{ $department->created_at->format('Y-m-d H:i') }} +
+
+ 수정일: + {{ $department->updated_at->format('Y-m-d H:i') }} +
+
+ 하위 부서 수: + {{ $department->children->count() }}개 +
+
+ 부서 ID: + #{{ $department->id }} +
+
+
+ + +
+ + 취소 + + +
+
+
+
+@endsection + +@push('scripts') + + +@endpush \ No newline at end of file diff --git a/resources/views/departments/index.blade.php b/resources/views/departments/index.blade.php new file mode 100644 index 00000000..78ce4474 --- /dev/null +++ b/resources/views/departments/index.blade.php @@ -0,0 +1,92 @@ +@extends('layouts.app') + +@section('title', '부서 관리') + +@section('content') + + @include('partials.tenant-selector') + + +
+

🏢 부서 관리

+ + + 새 부서 + +
+ + +
+
+ +
+ +
+ + +
+ +
+ + + +
+
+ + +
+ +
+
+
+
+@endsection + +@push('scripts') + + +@endpush \ No newline at end of file diff --git a/resources/views/departments/partials/table.blade.php b/resources/views/departments/partials/table.blade.php new file mode 100644 index 00000000..87c66070 --- /dev/null +++ b/resources/views/departments/partials/table.blade.php @@ -0,0 +1,65 @@ + + + + + + + + + + + + + @forelse($departments as $department) + + + + + + + + + @empty + + + + @endforelse + +
부서 코드부서명상위 부서상태정렬순서작업
+ {{ $department->code }} + +
{{ $department->name }}
+ @if($department->description) +
{{ Str::limit($department->description, 50) }}
+ @endif +
+ {{ $department->parent?->name ?? '-' }} + + @if($department->is_active) + + 활성 + + @else + + 비활성 + + @endif + + {{ $department->sort_order }} + + + 수정 + + +
+ 부서가 없습니다. +
+ + +@include('partials.pagination', [ + 'paginator' => $departments, + 'target' => '#department-table', + 'includeForm' => '#filterForm' +]) \ No newline at end of file diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 1631135b..206eaaad 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -55,8 +55,8 @@ class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-
  • - + diff --git a/routes/api.php b/routes/api.php index 6785d349..15b44511 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,6 @@ name('update'); Route::delete('/{id}', [RoleController::class, 'destroy'])->name('destroy'); }); + + // 부서 관리 API + Route::prefix('departments')->name('departments.')->group(function () { + Route::get('/', [DepartmentController::class, 'index'])->name('index'); + Route::post('/', [DepartmentController::class, 'store'])->name('store'); + Route::get('/{id}', [DepartmentController::class, 'show'])->name('show'); + Route::put('/{id}', [DepartmentController::class, 'update'])->name('update'); + Route::delete('/{id}', [DepartmentController::class, 'destroy'])->name('destroy'); + }); }); \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index b35f7e16..fb0408b3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ name('edit'); }); + // 부서 관리 (Blade 화면만) + Route::prefix('departments')->name('departments.')->group(function () { + Route::get('/', [DepartmentController::class, 'index'])->name('index'); + Route::get('/create', [DepartmentController::class, 'create'])->name('create'); + Route::get('/{id}/edit', [DepartmentController::class, 'edit'])->name('edit'); + }); + // 대시보드 Route::get('/dashboard', function () { return view('dashboard.index');