feat:레거시 영업관리 시스템 MNG 마이그레이션
- 영업/매니저 시나리오 모달 구현 (6단계 체크리스트) - 상담 기록 기능 (텍스트, 음성, 첨부파일) - 음성 녹음 + Speech-to-Text 변환 - 첨부파일 Drag & Drop 업로드 - 매니저 지정 드롭다운 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
286
app/Http/Controllers/Sales/ConsultationController.php
Normal file
286
app/Http/Controllers/Sales/ConsultationController.php
Normal file
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 상담 기록 관리 컨트롤러
|
||||
*
|
||||
* 테넌트별 상담 기록(텍스트, 음성, 파일)을 관리합니다.
|
||||
*/
|
||||
class ConsultationController extends Controller
|
||||
{
|
||||
/**
|
||||
* 상담 기록 목록 (HTMX 부분 뷰)
|
||||
*/
|
||||
public function index(int $tenantId, Request $request): View
|
||||
{
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$scenarioType = $request->input('scenario_type', 'sales');
|
||||
$stepId = $request->input('step_id');
|
||||
|
||||
// 캐시에서 상담 기록 조회
|
||||
$cacheKey = "consultations:{$tenantId}:{$scenarioType}";
|
||||
$consultations = cache()->get($cacheKey, []);
|
||||
|
||||
// 특정 단계 필터링
|
||||
if ($stepId) {
|
||||
$consultations = array_filter($consultations, fn($c) => ($c['step_id'] ?? null) == $stepId);
|
||||
}
|
||||
|
||||
// 최신순 정렬
|
||||
usort($consultations, fn($a, $b) => strtotime($b['created_at']) - strtotime($a['created_at']));
|
||||
|
||||
return view('sales.modals.consultation-log', [
|
||||
'tenant' => $tenant,
|
||||
'consultations' => $consultations,
|
||||
'scenarioType' => $scenarioType,
|
||||
'stepId' => $stepId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 상담 기록 저장
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
'step_id' => 'nullable|integer',
|
||||
'content' => 'required|string|max:5000',
|
||||
]);
|
||||
|
||||
$tenantId = $request->input('tenant_id');
|
||||
$scenarioType = $request->input('scenario_type');
|
||||
$stepId = $request->input('step_id');
|
||||
$content = $request->input('content');
|
||||
|
||||
// 캐시 키
|
||||
$cacheKey = "consultations:{$tenantId}:{$scenarioType}";
|
||||
$consultations = cache()->get($cacheKey, []);
|
||||
|
||||
// 새 상담 기록 추가
|
||||
$consultation = [
|
||||
'id' => uniqid('cons_'),
|
||||
'type' => 'text',
|
||||
'content' => $content,
|
||||
'step_id' => $stepId,
|
||||
'created_by' => auth()->id(),
|
||||
'created_by_name' => auth()->user()->name,
|
||||
'created_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$consultations[] = $consultation;
|
||||
|
||||
// 캐시에 저장 (90일 유지)
|
||||
cache()->put($cacheKey, $consultations, now()->addDays(90));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'consultation' => $consultation,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상담 기록 삭제
|
||||
*/
|
||||
public function destroy(string $consultationId, Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
]);
|
||||
|
||||
$tenantId = $request->input('tenant_id');
|
||||
$scenarioType = $request->input('scenario_type');
|
||||
|
||||
// 캐시 키
|
||||
$cacheKey = "consultations:{$tenantId}:{$scenarioType}";
|
||||
$consultations = cache()->get($cacheKey, []);
|
||||
|
||||
// 상담 기록 찾기 및 삭제
|
||||
$found = false;
|
||||
foreach ($consultations as $index => $consultation) {
|
||||
if ($consultation['id'] === $consultationId) {
|
||||
// 파일이 있으면 삭제
|
||||
if (isset($consultation['file_path'])) {
|
||||
Storage::delete($consultation['file_path']);
|
||||
}
|
||||
unset($consultations[$index]);
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$found) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '상담 기록을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// 캐시에 저장
|
||||
cache()->put($cacheKey, array_values($consultations), now()->addDays(90));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 음성 파일 업로드
|
||||
*/
|
||||
public function uploadAudio(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
'step_id' => 'nullable|integer',
|
||||
'audio' => 'required|file|mimes:webm,mp3,wav,ogg|max:51200', // 50MB
|
||||
'transcript' => 'nullable|string|max:10000',
|
||||
'duration' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$tenantId = $request->input('tenant_id');
|
||||
$scenarioType = $request->input('scenario_type');
|
||||
$stepId = $request->input('step_id');
|
||||
$transcript = $request->input('transcript');
|
||||
$duration = $request->input('duration');
|
||||
|
||||
// 파일 저장
|
||||
$file = $request->file('audio');
|
||||
$fileName = 'audio_' . now()->format('Ymd_His') . '_' . uniqid() . '.' . $file->getClientOriginalExtension();
|
||||
$path = $file->storeAs("tenant/consultations/{$tenantId}", $fileName, 'local');
|
||||
|
||||
// 캐시 키
|
||||
$cacheKey = "consultations:{$tenantId}:{$scenarioType}";
|
||||
$consultations = cache()->get($cacheKey, []);
|
||||
|
||||
// 새 상담 기록 추가
|
||||
$consultation = [
|
||||
'id' => uniqid('cons_'),
|
||||
'type' => 'audio',
|
||||
'file_path' => $path,
|
||||
'file_name' => $fileName,
|
||||
'transcript' => $transcript,
|
||||
'duration' => $duration,
|
||||
'step_id' => $stepId,
|
||||
'created_by' => auth()->id(),
|
||||
'created_by_name' => auth()->user()->name,
|
||||
'created_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$consultations[] = $consultation;
|
||||
|
||||
// 캐시에 저장
|
||||
cache()->put($cacheKey, $consultations, now()->addDays(90));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'consultation' => $consultation,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 업로드
|
||||
*/
|
||||
public function uploadFile(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
'step_id' => 'nullable|integer',
|
||||
'file' => 'required|file|max:20480', // 20MB
|
||||
]);
|
||||
|
||||
$tenantId = $request->input('tenant_id');
|
||||
$scenarioType = $request->input('scenario_type');
|
||||
$stepId = $request->input('step_id');
|
||||
|
||||
// 파일 저장
|
||||
$file = $request->file('file');
|
||||
$originalName = $file->getClientOriginalName();
|
||||
$fileName = now()->format('Ymd_His') . '_' . uniqid() . '_' . $originalName;
|
||||
$path = $file->storeAs("tenant/attachments/{$tenantId}", $fileName, 'local');
|
||||
|
||||
// 캐시 키
|
||||
$cacheKey = "consultations:{$tenantId}:{$scenarioType}";
|
||||
$consultations = cache()->get($cacheKey, []);
|
||||
|
||||
// 새 상담 기록 추가
|
||||
$consultation = [
|
||||
'id' => uniqid('cons_'),
|
||||
'type' => 'file',
|
||||
'file_path' => $path,
|
||||
'file_name' => $originalName,
|
||||
'file_size' => $file->getSize(),
|
||||
'file_type' => $file->getMimeType(),
|
||||
'step_id' => $stepId,
|
||||
'created_by' => auth()->id(),
|
||||
'created_by_name' => auth()->user()->name,
|
||||
'created_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$consultations[] = $consultation;
|
||||
|
||||
// 캐시에 저장
|
||||
cache()->put($cacheKey, $consultations, now()->addDays(90));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'consultation' => $consultation,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 삭제
|
||||
*/
|
||||
public function deleteFile(string $fileId, Request $request): JsonResponse
|
||||
{
|
||||
return $this->destroy($fileId, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 다운로드 URL 생성
|
||||
*/
|
||||
public function getDownloadUrl(string $consultationId, Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
]);
|
||||
|
||||
$tenantId = $request->input('tenant_id');
|
||||
$scenarioType = $request->input('scenario_type');
|
||||
|
||||
// 캐시 키
|
||||
$cacheKey = "consultations:{$tenantId}:{$scenarioType}";
|
||||
$consultations = cache()->get($cacheKey, []);
|
||||
|
||||
// 상담 기록 찾기
|
||||
$consultation = collect($consultations)->firstWhere('id', $consultationId);
|
||||
|
||||
if (!$consultation || !isset($consultation['file_path'])) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '파일을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// 임시 다운로드 URL 생성 (5분 유효)
|
||||
$url = Storage::temporaryUrl($consultation['file_path'], now()->addMinutes(5));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'url' => $url,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
@@ -114,4 +116,69 @@ private function getDashboardData(Request $request): array
|
||||
'endDate'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매니저 지정 변경
|
||||
*/
|
||||
public function assignManager(int $tenantId, Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'manager_id' => 'required|integer',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$managerId = $request->input('manager_id');
|
||||
|
||||
// 캐시 키
|
||||
$cacheKey = "tenant_manager:{$tenantId}";
|
||||
|
||||
if ($managerId === 0) {
|
||||
// 본인으로 설정 (현재 로그인 사용자)
|
||||
$manager = auth()->user();
|
||||
cache()->put($cacheKey, [
|
||||
'id' => $manager->id,
|
||||
'name' => $manager->name,
|
||||
'is_self' => true,
|
||||
], now()->addDays(365));
|
||||
} else {
|
||||
// 특정 매니저 지정
|
||||
$manager = User::find($managerId);
|
||||
if (!$manager) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '매니저를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
cache()->put($cacheKey, [
|
||||
'id' => $manager->id,
|
||||
'name' => $manager->name,
|
||||
'is_self' => $manager->id === auth()->id(),
|
||||
], now()->addDays(365));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'manager' => [
|
||||
'id' => $manager->id,
|
||||
'name' => $manager->name,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매니저 목록 조회 (드롭다운용)
|
||||
*/
|
||||
public function getManagers(Request $request): JsonResponse
|
||||
{
|
||||
// HQ 테넌트의 사용자 중 매니저 역할이 있는 사용자 조회
|
||||
$managers = User::whereHas('tenants', function ($query) {
|
||||
$query->where('tenant_type', 'HQ');
|
||||
})->get(['id', 'name', 'email']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'managers' => $managers,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
212
app/Http/Controllers/Sales/SalesScenarioController.php
Normal file
212
app/Http/Controllers/Sales/SalesScenarioController.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 영업 시나리오 관리 컨트롤러
|
||||
*
|
||||
* 영업 진행 및 매니저 상담 프로세스의 시나리오 모달과 체크리스트를 관리합니다.
|
||||
*/
|
||||
class SalesScenarioController extends Controller
|
||||
{
|
||||
/**
|
||||
* 영업 시나리오 모달 뷰
|
||||
*/
|
||||
public function salesScenario(int $tenantId, Request $request): View|Response
|
||||
{
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$steps = config('sales_scenario.sales_steps');
|
||||
$currentStep = (int) $request->input('step', 1);
|
||||
$icons = config('sales_scenario.icons');
|
||||
|
||||
// 체크리스트 진행 상태 조회
|
||||
$progress = $this->getChecklistProgress($tenantId, 'sales');
|
||||
|
||||
// HTMX 요청이면 단계 콘텐츠만 반환
|
||||
if ($request->header('HX-Request') && $request->has('step')) {
|
||||
return view('sales.modals.scenario-step', [
|
||||
'tenant' => $tenant,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'step' => collect($steps)->firstWhere('id', $currentStep),
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'sales',
|
||||
'icons' => $icons,
|
||||
]);
|
||||
}
|
||||
|
||||
return view('sales.modals.scenario-modal', [
|
||||
'tenant' => $tenant,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'sales',
|
||||
'icons' => $icons,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매니저 시나리오 모달 뷰
|
||||
*/
|
||||
public function managerScenario(int $tenantId, Request $request): View|Response
|
||||
{
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$steps = config('sales_scenario.manager_steps');
|
||||
$currentStep = (int) $request->input('step', 1);
|
||||
$icons = config('sales_scenario.icons');
|
||||
|
||||
// 체크리스트 진행 상태 조회
|
||||
$progress = $this->getChecklistProgress($tenantId, 'manager');
|
||||
|
||||
// HTMX 요청이면 단계 콘텐츠만 반환
|
||||
if ($request->header('HX-Request') && $request->has('step')) {
|
||||
return view('sales.modals.scenario-step', [
|
||||
'tenant' => $tenant,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'step' => collect($steps)->firstWhere('id', $currentStep),
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'manager',
|
||||
'icons' => $icons,
|
||||
]);
|
||||
}
|
||||
|
||||
return view('sales.modals.scenario-modal', [
|
||||
'tenant' => $tenant,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'manager',
|
||||
'icons' => $icons,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크리스트 항목 토글 (HTMX)
|
||||
*/
|
||||
public function toggleChecklist(Request $request): Response
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
'step_id' => 'required|integer',
|
||||
'checkpoint_id' => 'required|string',
|
||||
'checked' => 'required|boolean',
|
||||
]);
|
||||
|
||||
$tenantId = $request->input('tenant_id');
|
||||
$scenarioType = $request->input('scenario_type');
|
||||
$stepId = $request->input('step_id');
|
||||
$checkpointId = $request->input('checkpoint_id');
|
||||
$checked = $request->boolean('checked');
|
||||
|
||||
// 캐시 키 생성
|
||||
$cacheKey = "scenario_checklist:{$tenantId}:{$scenarioType}";
|
||||
|
||||
// 현재 체크리스트 상태 조회
|
||||
$checklist = cache()->get($cacheKey, []);
|
||||
|
||||
// 체크리스트 상태 업데이트
|
||||
$key = "{$stepId}_{$checkpointId}";
|
||||
if ($checked) {
|
||||
$checklist[$key] = [
|
||||
'checked_at' => now()->toDateTimeString(),
|
||||
'checked_by' => auth()->id(),
|
||||
];
|
||||
} else {
|
||||
unset($checklist[$key]);
|
||||
}
|
||||
|
||||
// 캐시에 저장 (30일 유지)
|
||||
cache()->put($cacheKey, $checklist, now()->addDays(30));
|
||||
|
||||
// 진행률 계산
|
||||
$progress = $this->calculateProgress($tenantId, $scenarioType);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'progress' => $progress,
|
||||
'checked' => $checked,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행률 조회
|
||||
*/
|
||||
public function getProgress(int $tenantId, string $type): Response
|
||||
{
|
||||
$progress = $this->calculateProgress($tenantId, $type);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'progress' => $progress,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크리스트 진행 상태 조회
|
||||
*/
|
||||
private function getChecklistProgress(int $tenantId, string $scenarioType): array
|
||||
{
|
||||
$cacheKey = "scenario_checklist:{$tenantId}:{$scenarioType}";
|
||||
|
||||
return cache()->get($cacheKey, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행률 계산
|
||||
*/
|
||||
private function calculateProgress(int $tenantId, string $scenarioType): array
|
||||
{
|
||||
$steps = config("sales_scenario.{$scenarioType}_steps");
|
||||
$checklist = $this->getChecklistProgress($tenantId, $scenarioType);
|
||||
|
||||
$totalCheckpoints = 0;
|
||||
$completedCheckpoints = 0;
|
||||
$stepProgress = [];
|
||||
|
||||
foreach ($steps as $step) {
|
||||
$stepCompleted = 0;
|
||||
$stepTotal = count($step['checkpoints']);
|
||||
$totalCheckpoints += $stepTotal;
|
||||
|
||||
foreach ($step['checkpoints'] as $checkpoint) {
|
||||
$key = "{$step['id']}_{$checkpoint['id']}";
|
||||
if (isset($checklist[$key])) {
|
||||
$completedCheckpoints++;
|
||||
$stepCompleted++;
|
||||
}
|
||||
}
|
||||
|
||||
$stepProgress[$step['id']] = [
|
||||
'total' => $stepTotal,
|
||||
'completed' => $stepCompleted,
|
||||
'percentage' => $stepTotal > 0 ? round(($stepCompleted / $stepTotal) * 100) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $totalCheckpoints,
|
||||
'completed' => $completedCheckpoints,
|
||||
'percentage' => $totalCheckpoints > 0 ? round(($completedCheckpoints / $totalCheckpoints) * 100) : 0,
|
||||
'steps' => $stepProgress,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 단계의 체크포인트 체크 여부 확인
|
||||
*/
|
||||
public function isCheckpointChecked(int $tenantId, string $scenarioType, int $stepId, string $checkpointId): bool
|
||||
{
|
||||
$checklist = $this->getChecklistProgress($tenantId, $scenarioType);
|
||||
$key = "{$stepId}_{$checkpointId}";
|
||||
|
||||
return isset($checklist[$key]);
|
||||
}
|
||||
}
|
||||
447
config/sales_scenario.php
Normal file
447
config/sales_scenario.php
Normal file
@@ -0,0 +1,447 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 영업 시나리오 설정
|
||||
*
|
||||
* 영업 진행 및 매니저 상담 프로세스의 단계별 체크리스트 정의
|
||||
*/
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 영업 시나리오 단계 (SALES_SCENARIO_STEPS)
|
||||
|--------------------------------------------------------------------------
|
||||
| 영업 담당자가 고객사와 계약을 체결하기까지의 6단계 프로세스
|
||||
*/
|
||||
'sales_steps' => [
|
||||
[
|
||||
'id' => 1,
|
||||
'title' => '사전 준비',
|
||||
'subtitle' => 'Preparation',
|
||||
'icon' => 'search',
|
||||
'color' => 'blue',
|
||||
'bg_class' => 'bg-blue-100',
|
||||
'text_class' => 'text-blue-600',
|
||||
'description' => '고객사를 만나기 전, 철저한 분석을 통해 성공 확률을 높이는 단계입니다.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'prep_1',
|
||||
'title' => '고객사 심층 분석',
|
||||
'detail' => '홈페이지, 뉴스 등을 통해 이슈와 비전을 파악하세요.',
|
||||
'pro_tip' => '직원들의 불만 사항을 미리 파악하면 미팅 시 강력한 무기가 됩니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'prep_2',
|
||||
'title' => '재무 건전성 확인',
|
||||
'detail' => '매출액, 영업이익 추이를 확인하고 IT 투자 여력을 가늠해보세요.',
|
||||
'pro_tip' => '성장 추세라면 \'확장성\'과 \'관리 효율\'을 강조하세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'prep_3',
|
||||
'title' => '경쟁사 및 시장 동향',
|
||||
'detail' => '핵심 기능에 집중하여 도입 속도가 빠르다는 점을 정리하세요.',
|
||||
'pro_tip' => '경쟁사를 비방하기보다 차별화된 가치를 제시하세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'prep_4',
|
||||
'title' => '가설 수립 (Hypothesis)',
|
||||
'detail' => '구체적인 페인포인트 가설을 세우고 질문을 준비하세요.',
|
||||
'pro_tip' => '\'만약 ~하다면\' 화법으로 고객의 \'Yes\'를 유도하세요.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'title' => '접근 및 탐색',
|
||||
'subtitle' => 'Approach',
|
||||
'icon' => 'phone',
|
||||
'color' => 'indigo',
|
||||
'bg_class' => 'bg-indigo-100',
|
||||
'text_class' => 'text-indigo-600',
|
||||
'description' => '담당자와의 첫 접점을 만들고, 미팅 기회를 확보하는 단계입니다.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'approach_1',
|
||||
'title' => 'Key-man 식별 및 컨택',
|
||||
'detail' => '실무 책임자(팀장급)와 의사결정권자(임원급) 라인을 파악하세요.',
|
||||
'pro_tip' => '전달드릴 자료가 있다고 하여 Gatekeeper를 통과하세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'approach_2',
|
||||
'title' => '맞춤형 콜드메일/콜',
|
||||
'detail' => '사전 조사 내용을 바탕으로 해결 방안을 제안하세요.',
|
||||
'pro_tip' => '제목에 고객사 이름을 넣어 클릭률을 높이세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'approach_3',
|
||||
'title' => '미팅 일정 확정',
|
||||
'detail' => '인사이트 공유를 목적으로 미팅을 제안하세요.',
|
||||
'pro_tip' => '두 가지 시간대를 제시하여 양자택일을 유도하세요.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 3,
|
||||
'title' => '현장 진단',
|
||||
'subtitle' => 'Diagnosis',
|
||||
'icon' => 'clipboard-check',
|
||||
'color' => 'purple',
|
||||
'bg_class' => 'bg-purple-100',
|
||||
'text_class' => 'text-purple-600',
|
||||
'description' => '고객의 업무 현장을 직접 확인하고 진짜 문제를 찾아내는 단계입니다.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'diag_1',
|
||||
'title' => 'AS-IS 프로세스 맵핑',
|
||||
'detail' => '고객과 함께 업무 흐름도를 그리며 병목을 찾으세요.',
|
||||
'pro_tip' => '고객 스스로 문제를 깨닫게 하는 것이 가장 효과적입니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'diag_2',
|
||||
'title' => '비효율/리스크 식별',
|
||||
'detail' => '데이터 누락, 중복 입력 등 리스크를 수치화하세요.',
|
||||
'pro_tip' => '불편함을 시간과 비용으로 환산하여 설명하세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'diag_3',
|
||||
'title' => 'To-Be 이미지 스케치',
|
||||
'detail' => '도입 후 업무가 어떻게 간소화될지 시각화하세요.',
|
||||
'pro_tip' => '비포/애프터의 극명한 차이를 보여주세요.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 4,
|
||||
'title' => '솔루션 제안',
|
||||
'subtitle' => 'Proposal',
|
||||
'icon' => 'presentation',
|
||||
'color' => 'pink',
|
||||
'bg_class' => 'bg-pink-100',
|
||||
'text_class' => 'text-pink-600',
|
||||
'description' => 'SAM을 통해 고객의 문제를 어떻게 해결할 수 있는지 증명하는 단계입니다.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'proposal_1',
|
||||
'title' => '맞춤형 데모 시연',
|
||||
'detail' => '핵심 기능을 위주로 고객사 데이터를 넣어 시연하세요.',
|
||||
'pro_tip' => '고객사 로고를 넣어 \'이미 우리 것\'이라는 느낌을 주세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'proposal_2',
|
||||
'title' => 'ROI 분석 보고서',
|
||||
'detail' => '비용 대비 절감 가능한 수치를 산출하여 증명하세요.',
|
||||
'pro_tip' => '보수적인 ROI가 훨씬 더 높은 신뢰를 줍니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'proposal_3',
|
||||
'title' => '단계별 도입 로드맵',
|
||||
'detail' => '부담을 줄이기 위해 단계적 확산 방안을 제시하세요.',
|
||||
'pro_tip' => '1단계는 핵심 문제 해결에만 집중하세요.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 5,
|
||||
'title' => '협상 및 조율',
|
||||
'subtitle' => 'Negotiation',
|
||||
'icon' => 'scale',
|
||||
'color' => 'orange',
|
||||
'bg_class' => 'bg-orange-100',
|
||||
'text_class' => 'text-orange-600',
|
||||
'description' => '도입을 가로막는 장애물을 제거하고 조건을 합의하는 단계입니다.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'nego_1',
|
||||
'title' => '가격/조건 협상',
|
||||
'detail' => '할인 대신 범위나 기간 조정 등으로 합의하세요.',
|
||||
'pro_tip' => 'Give & Take 원칙을 지키며 기대를 관리하세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'nego_2',
|
||||
'title' => '의사결정권자 설득',
|
||||
'detail' => 'CEO/CFO의 관심사에 맞는 보고용 장표를 제공하세요.',
|
||||
'pro_tip' => '실무자가 내부 보고 사업을 잘하게 돕는 것이 핵심입니다.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 6,
|
||||
'title' => '계약 체결',
|
||||
'subtitle' => 'Closing',
|
||||
'icon' => 'file-signature',
|
||||
'color' => 'green',
|
||||
'bg_class' => 'bg-green-100',
|
||||
'text_class' => 'text-green-600',
|
||||
'description' => '공식적인 파트너십을 맺고 법적 효력을 발생시키는 단계입니다.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'close_1',
|
||||
'title' => '계약서 날인 및 교부',
|
||||
'detail' => '전자계약 등을 통해 체결 시간을 단축하세요.',
|
||||
'pro_tip' => '원본은 항상 안전하게 보관하고 백업하세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'close_2',
|
||||
'title' => '세금계산서 발행',
|
||||
'detail' => '정확한 수금 일정을 확인하고 발행하세요.',
|
||||
'pro_tip' => '가입비 입금이 완료되어야 다음 단계가 시작됩니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'close_3',
|
||||
'title' => '계약 완료 (확정)',
|
||||
'detail' => '축하 인사를 전하고 후속 지원 일정을 잡으세요.',
|
||||
'pro_tip' => '계약은 진정한 서비스의 시작임을 강조하세요.',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 매니저 시나리오 단계 (MANAGER_SCENARIO_STEPS)
|
||||
|--------------------------------------------------------------------------
|
||||
| 매니저가 프로젝트를 인수받아 착수하기까지의 6단계 프로세스
|
||||
*/
|
||||
'manager_steps' => [
|
||||
[
|
||||
'id' => 1,
|
||||
'title' => '영업 이관',
|
||||
'subtitle' => 'Handover',
|
||||
'icon' => 'arrow-right-left',
|
||||
'color' => 'blue',
|
||||
'bg_class' => 'bg-blue-100',
|
||||
'text_class' => 'text-blue-600',
|
||||
'description' => '영업팀으로부터 고객 정보를 전달받고, 프로젝트의 배경과 핵심 요구사항을 파악하는 단계입니다.',
|
||||
'tips' => '잘못된 시작은 엉뚱한 결말을 낳습니다. 영업팀의 약속을 검증하세요.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'handover_1',
|
||||
'title' => '영업 히스토리 리뷰',
|
||||
'detail' => '영업 담당자가 작성한 미팅록, 고객의 페인포인트, 예산 범위, 예상 일정 등을 꼼꼼히 확인하세요.',
|
||||
'pro_tip' => '영업 담당자에게 \'고객이 가장 꽂힌 포인트\'가 무엇인지 꼭 물어보세요. 그게 프로젝트의 CSF입니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'handover_2',
|
||||
'title' => '고객사 기본 정보 파악',
|
||||
'detail' => '고객사의 업종, 규모, 주요 경쟁사 등을 파악하여 커뮤니케이션 톤앤매너를 준비하세요.',
|
||||
'pro_tip' => 'IT 지식이 부족한 고객이라면 전문 용어 사용을 자제하고 쉬운 비유를 준비해야 합니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'handover_3',
|
||||
'title' => 'RFP/요구사항 문서 분석',
|
||||
'detail' => '고객이 전달한 요구사항 문서(RFP 등)가 있다면 기술적으로 실현 가능한지 1차 검토하세요.',
|
||||
'pro_tip' => '모호한 문장을 찾아내어 구체적인 수치나 기능으로 정의할 준비를 하세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'handover_4',
|
||||
'title' => '내부 킥오프 (영업-매니저)',
|
||||
'detail' => '영업팀과 함께 프로젝트의 리스크 요인(까다로운 담당자 등)을 사전에 공유받으세요.',
|
||||
'pro_tip' => '영업 단계에서 \'무리하게 약속한 기능\'이 있는지 반드시 체크해야 합니다.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'title' => '요구사항 파악',
|
||||
'subtitle' => 'Requirements',
|
||||
'icon' => 'search',
|
||||
'color' => 'indigo',
|
||||
'bg_class' => 'bg-indigo-100',
|
||||
'text_class' => 'text-indigo-600',
|
||||
'description' => '고객과 직접 만나 구체적인 니즈를 청취하고, 숨겨진 요구사항까지 발굴하는 단계입니다.',
|
||||
'tips' => '고객은 자기가 뭘 원하는지 모를 때가 많습니다. 질문으로 답을 찾아주세요.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'req_1',
|
||||
'title' => '고객 인터뷰 및 실사',
|
||||
'detail' => '현업 담당자를 만나 실제 업무 프로세스를 확인하고 시스템이 필요한 진짜 이유를 찾으세요.',
|
||||
'pro_tip' => '\'왜 이 기능이 필요하세요?\'라고 3번 물어보세요(5 Whys). 목적을 찾아야 합니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'req_2',
|
||||
'title' => '요구사항 구체화 (Scope)',
|
||||
'detail' => '고객의 요구사항을 기능 단위로 쪼개고 우선순위(Must/Should/Could)를 매기세요.',
|
||||
'pro_tip' => '\'오픈 시점에 반드시 필요한 기능\'과 \'추후 고도화할 기능\'을 명확히 구분해 주세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'req_3',
|
||||
'title' => '제약 사항 확인',
|
||||
'detail' => '예산, 일정, 레거시 시스템 연동, 보안 규정 등 프로젝트의 제약 조건을 명확히 하세요.',
|
||||
'pro_tip' => '특히 \'데이터 이관\' 이슈를 조심하세요. 엑셀 데이터가 엉망인 경우가 태반입니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'req_4',
|
||||
'title' => '유사 레퍼런스 제시',
|
||||
'detail' => '비슷한 고민을 했던 다른 고객사의 해결 사례를 보여주며 제안하는 방향의 신뢰를 얻으세요.',
|
||||
'pro_tip' => '\'A사도 이렇게 푸셨습니다\'라는 한마디가 백 마디 설명보다 강력합니다.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 3,
|
||||
'title' => '개발자 협의',
|
||||
'subtitle' => 'Dev Consult',
|
||||
'icon' => 'code',
|
||||
'color' => 'purple',
|
||||
'bg_class' => 'bg-purple-100',
|
||||
'text_class' => 'text-purple-600',
|
||||
'description' => '파악된 요구사항을 개발팀에 전달하고 기술적 실현 가능성과 공수를 산정합니다.',
|
||||
'tips' => '개발자는 \'기능\'을 만들지만, 매니저는 \'가치\'를 만듭니다. 통역사가 되어주세요.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'dev_1',
|
||||
'title' => '요구사항 기술 검토',
|
||||
'detail' => '개발 리드와 함께 고객의 요구사항이 기술적으로 구현 가능한지 검토하세요.',
|
||||
'pro_tip' => '개발자가 \'안 돼요\'라고 하면 \'왜 안 되는지\', \'대안은 무엇인지\'를 반드시 물어보세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'dev_2',
|
||||
'title' => '공수 산정 (Estimation)',
|
||||
'detail' => '기능별 개발 예상 시간(M/M)을 산출하고 필요한 리소스를 파악하세요.',
|
||||
'pro_tip' => '개발 공수는 항상 버퍼(Buffer)를 20% 정도 두세요. 버그나 스펙 변경은 반드시 일어납니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'dev_3',
|
||||
'title' => '아키텍처/스택 선정',
|
||||
'detail' => '프로젝트에 적합한 기술 스택과 시스템 아키텍처를 확정하세요.',
|
||||
'pro_tip' => '최신 기술보다 유지보수 용이성과 개발팀의 숙련도를 최우선으로 고려하세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'dev_4',
|
||||
'title' => '리스크 식별 및 대안 수립',
|
||||
'detail' => '기술적 난이도가 높은 기능 등 리스크를 식별하고 대안(Plan B)을 마련하세요.',
|
||||
'pro_tip' => '리스크는 감추지 말고 공유해야 합니다. 미리 말하면 관리입니다.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 4,
|
||||
'title' => '제안 및 견적',
|
||||
'subtitle' => 'Proposal',
|
||||
'icon' => 'file-text',
|
||||
'color' => 'pink',
|
||||
'bg_class' => 'bg-pink-100',
|
||||
'text_class' => 'text-pink-600',
|
||||
'description' => '개발팀 검토 내용을 바탕으로 수행 계획서(SOW)와 견적서를 작성하여 제안합니다.',
|
||||
'tips' => '견적서는 숫자가 아니라 \'신뢰\'를 담아야 합니다.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'prop_1',
|
||||
'title' => 'WBS 및 일정 계획 수립',
|
||||
'detail' => '분석/설계/개발/테스트/오픈 등 단계별 상세 일정을 수립하세요.',
|
||||
'pro_tip' => '고객의 검수(UAT) 기간을 충분히 잡으세요. 고객은 생각보다 바빠서 피드백이 늦어집니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'prop_2',
|
||||
'title' => '견적서(Quotation) 작성',
|
||||
'detail' => '개발 공수, 솔루션 비용, 인프라 비용 등을 포함한 상세 견적서를 작성하세요.',
|
||||
'pro_tip' => '\'기능별 상세 견적\'을 제공하면 신뢰도가 높아지고 네고 방어에도 유리합니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'prop_3',
|
||||
'title' => '제안서(SOW) 작성',
|
||||
'detail' => '범위(Scope), 수행 방법론, 산출물 목록 등을 명시한 제안서를 작성하세요.',
|
||||
'pro_tip' => '\'제외 범위(Out of Scope)\'를 명확히 적으세요. 나중에 딴소리 듣지 않으려면요.',
|
||||
],
|
||||
[
|
||||
'id' => 'prop_4',
|
||||
'title' => '제안 발표 (PT)',
|
||||
'detail' => '고객에게 제안 내용을 설명하고 우리가 가장 적임자임을 설득하세요.',
|
||||
'pro_tip' => '발표 자료는 \'고객의 언어\'로 작성하세요. 기술 용어 남발은 금물입니다.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 5,
|
||||
'title' => '조율 및 협상',
|
||||
'subtitle' => 'Negotiation',
|
||||
'icon' => 'scale',
|
||||
'color' => 'orange',
|
||||
'bg_class' => 'bg-orange-100',
|
||||
'text_class' => 'text-orange-600',
|
||||
'description' => '제안 내용을 바탕으로 고객과 범위, 일정, 비용을 최종 조율하는 단계입니다.',
|
||||
'tips' => '협상은 이기는 게 아니라, 같이 갈 수 있는 길을 찾는 것입니다.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'nego_m_1',
|
||||
'title' => '범위 및 일정 조정',
|
||||
'detail' => '예산이나 일정에 맞춰 기능을 가감하거나 단계별 오픈 전략을 협의하세요.',
|
||||
'pro_tip' => '무리한 일정 단축은 단호하게 거절하되, \'선오픈\'과 같은 대안을 제시하세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'nego_m_2',
|
||||
'title' => '추가 요구사항 대응',
|
||||
'detail' => '제안 과정에서 나온 추가 요구사항에 대해 비용 청구 여부를 결정하세요.',
|
||||
'pro_tip' => '서비스로 해주더라도 \'원래 얼마짜리인데 이번만 하는 것\'이라고 인지시키세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'nego_m_3',
|
||||
'title' => 'R&R 명확화',
|
||||
'detail' => '우리 회사와 고객사가 각각 해야 할 역할을 명문화하세요.',
|
||||
'pro_tip' => '프로젝트 지연의 절반은 고객의 자료 전달 지연입니다. 숙제를 명확히 알려주세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'nego_m_4',
|
||||
'title' => '최종 합의 도출',
|
||||
'detail' => '모든 쟁점 사항을 정리하고 최종 합의된 내용을 문서로 남기세요.',
|
||||
'pro_tip' => '구두 합의는 힘이 없습니다. 반드시 이메일이나 회의록으로 남기세요.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 6,
|
||||
'title' => '착수 및 계약',
|
||||
'subtitle' => 'Kickoff',
|
||||
'icon' => 'flag',
|
||||
'color' => 'green',
|
||||
'bg_class' => 'bg-green-100',
|
||||
'text_class' => 'text-green-600',
|
||||
'description' => '계약을 체결하고 프로젝트를 공식적으로 시작하는 단계입니다.',
|
||||
'tips' => '시작이 좋아야 끝도 좋습니다. 룰을 명확히 세우세요.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'kick_1',
|
||||
'title' => '계약서 검토 및 날인',
|
||||
'detail' => '과업지시서, 기술협약서 등 계약 부속 서류를 꼼꼼히 챙기세요.',
|
||||
'pro_tip' => '계약서에 \'검수 조건\'을 명확히 넣으세요. 실현 가능한 조건이어야 합니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'kick_2',
|
||||
'title' => '프로젝트 팀 구성',
|
||||
'detail' => '수행 인력을 확정하고 내부 킥오프를 진행하세요.',
|
||||
'pro_tip' => '팀원들에게 프로젝트 배경뿐만 아니라 \'고객의 성향\'도 공유해 주세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'kick_3',
|
||||
'title' => '착수 보고회 (Kick-off)',
|
||||
'detail' => '전원이 모여 프로젝트의 목표, 일정, 커뮤니케이션 룰을 공유하세요.',
|
||||
'pro_tip' => '첫인상이 전문적이어야 프로젝트가 순탄합니다. 깔끔하게 준비하세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'kick_4',
|
||||
'title' => '협업 도구 세팅',
|
||||
'detail' => 'Jira, Slack 등 협업 도구를 세팅하고 고객을 초대하세요.',
|
||||
'pro_tip' => '소통 채널 단일화가 성공의 열쇠입니다. 간단 가이드를 제공하세요.',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 아이콘 매핑 (Heroicons SVG)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'icons' => [
|
||||
'search' => '<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" />',
|
||||
'phone' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />',
|
||||
'clipboard-check' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />',
|
||||
'presentation' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />',
|
||||
'scale' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />',
|
||||
'file-signature' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />',
|
||||
'arrow-right-left' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />',
|
||||
'code' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />',
|
||||
'file-text' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />',
|
||||
'flag' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />',
|
||||
],
|
||||
];
|
||||
@@ -43,4 +43,30 @@
|
||||
@include('sales.dashboard.partials.data-container')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 시나리오 모달용 포털 --}}
|
||||
<div id="modal-portal"></div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// Alpine.js x-collapse 플러그인이 없는 경우를 위한 폴백
|
||||
if (typeof Alpine !== 'undefined' && !Alpine.directive('collapse')) {
|
||||
Alpine.directive('collapse', (el, { expression }, { effect, evaluateLater }) => {
|
||||
let isOpen = evaluateLater(expression);
|
||||
|
||||
effect(() => {
|
||||
isOpen((value) => {
|
||||
if (value) {
|
||||
el.style.height = 'auto';
|
||||
el.style.overflow = 'visible';
|
||||
} else {
|
||||
el.style.height = '0px';
|
||||
el.style.overflow = 'hidden';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
{{-- 매니저 드롭다운 컴포넌트 --}}
|
||||
@php
|
||||
$cacheKey = "tenant_manager:{$tenant->id}";
|
||||
$assignedManager = cache()->get($cacheKey);
|
||||
$isSelf = !$assignedManager || ($assignedManager['is_self'] ?? true);
|
||||
$managerName = $assignedManager['name'] ?? '본인';
|
||||
@endphp
|
||||
|
||||
<div x-data="managerDropdown({{ $tenant->id }}, {{ json_encode($assignedManager) }})" class="relative">
|
||||
{{-- 드롭다운 트리거 --}}
|
||||
<button
|
||||
@click="toggle()"
|
||||
@click.away="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-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-56 bg-white rounded-lg shadow-lg border border-gray-200 py-1"
|
||||
@click.stop
|
||||
>
|
||||
{{-- 로딩 상태 --}}
|
||||
<div x-show="loading" class="px-4 py-3 text-center">
|
||||
<svg class="w-5 h-5 mx-auto animate-spin text-blue-600" 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>
|
||||
|
||||
{{-- 매니저 목록 --}}
|
||||
<div x-show="!loading">
|
||||
{{-- 본인 옵션 --}}
|
||||
<button
|
||||
@click="selectManager(0, '{{ auth()->user()->name }}')"
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm hover:bg-gray-50 transition-colors"
|
||||
: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 }}</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 class="border-t border-gray-100 my-1"></div>
|
||||
|
||||
{{-- 다른 매니저 목록 --}}
|
||||
<template x-for="manager in managers" :key="manager.id">
|
||||
<button
|
||||
@click="selectManager(manager.id, manager.name)"
|
||||
class="w-full flex items-center gap-3 px-4 py-2 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-gray-100 rounded-full flex items-center justify-center">
|
||||
<span class="text-sm font-medium text-gray-600" x-text="manager.name.charAt(0)"></span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-gray-900" x-text="manager.name"></div>
|
||||
<div class="text-xs text-gray-500" x-text="manager.email"></div>
|
||||
</div>
|
||||
<svg x-show="currentManager?.id === manager.id && !currentManager?.is_self" 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>
|
||||
</template>
|
||||
|
||||
{{-- 매니저가 없을 때 --}}
|
||||
<div x-show="managers.length === 0 && !loading" class="px-4 py-3 text-sm text-gray-500 text-center">
|
||||
등록된 매니저가 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function managerDropdown(tenantId, initialManager) {
|
||||
return {
|
||||
tenantId: tenantId,
|
||||
isOpen: false,
|
||||
loading: false,
|
||||
managers: [],
|
||||
currentManager: initialManager,
|
||||
|
||||
toggle() {
|
||||
this.isOpen = !this.isOpen;
|
||||
if (this.isOpen && this.managers.length === 0) {
|
||||
this.loadManagers();
|
||||
}
|
||||
},
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
},
|
||||
|
||||
async loadManagers() {
|
||||
this.loading = true;
|
||||
try {
|
||||
// HQ 테넌트의 사용자 목록 조회 (본인 제외)
|
||||
const response = await fetch('/api/admin/users?tenant_type=HQ', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
},
|
||||
});
|
||||
const result = await response.json();
|
||||
// 본인을 제외한 목록
|
||||
this.managers = (result.data || []).filter(m => m.id !== {{ auth()->id() }});
|
||||
} catch (error) {
|
||||
console.error('매니저 목록 조회 실패:', error);
|
||||
this.managers = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async selectManager(managerId, managerName) {
|
||||
try {
|
||||
const response = await fetch(`/sales/tenants/${this.tenantId}/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,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.currentManager = {
|
||||
id: result.manager.id,
|
||||
name: result.manager.name,
|
||||
is_self: managerId === 0 || result.manager.id === {{ auth()->id() }},
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('매니저 지정 실패:', error);
|
||||
alert('매니저 지정에 실패했습니다.');
|
||||
}
|
||||
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
{{-- 테넌트 및 계약 관리 --}}
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<div class="bg-white rounded-xl shadow-sm p-6" x-data="tenantListManager()">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-blue-100 rounded-lg">
|
||||
@@ -53,15 +53,8 @@
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded text-xs font-medium bg-gray-100 text-gray-700">
|
||||
영업: {{ auth()->user()->name }}
|
||||
</span>
|
||||
<div class="relative">
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium bg-blue-50 text-blue-700 border border-blue-200">
|
||||
<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>
|
||||
</div>
|
||||
{{-- 매니저 드롭다운 --}}
|
||||
@include('sales.dashboard.partials.manager-dropdown', ['tenant' => $tenant])
|
||||
</div>
|
||||
|
||||
<!-- 등록일 -->
|
||||
@@ -71,13 +64,16 @@
|
||||
|
||||
<!-- 계약관리 버튼들 -->
|
||||
<div class="col-span-4 flex items-center justify-center gap-2">
|
||||
<a href="{{ route('sales.records.create', ['tenant_id' => $tenant->id]) }}"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors">
|
||||
{{-- 영업 진행 버튼 (시나리오 모달 열기) --}}
|
||||
<button
|
||||
@click="openScenarioModal({{ $tenant->id }}, 'sales')"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
영업 진행
|
||||
</a>
|
||||
</button>
|
||||
|
||||
<a href="{{ route('tenants.edit', $tenant->id) }}"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-gray-100 text-gray-700 border border-gray-300 hover:bg-gray-200 transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -86,13 +82,16 @@ class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medi
|
||||
</svg>
|
||||
상세계약 설정
|
||||
</a>
|
||||
<a href="{{ route('sales.managers.index', ['tenant_id' => $tenant->id]) }}"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-white text-green-700 border border-green-500 hover:bg-green-50 transition-colors">
|
||||
|
||||
{{-- 매니저 진행 버튼 (시나리오 모달 열기) --}}
|
||||
<button
|
||||
@click="openScenarioModal({{ $tenant->id }}, 'manager')"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-white text-green-700 border border-green-500 hover:bg-green-50 transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
매니저 진행
|
||||
</a>
|
||||
</button>
|
||||
|
||||
<!-- 수정/삭제 아이콘 -->
|
||||
<div class="flex items-center gap-1 ml-2">
|
||||
@@ -103,7 +102,7 @@ class="p-1.5 text-gray-400 hover:text-blue-600 transition-colors" title="수정"
|
||||
</svg>
|
||||
</a>
|
||||
<button type="button"
|
||||
onclick="confirmDeleteTenant({{ $tenant->id }}, '{{ $tenant->company_name }}')"
|
||||
@click="confirmDeleteTenant({{ $tenant->id }}, '{{ $tenant->company_name }}')"
|
||||
class="p-1.5 text-gray-400 hover:text-red-600 transition-colors" title="삭제">
|
||||
<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 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
@@ -134,50 +133,70 @@ class="p-1.5 text-gray-400 hover:text-red-600 transition-colors" title="삭제">
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 시나리오 모달 컨테이너 --}}
|
||||
<div id="scenario-modal-container"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 상세보기 토글
|
||||
document.querySelectorAll('.toggle-detail').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const row = this.closest('.tenant-row');
|
||||
const detail = row.querySelector('.tenant-detail');
|
||||
const icon = this.querySelector('svg');
|
||||
function tenantListManager() {
|
||||
return {
|
||||
init() {
|
||||
// 상세보기 토글
|
||||
document.querySelectorAll('.toggle-detail').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const row = this.closest('.tenant-row');
|
||||
const detail = row.querySelector('.tenant-detail');
|
||||
const icon = this.querySelector('svg');
|
||||
|
||||
detail.classList.toggle('hidden');
|
||||
icon.classList.toggle('rotate-90');
|
||||
});
|
||||
});
|
||||
});
|
||||
detail.classList.toggle('hidden');
|
||||
icon.classList.toggle('rotate-90');
|
||||
});
|
||||
});
|
||||
|
||||
// 테넌트 삭제 확인
|
||||
function confirmDeleteTenant(tenantId, companyName) {
|
||||
if (confirm(`"${companyName}" 테넌트를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`)) {
|
||||
// API를 통한 삭제 요청
|
||||
fetch(`/api/admin/tenants/${tenantId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
// 모달 닫힘 이벤트 리스너
|
||||
window.addEventListener('scenario-modal-closed', () => {
|
||||
document.getElementById('scenario-modal-container').innerHTML = '';
|
||||
});
|
||||
},
|
||||
|
||||
openScenarioModal(tenantId, type) {
|
||||
const url = type === 'sales'
|
||||
? `/sales/scenarios/${tenantId}/sales`
|
||||
: `/sales/scenarios/${tenantId}/manager`;
|
||||
|
||||
htmx.ajax('GET', url, {
|
||||
target: '#scenario-modal-container',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
},
|
||||
|
||||
confirmDeleteTenant(tenantId, companyName) {
|
||||
if (confirm(`"${companyName}" 테넌트를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`)) {
|
||||
fetch(`/api/admin/tenants/${tenantId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
const row = document.querySelector(`.tenant-row[data-tenant-id="${tenantId}"]`);
|
||||
if (row) {
|
||||
row.remove();
|
||||
}
|
||||
alert('테넌트가 삭제되었습니다.');
|
||||
} else {
|
||||
alert('삭제에 실패했습니다.');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
// 성공 시 행 제거
|
||||
const row = document.querySelector(`.tenant-row[data-tenant-id="${tenantId}"]`);
|
||||
if (row) {
|
||||
row.remove();
|
||||
}
|
||||
alert('테넌트가 삭제되었습니다.');
|
||||
} else {
|
||||
alert('삭제에 실패했습니다.');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
201
resources/views/sales/modals/consultation-log.blade.php
Normal file
201
resources/views/sales/modals/consultation-log.blade.php
Normal file
@@ -0,0 +1,201 @@
|
||||
{{-- 상담 기록 컴포넌트 --}}
|
||||
<div x-data="consultationLog()" class="space-y-4">
|
||||
{{-- 상담 기록 입력 --}}
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-3">상담 기록 추가</h4>
|
||||
<div class="space-y-3">
|
||||
<textarea
|
||||
x-model="newContent"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
|
||||
placeholder="상담 내용을 입력하세요..."
|
||||
></textarea>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@click="saveConsultation()"
|
||||
:disabled="!newContent.trim() || saving"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
<svg x-show="saving" class="w-4 h-4 animate-spin" 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>
|
||||
<svg x-show="!saving" 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="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span x-text="saving ? '저장 중...' : '저장'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 상담 기록 목록 --}}
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-semibold text-gray-700">상담 기록 ({{ count($consultations) }}건)</h4>
|
||||
|
||||
@if(empty($consultations))
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<svg class="w-12 h-12 mx-auto mb-2 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<p class="text-sm">아직 상담 기록이 없습니다.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3 max-h-80 overflow-y-auto">
|
||||
@foreach($consultations as $consultation)
|
||||
<div class="group relative bg-gray-50 rounded-lg p-4 hover:bg-gray-100 transition-colors"
|
||||
data-consultation-id="{{ $consultation['id'] }}">
|
||||
{{-- 삭제 버튼 --}}
|
||||
<button
|
||||
@click="deleteConsultation('{{ $consultation['id'] }}')"
|
||||
class="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<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 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{{-- 콘텐츠 --}}
|
||||
<div class="flex items-start gap-3">
|
||||
{{-- 타입 아이콘 --}}
|
||||
<div class="flex-shrink-0 p-2 rounded-lg
|
||||
@if($consultation['type'] === 'text') bg-blue-100 text-blue-600
|
||||
@elseif($consultation['type'] === 'audio') bg-purple-100 text-purple-600
|
||||
@else bg-green-100 text-green-600 @endif">
|
||||
@if($consultation['type'] === 'text')
|
||||
<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="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
@elseif($consultation['type'] === 'audio')
|
||||
<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 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
@else
|
||||
<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.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>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
@if($consultation['type'] === 'text')
|
||||
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{ $consultation['content'] }}</p>
|
||||
@elseif($consultation['type'] === 'audio')
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-900">음성 녹음</span>
|
||||
@if(isset($consultation['duration']))
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ floor($consultation['duration'] / 60) }}:{{ str_pad($consultation['duration'] % 60, 2, '0', STR_PAD_LEFT) }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@if(isset($consultation['transcript']) && $consultation['transcript'])
|
||||
<p class="text-sm text-gray-600 italic">"{{ $consultation['transcript'] }}"</p>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-900">{{ $consultation['file_name'] }}</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ number_format(($consultation['file_size'] ?? 0) / 1024, 1) }} KB
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 메타 정보 --}}
|
||||
<div class="mt-2 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span>{{ $consultation['created_by_name'] }}</span>
|
||||
<span>|</span>
|
||||
<span>{{ \Carbon\Carbon::parse($consultation['created_at'])->format('Y-m-d H:i') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function consultationLog() {
|
||||
return {
|
||||
tenantId: {{ $tenant->id }},
|
||||
scenarioType: '{{ $scenarioType }}',
|
||||
stepId: {{ $stepId ?? 'null' }},
|
||||
newContent: '',
|
||||
saving: false,
|
||||
|
||||
async saveConsultation() {
|
||||
if (!this.newContent.trim() || this.saving) return;
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
const response = await fetch('{{ route('sales.consultations.store') }}', {
|
||||
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: this.stepId,
|
||||
content: this.newContent,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.newContent = '';
|
||||
// 목록 새로고침
|
||||
htmx.ajax('GET',
|
||||
`/sales/consultations/${this.tenantId}?scenario_type=${this.scenarioType}&step_id=${this.stepId || ''}`,
|
||||
{ target: '#consultation-log-container', swap: 'innerHTML' }
|
||||
);
|
||||
} else {
|
||||
alert('저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('상담 기록 저장 실패:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteConsultation(consultationId) {
|
||||
if (!confirm('이 상담 기록을 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/sales/consultations/${consultationId}`, {
|
||||
method: 'DELETE',
|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
// DOM에서 제거
|
||||
const element = document.querySelector(`[data-consultation-id="${consultationId}"]`);
|
||||
if (element) {
|
||||
element.remove();
|
||||
}
|
||||
} else {
|
||||
alert('삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('상담 기록 삭제 실패:', error);
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
260
resources/views/sales/modals/file-uploader.blade.php
Normal file
260
resources/views/sales/modals/file-uploader.blade.php
Normal file
@@ -0,0 +1,260 @@
|
||||
{{-- 첨부파일 업로드 컴포넌트 --}}
|
||||
<div x-data="fileUploader()" class="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-4 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>
|
||||
첨부파일
|
||||
</h4>
|
||||
|
||||
{{-- Drag & Drop 영역 --}}
|
||||
<div
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleDrop($event)"
|
||||
:class="isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-gray-50'"
|
||||
class="border-2 border-dashed rounded-lg p-6 text-center transition-colors cursor-pointer"
|
||||
@click="$refs.fileInput.click()"
|
||||
>
|
||||
<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-blue-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>
|
||||
|
||||
<p class="text-sm text-gray-600 mb-1">
|
||||
파일을 여기에 드래그하거나 <span class="text-blue-600 font-medium">클릭하여 선택</span>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
최대 20MB / PDF, 문서, 이미지, 압축파일 지원
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- 업로드 대기 파일 목록 --}}
|
||||
<div x-show="pendingFiles.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 pendingFiles" :key="index">
|
||||
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
{{-- 파일 아이콘 --}}
|
||||
<div class="flex-shrink-0 p-2 bg-white rounded-lg border border-gray-200">
|
||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</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-gray-500" x-text="formatFileSize(file.size)"></p>
|
||||
</div>
|
||||
|
||||
{{-- 진행률 또는 상태 --}}
|
||||
<div class="flex-shrink-0 w-20">
|
||||
<template x-if="file.uploading">
|
||||
<div class="space-y-1">
|
||||
<div class="h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-blue-600 transition-all duration-300" :style="'width: ' + file.progress + '%'"></div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 text-right" x-text="file.progress + '%'"></p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="file.uploaded">
|
||||
<span class="inline-flex items-center gap-1 text-xs text-green-600 font-medium">
|
||||
<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" />
|
||||
</svg>
|
||||
완료
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="file.error">
|
||||
<span class="inline-flex items-center gap-1 text-xs text-red-600 font-medium">
|
||||
<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>
|
||||
실패
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- 제거 버튼 --}}
|
||||
<button
|
||||
@click="removeFile(index)"
|
||||
:disabled="file.uploading"
|
||||
class="flex-shrink-0 p-1 text-gray-400 hover:text-red-600 disabled:opacity-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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- 업로드 버튼 --}}
|
||||
<div class="flex justify-end mt-3">
|
||||
<button
|
||||
@click="uploadAllFiles()"
|
||||
:disabled="uploading || pendingFiles.every(f => f.uploaded || f.error)"
|
||||
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 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
<svg x-show="!uploading" 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
<svg x-show="uploading" class="w-4 h-4 animate-spin" 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>
|
||||
<span x-text="uploading ? '업로드 중...' : '모두 업로드'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function fileUploader() {
|
||||
return {
|
||||
tenantId: {{ $tenant->id }},
|
||||
scenarioType: '{{ $scenarioType }}',
|
||||
stepId: {{ $stepId ?? 'null' }},
|
||||
|
||||
isDragging: false,
|
||||
pendingFiles: [],
|
||||
uploading: false,
|
||||
|
||||
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);
|
||||
this.addFiles(files);
|
||||
},
|
||||
|
||||
handleFileSelect(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
this.addFiles(files);
|
||||
event.target.value = ''; // 같은 파일 다시 선택 가능하도록
|
||||
},
|
||||
|
||||
addFiles(files) {
|
||||
const maxSize = 20 * 1024 * 1024; // 20MB
|
||||
|
||||
files.forEach(file => {
|
||||
// 중복 체크
|
||||
if (this.pendingFiles.some(f => f.name === file.name && f.size === file.size)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 크기 체크
|
||||
if (file.size > maxSize) {
|
||||
alert(`${file.name}: 파일 크기가 20MB를 초과합니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingFiles.push({
|
||||
file: file,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
progress: 0,
|
||||
uploading: false,
|
||||
uploaded: false,
|
||||
error: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
removeFile(index) {
|
||||
this.pendingFiles.splice(index, 1);
|
||||
},
|
||||
|
||||
async uploadAllFiles() {
|
||||
if (this.uploading) return;
|
||||
|
||||
this.uploading = true;
|
||||
|
||||
for (let i = 0; i < this.pendingFiles.length; i++) {
|
||||
const fileItem = this.pendingFiles[i];
|
||||
if (fileItem.uploaded || fileItem.error) continue;
|
||||
|
||||
await this.uploadFile(fileItem);
|
||||
}
|
||||
|
||||
this.uploading = false;
|
||||
|
||||
// 모두 완료되었으면 상담 기록 새로고침
|
||||
if (this.pendingFiles.every(f => f.uploaded)) {
|
||||
htmx.ajax('GET',
|
||||
`/sales/consultations/${this.tenantId}?scenario_type=${this.scenarioType}&step_id=${this.stepId || ''}`,
|
||||
{ target: '#consultation-log-container', swap: 'innerHTML' }
|
||||
);
|
||||
|
||||
// 3초 후 목록 초기화
|
||||
setTimeout(() => {
|
||||
this.pendingFiles = this.pendingFiles.filter(f => !f.uploaded);
|
||||
}, 3000);
|
||||
}
|
||||
},
|
||||
|
||||
async uploadFile(fileItem) {
|
||||
fileItem.uploading = true;
|
||||
fileItem.progress = 0;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('tenant_id', this.tenantId);
|
||||
formData.append('scenario_type', this.scenarioType);
|
||||
if (this.stepId) formData.append('step_id', this.stepId);
|
||||
formData.append('file', fileItem.file);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
// 진행률 추적
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
fileItem.progress = Math.round((e.loaded / e.total) * 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Promise로 감싸기
|
||||
await new Promise((resolve, reject) => {
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
const result = JSON.parse(xhr.responseText);
|
||||
if (result.success) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(new Error(result.message || '업로드 실패'));
|
||||
}
|
||||
} else {
|
||||
reject(new Error('업로드 실패'));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error('네트워크 오류'));
|
||||
|
||||
xhr.open('POST', '{{ route('sales.consultations.upload-file') }}');
|
||||
xhr.setRequestHeader('X-CSRF-TOKEN', document.querySelector('meta[name="csrf-token"]').content);
|
||||
xhr.setRequestHeader('Accept', 'application/json');
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
fileItem.uploading = false;
|
||||
fileItem.uploaded = true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('파일 업로드 실패:', error);
|
||||
fileItem.uploading = false;
|
||||
fileItem.error = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
185
resources/views/sales/modals/scenario-modal.blade.php
Normal file
185
resources/views/sales/modals/scenario-modal.blade.php
Normal file
@@ -0,0 +1,185 @@
|
||||
{{-- 영업/매니저 시나리오 모달 --}}
|
||||
<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">
|
||||
|
||||
{{-- 배경 오버레이 --}}
|
||||
<div class="absolute inset-0 bg-gray-900/50 backdrop-blur-sm" @click="close()"></div>
|
||||
|
||||
{{-- 모달 컨테이너 --}}
|
||||
<div class="relative flex items-center justify-center min-h-screen p-4">
|
||||
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-6xl max-h-[90vh] overflow-hidden flex flex-col"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
@click.stop>
|
||||
|
||||
{{-- 모달 헤더 --}}
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 bg-gradient-to-r {{ $scenarioType === 'sales' ? 'from-blue-600 to-indigo-600' : 'from-green-600 to-teal-600' }}">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-2 bg-white/20 rounded-lg">
|
||||
@if($scenarioType === 'sales')
|
||||
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
@else
|
||||
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-white">
|
||||
{{ $scenarioType === 'sales' ? '영업 전략 시나리오' : '매니저 상담 프로세스' }}
|
||||
</h2>
|
||||
<p class="text-sm text-white/80">{{ $tenant->company_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
{{-- 전체 진행률 --}}
|
||||
<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>
|
||||
</div>
|
||||
{{-- 닫기 버튼 --}}
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 모달 바디 --}}
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
{{-- 좌측 사이드바: 단계 네비게이션 --}}
|
||||
<div class="w-64 bg-gray-50 border-r border-gray-200 overflow-y-auto">
|
||||
<div class="p-4">
|
||||
<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
|
||||
@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="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">
|
||||
{!! $icons[$step['icon']] ?? '' !!}
|
||||
</svg>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</button>
|
||||
@endforeach
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 우측 메인 영역: 단계별 콘텐츠 --}}
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div id="scenario-step-content" class="p-6">
|
||||
@include('sales.modals.scenario-step', [
|
||||
'step' => collect($steps)->firstWhere('id', $currentStep),
|
||||
'tenant' => $tenant,
|
||||
'scenarioType' => $scenarioType,
|
||||
'progress' => $progress,
|
||||
'icons' => $icons,
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
184
resources/views/sales/modals/scenario-step.blade.php
Normal file
184
resources/views/sales/modals/scenario-step.blade.php
Normal file
@@ -0,0 +1,184 @@
|
||||
{{-- 시나리오 단계별 체크리스트 --}}
|
||||
@php
|
||||
$step = $step ?? collect($steps)->firstWhere('id', $currentStep ?? 1);
|
||||
$checkedItems = $progress[$step['id']] ?? [];
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6">
|
||||
{{-- 단계 헤더 --}}
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="{{ $step['bg_class'] }} {{ $step['text_class'] }} p-3 rounded-xl">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{!! $icons[$step['icon']] ?? '' !!}
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-500">STEP {{ $step['id'] }}</span>
|
||||
<span class="text-sm text-gray-400">{{ $step['subtitle'] }}</span>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-900">{{ $step['title'] }}</h2>
|
||||
<p class="mt-1 text-gray-600">{{ $step['description'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 매니저용 팁 (있는 경우) --}}
|
||||
@if(isset($step['tips']))
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-amber-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-amber-800">매니저 TIP</p>
|
||||
<p class="text-sm text-amber-700">{{ $step['tips'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 체크포인트 목록 --}}
|
||||
<div class="space-y-4">
|
||||
@foreach($step['checkpoints'] as $checkpoint)
|
||||
@php
|
||||
$checkKey = "{$step['id']}_{$checkpoint['id']}";
|
||||
$isChecked = isset($progress[$checkKey]);
|
||||
@endphp
|
||||
<div x-data="{ expanded: false, checked: {{ $isChecked ? 'true' : 'false' }} }"
|
||||
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)"
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{{-- 제목 및 설명 --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-semibold text-gray-900" :class="checked && 'line-through text-gray-500'">
|
||||
{{ $checkpoint['title'] }}
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 truncate">{{ $checkpoint['detail'] }}</p>
|
||||
</div>
|
||||
|
||||
{{-- 확장 아이콘 --}}
|
||||
<svg class="w-5 h-5 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>
|
||||
|
||||
{{-- 확장 콘텐츠 --}}
|
||||
<div x-show="expanded" x-collapse class="border-t border-gray-100">
|
||||
<div class="p-4 space-y-4">
|
||||
{{-- 상세 설명 --}}
|
||||
<div>
|
||||
<h5 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">상세 설명</h5>
|
||||
<p class="text-sm text-gray-700">{{ $checkpoint['detail'] }}</p>
|
||||
</div>
|
||||
|
||||
{{-- PRO TIP --}}
|
||||
<div class="bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="p-1.5 bg-indigo-100 rounded-lg">
|
||||
<svg class="w-4 h-4 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-indigo-800 uppercase">PRO TIP</p>
|
||||
<p class="text-sm text-indigo-700 mt-1">{{ $checkpoint['pro_tip'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- 하단: 상담 기록 및 파일 영역 (마지막 단계에서만) --}}
|
||||
@if($step['id'] === 6)
|
||||
<div class="border-t border-gray-200 pt-6 space-y-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">상담 기록 및 첨부파일</h3>
|
||||
|
||||
{{-- 상담 기록 --}}
|
||||
<div id="consultation-log-container"
|
||||
hx-get="{{ route('sales.consultations.index', $tenant->id) }}?scenario_type={{ $scenarioType }}&step_id={{ $step['id'] }}"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="animate-pulse flex space-x-4">
|
||||
<div class="flex-1 space-y-4 py-1">
|
||||
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div class="space-y-2">
|
||||
<div class="h-4 bg-gray-200 rounded"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 음성 녹음 --}}
|
||||
<div class="mt-4">
|
||||
@include('sales.modals.voice-recorder', [
|
||||
'tenant' => $tenant,
|
||||
'scenarioType' => $scenarioType,
|
||||
'stepId' => $step['id'],
|
||||
])
|
||||
</div>
|
||||
|
||||
{{-- 첨부파일 업로드 --}}
|
||||
<div class="mt-4">
|
||||
@include('sales.modals.file-uploader', [
|
||||
'tenant' => $tenant,
|
||||
'scenarioType' => $scenarioType,
|
||||
'stepId' => $step['id'],
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 단계 이동 버튼 --}}
|
||||
<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 }})"
|
||||
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" />
|
||||
</svg>
|
||||
이전 단계
|
||||
</button>
|
||||
@else
|
||||
<div></div>
|
||||
@endif
|
||||
|
||||
@if($step['id'] < count($steps))
|
||||
<button
|
||||
@click="$parent.selectStep({{ $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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
@else
|
||||
<button
|
||||
@click="$parent.close()"
|
||||
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" />
|
||||
</svg>
|
||||
완료
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
363
resources/views/sales/modals/voice-recorder.blade.php
Normal file
363
resources/views/sales/modals/voice-recorder.blade.php
Normal file
@@ -0,0 +1,363 @@
|
||||
{{-- 음성 녹음 컴포넌트 --}}
|
||||
<div x-data="voiceRecorder()" class="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
음성 녹음
|
||||
</h4>
|
||||
|
||||
{{-- 녹음 컨트롤 --}}
|
||||
<div class="space-y-4">
|
||||
{{-- 파형 시각화 --}}
|
||||
<div class="relative">
|
||||
<canvas
|
||||
x-ref="waveformCanvas"
|
||||
class="w-full h-24 bg-gray-50 rounded-lg border border-gray-200"
|
||||
></canvas>
|
||||
{{-- 타이머 오버레이 --}}
|
||||
<div x-show="isRecording" class="absolute top-2 right-2 flex items-center gap-2 bg-red-500 text-white px-2 py-1 rounded-full text-xs font-medium">
|
||||
<span class="w-2 h-2 bg-white rounded-full animate-pulse"></span>
|
||||
<span x-text="formatTime(timer)">00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 실시간 텍스트 변환 표시 --}}
|
||||
<div x-show="transcript || interimTranscript" class="p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<p class="text-xs font-medium text-gray-500 mb-1">음성 인식 결과</p>
|
||||
<p class="text-sm text-gray-700">
|
||||
<span x-text="transcript"></span>
|
||||
<span class="text-gray-400 italic" x-text="interimTranscript"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- 컨트롤 버튼 --}}
|
||||
<div class="flex items-center justify-center gap-4">
|
||||
{{-- 녹음 시작/중지 버튼 --}}
|
||||
<button
|
||||
@click="toggleRecording()"
|
||||
:class="isRecording ? 'bg-red-500 hover:bg-red-600' : 'bg-purple-600 hover:bg-purple-700'"
|
||||
class="flex items-center justify-center w-14 h-14 rounded-full text-white shadow-lg transition-all transform hover:scale-105 active:scale-95">
|
||||
<svg x-show="!isRecording" 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="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
<svg x-show="isRecording" class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{{-- 저장 버튼 (녹음 완료 후) --}}
|
||||
<button
|
||||
x-show="audioBlob && !isRecording"
|
||||
@click="saveRecording()"
|
||||
:disabled="saving"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 transition-colors">
|
||||
<svg x-show="!saving" 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="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
||||
</svg>
|
||||
<svg x-show="saving" class="w-4 h-4 animate-spin" 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>
|
||||
<span x-text="saving ? '저장 중...' : '저장'"></span>
|
||||
</button>
|
||||
|
||||
{{-- 취소 버튼 (녹음 완료 후) --}}
|
||||
<button
|
||||
x-show="audioBlob && !isRecording"
|
||||
@click="cancelRecording()"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 상태 메시지 --}}
|
||||
<p class="text-center text-sm" :class="isRecording ? 'text-red-600' : 'text-gray-500'" x-text="status"></p>
|
||||
|
||||
{{-- 저장된 녹음 목록 안내 --}}
|
||||
<p class="text-xs text-gray-400 text-center">
|
||||
녹음 파일은 상담 기록에 자동으로 저장됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function voiceRecorder() {
|
||||
return {
|
||||
tenantId: {{ $tenant->id }},
|
||||
scenarioType: '{{ $scenarioType }}',
|
||||
stepId: {{ $stepId ?? 'null' }},
|
||||
|
||||
isRecording: false,
|
||||
audioBlob: null,
|
||||
timer: 0,
|
||||
transcript: '',
|
||||
interimTranscript: '',
|
||||
status: '마이크 버튼을 눌러 녹음을 시작하세요',
|
||||
saving: false,
|
||||
|
||||
// 내부 참조
|
||||
mediaRecorder: null,
|
||||
audioChunks: [],
|
||||
timerInterval: null,
|
||||
recognition: null,
|
||||
stream: null,
|
||||
audioContext: null,
|
||||
analyser: null,
|
||||
animationId: null,
|
||||
|
||||
formatTime(seconds) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
async toggleRecording() {
|
||||
if (this.isRecording) {
|
||||
this.stopRecording();
|
||||
} else {
|
||||
await this.startRecording();
|
||||
}
|
||||
},
|
||||
|
||||
async startRecording() {
|
||||
try {
|
||||
// 마이크 권한 요청
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
this.status = '녹음 중...';
|
||||
|
||||
// AudioContext 및 Analyser 설정
|
||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
this.analyser = this.audioContext.createAnalyser();
|
||||
const source = this.audioContext.createMediaStreamSource(this.stream);
|
||||
source.connect(this.analyser);
|
||||
this.analyser.fftSize = 2048;
|
||||
|
||||
// 파형 그리기 시작
|
||||
this.drawWaveform();
|
||||
|
||||
// MediaRecorder 설정
|
||||
this.mediaRecorder = new MediaRecorder(this.stream);
|
||||
this.audioChunks = [];
|
||||
|
||||
this.mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
this.audioChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.mediaRecorder.onstop = () => {
|
||||
this.audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
|
||||
};
|
||||
|
||||
this.mediaRecorder.start();
|
||||
|
||||
// 타이머 시작
|
||||
this.timer = 0;
|
||||
this.timerInterval = setInterval(() => {
|
||||
this.timer++;
|
||||
}, 1000);
|
||||
|
||||
// 음성 인식 시작
|
||||
this.startSpeechRecognition();
|
||||
|
||||
this.isRecording = true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('녹음 시작 실패:', error);
|
||||
this.status = '마이크 접근 권한이 필요합니다.';
|
||||
}
|
||||
},
|
||||
|
||||
stopRecording() {
|
||||
// MediaRecorder 중지
|
||||
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
|
||||
this.mediaRecorder.stop();
|
||||
}
|
||||
|
||||
// 타이머 중지
|
||||
if (this.timerInterval) {
|
||||
clearInterval(this.timerInterval);
|
||||
this.timerInterval = null;
|
||||
}
|
||||
|
||||
// 음성 인식 중지
|
||||
if (this.recognition) {
|
||||
this.recognition.stop();
|
||||
}
|
||||
|
||||
// 파형 애니메이션 중지
|
||||
if (this.animationId) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
this.animationId = null;
|
||||
}
|
||||
|
||||
// 스트림 정리
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
// AudioContext 정리
|
||||
if (this.audioContext) {
|
||||
this.audioContext.close();
|
||||
}
|
||||
|
||||
this.isRecording = false;
|
||||
this.status = '녹음이 완료되었습니다. 저장하거나 취소하세요.';
|
||||
},
|
||||
|
||||
startSpeechRecognition() {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (!SpeechRecognition) {
|
||||
console.warn('음성 인식이 지원되지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.recognition = new SpeechRecognition();
|
||||
this.recognition.lang = 'ko-KR';
|
||||
this.recognition.continuous = true;
|
||||
this.recognition.interimResults = true;
|
||||
|
||||
this.transcript = '';
|
||||
this.interimTranscript = '';
|
||||
|
||||
this.recognition.onresult = (event) => {
|
||||
let finalTranscript = '';
|
||||
let interimTranscript = '';
|
||||
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const transcript = event.results[i][0].transcript;
|
||||
if (event.results[i].isFinal) {
|
||||
finalTranscript += transcript;
|
||||
} else {
|
||||
interimTranscript += transcript;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalTranscript) {
|
||||
this.transcript += finalTranscript;
|
||||
}
|
||||
this.interimTranscript = interimTranscript;
|
||||
};
|
||||
|
||||
this.recognition.onerror = (event) => {
|
||||
console.warn('음성 인식 오류:', event.error);
|
||||
};
|
||||
|
||||
this.recognition.start();
|
||||
},
|
||||
|
||||
drawWaveform() {
|
||||
if (!this.analyser || !this.$refs.waveformCanvas) return;
|
||||
|
||||
const canvas = this.$refs.waveformCanvas;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
|
||||
|
||||
// 캔버스 크기 설정
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
|
||||
const draw = () => {
|
||||
if (!this.isRecording) return;
|
||||
|
||||
this.analyser.getByteTimeDomainData(dataArray);
|
||||
|
||||
ctx.fillStyle = '#f9fafb';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = '#9333ea';
|
||||
ctx.beginPath();
|
||||
|
||||
const sliceWidth = canvas.width / dataArray.length;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
const v = dataArray[i] / 128.0;
|
||||
const y = v * canvas.height / 2;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
x += sliceWidth;
|
||||
}
|
||||
|
||||
ctx.lineTo(canvas.width, canvas.height / 2);
|
||||
ctx.stroke();
|
||||
|
||||
this.animationId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
draw();
|
||||
},
|
||||
|
||||
async saveRecording() {
|
||||
if (!this.audioBlob || this.saving) return;
|
||||
|
||||
this.saving = true;
|
||||
this.status = '저장 중...';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('tenant_id', this.tenantId);
|
||||
formData.append('scenario_type', this.scenarioType);
|
||||
if (this.stepId) formData.append('step_id', this.stepId);
|
||||
formData.append('audio', this.audioBlob, 'recording.webm');
|
||||
formData.append('transcript', this.transcript);
|
||||
formData.append('duration', this.timer);
|
||||
|
||||
const response = await fetch('{{ route('sales.consultations.upload-audio') }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.status = '저장되었습니다!';
|
||||
this.cancelRecording();
|
||||
|
||||
// 상담 기록 목록 새로고침
|
||||
htmx.ajax('GET',
|
||||
`/sales/consultations/${this.tenantId}?scenario_type=${this.scenarioType}&step_id=${this.stepId || ''}`,
|
||||
{ target: '#consultation-log-container', swap: 'innerHTML' }
|
||||
);
|
||||
} else {
|
||||
this.status = '저장에 실패했습니다.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('녹음 저장 실패:', error);
|
||||
this.status = '저장 중 오류가 발생했습니다.';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
cancelRecording() {
|
||||
this.audioBlob = null;
|
||||
this.timer = 0;
|
||||
this.transcript = '';
|
||||
this.interimTranscript = '';
|
||||
this.status = '마이크 버튼을 눌러 녹음을 시작하세요';
|
||||
|
||||
// 캔버스 초기화
|
||||
const canvas = this.$refs.waveformCanvas;
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#f9fafb';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@@ -791,4 +791,25 @@
|
||||
|
||||
// 영업 실적 관리
|
||||
Route::resource('records', \App\Http\Controllers\Sales\SalesRecordController::class);
|
||||
|
||||
// 영업 시나리오 관리
|
||||
Route::prefix('scenarios')->name('scenarios.')->group(function () {
|
||||
Route::get('/{tenant}/sales', [\App\Http\Controllers\Sales\SalesScenarioController::class, 'salesScenario'])->name('sales');
|
||||
Route::get('/{tenant}/manager', [\App\Http\Controllers\Sales\SalesScenarioController::class, 'managerScenario'])->name('manager');
|
||||
Route::post('/checklist/toggle', [\App\Http\Controllers\Sales\SalesScenarioController::class, 'toggleChecklist'])->name('checklist.toggle');
|
||||
Route::get('/{tenant}/{type}/progress', [\App\Http\Controllers\Sales\SalesScenarioController::class, 'getProgress'])->name('progress');
|
||||
});
|
||||
|
||||
// 상담 기록 관리
|
||||
Route::prefix('consultations')->name('consultations.')->group(function () {
|
||||
Route::get('/{tenant}', [\App\Http\Controllers\Sales\ConsultationController::class, 'index'])->name('index');
|
||||
Route::post('/', [\App\Http\Controllers\Sales\ConsultationController::class, 'store'])->name('store');
|
||||
Route::delete('/{consultation}', [\App\Http\Controllers\Sales\ConsultationController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/upload-audio', [\App\Http\Controllers\Sales\ConsultationController::class, 'uploadAudio'])->name('upload-audio');
|
||||
Route::post('/upload-file', [\App\Http\Controllers\Sales\ConsultationController::class, 'uploadFile'])->name('upload-file');
|
||||
Route::delete('/file/{file}', [\App\Http\Controllers\Sales\ConsultationController::class, 'deleteFile'])->name('delete-file');
|
||||
});
|
||||
|
||||
// 매니저 지정 변경
|
||||
Route::post('/tenants/{tenant}/assign-manager', [\App\Http\Controllers\Sales\SalesDashboardController::class, 'assignManager'])->name('tenants.assign-manager');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user