2026-01-29 11:27:21 +09:00
|
|
|
{{-- 첨부파일 업로드 컴포넌트 (자동 업로드) --}}
|
2026-01-31 19:50:46 +09:00
|
|
|
@php
|
|
|
|
|
$isProspectMode = isset($isProspect) && $isProspect;
|
|
|
|
|
$entityId = $isProspectMode ? $entity->id : ($entity->id ?? $tenant->id ?? 0);
|
|
|
|
|
@endphp
|
|
|
|
|
<div x-data="fileUploader({{ $entityId }}, {{ $isProspectMode ? 'true' : 'false' }}, '{{ $scenarioType }}', {{ $stepId ?? 'null' }})" class="bg-white border border-gray-200 rounded-lg overflow-hidden relative">
|
2026-01-29 11:27:21 +09:00
|
|
|
|
|
|
|
|
{{-- 업로드 중 오버레이 --}}
|
|
|
|
|
<div x-show="uploading"
|
|
|
|
|
x-transition:enter="transition ease-out duration-200"
|
|
|
|
|
x-transition:enter-start="opacity-0"
|
|
|
|
|
x-transition:enter-end="opacity-100"
|
|
|
|
|
class="absolute inset-0 z-10 bg-white/95 flex flex-col items-center justify-center">
|
|
|
|
|
<div class="w-48 space-y-4">
|
|
|
|
|
{{-- 아이콘 --}}
|
|
|
|
|
<div class="flex justify-center">
|
|
|
|
|
<div class="p-4 bg-green-100 rounded-full">
|
|
|
|
|
<svg class="w-8 h-8 text-green-600 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{{-- 텍스트 --}}
|
|
|
|
|
<p class="text-center text-sm font-medium text-gray-700">파일 업로드 중...</p>
|
|
|
|
|
{{-- 프로그레스바 --}}
|
|
|
|
|
<div class="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
|
|
|
|
|
<div class="h-full bg-green-600 transition-all duration-300 ease-out rounded-full"
|
|
|
|
|
:style="'width: ' + totalProgress + '%'"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="text-center text-xs text-gray-500" x-text="uploadStatus"></p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-01-29 11:12:08 +09:00
|
|
|
{{-- 헤더 (접기/펼치기) --}}
|
|
|
|
|
<button type="button"
|
|
|
|
|
@click="expanded = !expanded"
|
2026-01-29 11:27:21 +09:00
|
|
|
:disabled="uploading"
|
|
|
|
|
class="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors disabled:opacity-50">
|
2026-01-29 11:12:08 +09:00
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
|
|
|
|
</svg>
|
|
|
|
|
<span class="text-sm font-semibold text-gray-700">첨부파일</span>
|
2026-01-29 11:27:21 +09:00
|
|
|
<span x-show="uploadedCount > 0" class="px-2 py-0.5 bg-green-100 text-green-600 text-xs font-medium rounded-full" x-text="uploadedCount + '개 완료'"></span>
|
2026-01-29 11:12:08 +09:00
|
|
|
</div>
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<span class="text-xs text-gray-400" x-text="expanded ? '접기' : '펼치기'"></span>
|
|
|
|
|
<svg class="w-4 h-4 text-gray-400 transition-transform duration-200"
|
|
|
|
|
:class="expanded && '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>
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{{-- 콘텐츠 (접기/펼치기) --}}
|
|
|
|
|
<div x-show="expanded"
|
|
|
|
|
x-transition:enter="transition ease-out duration-200"
|
|
|
|
|
x-transition:enter-start="opacity-0"
|
|
|
|
|
x-transition:enter-end="opacity-100"
|
|
|
|
|
x-transition:leave="transition ease-in duration-150"
|
|
|
|
|
x-transition:leave-start="opacity-100"
|
|
|
|
|
x-transition:leave-end="opacity-0"
|
|
|
|
|
class="px-4 pb-4 border-t border-gray-100">
|
2026-01-28 21:45:11 +09:00
|
|
|
|
2026-01-29 11:27:21 +09:00
|
|
|
{{-- Drag & Drop 영역 --}}
|
|
|
|
|
<div
|
|
|
|
|
@dragover.prevent="isDragging = true"
|
|
|
|
|
@dragleave.prevent="isDragging = false"
|
|
|
|
|
@drop.prevent="handleDrop($event)"
|
|
|
|
|
:class="isDragging ? 'border-green-500 bg-green-50' : 'border-gray-300 bg-gray-50'"
|
|
|
|
|
class="mt-4 border-2 border-dashed rounded-lg p-6 text-center transition-colors cursor-pointer"
|
|
|
|
|
@click="$refs.fileInput.click()"
|
2026-01-28 21:45:11 +09:00
|
|
|
>
|
2026-01-29 11:27:21 +09:00
|
|
|
<input
|
|
|
|
|
type="file"
|
|
|
|
|
x-ref="fileInput"
|
|
|
|
|
@change="handleFileSelect($event)"
|
|
|
|
|
multiple
|
|
|
|
|
class="hidden"
|
|
|
|
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png,.gif,.zip,.rar"
|
|
|
|
|
>
|
|
|
|
|
|
|
|
|
|
<svg class="w-10 h-10 mx-auto mb-3" :class="isDragging ? 'text-green-500' : 'text-gray-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
|
|
|
|
</svg>
|
2026-01-28 21:45:11 +09:00
|
|
|
|
2026-01-29 11:27:21 +09:00
|
|
|
<p class="text-sm text-gray-600 mb-1">
|
|
|
|
|
파일을 여기에 드래그하거나 <span class="text-green-600 font-medium">클릭하여 선택</span>
|
|
|
|
|
</p>
|
|
|
|
|
<p class="text-xs text-gray-500">
|
|
|
|
|
선택 즉시 자동 업로드 / 최대 20MB
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-01-28 21:45:11 +09:00
|
|
|
|
2026-01-29 11:27:21 +09:00
|
|
|
{{-- 최근 업로드 파일 목록 --}}
|
|
|
|
|
<div x-show="recentFiles.length > 0" class="mt-4 space-y-2">
|
|
|
|
|
<h5 class="text-xs font-medium text-gray-500 uppercase tracking-wider">최근 업로드</h5>
|
|
|
|
|
<template x-for="(file, index) in recentFiles" :key="index">
|
|
|
|
|
<div class="flex items-center gap-3 p-3 bg-green-50 rounded-lg border border-green-100">
|
|
|
|
|
{{-- 파일 아이콘 --}}
|
|
|
|
|
<div class="flex-shrink-0 p-2 bg-white rounded-lg border border-green-200">
|
|
|
|
|
<svg class="w-5 h-5 text-green-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>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{{-- 파일 정보 --}}
|
|
|
|
|
<div class="flex-1 min-w-0">
|
|
|
|
|
<p class="text-sm font-medium text-gray-900 truncate" x-text="file.name"></p>
|
|
|
|
|
<p class="text-xs text-green-600">업로드 완료</p>
|
|
|
|
|
</div>
|
2026-01-28 21:45:11 +09:00
|
|
|
</div>
|
2026-01-29 11:27:21 +09:00
|
|
|
</template>
|
2026-01-28 21:45:11 +09:00
|
|
|
</div>
|
2026-01-29 11:27:21 +09:00
|
|
|
|
|
|
|
|
{{-- 안내 문구 --}}
|
|
|
|
|
<p class="mt-3 text-xs text-gray-400 text-center">
|
|
|
|
|
PDF, 문서, 이미지, 압축파일 지원
|
|
|
|
|
</p>
|
2026-01-29 11:12:08 +09:00
|
|
|
</div>
|
2026-01-28 21:45:11 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<script>
|
2026-01-31 19:50:46 +09:00
|
|
|
function fileUploader(entityId, isProspect, scenarioType, stepId) {
|
2026-01-28 21:45:11 +09:00
|
|
|
return {
|
2026-01-31 19:50:46 +09:00
|
|
|
entityId: entityId,
|
|
|
|
|
isProspect: isProspect,
|
2026-01-29 11:12:08 +09:00
|
|
|
expanded: false,
|
2026-01-31 19:50:46 +09:00
|
|
|
scenarioType: scenarioType,
|
|
|
|
|
stepId: stepId,
|
2026-01-28 21:45:11 +09:00
|
|
|
|
|
|
|
|
isDragging: false,
|
|
|
|
|
uploading: false,
|
2026-01-29 11:27:21 +09:00
|
|
|
totalProgress: 0,
|
|
|
|
|
uploadStatus: '',
|
|
|
|
|
uploadedCount: 0,
|
|
|
|
|
recentFiles: [],
|
2026-01-28 21:45:11 +09:00
|
|
|
|
|
|
|
|
formatFileSize(bytes) {
|
|
|
|
|
if (bytes < 1024) return bytes + ' B';
|
|
|
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
|
|
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
handleDrop(event) {
|
|
|
|
|
this.isDragging = false;
|
|
|
|
|
const files = Array.from(event.dataTransfer.files);
|
2026-01-29 11:27:21 +09:00
|
|
|
this.uploadFiles(files);
|
2026-01-28 21:45:11 +09:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
handleFileSelect(event) {
|
|
|
|
|
const files = Array.from(event.target.files);
|
2026-01-29 11:27:21 +09:00
|
|
|
this.uploadFiles(files);
|
|
|
|
|
event.target.value = '';
|
2026-01-28 21:45:11 +09:00
|
|
|
},
|
|
|
|
|
|
2026-01-29 11:27:21 +09:00
|
|
|
async uploadFiles(files) {
|
|
|
|
|
if (this.uploading || files.length === 0) return;
|
2026-01-28 21:45:11 +09:00
|
|
|
|
2026-01-29 11:27:21 +09:00
|
|
|
const maxSize = 20 * 1024 * 1024;
|
|
|
|
|
const validFiles = files.filter(file => {
|
2026-01-28 21:45:11 +09:00
|
|
|
if (file.size > maxSize) {
|
|
|
|
|
alert(`${file.name}: 파일 크기가 20MB를 초과합니다.`);
|
2026-01-29 11:27:21 +09:00
|
|
|
return false;
|
2026-01-28 21:45:11 +09:00
|
|
|
}
|
2026-01-29 11:27:21 +09:00
|
|
|
return true;
|
2026-01-28 21:45:11 +09:00
|
|
|
});
|
|
|
|
|
|
2026-01-29 11:27:21 +09:00
|
|
|
if (validFiles.length === 0) return;
|
2026-01-28 21:45:11 +09:00
|
|
|
|
|
|
|
|
this.uploading = true;
|
2026-01-29 11:27:21 +09:00
|
|
|
this.totalProgress = 0;
|
|
|
|
|
this.uploadStatus = `0 / ${validFiles.length} 파일 업로드 중...`;
|
|
|
|
|
|
|
|
|
|
let completed = 0;
|
|
|
|
|
|
|
|
|
|
for (const file of validFiles) {
|
|
|
|
|
this.uploadStatus = `${completed + 1} / ${validFiles.length} 파일 업로드 중...`;
|
2026-01-28 21:45:11 +09:00
|
|
|
|
2026-01-29 11:27:21 +09:00
|
|
|
try {
|
|
|
|
|
await this.uploadSingleFile(file, (progress) => {
|
|
|
|
|
// 전체 진행률 계산
|
|
|
|
|
const baseProgress = (completed / validFiles.length) * 100;
|
|
|
|
|
const fileProgress = (progress / validFiles.length);
|
|
|
|
|
this.totalProgress = Math.round(baseProgress + fileProgress);
|
|
|
|
|
});
|
2026-01-28 21:45:11 +09:00
|
|
|
|
2026-01-29 11:27:21 +09:00
|
|
|
this.recentFiles.unshift({ name: file.name });
|
|
|
|
|
if (this.recentFiles.length > 5) {
|
|
|
|
|
this.recentFiles.pop();
|
|
|
|
|
}
|
|
|
|
|
completed++;
|
|
|
|
|
this.uploadedCount++;
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('파일 업로드 실패:', file.name, error);
|
|
|
|
|
}
|
2026-01-28 21:45:11 +09:00
|
|
|
}
|
|
|
|
|
|
2026-01-29 11:27:21 +09:00
|
|
|
this.totalProgress = 100;
|
|
|
|
|
this.uploadStatus = '업로드 완료!';
|
2026-01-28 21:45:11 +09:00
|
|
|
|
2026-01-29 11:27:21 +09:00
|
|
|
// 잠시 후 오버레이 닫기
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
this.uploading = false;
|
|
|
|
|
this.totalProgress = 0;
|
|
|
|
|
|
|
|
|
|
// 상담 기록 새로고침
|
2026-01-31 19:50:46 +09:00
|
|
|
const refreshUrl = this.isProspect
|
|
|
|
|
? `/sales/consultations/prospect/${this.entityId}?scenario_type=${this.scenarioType}`
|
|
|
|
|
: `/sales/consultations/${this.entityId}?scenario_type=${this.scenarioType}`;
|
|
|
|
|
htmx.ajax('GET', refreshUrl, { target: '#consultation-log-container', swap: 'innerHTML' });
|
2026-01-28 21:45:11 +09:00
|
|
|
|
2026-01-29 11:27:21 +09:00
|
|
|
// 5초 후 최근 파일 목록 초기화
|
2026-01-28 21:45:11 +09:00
|
|
|
setTimeout(() => {
|
2026-01-29 11:27:21 +09:00
|
|
|
this.recentFiles = [];
|
|
|
|
|
}, 5000);
|
|
|
|
|
}, 1000);
|
2026-01-28 21:45:11 +09:00
|
|
|
},
|
|
|
|
|
|
2026-01-29 11:27:21 +09:00
|
|
|
async uploadSingleFile(file, onProgress) {
|
2026-01-31 19:50:46 +09:00
|
|
|
const self = this;
|
2026-01-29 11:27:21 +09:00
|
|
|
return new Promise((resolve, reject) => {
|
2026-01-28 21:45:11 +09:00
|
|
|
const formData = new FormData();
|
2026-01-31 19:50:46 +09:00
|
|
|
if (self.isProspect) {
|
|
|
|
|
formData.append('prospect_id', self.entityId);
|
|
|
|
|
} else {
|
|
|
|
|
formData.append('tenant_id', self.entityId);
|
|
|
|
|
}
|
|
|
|
|
formData.append('scenario_type', self.scenarioType);
|
|
|
|
|
if (self.stepId) formData.append('step_id', self.stepId);
|
2026-01-29 11:27:21 +09:00
|
|
|
formData.append('file', file);
|
2026-01-28 21:45:11 +09:00
|
|
|
|
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
|
|
|
|
|
|
|
|
xhr.upload.addEventListener('progress', (e) => {
|
|
|
|
|
if (e.lengthComputable) {
|
2026-01-29 11:27:21 +09:00
|
|
|
onProgress(Math.round((e.loaded / e.total) * 100));
|
2026-01-28 21:45:11 +09:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-29 11:27:21 +09:00
|
|
|
xhr.onload = () => {
|
|
|
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
|
|
|
const result = JSON.parse(xhr.responseText);
|
|
|
|
|
if (result.success) {
|
|
|
|
|
resolve(result);
|
2026-01-28 21:45:11 +09:00
|
|
|
} else {
|
2026-01-29 11:27:21 +09:00
|
|
|
reject(new Error(result.message || '업로드 실패'));
|
2026-01-28 21:45:11 +09:00
|
|
|
}
|
2026-01-29 11:27:21 +09:00
|
|
|
} else {
|
|
|
|
|
reject(new Error('업로드 실패'));
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-01-28 21:45:11 +09:00
|
|
|
|
2026-01-29 11:27:21 +09:00
|
|
|
xhr.onerror = () => reject(new Error('네트워크 오류'));
|
2026-01-28 21:45:11 +09:00
|
|
|
|
2026-01-31 19:50:46 +09:00
|
|
|
const uploadUrl = self.isProspect
|
|
|
|
|
? '/sales/consultations/prospect/upload-file'
|
|
|
|
|
: '/sales/consultations/upload-file';
|
|
|
|
|
xhr.open('POST', uploadUrl);
|
2026-01-29 11:27:21 +09:00
|
|
|
xhr.setRequestHeader('X-CSRF-TOKEN', document.querySelector('meta[name="csrf-token"]').content);
|
|
|
|
|
xhr.setRequestHeader('Accept', 'application/json');
|
|
|
|
|
xhr.send(formData);
|
|
|
|
|
});
|
2026-01-28 21:45:11 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
</script>
|