Files
sam-manage/resources/views/sales/dashboard/partials/manager-dropdown.blade.php
김보곤 4662bf225b fix:CSS selector 오류 수정 - dataset 방식으로 변경
숫자 ID 값은 CSS selector에서 따옴표가 필요하지만
x-data 내 따옴표 이스케이프 문제로 dataset 방식으로 변경
- querySelectorAll + dataset.prospectId로 행 검색
- 따옴표 이슈 완전히 회피

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 09:33:35 +09:00

261 lines
13 KiB
PHP

{{-- 매니저 검색 컴포넌트 (테넌트 또는 가망고객용) --}}
@once
<style>[x-cloak] { display: none !important; }</style>
@endonce
@php
// 테넌트 또는 가망고객에 따라 다르게 처리
$isProspect = isset($prospect);
$entityId = $isProspect ? $prospect->id : $tenant->id;
if ($isProspect) {
$management = $prospectManagement ?? \App\Models\Sales\SalesTenantManagement::findOrCreateByProspect($prospect->id);
} else {
$management = $managements[$tenant->id] ?? null;
}
$assignedManager = $management?->manager;
$isSelf = !$assignedManager || $assignedManager->id === auth()->id();
$managerName = $assignedManager?->name ?? '본인';
$currentManagerJson = json_encode($assignedManager ? ['id' => $assignedManager->id, 'name' => $assignedManager->name, 'email' => $assignedManager->email ?? '', 'is_self' => $isSelf] : null);
@endphp
<div x-data="{
entityId: {{ $entityId }},
isProspect: {{ $isProspect ? 'true' : 'false' }},
isOpen: false,
searchQuery: '',
searchResults: [],
isLoading: false,
currentManager: {{ $currentManagerJson }},
searchTimeout: null,
allManagers: [],
hasLoadedAll: false,
toggle() {
this.isOpen = !this.isOpen;
if (this.isOpen) {
this.$nextTick(() => {
this.$refs.searchInput?.focus();
});
this.searchQuery = '';
// 처음 열릴 때 전체 목록 로드
if (!this.hasLoadedAll) {
this.loadAllManagers();
} else {
this.searchResults = this.allManagers;
}
}
},
close() {
this.isOpen = false;
this.searchQuery = '';
},
loadAllManagers() {
this.isLoading = true;
fetch('/sales/managers/search?q=', {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
},
})
.then(response => response.json())
.then(result => {
this.isLoading = false;
if (result.success) {
this.allManagers = result.managers;
this.searchResults = result.managers;
this.hasLoadedAll = true;
}
})
.catch(error => {
this.isLoading = false;
console.error('매니저 목록 로드 실패:', error);
});
},
search() {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.performSearch();
}, 200);
},
performSearch() {
if (this.searchQuery.length < 1) {
// 검색어 없으면 전체 목록 표시
this.searchResults = this.allManagers;
return;
}
// 로컬에서 필터링 (이미 로드된 목록에서)
const query = this.searchQuery.toLowerCase();
this.searchResults = this.allManagers.filter(m =>
m.name.toLowerCase().includes(query) ||
(m.email && m.email.toLowerCase().includes(query))
);
},
selectManager(managerId, managerName, managerEmail) {
const endpoint = this.isProspect ? '/sales/prospects/' : '/sales/tenants/';
fetch(endpoint + this.entityId + '/assign-manager', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
},
body: JSON.stringify({ manager_id: managerId }),
})
.then(response => response.json())
.then(result => {
if (result.success) {
this.currentManager = {
id: result.manager.id,
name: result.manager.name,
email: managerEmail || '',
is_self: managerId === 0 || result.manager.id === {{ auth()->id() }},
};
// 동적 UI 업데이트: HTMX로 해당 행만 새로고침
if (this.isProspect) {
var entityId = this.entityId;
var rows = document.querySelectorAll('.prospect-row');
var row = null;
for (var i = 0; i < rows.length; i++) {
if (rows[i].dataset.prospectId == entityId) {
row = rows[i];
break;
}
}
if (row) {
htmx.ajax('GET', '/sales/salesmanagement/dashboard/prospect/' + entityId + '/row', {
target: row,
swap: 'outerHTML'
});
}
}
} else {
alert(result.message || '매니저 지정에 실패했습니다.');
}
})
.catch(error => {
console.error('매니저 지정 실패:', error);
alert('매니저 지정에 실패했습니다.');
});
this.close();
}
}" class="relative">
{{-- 드롭다운 트리거 --}}
<button
x-on:click="toggle()"
x-on:click.outside="close()"
type="button"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium transition-colors"
:class="isOpen ? 'bg-blue-100 text-blue-800 border border-blue-300' : 'bg-blue-50 text-blue-700 border border-blue-200 hover:bg-blue-100'">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>관리: <span x-text="currentManager?.name || '본인'" class="font-semibold">{{ $managerName }}</span></span>
<svg class="w-3 h-3 transition-transform" :class="isOpen && 'rotate-180'" 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>
{{-- 드롭다운 메뉴 --}}
<div
x-cloak
x-show="isOpen"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="absolute z-50 mt-1 w-72 bg-white rounded-lg shadow-lg border border-gray-200"
x-on:click.stop
>
{{-- 검색 입력 --}}
<div class="p-3 border-b border-gray-100">
<div class="relative">
<input
x-ref="searchInput"
type="text"
x-model="searchQuery"
x-on:input="search()"
placeholder="매니저 이름 또는 이메일 검색..."
class="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<svg class="absolute left-3 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>
</div>
</div>
<div class="max-h-64 overflow-y-auto">
{{-- 본인 옵션 (항상 표시) --}}
<button
type="button"
x-on:click="selectManager(0, '{{ auth()->user()->name }}', '')"
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-gray-50 transition-colors border-b border-gray-100"
:class="(currentManager?.is_self || !currentManager) && 'bg-blue-50'">
<div class="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div class="flex-1">
<div class="font-medium text-gray-900">본인</div>
<div class="text-xs text-gray-500">{{ auth()->user()->name }} ({{ auth()->user()->email }})</div>
</div>
<svg x-show="currentManager?.is_self || !currentManager" class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
{{-- 로딩 표시 --}}
<div x-show="isLoading" class="px-4 py-6 text-center">
<svg class="animate-spin h-5 w-5 text-blue-600 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<div class="text-xs text-gray-500 mt-2">검색 ...</div>
</div>
{{-- 검색 결과 (매니저 목록) --}}
<template x-if="!isLoading && searchResults.length > 0">
<div>
<div class="px-3 py-1.5 text-xs text-gray-500 bg-gray-50">
<span x-text="searchQuery.length > 0 ? '검색 결과' : '상담매니저 목록'"></span>
<span class="text-gray-400" x-text="'(' + searchResults.length + '명)'"></span>
</div>
<template x-for="manager in searchResults" :key="manager.id">
<button
type="button"
x-on:click="selectManager(manager.id, manager.name, manager.email)"
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-gray-50 transition-colors"
:class="currentManager?.id === manager.id && !currentManager?.is_self && 'bg-blue-50'">
<div class="flex-shrink-0 w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<span class="text-sm font-medium text-green-700" x-text="manager.name.charAt(0)"></span>
</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900 truncate" x-text="manager.name"></div>
<div class="text-xs text-gray-500 truncate" x-text="manager.email"></div>
</div>
<svg x-show="currentManager?.id === manager.id && !currentManager?.is_self" class="w-4 h-4 text-blue-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
</template>
</div>
</template>
{{-- 검색 결과 없음 --}}
<template x-if="!isLoading && searchResults.length === 0 && hasLoadedAll">
<div class="px-4 py-6 text-center">
<svg class="w-10 h-10 text-blue-200 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<div class="text-sm font-medium text-gray-700" x-text="searchQuery.length > 0 ? '검색 결과가 없습니다' : '상담매니저를 검색하세요'"></div>
<div class="text-xs text-gray-400 mt-1">이름 또는 이메일로 검색할 있습니다.</div>
</div>
</template>
</div>
</div>
</div>