refactor:영업관리 데이터를 DB 테이블로 변경
- 모델 추가: SalesPartner, SalesTenantManagement, SalesScenarioChecklist, SalesConsultation - 모델 위치 이동: app/Models/ → app/Models/Sales/ - 컨트롤러 수정: 캐시 대신 DB 모델 사용 - 뷰 수정: Eloquent 모델 속성 사용 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
248
app/Models/Sales/SalesConsultation.php
Normal file
248
app/Models/Sales/SalesConsultation.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sales;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* 영업 상담 기록 모델 (텍스트, 음성, 첨부파일)
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property string $scenario_type (sales/manager)
|
||||
* @property int|null $step_id
|
||||
* @property string $consultation_type (text/audio/file)
|
||||
* @property string|null $content
|
||||
* @property string|null $file_path
|
||||
* @property string|null $file_name
|
||||
* @property int|null $file_size
|
||||
* @property string|null $file_type
|
||||
* @property string|null $transcript
|
||||
* @property int|null $duration
|
||||
* @property int $created_by
|
||||
*/
|
||||
class SalesConsultation extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'sales_consultations';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'scenario_type',
|
||||
'step_id',
|
||||
'consultation_type',
|
||||
'content',
|
||||
'file_path',
|
||||
'file_name',
|
||||
'file_size',
|
||||
'file_type',
|
||||
'transcript',
|
||||
'duration',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'step_id' => '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);
|
||||
}
|
||||
}
|
||||
116
app/Models/Sales/SalesPartner.php
Normal file
116
app/Models/Sales/SalesPartner.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sales;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 영업 파트너(영업 담당자) 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property string $partner_code
|
||||
* @property string $partner_type
|
||||
* @property float $commission_rate
|
||||
* @property float $manager_commission_rate
|
||||
* @property string|null $bank_name
|
||||
* @property string|null $account_number
|
||||
* @property string|null $account_holder
|
||||
* @property string $status
|
||||
* @property \Carbon\Carbon|null $approved_at
|
||||
* @property int|null $approved_by
|
||||
* @property int $total_contracts
|
||||
* @property float $total_commission
|
||||
* @property string|null $notes
|
||||
*/
|
||||
class SalesPartner extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'sales_partners';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'partner_code',
|
||||
'partner_type',
|
||||
'commission_rate',
|
||||
'manager_commission_rate',
|
||||
'bank_name',
|
||||
'account_number',
|
||||
'account_holder',
|
||||
'status',
|
||||
'approved_at',
|
||||
'approved_by',
|
||||
'total_contracts',
|
||||
'total_commission',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'commission_rate' => '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');
|
||||
}
|
||||
}
|
||||
168
app/Models/Sales/SalesScenarioChecklist.php
Normal file
168
app/Models/Sales/SalesScenarioChecklist.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sales;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 영업 시나리오 체크리스트 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property string $scenario_type (sales/manager)
|
||||
* @property int $step_id
|
||||
* @property string|null $checkpoint_id
|
||||
* @property int|null $checkpoint_index
|
||||
* @property bool $is_checked
|
||||
* @property \Carbon\Carbon|null $checked_at
|
||||
* @property int|null $checked_by
|
||||
* @property string|null $memo
|
||||
*/
|
||||
class SalesScenarioChecklist extends Model
|
||||
{
|
||||
protected $table = 'sales_scenario_checklists';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'scenario_type',
|
||||
'step_id',
|
||||
'checkpoint_id',
|
||||
'checkpoint_index',
|
||||
'is_checked',
|
||||
'checked_at',
|
||||
'checked_by',
|
||||
'memo',
|
||||
'user_id', // 하위 호환성
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'step_id' => '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);
|
||||
}
|
||||
}
|
||||
203
app/Models/Sales/SalesTenantManagement.php
Normal file
203
app/Models/Sales/SalesTenantManagement.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sales;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 테넌트별 영업 관리 모델 (tenants 외래키 연결)
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int|null $sales_partner_id
|
||||
* @property int|null $manager_user_id
|
||||
* @property int $sales_scenario_step
|
||||
* @property int $manager_scenario_step
|
||||
* @property string $status
|
||||
* @property \Carbon\Carbon|null $first_contact_at
|
||||
* @property \Carbon\Carbon|null $contracted_at
|
||||
* @property \Carbon\Carbon|null $onboarding_completed_at
|
||||
* @property float|null $membership_fee
|
||||
* @property \Carbon\Carbon|null $membership_paid_at
|
||||
* @property string|null $membership_status
|
||||
* @property float|null $sales_commission
|
||||
* @property float|null $manager_commission
|
||||
* @property \Carbon\Carbon|null $commission_paid_at
|
||||
* @property string|null $commission_status
|
||||
* @property int $sales_progress
|
||||
* @property int $manager_progress
|
||||
* @property string|null $notes
|
||||
*/
|
||||
class SalesTenantManagement extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'sales_tenant_managements';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'sales_partner_id',
|
||||
'manager_user_id',
|
||||
'sales_scenario_step',
|
||||
'manager_scenario_step',
|
||||
'status',
|
||||
'first_contact_at',
|
||||
'contracted_at',
|
||||
'onboarding_completed_at',
|
||||
'membership_fee',
|
||||
'membership_paid_at',
|
||||
'membership_status',
|
||||
'sales_commission',
|
||||
'manager_commission',
|
||||
'commission_paid_at',
|
||||
'commission_status',
|
||||
'sales_progress',
|
||||
'manager_progress',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sales_scenario_step' => '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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 영업 시나리오 체크리스트 모델
|
||||
*/
|
||||
class SalesScenarioChecklist extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'step_id',
|
||||
'checkpoint_index',
|
||||
'is_checked',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'step_id' => 'integer',
|
||||
'checkpoint_index' => 'integer',
|
||||
'is_checked' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* 사용자 관계
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<div x-data="managerDropdown({{ $tenant->id }}, {{ json_encode($assignedManager) }})" class="relative">
|
||||
<div x-data="managerDropdown({{ $tenant->id }}, {{ json_encode($assignedManager ? ['id' => $assignedManager->id, 'name' => $assignedManager->name, 'is_self' => $isSelf] : null) }})" class="relative">
|
||||
{{-- 드롭다운 트리거 --}}
|
||||
<button
|
||||
@click="toggle()"
|
||||
|
||||
@@ -30,9 +30,9 @@ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white b
|
||||
|
||||
{{-- 상담 기록 목록 --}}
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-semibold text-gray-700">상담 기록 ({{ count($consultations) }}건)</h4>
|
||||
<h4 class="text-sm font-semibold text-gray-700">상담 기록 ({{ $consultations->count() }}건)</h4>
|
||||
|
||||
@if(empty($consultations))
|
||||
@if($consultations->isEmpty())
|
||||
<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" />
|
||||
@@ -43,10 +43,10 @@ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white b
|
||||
<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'] }}">
|
||||
data-consultation-id="{{ $consultation->id }}">
|
||||
{{-- 삭제 버튼 --}}
|
||||
<button
|
||||
@click="deleteConsultation('{{ $consultation['id'] }}')"
|
||||
@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" />
|
||||
@@ -57,14 +57,14 @@ class="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-600 opacity-0 gro
|
||||
<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
|
||||
@if($consultation->consultation_type === 'text') bg-blue-100 text-blue-600
|
||||
@elseif($consultation->consultation_type === 'audio') bg-purple-100 text-purple-600
|
||||
@else bg-green-100 text-green-600 @endif">
|
||||
@if($consultation['type'] === 'text')
|
||||
@if($consultation->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')
|
||||
@elseif($consultation->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>
|
||||
@@ -76,36 +76,36 @@ class="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-600 opacity-0 gro
|
||||
</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')
|
||||
@if($consultation->consultation_type === 'text')
|
||||
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{ $consultation->content }}</p>
|
||||
@elseif($consultation->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']))
|
||||
@if($consultation->duration)
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ floor($consultation['duration'] / 60) }}:{{ str_pad($consultation['duration'] % 60, 2, '0', STR_PAD_LEFT) }}
|
||||
{{ $consultation->formatted_duration }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@if(isset($consultation['transcript']) && $consultation['transcript'])
|
||||
<p class="text-sm text-gray-600 italic">"{{ $consultation['transcript'] }}"</p>
|
||||
@if($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-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
|
||||
{{ $consultation->formatted_file_size }}
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 메타 정보 --}}
|
||||
<div class="mt-2 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span>{{ $consultation['created_by_name'] }}</span>
|
||||
<span>{{ $consultation->creator?->name ?? '알 수 없음' }}</span>
|
||||
<span>|</span>
|
||||
<span>{{ \Carbon\Carbon::parse($consultation['created_at'])->format('Y-m-d H:i') }}</span>
|
||||
<span>{{ $consultation->created_at->format('Y-m-d H:i') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,10 +175,6 @@ function consultationLog() {
|
||||
'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();
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
{{-- 시나리오 단계별 체크리스트 --}}
|
||||
@php
|
||||
use App\Models\Sales\SalesScenarioChecklist;
|
||||
|
||||
$step = $step ?? collect($steps)->firstWhere('id', $currentStep ?? 1);
|
||||
$checkedItems = $progress[$step['id']] ?? [];
|
||||
// DB에서 체크된 항목 조회
|
||||
$checklist = SalesScenarioChecklist::getChecklist($tenant->id, $scenarioType);
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6">
|
||||
@@ -42,7 +45,7 @@
|
||||
@foreach($step['checkpoints'] as $checkpoint)
|
||||
@php
|
||||
$checkKey = "{$step['id']}_{$checkpoint['id']}";
|
||||
$isChecked = isset($progress[$checkKey]);
|
||||
$isChecked = isset($checklist[$checkKey]);
|
||||
@endphp
|
||||
<div x-data="{ expanded: false, checked: {{ $isChecked ? 'true' : 'false' }} }"
|
||||
class="bg-white border rounded-xl overflow-hidden transition-all duration-200"
|
||||
|
||||
Reference in New Issue
Block a user