feat:가망고객 단계에서 영업/매니저 시나리오 체크리스트 지원
- SalesTenantManagement, SalesScenarioChecklist에 tenant_prospect_id 지원 추가
- 가망고객 기반 시나리오 컨트롤러 메서드 추가
- 라우트 추가: /sales/scenarios/prospect/{id}/sales, manager
- 대시보드에서 가망고객 행에 영업/매니저 버튼 및 진행률 표시
- 시나리오 모달/스텝 뷰 prospect 모드 지원
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Sales\SalesScenarioChecklist;
|
||||
use App\Models\Sales\SalesTenantManagement;
|
||||
use App\Models\Sales\TenantProspect;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
@@ -164,4 +165,158 @@ public function getProgress(int $tenantId, string $type): JsonResponse
|
||||
'progress' => $progress,
|
||||
]);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 가망고객(Prospect) 기반 메서드
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 가망고객 영업 시나리오 모달 뷰
|
||||
*/
|
||||
public function prospectSalesScenario(int $prospectId, Request $request): View|Response
|
||||
{
|
||||
$prospect = TenantProspect::findOrFail($prospectId);
|
||||
$steps = config('sales_scenario.sales_steps');
|
||||
$currentStep = (int) $request->input('step', 1);
|
||||
$icons = config('sales_scenario.icons');
|
||||
|
||||
// 가망고객 영업 관리 정보 조회 또는 생성
|
||||
$management = SalesTenantManagement::findOrCreateByProspect($prospectId);
|
||||
|
||||
// 체크리스트 진행 상태 조회 (DB에서)
|
||||
$progress = SalesScenarioChecklist::calculateProgressByProspect($prospectId, 'sales', $steps);
|
||||
|
||||
// 진행률 업데이트
|
||||
$management->updateProgress('sales', $progress['percentage']);
|
||||
|
||||
// HTMX 요청이면 단계 콘텐츠만 반환
|
||||
if ($request->header('HX-Request') && $request->has('step')) {
|
||||
return view('sales.modals.scenario-step', [
|
||||
'prospect' => $prospect,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'step' => collect($steps)->firstWhere('id', $currentStep),
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'sales',
|
||||
'icons' => $icons,
|
||||
'management' => $management,
|
||||
'isProspect' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
return view('sales.modals.scenario-modal', [
|
||||
'prospect' => $prospect,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'sales',
|
||||
'icons' => $icons,
|
||||
'management' => $management,
|
||||
'isProspect' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 가망고객 매니저 시나리오 모달 뷰
|
||||
*/
|
||||
public function prospectManagerScenario(int $prospectId, Request $request): View|Response
|
||||
{
|
||||
$prospect = TenantProspect::findOrFail($prospectId);
|
||||
$steps = config('sales_scenario.manager_steps');
|
||||
$currentStep = (int) $request->input('step', 1);
|
||||
$icons = config('sales_scenario.icons');
|
||||
|
||||
// 가망고객 영업 관리 정보 조회 또는 생성
|
||||
$management = SalesTenantManagement::findOrCreateByProspect($prospectId);
|
||||
|
||||
// 체크리스트 진행 상태 조회 (DB에서)
|
||||
$progress = SalesScenarioChecklist::calculateProgressByProspect($prospectId, 'manager', $steps);
|
||||
|
||||
// 진행률 업데이트
|
||||
$management->updateProgress('manager', $progress['percentage']);
|
||||
|
||||
// HTMX 요청이면 단계 콘텐츠만 반환
|
||||
if ($request->header('HX-Request') && $request->has('step')) {
|
||||
return view('sales.modals.scenario-step', [
|
||||
'prospect' => $prospect,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'step' => collect($steps)->firstWhere('id', $currentStep),
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'manager',
|
||||
'icons' => $icons,
|
||||
'management' => $management,
|
||||
'isProspect' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
return view('sales.modals.scenario-modal', [
|
||||
'prospect' => $prospect,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'manager',
|
||||
'icons' => $icons,
|
||||
'management' => $management,
|
||||
'isProspect' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 가망고객 체크리스트 항목 토글 (HTMX)
|
||||
*/
|
||||
public function toggleProspectChecklist(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'prospect_id' => 'required|integer|exists:tenant_prospects,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
'step_id' => 'required|integer',
|
||||
'checkpoint_id' => 'required|string',
|
||||
'checked' => 'required|boolean',
|
||||
]);
|
||||
|
||||
$prospectId = $request->input('prospect_id');
|
||||
$scenarioType = $request->input('scenario_type');
|
||||
$stepId = $request->input('step_id');
|
||||
$checkpointId = $request->input('checkpoint_id');
|
||||
$checked = $request->boolean('checked');
|
||||
|
||||
// 체크리스트 토글 (DB에 저장)
|
||||
SalesScenarioChecklist::toggleByProspect(
|
||||
$prospectId,
|
||||
$scenarioType,
|
||||
$stepId,
|
||||
$checkpointId,
|
||||
$checked,
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
// 진행률 재계산
|
||||
$steps = config("sales_scenario.{$scenarioType}_steps");
|
||||
$progress = SalesScenarioChecklist::calculateProgressByProspect($prospectId, $scenarioType, $steps);
|
||||
|
||||
// 영업 관리 정보 업데이트
|
||||
$management = SalesTenantManagement::findOrCreateByProspect($prospectId);
|
||||
$management->updateProgress($scenarioType, $progress['percentage']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'progress' => $progress,
|
||||
'checked' => $checked,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 가망고객 진행률 조회
|
||||
*/
|
||||
public function getProspectProgress(int $prospectId, string $type): JsonResponse
|
||||
{
|
||||
$steps = config("sales_scenario.{$type}_steps");
|
||||
$progress = SalesScenarioChecklist::calculateProgressByProspect($prospectId, $type, $steps);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'progress' => $progress,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ class SalesScenarioChecklist extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'tenant_prospect_id',
|
||||
'scenario_type',
|
||||
'step_id',
|
||||
'checkpoint_id',
|
||||
@@ -53,6 +54,14 @@ public function tenant(): BelongsTo
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 가망고객 관계
|
||||
*/
|
||||
public function tenantProspect(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TenantProspect::class, 'tenant_prospect_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크한 사용자 관계
|
||||
*/
|
||||
@@ -70,7 +79,7 @@ public function user(): BelongsTo
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크포인트 토글
|
||||
* 체크포인트 토글 (tenant_id 기반)
|
||||
*/
|
||||
public static function toggle(int $tenantId, string $scenarioType, int $stepId, string $checkpointId, bool $checked, ?int $userId = null): self
|
||||
{
|
||||
@@ -97,6 +106,34 @@ public static function toggle(int $tenantId, string $scenarioType, int $stepId,
|
||||
return $checklist;
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크포인트 토글 (prospect_id 기반)
|
||||
*/
|
||||
public static function toggleByProspect(int $prospectId, string $scenarioType, int $stepId, string $checkpointId, bool $checked, ?int $userId = null): self
|
||||
{
|
||||
$currentUserId = $userId ?? auth()->id();
|
||||
|
||||
$checklist = self::firstOrNew([
|
||||
'tenant_prospect_id' => $prospectId,
|
||||
'scenario_type' => $scenarioType,
|
||||
'step_id' => $stepId,
|
||||
'checkpoint_id' => $checkpointId,
|
||||
]);
|
||||
|
||||
// 새 레코드인 경우 필수 필드 설정
|
||||
if (!$checklist->exists) {
|
||||
$checklist->user_id = $currentUserId;
|
||||
$checklist->checkpoint_index = 0;
|
||||
}
|
||||
|
||||
$checklist->is_checked = $checked;
|
||||
$checklist->checked_at = $checked ? now() : null;
|
||||
$checklist->checked_by = $checked ? $currentUserId : null;
|
||||
$checklist->save();
|
||||
|
||||
return $checklist;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테넌트/시나리오의 체크리스트 조회
|
||||
*/
|
||||
@@ -119,6 +156,28 @@ public static function getChecklist(int $tenantId, string $scenarioType): array
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 가망고객/시나리오의 체크리스트 조회
|
||||
*/
|
||||
public static function getChecklistByProspect(int $prospectId, string $scenarioType): array
|
||||
{
|
||||
$items = self::where('tenant_prospect_id', $prospectId)
|
||||
->where('scenario_type', $scenarioType)
|
||||
->where('is_checked', true)
|
||||
->get();
|
||||
|
||||
$result = [];
|
||||
foreach ($items as $item) {
|
||||
$key = "{$item->step_id}_{$item->checkpoint_id}";
|
||||
$result[$key] = [
|
||||
'checked_at' => $item->checked_at?->toDateTimeString(),
|
||||
'checked_by' => $item->checked_by,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행률 계산
|
||||
*/
|
||||
@@ -158,6 +217,45 @@ public static function calculateProgress(int $tenantId, string $scenarioType, ar
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 가망고객 기반 진행률 계산
|
||||
*/
|
||||
public static function calculateProgressByProspect(int $prospectId, string $scenarioType, array $steps): array
|
||||
{
|
||||
$checklist = self::getChecklistByProspect($prospectId, $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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 시나리오 타입 스코프
|
||||
*/
|
||||
@@ -231,4 +329,55 @@ public static function getTenantProgress(int $tenantId): array
|
||||
'manager' => self::getSimpleProgress($tenantId, 'manager'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 가망고객별 간단한 진행률 계산
|
||||
*/
|
||||
public static function getSimpleProgressByProspect(int $prospectId, string $scenarioType): array
|
||||
{
|
||||
$configKey = $scenarioType === 'sales' ? 'sales_scenario.sales_steps' : 'sales_scenario.manager_steps';
|
||||
$steps = config($configKey, []);
|
||||
|
||||
$total = 0;
|
||||
$validCheckpointKeys = [];
|
||||
|
||||
foreach ($steps as $step) {
|
||||
foreach ($step['checkpoints'] ?? [] as $checkpoint) {
|
||||
$total++;
|
||||
$validCheckpointKeys[] = "{$step['id']}_{$checkpoint['id']}";
|
||||
}
|
||||
}
|
||||
|
||||
$checkedItems = self::where('tenant_prospect_id', $prospectId)
|
||||
->where('scenario_type', $scenarioType)
|
||||
->where('is_checked', true)
|
||||
->get();
|
||||
|
||||
$completed = 0;
|
||||
foreach ($checkedItems as $item) {
|
||||
$key = "{$item->step_id}_{$item->checkpoint_id}";
|
||||
if (in_array($key, $validCheckpointKeys)) {
|
||||
$completed++;
|
||||
}
|
||||
}
|
||||
|
||||
$percentage = $total > 0 ? round(($completed / $total) * 100) : 0;
|
||||
|
||||
return [
|
||||
'completed' => $completed,
|
||||
'total' => $total,
|
||||
'percentage' => $percentage,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 가망고객별 영업/매니저 진행률 한번에 조회
|
||||
*/
|
||||
public static function getProspectProgress(int $prospectId): array
|
||||
{
|
||||
return [
|
||||
'sales' => self::getSimpleProgressByProspect($prospectId, 'sales'),
|
||||
'manager' => self::getSimpleProgressByProspect($prospectId, 'manager'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ class SalesTenantManagement extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'tenant_prospect_id',
|
||||
'sales_partner_id',
|
||||
'manager_user_id',
|
||||
'sales_scenario_step',
|
||||
@@ -180,6 +181,14 @@ public function tenant(): BelongsTo
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 가망고객 관계
|
||||
*/
|
||||
public function tenantProspect(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TenantProspect::class, 'tenant_prospect_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 영업 담당자 관계
|
||||
*/
|
||||
@@ -243,6 +252,35 @@ public static function findOrCreateByTenant(int $tenantId): self
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 가망고객 ID로 조회 또는 생성
|
||||
*/
|
||||
public static function findOrCreateByProspect(int $prospectId): self
|
||||
{
|
||||
return self::firstOrCreate(
|
||||
['tenant_prospect_id' => $prospectId],
|
||||
[
|
||||
'status' => self::STATUS_PROSPECT,
|
||||
'sales_scenario_step' => 1,
|
||||
'manager_scenario_step' => 1,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트 또는 가망고객 ID로 조회 또는 생성
|
||||
*/
|
||||
public static function findOrCreateByTenantOrProspect(?int $tenantId, ?int $prospectId): self
|
||||
{
|
||||
if ($tenantId) {
|
||||
return self::findOrCreateByTenant($tenantId);
|
||||
}
|
||||
if ($prospectId) {
|
||||
return self::findOrCreateByProspect($prospectId);
|
||||
}
|
||||
throw new \InvalidArgumentException('tenant_id 또는 prospect_id 중 하나는 필수입니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행률 업데이트
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,15 @@
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
},
|
||||
openProspectScenarioModal(prospectId, type) {
|
||||
const url = type === 'sales'
|
||||
? `/sales/scenarios/prospect/${prospectId}/sales`
|
||||
: `/sales/scenarios/prospect/${prospectId}/manager`;
|
||||
htmx.ajax('GET', url, {
|
||||
target: '#scenario-modal-container',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
},
|
||||
confirmDeleteTenant(tenantId, companyName) {
|
||||
if (confirm(`"${companyName}" 테넌트를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`)) {
|
||||
fetch(`/api/admin/tenants/${tenantId}`, {
|
||||
@@ -63,32 +72,77 @@
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 border border-orange-200 rounded-lg bg-orange-50/30">
|
||||
@foreach($prospects as $prospect)
|
||||
<div class="flex items-center gap-4 px-4 py-3 hover:bg-orange-50 transition-colors">
|
||||
<!-- 업체명 및 정보 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-bold text-gray-900 truncate">{{ $prospect->company_name }}</div>
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full {{ $prospect->status_color }}">
|
||||
{{ $prospect->status_label }}
|
||||
</span>
|
||||
@php
|
||||
$prospectProgress = \App\Models\Sales\SalesScenarioChecklist::getProspectProgress($prospect->id);
|
||||
$prospectManagement = \App\Models\Sales\SalesTenantManagement::findOrCreateByProspect($prospect->id);
|
||||
@endphp
|
||||
<div class="prospect-row flex items-center gap-4 px-4 py-3 hover:bg-orange-50 transition-colors" data-prospect-id="{{ $prospect->id }}">
|
||||
<!-- 업체명 및 정보 + 영업/매니저 버튼 -->
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-bold text-gray-900 truncate">{{ $prospect->company_name }}</div>
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full {{ $prospect->status_color }}">
|
||||
{{ $prospect->status_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">
|
||||
@if($prospect->ceo_name)
|
||||
<span>대표: {{ $prospect->ceo_name }}</span>
|
||||
<span class="mx-1">|</span>
|
||||
@endif
|
||||
<span>{{ $prospect->business_number ?? '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">
|
||||
@if($prospect->ceo_name)
|
||||
<span>대표: {{ $prospect->ceo_name }}</span>
|
||||
<span class="mx-1">|</span>
|
||||
@endif
|
||||
<span>{{ $prospect->business_number ?? '-' }}</span>
|
||||
@if($prospect->contact_phone)
|
||||
<span class="mx-1">|</span>
|
||||
<span>{{ $prospect->contact_phone }}</span>
|
||||
@endif
|
||||
{{-- 영업/매니저 진행 버튼 --}}
|
||||
<div class="flex-shrink-0 flex items-center gap-1">
|
||||
<button
|
||||
x-on:click="openProspectScenarioModal({{ $prospect->id }}, 'sales')"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
title="영업 시나리오">
|
||||
<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="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
영업
|
||||
</button>
|
||||
<button
|
||||
x-on:click="openProspectScenarioModal({{ $prospect->id }}, 'manager')"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-green-600 text-white hover:bg-green-700 transition-colors"
|
||||
title="매니저 시나리오">
|
||||
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
매니저
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 진행 현황 (영업/매니저 프로그레스 바) -->
|
||||
<div class="flex-1 min-w-0" id="prospect-progress-{{ $prospect->id }}">
|
||||
<div class="space-y-1.5">
|
||||
{{-- 영업 --}}
|
||||
<div class="flex items-center gap-2" title="영업 {{ $prospectProgress['sales']['percentage'] }}%">
|
||||
<span class="text-xs font-medium text-blue-600 w-6 flex-shrink-0">영업</span>
|
||||
<div class="flex-1 bg-gray-200 rounded-full h-2 min-w-0">
|
||||
<div class="bg-blue-500 h-2 rounded-full transition-all" id="prospect-sales-bar-{{ $prospect->id }}" style="width: {{ $prospectProgress['sales']['percentage'] }}%"></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 w-10 text-right flex-shrink-0" id="prospect-sales-pct-{{ $prospect->id }}">{{ $prospectProgress['sales']['percentage'] }}%</span>
|
||||
</div>
|
||||
{{-- 매니저 --}}
|
||||
<div class="flex items-center gap-2" title="매니저 {{ $prospectProgress['manager']['percentage'] }}%">
|
||||
<span class="text-xs font-medium text-green-600 w-6 flex-shrink-0">매니</span>
|
||||
<div class="flex-1 bg-gray-200 rounded-full h-2 min-w-0">
|
||||
<div class="bg-green-500 h-2 rounded-full transition-all" id="prospect-manager-bar-{{ $prospect->id }}" style="width: {{ $prospectProgress['manager']['percentage'] }}%"></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 w-10 text-right flex-shrink-0" id="prospect-manager-pct-{{ $prospect->id }}">{{ $prospectProgress['manager']['percentage'] }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 영업권 유효기간 -->
|
||||
<div class="flex-shrink-0 text-center">
|
||||
<div class="flex-shrink-0 text-center border-l border-gray-200 pl-4">
|
||||
@if($prospect->isActive())
|
||||
<div class="text-xs text-gray-400">영업권 만료까지</div>
|
||||
<div class="text-xs text-gray-400">영업권 만료</div>
|
||||
<div class="text-lg font-bold text-orange-600">D-{{ $prospect->remaining_days }}</div>
|
||||
@elseif($prospect->isExpired())
|
||||
<div class="text-xs text-gray-400">상태</div>
|
||||
@@ -96,16 +150,10 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- 등록일 -->
|
||||
<div class="flex-shrink-0 text-right border-l border-gray-200 pl-4">
|
||||
<div class="text-xs text-gray-400">등록일</div>
|
||||
<div class="text-sm font-medium text-gray-700">{{ $prospect->registered_at?->format('Y-m-d') ?? $prospect->created_at->format('Y-m-d') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 고객 관리 바로가기 -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex-shrink-0 border-l border-gray-200 pl-4">
|
||||
<a href="{{ route('sales.prospects.index') }}?search={{ $prospect->business_number }}"
|
||||
class="inline-flex items-center gap-1 px-3 py-1.5 rounded text-xs font-medium bg-orange-600 text-white hover:bg-orange-700 transition-colors">
|
||||
class="inline-flex items-center gap-1 px-3 py-1.5 rounded text-xs font-medium bg-gray-600 text-white hover:bg-gray-700 transition-colors">
|
||||
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{{-- 영업/매니저 시나리오 모달 --}}
|
||||
@php
|
||||
$stepProgressJson = json_encode($progress['steps'] ?? []);
|
||||
$isProspectMode = isset($isProspect) && $isProspect;
|
||||
$entity = $isProspectMode ? $prospect : $tenant;
|
||||
$entityId = $entity->id;
|
||||
@endphp
|
||||
|
||||
<div x-data="{
|
||||
@@ -8,14 +11,16 @@
|
||||
currentStep: {{ $currentStep }},
|
||||
totalProgress: {{ $progress['percentage'] ?? 0 }},
|
||||
stepProgress: {{ $stepProgressJson }},
|
||||
tenantId: {{ $tenant->id }},
|
||||
entityId: {{ $entityId }},
|
||||
isProspect: {{ $isProspectMode ? 'true' : 'false' }},
|
||||
scenarioType: '{{ $scenarioType }}',
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
window.dispatchEvent(new CustomEvent('scenario-modal-closed', {
|
||||
detail: {
|
||||
tenantId: this.tenantId,
|
||||
tenantId: this.isProspect ? null : this.entityId,
|
||||
prospectId: this.isProspect ? this.entityId : null,
|
||||
scenarioType: this.scenarioType,
|
||||
progress: this.totalProgress
|
||||
}
|
||||
@@ -29,7 +34,8 @@
|
||||
// 모달 닫힘 이벤트 발송
|
||||
window.dispatchEvent(new CustomEvent('scenario-modal-closed', {
|
||||
detail: {
|
||||
tenantId: detail.tenantId,
|
||||
tenantId: this.isProspect ? null : this.entityId,
|
||||
prospectId: this.isProspect ? this.entityId : null,
|
||||
scenarioType: detail.scenarioType,
|
||||
progress: this.totalProgress,
|
||||
completed: true
|
||||
@@ -40,28 +46,41 @@
|
||||
selectStep(stepId) {
|
||||
if (this.currentStep === stepId) return;
|
||||
this.currentStep = stepId;
|
||||
htmx.ajax('GET',
|
||||
`/sales/scenarios/${this.tenantId}/${this.scenarioType}?step=${stepId}`,
|
||||
{ target: '#scenario-step-content', swap: 'innerHTML' }
|
||||
);
|
||||
const baseUrl = this.isProspect
|
||||
? `/sales/scenarios/prospect/${this.entityId}/${this.scenarioType}`
|
||||
: `/sales/scenarios/${this.entityId}/${this.scenarioType}`;
|
||||
htmx.ajax('GET', `${baseUrl}?step=${stepId}`, { target: '#scenario-step-content', swap: 'innerHTML' });
|
||||
},
|
||||
|
||||
async toggleCheckpoint(stepId, checkpointId, checked) {
|
||||
try {
|
||||
const response = await fetch('/sales/scenarios/checklist/toggle', {
|
||||
const url = this.isProspect
|
||||
? '/sales/scenarios/prospect/checklist/toggle'
|
||||
: '/sales/scenarios/checklist/toggle';
|
||||
const bodyData = this.isProspect
|
||||
? {
|
||||
prospect_id: this.entityId,
|
||||
scenario_type: this.scenarioType,
|
||||
step_id: stepId,
|
||||
checkpoint_id: checkpointId,
|
||||
checked: checked,
|
||||
}
|
||||
: {
|
||||
tenant_id: this.entityId,
|
||||
scenario_type: this.scenarioType,
|
||||
step_id: stepId,
|
||||
checkpoint_id: checkpointId,
|
||||
checked: checked,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
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,
|
||||
}),
|
||||
body: JSON.stringify(bodyData),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
@@ -119,7 +138,7 @@ class="fixed inset-0 z-50 overflow-hidden"
|
||||
<h2 class="text-xl font-bold text-white">
|
||||
{{ $scenarioType === 'sales' ? '영업 전략 시나리오' : '매니저 상담 프로세스' }}
|
||||
</h2>
|
||||
<p class="text-sm text-white/80">{{ $tenant->company_name }}</p>
|
||||
<p class="text-sm text-white/80">{{ $entity->company_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -189,7 +208,8 @@ class="w-full flex items-center gap-3 px-3 py-3 rounded-r-lg text-left transitio
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 하단 고정: 상담 기록 및 첨부파일 (모든 단계 공유) --}}
|
||||
{{-- 하단 고정: 상담 기록 및 첨부파일 (테넌트 전용, 가망고객은 미지원) --}}
|
||||
@if(!$isProspectMode)
|
||||
<div x-data="{ consultationExpanded: false }" class="flex-shrink-0 border-t border-gray-200 bg-gray-50">
|
||||
{{-- 아코디언 헤더 --}}
|
||||
<button type="button"
|
||||
@@ -229,7 +249,7 @@ class="overflow-y-auto bg-white border-t border-gray-200"
|
||||
<div class="p-6 space-y-4">
|
||||
{{-- 상담 기록 --}}
|
||||
<div id="consultation-log-container"
|
||||
hx-get="{{ route('sales.consultations.index', $tenant->id) }}?scenario_type={{ $scenarioType }}"
|
||||
hx-get="{{ route('sales.consultations.index', $entity->id) }}?scenario_type={{ $scenarioType }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="innerHTML">
|
||||
<div class="animate-pulse flex space-x-4">
|
||||
@@ -246,7 +266,7 @@ class="overflow-y-auto bg-white border-t border-gray-200"
|
||||
{{-- 음성 녹음 --}}
|
||||
<div>
|
||||
@include('sales.modals.voice-recorder', [
|
||||
'tenant' => $tenant,
|
||||
'tenant' => $entity,
|
||||
'scenarioType' => $scenarioType,
|
||||
'stepId' => null,
|
||||
])
|
||||
@@ -255,7 +275,7 @@ class="overflow-y-auto bg-white border-t border-gray-200"
|
||||
{{-- 첨부파일 업로드 --}}
|
||||
<div>
|
||||
@include('sales.modals.file-uploader', [
|
||||
'tenant' => $tenant,
|
||||
'tenant' => $entity,
|
||||
'scenarioType' => $scenarioType,
|
||||
'stepId' => null,
|
||||
])
|
||||
@@ -263,6 +283,7 @@ class="overflow-y-auto bg-white border-t border-gray-200"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,16 @@
|
||||
$steps = config($scenarioType === 'sales' ? 'sales_scenario.sales_steps' : 'sales_scenario.manager_steps', []);
|
||||
}
|
||||
$step = $step ?? collect($steps)->firstWhere('id', $currentStep ?? 1);
|
||||
|
||||
// prospect 모드 확인
|
||||
$isProspectMode = isset($isProspect) && $isProspect;
|
||||
$entity = $isProspectMode ? $prospect : $tenant;
|
||||
$entityId = $entity->id;
|
||||
|
||||
// DB에서 체크된 항목 조회
|
||||
$checklist = SalesScenarioChecklist::getChecklist($tenant->id, $scenarioType);
|
||||
$checklist = $isProspectMode
|
||||
? SalesScenarioChecklist::getChecklistByProspect($entityId, $scenarioType)
|
||||
: SalesScenarioChecklist::getChecklist($entityId, $scenarioType);
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6">
|
||||
@@ -54,23 +62,38 @@
|
||||
<div x-data="{
|
||||
expanded: false,
|
||||
checked: {{ $isChecked ? 'true' : 'false' }},
|
||||
isProspect: {{ $isProspectMode ? 'true' : 'false' }},
|
||||
entityId: {{ $entityId }},
|
||||
async toggle() {
|
||||
this.checked = !this.checked;
|
||||
try {
|
||||
const response = await fetch('/sales/scenarios/checklist/toggle', {
|
||||
const url = this.isProspect
|
||||
? '/sales/scenarios/prospect/checklist/toggle'
|
||||
: '/sales/scenarios/checklist/toggle';
|
||||
const bodyData = this.isProspect
|
||||
? {
|
||||
prospect_id: this.entityId,
|
||||
scenario_type: '{{ $scenarioType }}',
|
||||
step_id: {{ $step['id'] }},
|
||||
checkpoint_id: '{{ $checkpoint['id'] }}',
|
||||
checked: this.checked,
|
||||
}
|
||||
: {
|
||||
tenant_id: this.entityId,
|
||||
scenario_type: '{{ $scenarioType }}',
|
||||
step_id: {{ $step['id'] }},
|
||||
checkpoint_id: '{{ $checkpoint['id'] }}',
|
||||
checked: this.checked,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tenant_id: {{ $tenant->id }},
|
||||
scenario_type: '{{ $scenarioType }}',
|
||||
step_id: {{ $step['id'] }},
|
||||
checkpoint_id: '{{ $checkpoint['id'] }}',
|
||||
checked: this.checked,
|
||||
}),
|
||||
body: JSON.stringify(bodyData),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
@@ -149,9 +172,9 @@ class="border-t border-gray-100">
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- 계약 체결 단계 (Step 6)에서만 상품 선택 컴포넌트 표시 --}}
|
||||
@if($step['id'] === 6 && $scenarioType === 'sales')
|
||||
@include('sales.modals.partials.product-selection', ['tenant' => $tenant])
|
||||
{{-- 계약 체결 단계 (Step 6)에서만 상품 선택 컴포넌트 표시 (테넌트 전용) --}}
|
||||
@if($step['id'] === 6 && $scenarioType === 'sales' && !$isProspectMode)
|
||||
@include('sales.modals.partials.product-selection', ['tenant' => $entity])
|
||||
@endif
|
||||
|
||||
{{-- 단계 이동 버튼 --}}
|
||||
@@ -162,12 +185,17 @@ class="border-t border-gray-100">
|
||||
$nextStepId = $currentStepId + 1;
|
||||
$prevStepId = $currentStepId - 1;
|
||||
$stepColor = $step['color'] ?? 'blue';
|
||||
|
||||
// 라우트 결정
|
||||
$routeName = $isProspectMode
|
||||
? 'sales.scenarios.prospect.' . $scenarioType
|
||||
: 'sales.scenarios.' . $scenarioType;
|
||||
@endphp
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||
{{-- 이전 단계 버튼 --}}
|
||||
@if($currentStepId > 1)
|
||||
<button type="button"
|
||||
hx-get="{{ route('sales.scenarios.' . $scenarioType, $tenant->id) }}?step={{ $prevStepId }}"
|
||||
hx-get="{{ route($routeName, $entityId) }}?step={{ $prevStepId }}"
|
||||
hx-target="#scenario-step-content"
|
||||
hx-swap="innerHTML"
|
||||
x-on:click="window.dispatchEvent(new CustomEvent('step-changed', { detail: {{ $prevStepId }} }))"
|
||||
@@ -184,7 +212,7 @@ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-70
|
||||
{{-- 다음 단계 / 완료 버튼 --}}
|
||||
@if($isLastStep)
|
||||
<button type="button"
|
||||
x-on:click="window.dispatchEvent(new CustomEvent('scenario-completed', { detail: { tenantId: {{ $tenant->id }}, scenarioType: '{{ $scenarioType }}' } }))"
|
||||
x-on:click="window.dispatchEvent(new CustomEvent('scenario-completed', { detail: { {{ $isProspectMode ? 'prospectId' : 'tenantId' }}: {{ $entityId }}, scenarioType: '{{ $scenarioType }}' } }))"
|
||||
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" />
|
||||
@@ -193,7 +221,7 @@ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white b
|
||||
</button>
|
||||
@else
|
||||
<button type="button"
|
||||
hx-get="{{ route('sales.scenarios.' . $scenarioType, $tenant->id) }}?step={{ $nextStepId }}"
|
||||
hx-get="{{ route($routeName, $entityId) }}?step={{ $nextStepId }}"
|
||||
hx-target="#scenario-step-content"
|
||||
hx-swap="innerHTML"
|
||||
x-on:click="window.dispatchEvent(new CustomEvent('step-changed', { detail: {{ $nextStepId }} }))"
|
||||
|
||||
@@ -909,10 +909,17 @@
|
||||
|
||||
// 영업 시나리오 관리
|
||||
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::get('/prospect/{prospect}/sales', [\App\Http\Controllers\Sales\SalesScenarioController::class, 'prospectSalesScenario'])->name('prospect.sales');
|
||||
Route::get('/prospect/{prospect}/manager', [\App\Http\Controllers\Sales\SalesScenarioController::class, 'prospectManagerScenario'])->name('prospect.manager');
|
||||
Route::post('/prospect/checklist/toggle', [\App\Http\Controllers\Sales\SalesScenarioController::class, 'toggleProspectChecklist'])->name('prospect.checklist.toggle');
|
||||
Route::get('/prospect/{prospect}/{type}/progress', [\App\Http\Controllers\Sales\SalesScenarioController::class, 'getProspectProgress'])->name('prospect.progress');
|
||||
});
|
||||
|
||||
// 상담 기록 관리
|
||||
|
||||
Reference in New Issue
Block a user