feat: [approvals] 결재선 에디터 2패널 UI/UX 개선
- 좌측 패널: 부서별 인원 목록 (접이식 그룹핑, 검색 필터) - 우측 패널: 결재선 (SortableJS 드래그앤드롭 순서 변경) - 부서별 전체 인원 API 추가 (GET /api/admin/tenant-users/list) - 결재/합의/참조 유형별 요약 바 추가 - position_key → positions 테이블 조인으로 직위 라벨 표시
This commit is contained in:
@@ -50,4 +50,80 @@ public function search(Request $request): JsonResponse
|
||||
'data' => $users,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트 전체 인원 목록 (부서별 그룹핑, 결재선 에디터용)
|
||||
*/
|
||||
public function list(): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$users = DB::table('users')
|
||||
->join('user_tenants', function ($join) use ($tenantId) {
|
||||
$join->on('users.id', '=', 'user_tenants.user_id')
|
||||
->where('user_tenants.tenant_id', $tenantId)
|
||||
->where('user_tenants.is_active', true);
|
||||
})
|
||||
->leftJoin('tenant_user_profiles as tp', function ($join) use ($tenantId) {
|
||||
$join->on('tp.user_id', '=', 'users.id')
|
||||
->where('tp.tenant_id', $tenantId);
|
||||
})
|
||||
->leftJoin('departments', 'departments.id', '=', 'tp.department_id')
|
||||
->leftJoin('positions as pos_rank', function ($join) use ($tenantId) {
|
||||
$join->on('pos_rank.tenant_id', '=', DB::raw($tenantId))
|
||||
->where('pos_rank.type', 'rank')
|
||||
->whereRaw('pos_rank.`key` COLLATE utf8mb4_unicode_ci = tp.position_key COLLATE utf8mb4_unicode_ci');
|
||||
})
|
||||
->leftJoin('positions as pos_title', function ($join) use ($tenantId) {
|
||||
$join->on('pos_title.tenant_id', '=', DB::raw($tenantId))
|
||||
->where('pos_title.type', 'title')
|
||||
->whereRaw('pos_title.`key` COLLATE utf8mb4_unicode_ci = tp.job_title_key COLLATE utf8mb4_unicode_ci');
|
||||
})
|
||||
->whereNull('users.deleted_at')
|
||||
->orderBy('departments.name')
|
||||
->orderBy('users.name')
|
||||
->select([
|
||||
'users.id',
|
||||
'users.name',
|
||||
'tp.department_id',
|
||||
'departments.name as department_name',
|
||||
DB::raw('COALESCE(pos_rank.name, tp.position_key, \'\') as position'),
|
||||
DB::raw('COALESCE(pos_title.name, tp.job_title_key, \'\') as job_title'),
|
||||
])
|
||||
->get();
|
||||
|
||||
$grouped = $users->groupBy(fn ($u) => $u->department_id ?? 'none');
|
||||
|
||||
$data = [];
|
||||
foreach ($grouped as $deptId => $deptUsers) {
|
||||
$first = $deptUsers->first();
|
||||
$data[] = [
|
||||
'department_id' => $deptId === 'none' ? null : (int) $deptId,
|
||||
'department_name' => $deptId === 'none' ? '미배정' : $first->department_name,
|
||||
'users' => $deptUsers->map(fn ($u) => [
|
||||
'id' => $u->id,
|
||||
'name' => $u->name,
|
||||
'position' => $u->position,
|
||||
'job_title' => $u->job_title,
|
||||
])->values()->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
// 미배정 그룹을 마지막으로
|
||||
usort($data, function ($a, $b) {
|
||||
if ($a['department_id'] === null) {
|
||||
return 1;
|
||||
}
|
||||
if ($b['department_id'] === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return strcmp($a['department_name'], $b['department_name']);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-
|
||||
</div>
|
||||
|
||||
{{-- 우측: 결재선 --}}
|
||||
<div class="shrink-0" style="width: 100%; max-width: 380px;">
|
||||
<div class="shrink-0" style="width: 100%; max-width: 680px;">
|
||||
@include('approvals.partials._approval-line-editor', [
|
||||
'lines' => $lines,
|
||||
'initialSteps' => [],
|
||||
|
||||
@@ -74,7 +74,7 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-
|
||||
</div>
|
||||
|
||||
{{-- 우측: 결재선 --}}
|
||||
<div class="shrink-0" style="width: 100%; max-width: 380px;">
|
||||
<div class="shrink-0" style="width: 100%; max-width: 680px;">
|
||||
@php
|
||||
$initialSteps = $approval->steps->map(fn($s) => [
|
||||
'user_id' => $s->approver_id,
|
||||
|
||||
@@ -1,93 +1,190 @@
|
||||
{{-- 결재선 편집 컴포넌트 (Alpine.js) --}}
|
||||
<div id="approval-line-editor" x-data="approvalLineEditor()" class="bg-white rounded-lg shadow-sm p-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">결재선</h3>
|
||||
{{-- 결재선 편집 컴포넌트 (2패널 구조 — Alpine.js + SortableJS) --}}
|
||||
<div id="approval-line-editor"
|
||||
x-data="approvalLineEditor()"
|
||||
x-init="init()"
|
||||
class="bg-white rounded-lg shadow-sm">
|
||||
|
||||
{{-- 결재선 템플릿 선택 --}}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">결재선 템플릿</label>
|
||||
<select x-model="selectedLineId" @change="loadLine()" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">직접 설정</option>
|
||||
<template x-for="line in lines" :key="line.id">
|
||||
<option :value="line.id" x-text="line.name + ' (' + (line.steps?.length || 0) + '단계)'"></option>
|
||||
</template>
|
||||
</select>
|
||||
<div class="p-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800">결재선</h3>
|
||||
</div>
|
||||
|
||||
{{-- 결재자 추가 --}}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">결재자 검색</label>
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
x-model="searchQuery"
|
||||
@input.debounce.300ms="searchUsers()"
|
||||
placeholder="이름 또는 부서로 검색..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
{{-- 2패널 컨테이너 --}}
|
||||
<div class="flex" style="min-height: 400px;">
|
||||
|
||||
{{-- 검색 결과 드롭다운 --}}
|
||||
<div x-show="searchResults.length > 0" x-cloak
|
||||
@click.away="searchResults = []"
|
||||
class="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||
<template x-for="user in searchResults" :key="user.id">
|
||||
<button type="button"
|
||||
@click="addStep(user)"
|
||||
class="w-full text-left px-3 py-2 hover:bg-blue-50 text-sm flex justify-between items-center">
|
||||
<span>
|
||||
<span x-text="user.name" class="font-medium"></span>
|
||||
<span x-text="user.department || ''" class="text-gray-500 ml-1"></span>
|
||||
</span>
|
||||
<span x-text="user.position || ''" class="text-xs text-gray-400"></span>
|
||||
</button>
|
||||
{{-- 좌측: 인원 목록 --}}
|
||||
<div class="border-r border-gray-200" style="flex: 0 0 280px; max-width: 280px;">
|
||||
{{-- 검색 --}}
|
||||
<div class="p-3 border-b border-gray-100">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-2.5 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input type="text"
|
||||
x-model="searchQuery"
|
||||
placeholder="이름/부서 검색..."
|
||||
class="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 부서별 인원 목록 (스크롤) --}}
|
||||
<div class="overflow-y-auto" style="max-height: 420px;">
|
||||
<template x-if="loading">
|
||||
<div class="flex items-center justify-center py-8 text-gray-400 text-sm">
|
||||
<svg class="animate-spin w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
로딩 중...
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && filteredDepartments.length === 0">
|
||||
<div class="py-8 text-center text-gray-400 text-xs">
|
||||
<template x-if="searchQuery">
|
||||
<span>검색 결과가 없습니다.</span>
|
||||
</template>
|
||||
<template x-if="!searchQuery">
|
||||
<span>인원 정보가 없습니다.</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-for="dept in filteredDepartments" :key="dept.department_id ?? 'none'">
|
||||
<div class="border-b border-gray-50">
|
||||
{{-- 부서 헤더 --}}
|
||||
<button type="button"
|
||||
@click="toggleDept(dept.department_id)"
|
||||
class="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-gray-600 bg-gray-50 hover:bg-gray-100 transition">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg :class="isDeptExpanded(dept.department_id) ? 'rotate-90' : ''"
|
||||
class="w-3 h-3 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<span x-text="dept.department_name"></span>
|
||||
</span>
|
||||
<span class="text-gray-400" x-text="dept.users.length + '명'"></span>
|
||||
</button>
|
||||
|
||||
{{-- 부서 인원 --}}
|
||||
<div x-show="isDeptExpanded(dept.department_id)"
|
||||
x-collapse>
|
||||
<template x-for="user in dept.users" :key="user.id">
|
||||
<div class="flex items-center justify-between px-3 py-1.5 hover:bg-blue-50 transition"
|
||||
:class="isAdded(user.id) ? 'opacity-50' : ''">
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-xs font-medium text-gray-800" x-text="user.name"></span>
|
||||
<span class="text-xs text-gray-400 ml-1" x-text="user.position || user.job_title || ''"></span>
|
||||
</div>
|
||||
<button type="button"
|
||||
@click="addStep(user, dept.department_name)"
|
||||
:disabled="isAdded(user.id)"
|
||||
class="shrink-0 ml-2 text-xs px-2 py-0.5 rounded transition"
|
||||
:class="isAdded(user.id)
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-50 text-blue-600 hover:bg-blue-100'">
|
||||
<span x-text="isAdded(user.id) ? '추가됨' : '+ 추가'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 우측: 결재선 --}}
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
{{-- 결재선 템플릿 --}}
|
||||
<div class="p-3 border-b border-gray-100">
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">결재선 템플릿</label>
|
||||
<select x-model="selectedLineId"
|
||||
@change="loadLine()"
|
||||
class="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">직접 설정</option>
|
||||
<template x-for="line in lines" :key="line.id">
|
||||
<option :value="line.id" x-text="line.name + ' (' + (line.steps?.length || 0) + '단계)'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- 결재자 목록 (드래그 가능) --}}
|
||||
<div class="flex-1 overflow-y-auto p-3" style="max-height: 380px;">
|
||||
<div x-ref="sortableList" class="space-y-2">
|
||||
<template x-for="(step, index) in steps" :key="step._key">
|
||||
<div class="flex items-center gap-2 p-2 bg-gray-50 rounded-lg border border-gray-200 group"
|
||||
:data-index="index">
|
||||
{{-- 드래그 핸들 --}}
|
||||
<span class="drag-handle shrink-0 cursor-grab active:cursor-grabbing text-gray-300 hover:text-gray-500 transition">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="9" cy="5" r="1.5"/><circle cx="15" cy="5" r="1.5"/>
|
||||
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
|
||||
<circle cx="9" cy="19" r="1.5"/><circle cx="15" cy="19" r="1.5"/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
{{-- 순번 --}}
|
||||
<span class="shrink-0 flex items-center justify-center bg-blue-100 text-blue-700 rounded-full font-medium text-xs"
|
||||
style="width: 22px; height: 22px;"
|
||||
x-text="index + 1"></span>
|
||||
|
||||
{{-- 이름/부서/직위 --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="font-medium text-xs text-gray-800" x-text="step.user_name"></span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 truncate"
|
||||
x-text="[step.department, step.position].filter(Boolean).join(' / ')"></div>
|
||||
</div>
|
||||
|
||||
{{-- 유형 선택 --}}
|
||||
<select x-model="step.step_type"
|
||||
class="shrink-0 px-1.5 py-1 border border-gray-300 rounded text-xs focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
style="width: 60px;">
|
||||
<option value="approval">결재</option>
|
||||
<option value="agreement">합의</option>
|
||||
<option value="reference">참조</option>
|
||||
</select>
|
||||
|
||||
{{-- 삭제 --}}
|
||||
<button type="button"
|
||||
@click="removeStep(index)"
|
||||
class="shrink-0 p-1 text-gray-300 hover:text-red-500 transition opacity-0 group-hover:opacity-100">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- 빈 상태 --}}
|
||||
<div x-show="steps.length === 0" class="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<span class="text-xs">좌측에서 결재자를 추가하세요</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 결재 단계 목록 --}}
|
||||
<div class="space-y-2">
|
||||
<template x-for="(step, index) in steps" :key="index">
|
||||
<div class="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
<span class="shrink-0 flex items-center justify-center bg-blue-100 text-blue-700 rounded-full font-medium text-xs" style="width: 24px; height: 24px;"
|
||||
x-text="index + 1"></span>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="font-medium text-sm" x-text="step.user_name"></span>
|
||||
<span class="text-xs text-gray-500" x-text="step.department || ''"></span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400" x-text="step.position || ''"></span>
|
||||
</div>
|
||||
|
||||
<select x-model="step.step_type" class="shrink-0 px-2 py-1 border border-gray-300 rounded text-xs focus:outline-none">
|
||||
<option value="approval">결재</option>
|
||||
<option value="agreement">합의</option>
|
||||
<option value="reference">참조</option>
|
||||
</select>
|
||||
|
||||
{{-- 순서 이동 --}}
|
||||
<button type="button" @click="moveStep(index, -1)" :disabled="index === 0"
|
||||
class="shrink-0 p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/></svg>
|
||||
</button>
|
||||
<button type="button" @click="moveStep(index, 1)" :disabled="index === steps.length - 1"
|
||||
class="shrink-0 p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||
</button>
|
||||
|
||||
{{-- 삭제 --}}
|
||||
<button type="button" @click="removeStep(index)"
|
||||
class="shrink-0 p-1 text-red-400 hover:text-red-600">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div x-show="steps.length === 0" class="text-center py-8 text-gray-400 text-sm">
|
||||
결재선이 비어 있습니다. 결재자를 추가해주세요.
|
||||
{{-- 하단: 요약 바 --}}
|
||||
<div class="px-4 py-2 border-t border-gray-200 bg-gray-50 rounded-b-lg flex items-center justify-between text-xs text-gray-500">
|
||||
<div>
|
||||
<span>결재: <strong class="text-gray-700" x-text="countByType('approval')"></strong>명</span>
|
||||
<span class="mx-1">|</span>
|
||||
<span>합의: <strong class="text-gray-700" x-text="countByType('agreement')"></strong>명</span>
|
||||
<span class="mx-1">|</span>
|
||||
<span>참조: <strong class="text-gray-700" x-text="countByType('reference')"></strong>명</span>
|
||||
</div>
|
||||
<div>
|
||||
합계 <strong class="text-gray-700" x-text="steps.length"></strong>명
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- hidden inputs --}}
|
||||
<template x-for="(step, index) in steps" :key="'hidden-' + index">
|
||||
<template x-for="(step, index) in steps" :key="'hidden-' + step._key">
|
||||
<div>
|
||||
<input type="hidden" :name="'steps[' + index + '][user_id]'" :value="step.user_id">
|
||||
<input type="hidden" :name="'steps[' + index + '][step_type]'" :value="step.step_type">
|
||||
@@ -97,76 +194,136 @@ class="shrink-0 p-1 text-red-400 hover:text-red-600">
|
||||
|
||||
<script>
|
||||
function approvalLineEditor() {
|
||||
let keyCounter = 0;
|
||||
|
||||
return {
|
||||
departments: [],
|
||||
steps: (@json($initialSteps ?? [])).map(s => ({ ...s, _key: ++keyCounter })),
|
||||
lines: @json($lines ?? []),
|
||||
steps: @json($initialSteps ?? []),
|
||||
selectedLineId: '{{ $selectedLineId ?? '' }}',
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
expandedDepts: {},
|
||||
loading: true,
|
||||
sortableInstance: null,
|
||||
|
||||
async init() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/tenant-users/list');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.departments = data.data;
|
||||
// 기본적으로 모든 부서 펼침
|
||||
this.departments.forEach(d => {
|
||||
this.expandedDepts[d.department_id ?? 'none'] = true;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('인원 목록 로딩 실패:', e);
|
||||
}
|
||||
this.loading = false;
|
||||
|
||||
this.$nextTick(() => this.initSortable());
|
||||
},
|
||||
|
||||
get filteredDepartments() {
|
||||
if (!this.searchQuery.trim()) return this.departments;
|
||||
const q = this.searchQuery.trim().toLowerCase();
|
||||
return this.departments
|
||||
.map(dept => {
|
||||
const deptMatch = dept.department_name.toLowerCase().includes(q);
|
||||
const matchedUsers = dept.users.filter(u =>
|
||||
u.name.toLowerCase().includes(q) ||
|
||||
(u.position && u.position.toLowerCase().includes(q)) ||
|
||||
(u.job_title && u.job_title.toLowerCase().includes(q))
|
||||
);
|
||||
if (deptMatch) return dept;
|
||||
if (matchedUsers.length > 0) return { ...dept, users: matchedUsers };
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
},
|
||||
|
||||
toggleDept(deptId) {
|
||||
const key = deptId ?? 'none';
|
||||
this.expandedDepts[key] = !this.expandedDepts[key];
|
||||
},
|
||||
|
||||
isDeptExpanded(deptId) {
|
||||
const key = deptId ?? 'none';
|
||||
return this.expandedDepts[key] ?? false;
|
||||
},
|
||||
|
||||
isAdded(userId) {
|
||||
return this.steps.some(s => s.user_id === userId);
|
||||
},
|
||||
|
||||
addStep(user, deptName) {
|
||||
if (this.isAdded(user.id)) {
|
||||
if (typeof showToast === 'function') showToast('이미 추가된 결재자입니다.', 'warning');
|
||||
return;
|
||||
}
|
||||
this.steps.push({
|
||||
_key: ++keyCounter,
|
||||
user_id: user.id,
|
||||
user_name: user.name,
|
||||
department: deptName || '',
|
||||
position: user.position || user.job_title || '',
|
||||
step_type: 'approval',
|
||||
});
|
||||
this.$nextTick(() => this.initSortable());
|
||||
},
|
||||
|
||||
removeStep(index) {
|
||||
this.steps.splice(index, 1);
|
||||
this.$nextTick(() => this.initSortable());
|
||||
},
|
||||
|
||||
loadLine() {
|
||||
if (!this.selectedLineId) return;
|
||||
const line = this.lines.find(l => l.id == this.selectedLineId);
|
||||
if (line && line.steps) {
|
||||
this.steps = line.steps.map(s => ({
|
||||
_key: ++keyCounter,
|
||||
user_id: s.user_id,
|
||||
user_name: s.user_name || '사용자 ' + s.user_id,
|
||||
department: s.department || '',
|
||||
position: s.position || '',
|
||||
step_type: s.step_type || 'approval',
|
||||
}));
|
||||
this.$nextTick(() => this.initSortable());
|
||||
}
|
||||
},
|
||||
|
||||
async searchUsers() {
|
||||
if (this.searchQuery.length < 1) {
|
||||
this.searchResults = [];
|
||||
return;
|
||||
initSortable() {
|
||||
if (this.sortableInstance) {
|
||||
this.sortableInstance.destroy();
|
||||
this.sortableInstance = null;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/admin/tenant-users/search?q=${encodeURIComponent(this.searchQuery)}`);
|
||||
const data = await response.json();
|
||||
this.searchResults = (data.data || data).slice(0, 10);
|
||||
} catch (e) {
|
||||
this.searchResults = [];
|
||||
}
|
||||
},
|
||||
|
||||
addStep(user) {
|
||||
if (this.steps.find(s => s.user_id === user.id)) {
|
||||
showToast('이미 추가된 결재자입니다.', 'warning');
|
||||
return;
|
||||
}
|
||||
this.steps.push({
|
||||
user_id: user.id,
|
||||
user_name: user.name,
|
||||
department: user.department || '',
|
||||
position: user.position || '',
|
||||
step_type: 'approval',
|
||||
const el = this.$refs.sortableList;
|
||||
if (!el || typeof Sortable === 'undefined') return;
|
||||
|
||||
this.sortableInstance = Sortable.create(el, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'opacity-30',
|
||||
onEnd: (evt) => {
|
||||
const item = this.steps.splice(evt.oldIndex, 1)[0];
|
||||
this.steps.splice(evt.newIndex, 0, item);
|
||||
},
|
||||
});
|
||||
this.searchQuery = '';
|
||||
this.searchResults = [];
|
||||
},
|
||||
|
||||
removeStep(index) {
|
||||
this.steps.splice(index, 1);
|
||||
},
|
||||
|
||||
moveStep(index, direction) {
|
||||
const newIndex = index + direction;
|
||||
if (newIndex < 0 || newIndex >= this.steps.length) return;
|
||||
const temp = this.steps[index];
|
||||
this.steps[index] = this.steps[newIndex];
|
||||
this.steps[newIndex] = temp;
|
||||
this.steps = [...this.steps];
|
||||
countByType(type) {
|
||||
return this.steps.filter(s => s.step_type === type).length;
|
||||
},
|
||||
|
||||
getStepsData() {
|
||||
return this.steps.map((s, i) => ({
|
||||
return this.steps.map(s => ({
|
||||
user_id: s.user_id,
|
||||
step_type: s.step_type,
|
||||
}));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -891,6 +891,7 @@
|
||||
*/
|
||||
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/tenant-users')->name('api.admin.tenant-users.')->group(function () {
|
||||
Route::get('/search', [\App\Http\Controllers\Api\Admin\TenantUserApiController::class, 'search'])->name('search');
|
||||
Route::get('/list', [\App\Http\Controllers\Api\Admin\TenantUserApiController::class, 'list'])->name('list');
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user