diff --git a/app/Http/Controllers/Sales/ConsultationController.php b/app/Http/Controllers/Sales/ConsultationController.php index fea2eade..33a1e610 100644 --- a/app/Http/Controllers/Sales/ConsultationController.php +++ b/app/Http/Controllers/Sales/ConsultationController.php @@ -3,9 +3,9 @@ namespace App\Http\Controllers\Sales; use App\Http\Controllers\Controller; +use App\Models\Sales\SalesConsultation; 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; @@ -14,6 +14,7 @@ * 상담 기록 관리 컨트롤러 * * 테넌트별 상담 기록(텍스트, 음성, 파일)을 관리합니다. + * 데이터는 sales_consultations 테이블에 저장됩니다. */ class ConsultationController extends Controller { @@ -26,17 +27,8 @@ public function index(int $tenantId, Request $request): View $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'])); + // DB에서 상담 기록 조회 + $consultations = SalesConsultation::getByTenantAndType($tenantId, $scenarioType, $stepId); return view('sales.modals.consultation-log', [ 'tenant' => $tenant, @@ -58,77 +50,36 @@ public function store(Request $request): JsonResponse 'content' => 'required|string|max:5000', ]); - $tenantId = $request->input('tenant_id'); - $scenarioType = $request->input('scenario_type'); - $stepId = $request->input('step_id'); - $content = $request->input('content'); + $consultation = SalesConsultation::createText( + $request->input('tenant_id'), + $request->input('scenario_type'), + $request->input('step_id'), + $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)); + $consultation->load('creator'); return response()->json([ 'success' => true, - 'consultation' => $consultation, + 'consultation' => [ + 'id' => $consultation->id, + 'type' => $consultation->consultation_type, + 'content' => $consultation->content, + 'created_by_name' => $consultation->creator->name, + 'created_at' => $consultation->created_at->format('Y-m-d H:i'), + ], ]); } /** * 상담 기록 삭제 */ - public function destroy(string $consultationId, Request $request): JsonResponse + public function destroy(int $consultationId, Request $request): JsonResponse { - $request->validate([ - 'tenant_id' => 'required|integer|exists:tenants,id', - 'scenario_type' => 'required|in:sales,manager', - ]); + $consultation = SalesConsultation::findOrFail($consultationId); - $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)); + // 파일이 있으면 함께 삭제 + $consultation->deleteWithFile(); return response()->json([ 'success' => true, @@ -160,32 +111,32 @@ public function uploadAudio(Request $request): JsonResponse $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, []); + // DB에 저장 + $consultation = SalesConsultation::createAudio( + $tenantId, + $scenarioType, + $stepId, + $path, + $fileName, + $file->getSize(), + $transcript, + $duration + ); - // 새 상담 기록 추가 - $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)); + $consultation->load('creator'); return response()->json([ 'success' => true, - 'consultation' => $consultation, + 'consultation' => [ + 'id' => $consultation->id, + 'type' => $consultation->consultation_type, + 'file_name' => $consultation->file_name, + 'transcript' => $consultation->transcript, + 'duration' => $consultation->duration, + 'formatted_duration' => $consultation->formatted_duration, + 'created_by_name' => $consultation->creator->name, + 'created_at' => $consultation->created_at->format('Y-m-d H:i'), + ], ]); } @@ -211,76 +162,38 @@ public function uploadFile(Request $request): JsonResponse $fileName = now()->format('Ymd_His') . '_' . uniqid() . '_' . $originalName; $path = $file->storeAs("tenant/attachments/{$tenantId}", $fileName, 'local'); - // 캐시 키 - $cacheKey = "consultations:{$tenantId}:{$scenarioType}"; - $consultations = cache()->get($cacheKey, []); + // DB에 저장 + $consultation = SalesConsultation::createFile( + $tenantId, + $scenarioType, + $stepId, + $path, + $originalName, + $file->getSize(), + $file->getMimeType() + ); - // 새 상담 기록 추가 - $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)); + $consultation->load('creator'); return response()->json([ 'success' => true, - 'consultation' => $consultation, + 'consultation' => [ + 'id' => $consultation->id, + 'type' => $consultation->consultation_type, + 'file_name' => $consultation->file_name, + 'file_size' => $consultation->file_size, + 'formatted_file_size' => $consultation->formatted_file_size, + 'created_by_name' => $consultation->creator->name, + 'created_at' => $consultation->created_at->format('Y-m-d H:i'), + ], ]); } /** * 파일 삭제 */ - public function deleteFile(string $fileId, Request $request): JsonResponse + public function deleteFile(int $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, - ]); - } } diff --git a/app/Http/Controllers/Sales/SalesDashboardController.php b/app/Http/Controllers/Sales/SalesDashboardController.php index 5e4c41dc..289f15ed 100644 --- a/app/Http/Controllers/Sales/SalesDashboardController.php +++ b/app/Http/Controllers/Sales/SalesDashboardController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Sales; use App\Http\Controllers\Controller; +use App\Models\Sales\SalesTenantManagement; use App\Models\Tenants\Tenant; use App\Models\User; use Illuminate\Http\JsonResponse; @@ -103,12 +104,20 @@ private function getDashboardData(Request $request): array ->orderBy('created_at', 'desc') ->get(); + // 각 테넌트의 영업 관리 정보 로드 + $tenantIds = $tenants->pluck('id')->toArray(); + $managements = SalesTenantManagement::whereIn('tenant_id', $tenantIds) + ->with('manager') + ->get() + ->keyBy('tenant_id'); + return compact( 'stats', 'commissionByRole', 'totalCommissionRatio', 'tenantStats', 'tenants', + 'managements', 'period', 'year', 'month', @@ -129,17 +138,15 @@ public function assignManager(int $tenantId, Request $request): JsonResponse $tenant = Tenant::findOrFail($tenantId); $managerId = $request->input('manager_id'); - // 캐시 키 - $cacheKey = "tenant_manager:{$tenantId}"; + // 테넌트 영업 관리 정보 조회 또는 생성 + $management = SalesTenantManagement::findOrCreateByTenant($tenantId); if ($managerId === 0) { // 본인으로 설정 (현재 로그인 사용자) $manager = auth()->user(); - cache()->put($cacheKey, [ - 'id' => $manager->id, - 'name' => $manager->name, - 'is_self' => true, - ], now()->addDays(365)); + $management->update([ + 'manager_user_id' => $manager->id, + ]); } else { // 특정 매니저 지정 $manager = User::find($managerId); @@ -150,11 +157,9 @@ public function assignManager(int $tenantId, Request $request): JsonResponse ], 404); } - cache()->put($cacheKey, [ - 'id' => $manager->id, - 'name' => $manager->name, - 'is_self' => $manager->id === auth()->id(), - ], now()->addDays(365)); + $management->update([ + 'manager_user_id' => $manager->id, + ]); } return response()->json([ diff --git a/app/Http/Controllers/Sales/SalesScenarioController.php b/app/Http/Controllers/Sales/SalesScenarioController.php index 43874a75..d2ec9559 100644 --- a/app/Http/Controllers/Sales/SalesScenarioController.php +++ b/app/Http/Controllers/Sales/SalesScenarioController.php @@ -3,15 +3,19 @@ namespace App\Http\Controllers\Sales; use App\Http\Controllers\Controller; +use App\Models\Sales\SalesScenarioChecklist; +use App\Models\Sales\SalesTenantManagement; use App\Models\Tenants\Tenant; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Http\JsonResponse; use Illuminate\View\View; /** * 영업 시나리오 관리 컨트롤러 * * 영업 진행 및 매니저 상담 프로세스의 시나리오 모달과 체크리스트를 관리합니다. + * 데이터는 sales_scenario_checklists 테이블에 저장됩니다. */ class SalesScenarioController extends Controller { @@ -25,8 +29,14 @@ public function salesScenario(int $tenantId, Request $request): View|Response $currentStep = (int) $request->input('step', 1); $icons = config('sales_scenario.icons'); - // 체크리스트 진행 상태 조회 - $progress = $this->getChecklistProgress($tenantId, 'sales'); + // 테넌트 영업 관리 정보 조회 또는 생성 + $management = SalesTenantManagement::findOrCreateByTenant($tenantId); + + // 체크리스트 진행 상태 조회 (DB에서) + $progress = SalesScenarioChecklist::calculateProgress($tenantId, 'sales', $steps); + + // 진행률 업데이트 + $management->updateProgress('sales', $progress['percentage']); // HTMX 요청이면 단계 콘텐츠만 반환 if ($request->header('HX-Request') && $request->has('step')) { @@ -38,6 +48,7 @@ public function salesScenario(int $tenantId, Request $request): View|Response 'progress' => $progress, 'scenarioType' => 'sales', 'icons' => $icons, + 'management' => $management, ]); } @@ -48,6 +59,7 @@ public function salesScenario(int $tenantId, Request $request): View|Response 'progress' => $progress, 'scenarioType' => 'sales', 'icons' => $icons, + 'management' => $management, ]); } @@ -61,8 +73,14 @@ public function managerScenario(int $tenantId, Request $request): View|Response $currentStep = (int) $request->input('step', 1); $icons = config('sales_scenario.icons'); - // 체크리스트 진행 상태 조회 - $progress = $this->getChecklistProgress($tenantId, 'manager'); + // 테넌트 영업 관리 정보 조회 또는 생성 + $management = SalesTenantManagement::findOrCreateByTenant($tenantId); + + // 체크리스트 진행 상태 조회 (DB에서) + $progress = SalesScenarioChecklist::calculateProgress($tenantId, 'manager', $steps); + + // 진행률 업데이트 + $management->updateProgress('manager', $progress['percentage']); // HTMX 요청이면 단계 콘텐츠만 반환 if ($request->header('HX-Request') && $request->has('step')) { @@ -74,6 +92,7 @@ public function managerScenario(int $tenantId, Request $request): View|Response 'progress' => $progress, 'scenarioType' => 'manager', 'icons' => $icons, + 'management' => $management, ]); } @@ -84,13 +103,14 @@ public function managerScenario(int $tenantId, Request $request): View|Response 'progress' => $progress, 'scenarioType' => 'manager', 'icons' => $icons, + 'management' => $management, ]); } /** * 체크리스트 항목 토글 (HTMX) */ - public function toggleChecklist(Request $request): Response + public function toggleChecklist(Request $request): JsonResponse { $request->validate([ 'tenant_id' => 'required|integer|exists:tenants,id', @@ -106,28 +126,23 @@ public function toggleChecklist(Request $request): Response $checkpointId = $request->input('checkpoint_id'); $checked = $request->boolean('checked'); - // 캐시 키 생성 - $cacheKey = "scenario_checklist:{$tenantId}:{$scenarioType}"; + // 체크리스트 토글 (DB에 저장) + SalesScenarioChecklist::toggle( + $tenantId, + $scenarioType, + $stepId, + $checkpointId, + $checked, + auth()->id() + ); - // 현재 체크리스트 상태 조회 - $checklist = cache()->get($cacheKey, []); + // 진행률 재계산 + $steps = config("sales_scenario.{$scenarioType}_steps"); + $progress = SalesScenarioChecklist::calculateProgress($tenantId, $scenarioType, $steps); - // 체크리스트 상태 업데이트 - $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); + // 테넌트 영업 관리 정보 업데이트 + $management = SalesTenantManagement::findOrCreateByTenant($tenantId); + $management->updateProgress($scenarioType, $progress['percentage']); return response()->json([ 'success' => true, @@ -139,74 +154,14 @@ public function toggleChecklist(Request $request): Response /** * 진행률 조회 */ - public function getProgress(int $tenantId, string $type): Response + public function getProgress(int $tenantId, string $type): JsonResponse { - $progress = $this->calculateProgress($tenantId, $type); + $steps = config("sales_scenario.{$type}_steps"); + $progress = SalesScenarioChecklist::calculateProgress($tenantId, $type, $steps); 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]); - } } diff --git a/app/Models/Sales/SalesConsultation.php b/app/Models/Sales/SalesConsultation.php new file mode 100644 index 00000000..5f5dbf46 --- /dev/null +++ b/app/Models/Sales/SalesConsultation.php @@ -0,0 +1,248 @@ + 'integer', + 'file_size' => 'integer', + 'duration' => 'integer', + ]; + + /** + * 상담 유형 상수 + */ + const TYPE_TEXT = 'text'; + const TYPE_AUDIO = 'audio'; + const TYPE_FILE = 'file'; + + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * 작성자 관계 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 텍스트 상담 기록 생성 + */ + public static function createText(int $tenantId, string $scenarioType, ?int $stepId, string $content): self + { + return self::create([ + 'tenant_id' => $tenantId, + 'scenario_type' => $scenarioType, + 'step_id' => $stepId, + 'consultation_type' => self::TYPE_TEXT, + 'content' => $content, + 'created_by' => auth()->id(), + ]); + } + + /** + * 음성 상담 기록 생성 + */ + public static function createAudio( + int $tenantId, + string $scenarioType, + ?int $stepId, + string $filePath, + string $fileName, + int $fileSize, + ?string $transcript = null, + ?int $duration = null + ): self { + return self::create([ + 'tenant_id' => $tenantId, + 'scenario_type' => $scenarioType, + 'step_id' => $stepId, + 'consultation_type' => self::TYPE_AUDIO, + 'file_path' => $filePath, + 'file_name' => $fileName, + 'file_size' => $fileSize, + 'file_type' => 'audio/webm', + 'transcript' => $transcript, + 'duration' => $duration, + 'created_by' => auth()->id(), + ]); + } + + /** + * 파일 상담 기록 생성 + */ + public static function createFile( + int $tenantId, + string $scenarioType, + ?int $stepId, + string $filePath, + string $fileName, + int $fileSize, + string $fileType + ): self { + return self::create([ + 'tenant_id' => $tenantId, + 'scenario_type' => $scenarioType, + 'step_id' => $stepId, + 'consultation_type' => self::TYPE_FILE, + 'file_path' => $filePath, + 'file_name' => $fileName, + 'file_size' => $fileSize, + 'file_type' => $fileType, + 'created_by' => auth()->id(), + ]); + } + + /** + * 파일 삭제 (storage 포함) + */ + public function deleteWithFile(): bool + { + if ($this->file_path && Storage::disk('local')->exists($this->file_path)) { + Storage::disk('local')->delete($this->file_path); + } + + return $this->delete(); + } + + /** + * 포맷된 duration Accessor + */ + public function getFormattedDurationAttribute(): ?string + { + if (!$this->duration) { + return null; + } + + $minutes = floor($this->duration / 60); + $seconds = $this->duration % 60; + + return sprintf('%02d:%02d', $minutes, $seconds); + } + + /** + * 포맷된 file size Accessor + */ + public function getFormattedFileSizeAttribute(): ?string + { + if (!$this->file_size) { + return null; + } + + if ($this->file_size < 1024) { + return $this->file_size . ' B'; + } elseif ($this->file_size < 1024 * 1024) { + return round($this->file_size / 1024, 1) . ' KB'; + } else { + return round($this->file_size / (1024 * 1024), 1) . ' MB'; + } + } + + /** + * 테넌트 + 시나리오 타입으로 조회 + */ + public static function getByTenantAndType(int $tenantId, string $scenarioType, ?int $stepId = null) + { + $query = self::where('tenant_id', $tenantId) + ->where('scenario_type', $scenarioType) + ->with('creator') + ->orderBy('created_at', 'desc'); + + if ($stepId !== null) { + $query->where('step_id', $stepId); + } + + return $query->get(); + } + + /** + * 시나리오 타입 스코프 + */ + public function scopeByScenarioType($query, string $type) + { + return $query->where('scenario_type', $type); + } + + /** + * 상담 유형 스코프 + */ + public function scopeByType($query, string $type) + { + return $query->where('consultation_type', $type); + } + + /** + * 텍스트만 스코프 + */ + public function scopeTextOnly($query) + { + return $query->where('consultation_type', self::TYPE_TEXT); + } + + /** + * 오디오만 스코프 + */ + public function scopeAudioOnly($query) + { + return $query->where('consultation_type', self::TYPE_AUDIO); + } + + /** + * 파일만 스코프 + */ + public function scopeFileOnly($query) + { + return $query->where('consultation_type', self::TYPE_FILE); + } +} diff --git a/app/Models/Sales/SalesPartner.php b/app/Models/Sales/SalesPartner.php new file mode 100644 index 00000000..627be054 --- /dev/null +++ b/app/Models/Sales/SalesPartner.php @@ -0,0 +1,116 @@ + 'decimal:2', + 'manager_commission_rate' => 'decimal:2', + 'total_contracts' => 'integer', + 'total_commission' => 'decimal:2', + 'approved_at' => 'datetime', + ]; + + /** + * 연결된 사용자 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * 승인자 + */ + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + /** + * 담당 테넌트 관리 목록 + */ + public function tenantManagements(): HasMany + { + return $this->hasMany(SalesTenantManagement::class, 'sales_partner_id'); + } + + /** + * 파트너 코드 자동 생성 + */ + public static function generatePartnerCode(): string + { + $prefix = 'SP'; + $year = now()->format('y'); + $lastPartner = self::whereYear('created_at', now()->year) + ->orderBy('id', 'desc') + ->first(); + + $sequence = $lastPartner ? (int) substr($lastPartner->partner_code, -4) + 1 : 1; + + return $prefix . $year . str_pad($sequence, 4, '0', STR_PAD_LEFT); + } + + /** + * 활성 파트너 스코프 + */ + public function scopeActive($query) + { + return $query->where('status', 'active'); + } + + /** + * 승인 대기 스코프 + */ + public function scopePending($query) + { + return $query->where('status', 'pending'); + } +} diff --git a/app/Models/Sales/SalesScenarioChecklist.php b/app/Models/Sales/SalesScenarioChecklist.php new file mode 100644 index 00000000..0356f695 --- /dev/null +++ b/app/Models/Sales/SalesScenarioChecklist.php @@ -0,0 +1,168 @@ + 'integer', + 'checkpoint_index' => 'integer', + 'is_checked' => 'boolean', + 'checked_at' => 'datetime', + ]; + + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * 체크한 사용자 관계 + */ + public function checkedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'checked_by'); + } + + /** + * 사용자 관계 (하위 호환성) + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * 체크포인트 토글 + */ + public static function toggle(int $tenantId, string $scenarioType, int $stepId, string $checkpointId, bool $checked, ?int $userId = null): self + { + $checklist = self::firstOrNew([ + 'tenant_id' => $tenantId, + 'scenario_type' => $scenarioType, + 'step_id' => $stepId, + 'checkpoint_id' => $checkpointId, + ]); + + $checklist->is_checked = $checked; + $checklist->checked_at = $checked ? now() : null; + $checklist->checked_by = $checked ? ($userId ?? auth()->id()) : null; + $checklist->save(); + + return $checklist; + } + + /** + * 특정 테넌트/시나리오의 체크리스트 조회 + */ + public static function getChecklist(int $tenantId, string $scenarioType): array + { + $items = self::where('tenant_id', $tenantId) + ->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; + } + + /** + * 진행률 계산 + */ + public static function calculateProgress(int $tenantId, string $scenarioType, array $steps): array + { + $checklist = self::getChecklist($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 scopeByScenarioType($query, string $type) + { + return $query->where('scenario_type', $type); + } + + /** + * 체크된 항목만 스코프 + */ + public function scopeChecked($query) + { + return $query->where('is_checked', true); + } +} diff --git a/app/Models/Sales/SalesTenantManagement.php b/app/Models/Sales/SalesTenantManagement.php new file mode 100644 index 00000000..02a72e38 --- /dev/null +++ b/app/Models/Sales/SalesTenantManagement.php @@ -0,0 +1,203 @@ + 'integer', + 'manager_scenario_step' => 'integer', + 'membership_fee' => 'decimal:2', + 'sales_commission' => 'decimal:2', + 'manager_commission' => 'decimal:2', + 'sales_progress' => 'integer', + 'manager_progress' => 'integer', + 'first_contact_at' => 'datetime', + 'contracted_at' => 'datetime', + 'onboarding_completed_at' => 'datetime', + 'membership_paid_at' => 'datetime', + 'commission_paid_at' => 'datetime', + ]; + + /** + * 상태 상수 + */ + const STATUS_PROSPECT = 'prospect'; + const STATUS_APPROACH = 'approach'; + const STATUS_NEGOTIATION = 'negotiation'; + const STATUS_CONTRACTED = 'contracted'; + const STATUS_ONBOARDING = 'onboarding'; + const STATUS_ACTIVE = 'active'; + const STATUS_CHURNED = 'churned'; + + /** + * 상태 라벨 + */ + public static array $statusLabels = [ + self::STATUS_PROSPECT => '잠재 고객', + self::STATUS_APPROACH => '접근 중', + self::STATUS_NEGOTIATION => '협상 중', + self::STATUS_CONTRACTED => '계약 완료', + self::STATUS_ONBOARDING => '온보딩 중', + self::STATUS_ACTIVE => '활성 고객', + self::STATUS_CHURNED => '이탈', + ]; + + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * 영업 담당자 관계 + */ + public function salesPartner(): BelongsTo + { + return $this->belongsTo(SalesPartner::class, 'sales_partner_id'); + } + + /** + * 관리 매니저 관계 + */ + public function manager(): BelongsTo + { + return $this->belongsTo(User::class, 'manager_user_id'); + } + + /** + * 체크리스트 관계 + */ + public function checklists(): HasMany + { + return $this->hasMany(SalesScenarioChecklist::class, 'tenant_id', 'tenant_id'); + } + + /** + * 상담 기록 관계 + */ + public function consultations(): HasMany + { + return $this->hasMany(SalesConsultation::class, 'tenant_id', 'tenant_id'); + } + + /** + * 테넌트 ID로 조회 또는 생성 + */ + public static function findOrCreateByTenant(int $tenantId): self + { + return self::firstOrCreate( + ['tenant_id' => $tenantId], + [ + 'status' => self::STATUS_PROSPECT, + 'sales_scenario_step' => 1, + 'manager_scenario_step' => 1, + ] + ); + } + + /** + * 진행률 업데이트 + */ + public function updateProgress(string $scenarioType, int $progress): void + { + $field = $scenarioType === 'sales' ? 'sales_progress' : 'manager_progress'; + $this->update([$field => $progress]); + } + + /** + * 현재 단계 업데이트 + */ + public function updateStep(string $scenarioType, int $step): void + { + $field = $scenarioType === 'sales' ? 'sales_scenario_step' : 'manager_scenario_step'; + $this->update([$field => $step]); + } + + /** + * 상태 라벨 Accessor + */ + public function getStatusLabelAttribute(): string + { + return self::$statusLabels[$this->status] ?? $this->status; + } + + /** + * 특정 상태 스코프 + */ + public function scopeByStatus($query, string $status) + { + return $query->where('status', $status); + } + + /** + * 계약 완료 스코프 + */ + public function scopeContracted($query) + { + return $query->whereIn('status', [ + self::STATUS_CONTRACTED, + self::STATUS_ONBOARDING, + self::STATUS_ACTIVE, + ]); + } +} diff --git a/app/Models/SalesScenarioChecklist.php b/app/Models/SalesScenarioChecklist.php deleted file mode 100644 index 7db52802..00000000 --- a/app/Models/SalesScenarioChecklist.php +++ /dev/null @@ -1,37 +0,0 @@ - 'integer', - 'checkpoint_index' => 'integer', - 'is_checked' => 'boolean', - ]; - - /** - * 사용자 관계 - */ - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } -} diff --git a/resources/views/sales/dashboard/partials/manager-dropdown.blade.php b/resources/views/sales/dashboard/partials/manager-dropdown.blade.php index d2435751..91b4cd6f 100644 --- a/resources/views/sales/dashboard/partials/manager-dropdown.blade.php +++ b/resources/views/sales/dashboard/partials/manager-dropdown.blade.php @@ -1,12 +1,12 @@ {{-- 매니저 드롭다운 컴포넌트 --}} @php - $cacheKey = "tenant_manager:{$tenant->id}"; - $assignedManager = cache()->get($cacheKey); - $isSelf = !$assignedManager || ($assignedManager['is_self'] ?? true); - $managerName = $assignedManager['name'] ?? '본인'; + $management = $managements[$tenant->id] ?? null; + $assignedManager = $management?->manager; + $isSelf = !$assignedManager || $assignedManager->id === auth()->id(); + $managerName = $assignedManager?->name ?? '본인'; @endphp -
+
{{-- 드롭다운 트리거 --}}