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:
157
app/Http/Controllers/Api/Admin/DepartmentController.php
Normal file
157
app/Http/Controllers/Api/Admin/DepartmentController.php
Normal 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' => '부서가 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
48
app/Http/Controllers/DepartmentController.php
Normal file
48
app/Http/Controllers/DepartmentController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
72
app/Http/Requests/StoreDepartmentRequest.php
Normal file
72
app/Http/Requests/StoreDepartmentRequest.php
Normal 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 이상이어야 합니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
79
app/Http/Requests/UpdateDepartmentRequest.php
Normal file
79
app/Http/Requests/UpdateDepartmentRequest.php
Normal 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 이상이어야 합니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
191
app/Services/DepartmentService.php
Normal file
191
app/Services/DepartmentService.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
134
resources/views/departments/create.blade.php
Normal file
134
resources/views/departments/create.blade.php
Normal 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
|
||||
166
resources/views/departments/edit.blade.php
Normal file
166
resources/views/departments/edit.blade.php
Normal 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
|
||||
92
resources/views/departments/index.blade.php
Normal file
92
resources/views/departments/index.blade.php
Normal 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
|
||||
65
resources/views/departments/partials/table.blade.php
Normal file
65
resources/views/departments/partials/table.blade.php
Normal 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'
|
||||
])
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user