feat: Phase 4-3 부서 관리 시스템 구현

- DepartmentService, DepartmentController (Blade/API) 생성
- 부서 CRUD 기능 완성 (목록/생성/수정/삭제)
- FormRequest 검증 (StoreDepartmentRequest, UpdateDepartmentRequest)
- HTMX 패턴 적용 (index, create, edit, partials/table)
- 계층 구조 지원 (parent-child relationships)
- 활성/비활성 상태 관리
- 정렬 순서 관리
- Tenant Selector 통합
- Sidebar 메뉴에 부서 관리 추가

주요 기능:
- 자기 참조 방지 (parent_id validation)
- 하위 부서 존재 시 삭제 방지
- Conditional tenant filtering 적용
- Active/Inactive 필터링
- HTMX 페이지네이션 (target, includeForm 파라미터 포함)

정책:
- 모든 리스트 화면은 1페이지만 있어도 페이지네이션 표시
This commit is contained in:
2025-11-24 16:52:23 +09:00
parent 5f50716d7f
commit 6738505106
12 changed files with 1024 additions and 2 deletions

View File

@@ -0,0 +1,157 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreDepartmentRequest;
use App\Http\Requests\UpdateDepartmentRequest;
use App\Services\DepartmentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DepartmentController extends Controller
{
public function __construct(
private readonly DepartmentService $departmentService
) {}
/**
* 부서 목록 조회
*/
public function index(Request $request): JsonResponse
{
$departments = $this->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' => '부서가 삭제되었습니다.',
]);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers;
use App\Services\DepartmentService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DepartmentController extends Controller
{
public function __construct(
private readonly DepartmentService $departmentService
) {}
/**
* 부서 목록 (Blade 화면만)
*/
public function index(Request $request): View
{
return view('departments.index');
}
/**
* 부서 생성 화면
*/
public function create(): View
{
$departments = $this->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'));
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreDepartmentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // 권한 체크는 middleware에서 처리
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$tenantId = session('selected_tenant_id');
return [
'code' => [
'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 이상이어야 합니다.',
];
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateDepartmentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // 권한 체크는 middleware에서 처리
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$tenantId = session('selected_tenant_id');
$departmentId = $this->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 이상이어야 합니다.',
];
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace App\Services;
use App\Models\Tenants\Department;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
class DepartmentService
{
/**
* 부서 목록 조회 (페이지네이션)
*/
public function getDepartments(array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$tenantId = session('selected_tenant_id');
$query = Department::query()->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(),
];
}
}

View File

@@ -0,0 +1,134 @@
@extends('layouts.app')
@section('title', '부서 생성')
@section('content')
<div class="container mx-auto max-w-4xl">
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">🏢 부서 생성</h1>
<a href="{{ route('departments.index') }}" class="text-gray-600 hover:text-gray-800">
목록으로
</a>
</div>
<!-- 영역 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<form id="departmentForm"
hx-post="/api/admin/departments"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
hx-swap="none">
<!-- 기본 정보 -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">기본 정보</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
부서 코드 <span class="text-red-500">*</span>
</label>
<input type="text" name="code" required maxlength="50"
placeholder="예: DEV, SALES"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">최대 50자까지 입력 가능합니다.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
부서명 <span class="text-red-500">*</span>
</label>
<input type="text" name="name" required maxlength="100"
placeholder="예: 개발팀, 영업팀"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">최대 100자까지 입력 가능합니다.</p>
</div>
</div>
</div>
<!-- 부서 설정 -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">부서 설정</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">상위 부서</label>
<select name="parent_id" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">없음 (최상위 부서)</option>
@foreach($departments as $dept)
<option value="{{ $dept->id }}">{{ $dept->name }} ({{ $dept->code }})</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">정렬 순서</label>
<input type="number" name="sort_order" value="0" min="0"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">숫자가 작을수록 먼저 표시됩니다.</p>
</div>
</div>
</div>
<!-- 설명 상태 -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">추가 정보</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">설명</label>
<textarea name="description" rows="3" maxlength="500"
placeholder="부서에 대한 설명을 입력하세요"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
<p class="text-xs text-gray-500 mt-1">최대 500자까지 입력 가능합니다.</p>
</div>
<div class="flex items-center">
<input type="checkbox" name="is_active" value="1" checked
class="h-4 w-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500">
<label class="ml-2 text-sm text-gray-700">활성 상태</label>
</div>
</div>
</div>
<!-- 버튼 영역 -->
<div class="flex justify-end gap-3">
<a href="{{ route('departments.index') }}"
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
취소
</a>
<button type="submit"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
생성
</button>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script>
// HTMX 응답 처리
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.target.id === 'departmentForm') {
const response = JSON.parse(event.detail.xhr.response);
if (response.success) {
alert(response.message);
window.location.href = response.redirect;
} else {
alert('오류: ' + (response.message || '부서 생성에 실패했습니다.'));
}
}
});
// 에러 처리
document.body.addEventListener('htmx:responseError', function(event) {
if (event.detail.xhr.status === 422) {
const errors = JSON.parse(event.detail.xhr.response).errors;
let errorMsg = '입력 오류:\n';
for (let field in errors) {
errorMsg += '- ' + errors[field].join('\n') + '\n';
}
alert(errorMsg);
} else {
alert('서버 오류가 발생했습니다.');
}
});
</script>
@endpush

