fix:영업 시나리오 모달 Alpine.js 오류 수정

- @push('scripts') 대신 인라인 x-data로 변경 (HTMX 호환)
- x-collapse 플러그인 의존성 제거, x-transition 사용
- $parent 참조 대신 window 이벤트(CustomEvent) 사용
- 체크리스트 토글, 진행률 업데이트, 단계 이동 정상화

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
pro
2026-01-29 08:14:07 +09:00
parent 8c089e54a7
commit ae9aa4e91f
2 changed files with 130 additions and 102 deletions

View File

@@ -1,12 +1,70 @@
{{-- 영업/매니저 시나리오 모달 --}}
<div x-data="scenarioModal()" x-show="isOpen" x-cloak
class="fixed inset-0 z-50 overflow-hidden"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
@php
$stepProgressJson = json_encode($progress['steps'] ?? []);
@endphp
<div x-data="{
isOpen: true,
currentStep: {{ $currentStep }},
totalProgress: {{ $progress['percentage'] ?? 0 }},
stepProgress: {{ $stepProgressJson }},
tenantId: {{ $tenant->id }},
scenarioType: '{{ $scenarioType }}',
close() {
this.isOpen = false;
window.dispatchEvent(new CustomEvent('scenario-modal-closed'));
},
selectStep(stepId) {
if (this.currentStep === stepId) return;
this.currentStep = stepId;
htmx.ajax('GET',
`/sales/scenarios/${this.tenantId}/${this.scenarioType}?step=${stepId}`,
{ target: '#scenario-step-content', swap: 'innerHTML' }
);
},
async toggleCheckpoint(stepId, checkpointId, checked) {
try {
const response = await fetch('/sales/scenarios/checklist/toggle', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
'Accept': 'application/json',
},
body: JSON.stringify({
tenant_id: this.tenantId,
scenario_type: this.scenarioType,
step_id: stepId,
checkpoint_id: checkpointId,
checked: checked,
}),
});
const result = await response.json();
if (result.success) {
this.totalProgress = result.progress.percentage;
this.stepProgress = result.progress.steps;
}
} catch (error) {
console.error('체크리스트 토글 실패:', error);
}
}
}"
x-show="isOpen"
x-cloak
@keydown.escape.window="close()"
@progress-updated.window="totalProgress = $event.detail.percentage; stepProgress = $event.detail.steps"
@step-changed.window="currentStep = $event.detail"
@scenario-modal-closed.window="close()"
class="fixed inset-0 z-50 overflow-hidden"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
{{-- 배경 오버레이 --}}
<div class="absolute inset-0 bg-gray-900/50 backdrop-blur-sm" @click="close()"></div>
@@ -47,10 +105,10 @@ class="fixed inset-0 z-50 overflow-hidden"
{{-- 전체 진행률 --}}
<div class="flex items-center gap-2 bg-white/20 rounded-full px-4 py-2">
<span class="text-sm font-medium text-white">진행률</span>
<span class="text-lg font-bold text-white" x-text="totalProgress + '%'">{{ $progress['percentage'] }}%</span>
<span class="text-lg font-bold text-white" x-text="totalProgress + '%'"></span>
</div>
{{-- 닫기 버튼 --}}
<button @click="close()" class="p-2 text-white/80 hover:text-white hover:bg-white/20 rounded-lg transition-colors">
<button type="button" @click="close()" class="p-2 text-white/80 hover:text-white hover:bg-white/20 rounded-lg transition-colors">
<svg class="w-6 h-6" 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>
@@ -66,9 +124,11 @@ class="fixed inset-0 z-50 overflow-hidden"
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-4">단계별 진행</h3>
<nav class="space-y-2">
@foreach($steps as $step)
<button
<button type="button"
@click="selectStep({{ $step['id'] }})"
:class="currentStep === {{ $step['id'] }} ? 'bg-white shadow-sm border-{{ $step['color'] }}-500 border-l-4' : 'hover:bg-white/50 border-l-4 border-transparent'"
:class="currentStep === {{ $step['id'] }}
? 'bg-white shadow-sm border-l-4 border-{{ $step['color'] }}-500'
: 'hover:bg-white/50 border-l-4 border-transparent'"
class="w-full flex items-center gap-3 px-3 py-3 rounded-r-lg text-left transition-all">
<div class="{{ $step['bg_class'] }} {{ $step['text_class'] }} p-2 rounded-lg">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -78,14 +138,12 @@ class="w-full flex items-center gap-3 px-3 py-3 rounded-r-lg text-left transitio
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-900 truncate">{{ $step['title'] }}</span>
<span class="text-xs text-gray-500" x-text="stepProgress[{{ $step['id'] }}]?.percentage + '%' || '0%'">
{{ $progress['steps'][$step['id']]['percentage'] ?? 0 }}%
</span>
<span class="text-xs text-gray-500"
x-text="(stepProgress[{{ $step['id'] }}]?.percentage ?? 0) + '%'"></span>
</div>
<div class="mt-1 h-1 bg-gray-200 rounded-full overflow-hidden">
<div class="h-full {{ str_replace('bg-', 'bg-', $step['bg_class']) }} transition-all duration-300"
:style="'width: ' + (stepProgress[{{ $step['id'] }}]?.percentage || 0) + '%'"
style="width: {{ $progress['steps'][$step['id']]['percentage'] ?? 0 }}%"></div>
<div class="h-full bg-{{ $step['color'] }}-500 transition-all duration-300"
:style="'width: ' + (stepProgress[{{ $step['id'] }}]?.percentage ?? 0) + '%'"></div>
</div>
</div>
</button>
@@ -110,76 +168,3 @@ class="w-full flex items-center gap-3 px-3 py-3 rounded-r-lg text-left transitio
</div>
</div>
</div>
@push('scripts')
<script>
function scenarioModal() {
return {
isOpen: true,
currentStep: {{ $currentStep }},
totalProgress: {{ $progress['percentage'] }},
stepProgress: @json($progress['steps']),
tenantId: {{ $tenant->id }},
scenarioType: '{{ $scenarioType }}',
close() {
this.isOpen = false;
// 부모에게 닫힘 이벤트 전달
window.dispatchEvent(new CustomEvent('scenario-modal-closed'));
},
selectStep(stepId) {
if (this.currentStep === stepId) return;
this.currentStep = stepId;
// HTMX로 단계 콘텐츠 로드
htmx.ajax('GET',
`/sales/scenarios/${this.tenantId}/${this.scenarioType}?step=${stepId}`,
{
target: '#scenario-step-content',
swap: 'innerHTML'
}
);
},
async toggleCheckpoint(stepId, checkpointId, checked) {
try {
const response = await fetch('/sales/scenarios/checklist/toggle', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: JSON.stringify({
tenant_id: this.tenantId,
scenario_type: this.scenarioType,
step_id: stepId,
checkpoint_id: checkpointId,
checked: checked,
}),
});
const result = await response.json();
if (result.success) {
// 진행률 업데이트
this.totalProgress = result.progress.percentage;
this.stepProgress = result.progress.steps;
}
} catch (error) {
console.error('체크리스트 토글 실패:', error);
}
},
init() {
// ESC 키로 모달 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.isOpen) {
this.close();
}
});
}
};
}
</script>
@endpush

