json([ 'data' => [ 'preferred_view' => $request->user()->sales_agent_preferred_view ?? 'auto', 'call_results' => CallResult::where('is_active', true) ->orderBy('sort_order') ->get(['name', 'slug', 'requires_follow_up', 'is_final']) ->map(fn (CallResult $result) => [ 'name' => $result->name, 'slug' => $result->slug, 'requires_follow_up' => $result->requires_follow_up, 'is_final' => $result->is_final, ]) ->values(), ], ]); } public function dashboard(Request $request): JsonResponse { $user = $request->user(); $today = now()->toDateString(); $leadQuery = $this->assignedLeadQuery($user->id); $callQuery = Call::where('user_id', $user->id)->whereDate('created_at', $today); $followUpQuery = FollowUp::where('user_id', $user->id); $nextLead = $this->queueQuery($user->id)->first(); return response()->json([ 'data' => [ 'stats' => [ 'today_calls_count' => (clone $callQuery)->count(), 'today_follow_ups_count' => (clone $followUpQuery)->whereDate('scheduled_at', $today)->where('status', 'pending')->count(), 'overdue_follow_ups_count' => (clone $followUpQuery)->where('status', 'pending')->where('scheduled_at', '<', now())->count(), 'new_assigned_leads_count' => (clone $leadQuery)->whereDate('created_at', $today)->count(), 'successful_calls_today' => (clone $callQuery)->whereIn('result', $this->successfulResults())->count(), 'failed_calls_today' => (clone $callQuery)->whereNotNull('result')->whereNotIn('result', $this->successfulResults())->count(), 'leads_needing_action' => $this->queueQuery($user->id)->count(), ], 'suggested_call' => $nextLead ? $this->leadCard($nextLead) : null, ], ]); } public function callQueue(Request $request): JsonResponse { $query = $this->queueQuery($request->user()->id); $this->applyMobileFilter($query, $request->query('filter')); return response()->json([ 'data' => $query->limit(30)->get()->map(fn (Lead $lead) => $this->leadCard($lead))->values(), ]); } public function leads(Request $request): JsonResponse { $query = $this->assignedLeadQuery($request->user()->id); $this->applyMobileFilter($query, $request->query('filter')); if ($request->query('filter') === 'interested') { $query->where(function ($q) { $q->where('interest_level', 'hot') ->orWhere('last_call_result', 'علاقه‌مند بود') ->orWhereHas('pipelineStage', fn ($stage) => $stage->where('name', 'like', '%علاقه‌مند%')); }); } if ($request->query('filter') === 'proposal') { $query->whereHas('pipelineStage', fn ($stage) => $stage->where('name', 'like', '%پیشنهاد%')); } if ($request->query('filter') === 'closed') { $query->whereNotNull('final_result'); } return response()->json([ 'data' => $query ->orderByDesc('priority') ->orderByRaw('next_follow_up_at IS NULL') ->orderBy('next_follow_up_at') ->limit(40) ->get() ->map(fn (Lead $lead) => $this->leadCard($lead)) ->values(), ]); } public function lead(Request $request, Lead $lead): JsonResponse { if ($lead->assigned_to !== $request->user()->id) { return response()->json(['message' => 'دسترسی غیرمجاز'], 403); } $lead->load([ 'leadStatus:id,name,color', 'pipelineStage:id,name,color', 'contacts.phones:id,contact_id,type,status,call_count,successful_call_count,failed_call_count,last_called_at,last_call_result', 'contactRelations.fromContact:id,name,role', 'contactRelations.toContact:id,name,role', 'calls' => fn ($q) => $q->with('contact:id,name,role')->latest()->limit(12), 'callLogs' => fn ($q) => $q->with('contact:id,name,role')->latest('called_at')->limit(12), 'followUps' => fn ($q) => $q->latest('scheduled_at')->limit(8), ]); $primary = $this->primaryContact($lead); return response()->json([ 'data' => [ 'card' => $this->leadCard($lead), 'primary_contact' => $primary ? $this->contactSummary($primary) : null, 'summary' => [ 'id' => $lead->id, 'name' => $this->leadName($lead), 'company' => $lead->company, 'city' => $lead->city, 'province' => $lead->province, 'source' => $lead->source, 'interest_level' => $lead->interest_level, 'priority' => $lead->priority, 'score' => $lead->lead_score, 'status' => $lead->leadStatus?->name, 'stage' => $lead->pipelineStage?->name, 'notes' => $lead->notes, 'tags' => $this->tags($lead), 'final_result' => $lead->final_result, ], 'calls' => $lead->calls->map(fn (Call $call) => [ 'id' => $call->id, 'contact_name' => $call->contact?->name, 'result' => $call->result, 'notes' => $call->notes, 'created_at' => optional($call->created_at)->toIso8601String(), ])->values(), 'contacts' => $lead->contacts->map(fn ($contact) => $this->contactSummary($contact))->values(), 'notes' => array_values(array_filter([ $lead->notes ? ['id' => 'lead-notes', 'text' => $lead->notes, 'created_at' => optional($lead->updated_at)->toIso8601String()] : null, $lead->customer_notes ? ['id' => 'customer-notes', 'text' => $lead->customer_notes, 'created_at' => optional($lead->updated_at)->toIso8601String()] : null, ])), 'files' => [], 'contact_routes' => $lead->contactRelations->map(fn ($relation) => [ 'id' => $relation->id, 'from' => $relation->fromContact?->name, 'to' => $relation->toContact?->name, 'type' => $relation->relation_type, 'description' => $relation->description, ])->values(), ], ]); } public function followUps(Request $request): JsonResponse { $items = FollowUp::with([ 'lead:id,first_name,last_name,company,lead_status_id,pipeline_stage_id,last_call_result,next_follow_up_at,assigned_to', 'lead.leadStatus:id,name,color', 'lead.pipelineStage:id,name,color', 'lead.contacts.phones:id,contact_id,type,status,call_count,last_call_result,last_called_at', ]) ->where('user_id', $request->user()->id) ->where('scheduled_at', '<=', now()->addWeek()) ->orderBy('scheduled_at') ->limit(80) ->get() ->map(fn (FollowUp $followUp) => $this->followUpCard($followUp)); return response()->json([ 'data' => [ 'overdue' => $items->where('group', 'overdue')->values(), 'today' => $items->where('group', 'today')->values(), 'tomorrow' => $items->where('group', 'tomorrow')->values(), 'week' => $items->where('group', 'week')->values(), 'done' => $items->where('group', 'done')->values(), ], ]); } public function preferredView(Request $request): JsonResponse { $validated = $request->validate([ 'preferred_view' => ['required', Rule::in(['auto', 'mobile', 'desktop'])], ]); $user = $request->user(); $user->forceFill([ 'sales_agent_preferred_view' => $validated['preferred_view'], ])->save(); ActivityLogger::log('agent_mobile_preferred_view_updated', "User {$user->phone} updated Sales Agent preferred view"); $user->load('roles.permissions', 'teams'); $user->permissions = $user->getAllPermissions()->pluck('name'); return response()->json(['data' => $user]); } public function startCall(Request $request): JsonResponse { $validated = $request->validate([ 'lead_id' => 'required|exists:leads,id', 'contact_phone_id' => 'nullable|exists:contact_phones,id', ]); $lead = Lead::findOrFail($validated['lead_id']); if (! $this->canAccessLead($request, $lead)) { return response()->json(['message' => 'دسترسی غیرمجاز'], 403); } if (! empty($validated['contact_phone_id'])) { $belongsToLead = ContactPhone::where('id', $validated['contact_phone_id']) ->whereHas('contact', fn ($query) => $query->where('lead_id', $lead->id)) ->exists(); if (! $belongsToLead) { return response()->json(['message' => 'شماره انتخاب‌شده برای این لید معتبر نیست'], 422); } } $payload = $this->callService->initiateCall( $lead->id, $request->user()->id, $validated['contact_phone_id'] ?? null, false ); unset($payload['recording_url'], $payload['provider_call_id']); return response()->json(['data' => $payload], 201); } public function callResult(Request $request): JsonResponse { $validated = $request->validate([ 'call_id' => 'required|exists:calls,id', 'result' => ['required', Rule::in($this->mobileCallResults())], 'notes' => 'nullable|string|max:2000', 'next_follow_up_at' => 'nullable|date', 'mark_phone_wrong' => 'nullable|boolean', 'disinterest_reason' => 'nullable|string|max:500', 'close_lead' => 'nullable|boolean', 'referral' => 'nullable|array', 'referral.name' => 'required_if:result,معرفی شماره جدید|string|max:255', 'referral.phone' => 'required_if:result,معرفی شماره جدید|string|max:30', 'referral.phone_type' => 'nullable|string|in:mobile,landline,extension', 'referral.role' => 'nullable|string|max:80', 'referral.relation_description' => 'nullable|string|max:255', 'referral.description' => 'nullable|string|max:1000', 'referral.make_primary' => 'nullable|boolean', 'referral.next_follow_up_at' => 'nullable|date', ]); $call = Call::with('lead')->findOrFail($validated['call_id']); if ($call->user_id !== $request->user()->id || ! $call->lead || ! $this->canAccessLead($request, $call->lead)) { return response()->json(['message' => 'دسترسی غیرمجاز'], 403); } $followUpAt = $validated['next_follow_up_at'] ?? $validated['referral']['next_follow_up_at'] ?? null; $callResult = CallResult::where('name', $validated['result'])->first(); if ($callResult?->requires_follow_up && ! $followUpAt) { return response()->json(['message' => 'برای این نتیجه تماس، زمان پیگیری الزامی است'], 422); } $registeredCall = $this->callService->registerResult( $call->id, $validated['result'], $validated['notes'] ?? null, $followUpAt, $validated['referral'] ?? null, [ 'mark_phone_wrong' => (bool) ($validated['mark_phone_wrong'] ?? false), 'disinterest_reason' => $validated['disinterest_reason'] ?? null, 'close_lead' => (bool) ($validated['close_lead'] ?? false), ] ); return response()->json([ 'message' => 'نتیجه تماس ثبت شد', 'data' => [ 'call_id' => $registeredCall->id, 'lead_id' => $registeredCall->lead_id, 'result' => $registeredCall->result, 'next_call' => optional($this->queueQuery($request->user()->id)->where('id', '!=', $registeredCall->lead_id)->first(), fn ($lead) => $this->leadCard($lead)), ], ]); } public function storeFollowUp(Request $request): JsonResponse { $validated = $request->validate([ 'lead_id' => 'required|exists:leads,id', 'call_id' => 'nullable|exists:calls,id', 'scheduled_at' => 'required|date', 'notes' => 'nullable|string|max:2000', ]); $lead = Lead::findOrFail($validated['lead_id']); if (! $this->canAccessLead($request, $lead)) { return response()->json(['message' => 'دسترسی غیرمجاز'], 403); } if (! empty($validated['call_id']) && ! Call::where('id', $validated['call_id'])->where('user_id', $request->user()->id)->where('lead_id', $lead->id)->exists()) { return response()->json(['message' => 'تماس انتخاب‌شده معتبر نیست'], 422); } $followUp = $this->followUps->schedule( $lead, $request->user()->id, $validated['scheduled_at'], $request->user(), $validated['notes'] ?? null, $validated['call_id'] ?? null, 'agent_mobile', ); return response()->json(['data' => $this->followUpCard($followUp->load('lead.contacts.phones'))], 201); } public function newContact(Request $request): JsonResponse { $validated = $request->validate([ 'lead_id' => 'required|exists:leads,id', 'from_contact_id' => 'nullable|exists:contacts,id', 'name' => 'required|string|max:255', 'phone' => 'required|string|max:30', 'phone_type' => 'nullable|string|in:mobile,landline,extension', 'role' => 'nullable|string|max:80', 'relation_description' => 'nullable|string|max:255', 'description' => 'nullable|string|max:1000', 'make_primary' => 'nullable|boolean', ]); $lead = Lead::findOrFail($validated['lead_id']); if (! $this->canAccessLead($request, $lead)) { return response()->json(['message' => 'دسترسی غیرمجاز'], 403); } if (! empty($validated['from_contact_id']) && ! Contact::where('id', $validated['from_contact_id'])->where('lead_id', $lead->id)->exists()) { return response()->json(['message' => 'مخاطب معرف برای این لید معتبر نیست'], 422); } $contact = DB::transaction(function () use ($validated, $lead, $request) { if ($validated['make_primary'] ?? false) { Contact::where('lead_id', $lead->id)->update(['is_primary' => false]); } $contact = Contact::create([ 'lead_id' => $lead->id, 'name' => $validated['name'], 'role' => $validated['role'] ?? null, 'description' => $validated['description'] ?? null, 'status' => 'active', 'is_primary' => (bool) ($validated['make_primary'] ?? false), 'primary_reason' => ($validated['make_primary'] ?? false) ? 'ثبت‌شده از نسخه موبایل' : null, 'created_by' => $request->user()->id, ]); ContactPhone::create([ 'contact_id' => $contact->id, 'phone' => $validated['phone'], 'type' => $validated['phone_type'] ?? 'mobile', 'status' => 'active', ]); ContactRelation::create([ 'lead_id' => $lead->id, 'from_contact_id' => $validated['from_contact_id'] ?? null, 'to_contact_id' => $contact->id, 'relation_type' => 'introduced', 'description' => $validated['relation_description'] ?? null, 'created_by' => $request->user()->id, ]); return $contact->load('phones'); }); ActivityLogger::log('agent_mobile_contact_created', "Mobile contact {$contact->name} created for lead {$lead->id}", $contact); return response()->json(['data' => $this->contactSummary($contact)], 201); } private function assignedLeadQuery(int $userId) { return Lead::query() ->with([ 'leadStatus:id,name,color', 'pipelineStage:id,name,color', 'contacts.phones:id,contact_id,type,status,call_count,last_call_result,last_called_at', ]) ->where('assigned_to', $userId); } private function canAccessLead(Request $request, Lead $lead): bool { return (int) $lead->assigned_to === (int) $request->user()->id; } private function mobileCallResults(): array { $results = CallResult::where('is_active', true)->orderBy('sort_order')->pluck('name')->all(); return array_values(array_unique(array_merge($results, [ 'پاسخ داد', 'جواب نداد', 'اشغال بود', 'بعداً تماس بگیر', 'علاقه‌مند است', 'پیشنهاد ارسال شد', 'شماره اشتباه', 'معرفی شماره جدید', 'عدم تمایل', ]))); } private function queueQuery(int $userId) { return $this->assignedLeadQuery($userId) ->where(function ($query) { $query->whereNull('last_call_at') ->orWhere('next_follow_up_at', '<=', now()) ->orWhereHas('followUps', fn ($followUp) => $followUp->where('status', 'pending')->where('scheduled_at', '<=', now())); }) ->orderByDesc('priority') ->orderByRaw('next_follow_up_at IS NULL') ->orderBy('next_follow_up_at') ->orderBy('created_at'); } private function applyMobileFilter($query, ?string $filter): void { match ($filter) { 'today' => $query->whereDate('next_follow_up_at', now()->toDateString()), 'overdue' => $query->where('next_follow_up_at', '<', now()), 'no-call' => $query->whereNull('last_call_at'), 'high-priority' => $query->where('priority', '>=', 7), default => null, }; } private function leadCard(Lead $lead): array { $primary = $this->primaryContact($lead); return [ 'id' => $lead->id, 'lead_name' => $this->leadName($lead), 'main_contact_name' => $primary?->name, 'main_contact_role' => $primary?->role, 'main_contact_phone_id' => $primary?->phones?->first()?->id, 'lead_status' => $lead->leadStatus?->name, 'lead_status_color' => $lead->leadStatus?->color, 'stage' => $lead->pipelineStage?->name, 'stage_color' => $lead->pipelineStage?->color, 'last_call_result' => $lead->last_call_result, 'last_call_at' => optional($lead->last_call_at)->toIso8601String(), 'follow_up_time' => optional($lead->next_follow_up_at)->toIso8601String(), 'priority' => $lead->priority ?? 0, 'priority_label' => $this->priorityLabel($lead->priority ?? 0), 'short_note' => $lead->notes ? mb_strimwidth($lead->notes, 0, 120, '...') : null, 'next_action' => $this->nextAction($lead), 'follow_up_status' => $this->followUpStatus($lead), 'tags' => array_slice($this->tags($lead), 0, 4), 'call_attempts' => $lead->call_attempts ?? 0, ]; } private function followUpCard(FollowUp $followUp): array { $lead = $followUp->lead; $primary = $lead ? $this->primaryContact($lead) : null; $scheduled = $followUp->scheduled_at; return [ 'id' => $followUp->id, 'lead_id' => $lead?->id, 'lead_name' => $lead ? $this->leadName($lead) : 'لید حذف‌شده', 'contact_name' => $primary?->name, 'contact_phone_id' => $primary?->phones?->first()?->id, 'follow_up_time' => optional($scheduled)->toIso8601String(), 'follow_up_type' => $followUp->call_id ? 'بعد از تماس' : 'پیگیری', 'last_call_result' => $lead?->last_call_result, 'notes' => $followUp->notes, 'status' => $followUp->status, 'group' => $this->followUpGroup($followUp), ]; } private function contactSummary($contact): array { $phones = $contact->phones ?? collect(); $mainPhone = $phones->first(); return [ 'id' => $contact->id, 'name' => $contact->name, 'role' => $contact->role, 'description' => $contact->description, 'status' => $contact->status, 'is_primary' => (bool) $contact->is_primary, 'phone_id' => $mainPhone?->id, 'phone_type' => $mainPhone?->type, 'phone_status' => $mainPhone?->status, 'call_count' => (int) $phones->sum('call_count'), 'successful_call_count' => (int) $phones->sum('successful_call_count'), 'failed_call_count' => (int) $phones->sum('failed_call_count'), 'last_call_result' => $mainPhone?->last_call_result, 'last_called_at' => optional($mainPhone?->last_called_at)->toIso8601String(), ]; } private function primaryContact(Lead $lead) { $contacts = $lead->relationLoaded('contacts') ? $lead->contacts : $lead->contacts()->with('phones')->get(); return $contacts->firstWhere('is_primary', true) ?? $contacts->first(); } private function leadName(Lead $lead): string { return trim("{$lead->first_name} {$lead->last_name}") ?: ($lead->company ?: "لید {$lead->id}"); } private function tags(Lead $lead): array { if (! $lead->tags) { return []; } $decoded = json_decode($lead->tags, true); if (is_array($decoded)) { return array_values(array_filter($decoded, fn ($tag) => is_string($tag) && $tag !== '')); } return array_values(array_filter(array_map('trim', explode(',', $lead->tags)))); } private function priorityLabel(int $priority): string { if ($priority >= 8) { return 'خیلی بالا'; } if ($priority >= 5) { return 'بالا'; } if ($priority >= 2) { return 'متوسط'; } return 'عادی'; } private function nextAction(Lead $lead): string { if (! $lead->last_call_at) { return 'تماس اول'; } if ($lead->next_follow_up_at && $lead->next_follow_up_at->isPast()) { return 'پیگیری عقب‌افتاده'; } if ($lead->next_follow_up_at && $lead->next_follow_up_at->isToday()) { return 'پیگیری امروز'; } return 'ادامه پیگیری'; } private function followUpStatus(Lead $lead): string { if (! $lead->next_follow_up_at) { return 'بدون زمان پیگیری'; } if ($lead->next_follow_up_at->isPast()) { return 'عقب‌افتاده'; } if ($lead->next_follow_up_at->isToday()) { return 'امروز'; } return 'زمان‌بندی‌شده'; } private function followUpGroup(FollowUp $followUp): string { if ($followUp->status === 'completed') { return 'done'; } if ($followUp->scheduled_at->isPast() && ! $followUp->scheduled_at->isToday()) { return 'overdue'; } if ($followUp->scheduled_at->isToday()) { return 'today'; } if ($followUp->scheduled_at->isTomorrow()) { return 'tomorrow'; } return 'week'; } private function successfulResults(): array { return CallResult::where('is_positive', true)->pluck('name')->all(); } }