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 중 하나는 필수입니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행률 업데이트
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user