View File

@@ -0,0 +1,166 @@
@extends('layouts.app')
@section('title', '부서 수정')
@section('content')
<div class="container mx-auto max-w-4xl">
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">🏢 부서 수정</h1>
<a href="{{ route('departments.index') }}" class="text-gray-600 hover:text-gray-800">
목록으로
</a>
</div>
<!-- 영역 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<form id="departmentForm"
hx-post="/api/admin/departments/{{ $department->id }}"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
hx-swap="none">
<input type="hidden" name="_method" value="PUT">
<!-- 기본 정보 -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">기본 정보</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
부서 코드 <span class="text-red-500">*</span>
</label>
<input type="text" name="code" required maxlength="50"
value="{{ old('code', $department->code) }}"
placeholder="예: DEV, SALES"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">최대 50자까지 입력 가능합니다.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
부서명 <span class="text-red-500">*</span>
</label>
<input type="text" name="name" required maxlength="100"
value="{{ old('name', $department->name) }}"
placeholder="예: 개발팀, 영업팀"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">최대 100자까지 입력 가능합니다.</p>
</div>
</div>
</div>
<!-- 부서 설정 -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">부서 설정</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">상위 부서</label>
<select name="parent_id" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">없음 (최상위 부서)</option>
@foreach($departments as $dept)
@if($dept->id !== $department->id)
<option value="{{ $dept->id }}" {{ old('parent_id', $department->parent_id) == $dept->id ? 'selected' : '' }}>
{{ $dept->name }} ({{ $dept->code }})
</option>
@endif
@endforeach
</select>
<p class="text-xs text-gray-500 mt-1">자기 자신은 선택할 없습니다.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">정렬 순서</label>
<input type="number" name="sort_order" min="0"
value="{{ old('sort_order', $department->sort_order) }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">숫자가 작을수록 먼저 표시됩니다.</p>
</div>
</div>
</div>
<!-- 설명 상태 -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">추가 정보</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">설명</label>
<textarea name="description" rows="3" maxlength="500"
placeholder="부서에 대한 설명을 입력하세요"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">{{ old('description', $department->description) }}</textarea>
<p class="text-xs text-gray-500 mt-1">최대 500자까지 입력 가능합니다.</p>
</div>
<div class="flex items-center">
<input type="checkbox" name="is_active" value="1" {{ old('is_active', $department->is_active) ? 'checked' : '' }}
class="h-4 w-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500">
<label class="ml-2 text-sm text-gray-700">활성 상태</label>
</div>
</div>
</div>
<!-- 부서 정보 -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">부서 정보</h2>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-600">생성일:</span>
<span class="font-medium">{{ $department->created_at->format('Y-m-d H:i') }}</span>
</div>
<div>
<span class="text-gray-600">수정일:</span>
<span class="font-medium">{{ $department->updated_at->format('Y-m-d H:i') }}</span>
</div>
<div>
<span class="text-gray-600">하위 부서 :</span>
<span class="font-medium">{{ $department->children->count() }}</span>
</div>
<div>
<span class="text-gray-600">부서 ID:</span>
<span class="font-medium">#{{ $department->id }}</span>
</div>
</div>
</div>
<!-- 버튼 영역 -->
<div class="flex justify-end gap-3">
<a href="{{ route('departments.index') }}"
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
취소
</a>
<button type="submit"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
수정
</button>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script>
// HTMX 응답 처리
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.target.id === 'departmentForm') {
const response = JSON.parse(event.detail.xhr.response);
if (response.success) {
alert(response.message);
window.location.href = response.redirect;
} else {
alert('오류: ' + (response.message || '부서 수정에 실패했습니다.'));
}
}
});
// 에러 처리
document.body.addEventListener('htmx:responseError', function(event) {
if (event.detail.xhr.status === 422) {
const errors = JSON.parse(event.detail.xhr.response).errors;
let errorMsg = '입력 오류:\n';
for (let field in errors) {
errorMsg += '- ' + errors[field].join('\n') + '\n';
}
alert(errorMsg);
} else {
alert('서버 오류가 발생했습니다.');
}
});
</script>
@endpush