View File

@@ -47,15 +47,45 @@
$checkKey = "{$step['id']}_{$checkpoint['id']}";
$isChecked = isset($checklist[$checkKey]);
@endphp
<div x-data="{ expanded: false, checked: {{ $isChecked ? 'true' : 'false' }} }"
<div x-data="{
expanded: false,
checked: {{ $isChecked ? 'true' : 'false' }},
async toggle() {
this.checked = !this.checked;
try {
const response = await fetch('/sales/scenarios/checklist/toggle', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
'Accept': 'application/json',
},
body: JSON.stringify({
tenant_id: {{ $tenant->id }},
scenario_type: '{{ $scenarioType }}',
step_id: {{ $step['id'] }},
checkpoint_id: '{{ $checkpoint['id'] }}',
checked: this.checked,
}),
});
const result = await response.json();
if (result.success) {
window.dispatchEvent(new CustomEvent('progress-updated', { detail: result.progress }));
}
} catch (error) {
console.error('체크리스트 토글 실패:', error);
this.checked = !this.checked;
}
}
}"
class="bg-white border rounded-xl overflow-hidden transition-all duration-200"
:class="checked ? 'border-green-300 bg-green-50/50' : 'border-gray-200 hover:border-gray-300'">
{{-- 체크포인트 헤더 --}}
<div class="flex items-center gap-4 p-4 cursor-pointer" @click="expanded = !expanded">
{{-- 체크박스 --}}
<button
@click.stop="checked = !checked; $parent.toggleCheckpoint({{ $step['id'] }}, '{{ $checkpoint['id'] }}', checked)"
<button type="button"
@click.stop="toggle()"
class="flex-shrink-0 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all"
:class="checked ? 'bg-green-500 border-green-500' : 'border-gray-300 hover:border-green-400'">
<svg x-show="checked" class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -80,7 +110,14 @@ class="flex-shrink-0 w-6 h-6 rounded-full border-2 flex items-center justify-cen
</div>
{{-- 확장 콘텐츠 --}}
<div x-show="expanded" x-collapse class="border-t border-gray-100">
<div x-show="expanded"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
class="border-t border-gray-100">
<div class="p-4 space-y-4">
{{-- 상세 설명 --}}
<div>
@@ -152,8 +189,11 @@ class="flex-shrink-0 w-6 h-6 rounded-full border-2 flex items-center justify-cen
{{-- 단계 이동 버튼 --}}
<div class="flex items-center justify-between pt-4 border-t border-gray-200">
@if($step['id'] > 1)
<button
@click="$parent.selectStep({{ $step['id'] - 1 }})"
<button type="button"
hx-get="{{ route('sales.scenarios.' . $scenarioType, $tenant->id) }}?step={{ $step['id'] - 1 }}"
hx-target="#scenario-step-content"
hx-swap="innerHTML"
@click="window.dispatchEvent(new CustomEvent('step-changed', { detail: {{ $step['id'] - 1 }} }))"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<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="M15 19l-7-7 7-7" />
@@ -164,9 +204,12 @@ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-70
<div></div>
@endif
@if($step['id'] < count($steps))
<button
@click="$parent.selectStep({{ $step['id'] + 1 }})"
@if($step['id'] < count($steps ?? []))
<button type="button"
hx-get="{{ route('sales.scenarios.' . $scenarioType, $tenant->id) }}?step={{ $step['id'] + 1 }}"
hx-target="#scenario-step-content"
hx-swap="innerHTML"
@click="window.dispatchEvent(new CustomEvent('step-changed', { detail: {{ $step['id'] + 1 }} }))"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-{{ $step['color'] }}-600 rounded-lg hover:bg-{{ $step['color'] }}-700 transition-colors">
다음 단계
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -174,8 +217,8 @@ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white b
</svg>
</button>
@else
<button
@click="$parent.close()"
<button type="button"
@click="window.dispatchEvent(new CustomEvent('scenario-modal-closed'))"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors">
<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 13l4 4L19 7" />