feat:레거시 영업관리 시스템 MNG 마이그레이션

- 영업/매니저 시나리오 모달 구현 (6단계 체크리스트)
- 상담 기록 기능 (텍스트, 음성, 첨부파일)
- 음성 녹음 + Speech-to-Text 변환
- 첨부파일 Drag & Drop 업로드
- 매니저 지정 드롭다운

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
pro
2026-01-28 21:45:11 +09:00
parent c20624ad0d
commit 2f381b2285
13 changed files with 2497 additions and 56 deletions

View 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,
]);
}
}

View File

@@ -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,
]);
}
}

View 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
View 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" />',
],
];

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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

View 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>

View 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>

View File

@@ -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');
});