View File

@@ -0,0 +1,92 @@
@extends('layouts.app')
@section('title', '부서 관리')
@section('content')
<!-- Tenant Selector -->
@include('partials.tenant-selector')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mt-6 mb-6">
<h1 class="text-2xl font-bold text-gray-800">🏢 부서 관리</h1>
<a href="{{ route('departments.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition">
+ 부서
</a>
</div>
<!-- 필터 영역 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<form id="filterForm" class="flex gap-4">
<!-- 검색 -->
<div class="flex-1">
<input type="text"
name="search"
placeholder="부서명, 코드, 설명으로 검색..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 활성 상태 필터 -->
<div class="w-48">
<select name="is_active" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체 상태</option>
<option value="1">활성</option>
<option value="0">비활성</option>
</select>
</div>
<!-- 검색 버튼 -->
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition">
검색
</button>
</form>
</div>
<!-- 테이블 영역 (HTMX로 로드) -->
<div id="department-table"
hx-get="/api/admin/departments"
hx-trigger="load, filterSubmit from:body"
hx-include="#filterForm"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="bg-white rounded-lg shadow-sm overflow-hidden">
<!-- 로딩 스피너 -->
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</div>
@endsection
@push('scripts')
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script>
// 폼 제출 시 HTMX 이벤트 트리거
document.getElementById('filterForm').addEventListener('submit', function(e) {
e.preventDefault();
htmx.trigger('#department-table', 'filterSubmit');
});
// HTMX 응답 처리
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'department-table') {
const response = JSON.parse(event.detail.xhr.response);
if (response.html) {
event.detail.target.innerHTML = response.html;
}
}
});
// 삭제 확인
window.confirmDelete = function(id, name) {
if (confirm(`"${name}" 부서를 삭제하시겠습니까?\n\n하위 부서가 있으면 삭제할 수 없습니다.`)) {
htmx.ajax('DELETE', `/api/admin/departments/${id}`, {
target: '#department-table',
swap: 'none',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
}).then(() => {
htmx.trigger('#department-table', 'filterSubmit');
});
}
};
</script>
@endpush

View File

@@ -0,0 +1,65 @@
<table class="w-full">
<thead class="bg-gray-50 border-b">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">부서 코드</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">부서명</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">상위 부서</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">상태</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">정렬순서</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">작업</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($departments as $department)
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ $department->code }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ $department->name }}</div>
@if($department->description)
<div class="text-sm text-gray-500">{{ Str::limit($department->description, 50) }}</div>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $department->parent?->name ?? '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if($department->is_active)
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
활성
</span>
@else
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
비활성
</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $department->sort_order }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<a href="{{ route('departments.edit', $department->id) }}" class="text-blue-600 hover:text-blue-900 mr-3">
수정
</a>
<button onclick="confirmDelete({{ $department->id }}, '{{ $department->name }}')" class="text-red-600 hover:text-red-900">
삭제
</button>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-6 py-4 text-center text-gray-500">
부서가 없습니다.
</td>
</tr>
@endforelse
</tbody>
</table>
<!-- 페이지네이션 -->
@include('partials.pagination', [
'paginator' => $departments,
'target' => '#department-table',
'includeForm' => '#filterForm'
])

View File

@@ -55,8 +55,8 @@ class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-
<!-- 부서 관리 -->
<li>
<a href="#"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100">
<a href="{{ route('departments.index') }}"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100 {{ request()->routeIs('departments.*') ? 'bg-primary text-white hover:bg-primary' : '' }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>

View File

@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Api\Admin\DepartmentController;
use App\Http\Controllers\Api\Admin\RoleController;
use App\Http\Controllers\Api\Admin\TenantController;
use Illuminate\Support\Facades\Route;
@@ -40,4 +41,13 @@
Route::put('/{id}', [RoleController::class, 'update'])->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');
});
});

View File

@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\DepartmentController;
use App\Http\Controllers\RoleController;
use App\Http\Controllers\TenantController;
use Illuminate\Support\Facades\Route;
@@ -42,6 +43,13 @@
Route::get('/{id}/edit', [RoleController::class, 'edit'])->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');