implement client calling
This commit is contained in:
parent
193d14d643
commit
e03d6ce250
@ -2,9 +2,12 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\App;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCallNote;
|
||||
use App\Models\ClientMeeting;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Models\UserAppOverride;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@ -30,6 +33,9 @@ public function index()
|
||||
// Fetch all overrides
|
||||
$overrides = UserAppOverride::with(['user', 'app'])->orderBy('created_at', 'desc')->get();
|
||||
|
||||
// Fetch all client projects & meetings
|
||||
$clients = Client::with(['responsibleUser', 'creator', 'meetings', 'callNotes'])->orderBy('created_at', 'desc')->get();
|
||||
|
||||
// Calculate statistics
|
||||
$stats = [
|
||||
'total_users' => User::count(),
|
||||
@ -37,11 +43,14 @@ public function index()
|
||||
'blocked_count' => User::where('is_blocked', true)->count(),
|
||||
'apps_count' => App::count(),
|
||||
'roles_count' => Role::count(),
|
||||
'clients_count' => Client::count(),
|
||||
'client_meetings_count' => ClientMeeting::count(),
|
||||
'angry_clients_count' => Client::where('latest_sentiment', 'angry')->count(),
|
||||
];
|
||||
|
||||
$defaultRoleName = \App\Models\Setting::get('default_role', 'Developer');
|
||||
|
||||
return view('admin.dashboard', compact('admin', 'users', 'apps', 'roles', 'overrides', 'stats', 'defaultRoleName'));
|
||||
return view('admin.dashboard', compact('admin', 'users', 'apps', 'roles', 'overrides', 'clients', 'stats', 'defaultRoleName'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
691
app/Http/Controllers/ClientCallController.php
Normal file
691
app/Http/Controllers/ClientCallController.php
Normal file
@ -0,0 +1,691 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Mail\ClientMeetingInviteMail;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCallNote;
|
||||
use App\Models\ClientMeeting;
|
||||
use App\Models\User;
|
||||
use App\Services\AiClientCallService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ClientCallController extends Controller
|
||||
{
|
||||
protected AiClientCallService $aiService;
|
||||
|
||||
public function __construct(AiClientCallService $aiService)
|
||||
{
|
||||
$this->aiService = $aiService;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all client calls and projects.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user->canAccessClientCalls()) {
|
||||
return redirect()->route('dashboard')->with('error', 'Client Calls management is reserved for PMs, Team Leads, Directors, and Administrators.');
|
||||
}
|
||||
|
||||
$clients = $user->getAccessibleClients();
|
||||
$allUsers = User::orderBy('name', 'asc')->get();
|
||||
|
||||
// Filter managers / leads for dropdowns
|
||||
$managersAndLeads = $allUsers->filter(function ($u) {
|
||||
return $u->canAccessClientCalls();
|
||||
});
|
||||
|
||||
// Quick stats
|
||||
$totalClients = $clients->count();
|
||||
$totalMeetings = ClientMeeting::whereIn('client_id', $clients->pluck('id'))->count();
|
||||
$angryClientsCount = $clients->where('latest_sentiment', 'angry')->count();
|
||||
|
||||
return view('client_calls.index', compact('clients', 'allUsers', 'managersAndLeads', 'totalClients', 'totalMeetings', 'angryClientsCount'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new Client & Project profile.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user->canAccessClientCalls()) {
|
||||
return redirect()->route('dashboard')->with('error', 'Unauthorized action.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'client_name' => 'required|string|max:255',
|
||||
'client_email' => 'required|email|max:255',
|
||||
'client_phone' => 'nullable|string|max:50',
|
||||
'company_name' => 'nullable|string|max:255',
|
||||
'project_name' => 'required|string|max:255',
|
||||
'project_details' => 'nullable|string',
|
||||
'responsible_user_id' => 'required|exists:users,id',
|
||||
'assigned_team_members' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$client = Client::create([
|
||||
'client_name' => $request->client_name,
|
||||
'client_email' => strtolower(trim($request->client_email)),
|
||||
'client_phone' => $request->client_phone,
|
||||
'company_name' => $request->company_name,
|
||||
'project_name' => $request->project_name,
|
||||
'project_details' => $request->project_details,
|
||||
'responsible_user_id' => $request->responsible_user_id,
|
||||
'created_by' => $user->id,
|
||||
'assigned_team_members' => $request->assigned_team_members ?? [],
|
||||
'status' => 'active',
|
||||
'latest_sentiment' => 'neutral',
|
||||
]);
|
||||
|
||||
return redirect()->route('client-calls.index')->with('success', "Client '{$client->client_name}' & Project '{$client->project_name}' created successfully!");
|
||||
}
|
||||
|
||||
/**
|
||||
* Show Client Call Hub (opens in new tab).
|
||||
*/
|
||||
public function show($id)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user->canAccessClientCalls()) {
|
||||
return redirect()->route('dashboard')->with('error', 'Unauthorized access.');
|
||||
}
|
||||
|
||||
$client = Client::with(['responsibleUser', 'creator', 'meetings.host', 'meetings.latestNote', 'callNotes.creator'])->findOrFail($id);
|
||||
$allUsers = User::orderBy('name', 'asc')->get();
|
||||
|
||||
$canViewAngry = $client->canViewAngryAnalysis($user);
|
||||
|
||||
return view('client_calls.show', compact('client', 'allUsers', 'canViewAngry'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a future meeting.
|
||||
*/
|
||||
public function scheduleMeeting(Request $request, $clientId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$client = Client::findOrFail($clientId);
|
||||
|
||||
$request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'agenda' => 'nullable|string',
|
||||
'scheduled_at' => 'required|date',
|
||||
'duration_minutes' => 'required|integer|min:5|max:480',
|
||||
'team_attendees' => 'nullable|array',
|
||||
'client_emails' => 'nullable|string', // comma separated emails
|
||||
]);
|
||||
|
||||
// Parse client emails
|
||||
$clientEmails = [];
|
||||
if (!empty($request->client_emails)) {
|
||||
$rawEmails = explode(',', $request->client_emails);
|
||||
foreach ($rawEmails as $email) {
|
||||
$trimmed = strtolower(trim($email));
|
||||
if (filter_var($trimmed, FILTER_VALIDATE_EMAIL)) {
|
||||
$clientEmails[] = $trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Always include primary client email
|
||||
if (!in_array(strtolower(trim($client->client_email)), $clientEmails)) {
|
||||
$clientEmails[] = strtolower(trim($client->client_email));
|
||||
}
|
||||
|
||||
$meetingCode = 'call-' . Str::lower(Str::random(9));
|
||||
|
||||
$meeting = ClientMeeting::create([
|
||||
'client_id' => $client->id,
|
||||
'title' => $request->title,
|
||||
'agenda' => $request->agenda,
|
||||
'meeting_code' => $meetingCode,
|
||||
'meeting_type' => 'scheduled',
|
||||
'scheduled_at' => Carbon::parse($request->scheduled_at),
|
||||
'duration_minutes' => (int)$request->duration_minutes,
|
||||
'host_user_id' => $user->id,
|
||||
'team_attendees' => $request->team_attendees ?? [],
|
||||
'client_emails' => $clientEmails,
|
||||
'status' => 'scheduled',
|
||||
]);
|
||||
|
||||
// Send meeting invitation email to clients & internal team attendees
|
||||
$this->sendMeetingInvitations($meeting);
|
||||
|
||||
return redirect()->route('client-calls.show', $client->id)->with('success', "Meeting '{$meeting->title}' scheduled! Invitations with join link have been emailed to attendees.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an instant meeting room immediately.
|
||||
*/
|
||||
public function createInstantMeeting(Request $request, $clientId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$client = Client::findOrFail($clientId);
|
||||
|
||||
$title = $request->input('title') ?: ("Instant Sync - " . $client->project_name);
|
||||
$meetingCode = 'quick-' . Str::lower(Str::random(9));
|
||||
|
||||
$clientEmails = [strtolower(trim($client->client_email))];
|
||||
if ($request->filled('client_emails')) {
|
||||
$raw = explode(',', $request->client_emails);
|
||||
foreach ($raw as $e) {
|
||||
$t = strtolower(trim($e));
|
||||
if (filter_var($t, FILTER_VALIDATE_EMAIL) && !in_array($t, $clientEmails)) {
|
||||
$clientEmails[] = $t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$meeting = ClientMeeting::create([
|
||||
'client_id' => $client->id,
|
||||
'title' => $title,
|
||||
'agenda' => $request->input('agenda', 'Instant ad-hoc collaboration call'),
|
||||
'meeting_code' => $meetingCode,
|
||||
'meeting_type' => 'instant',
|
||||
'scheduled_at' => now(),
|
||||
'duration_minutes' => 45,
|
||||
'host_user_id' => $user->id,
|
||||
'team_attendees' => $request->team_attendees ?? [$user->id],
|
||||
'client_emails' => $clientEmails,
|
||||
'status' => 'in_progress',
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
// If requested to send invite email immediately
|
||||
if ($request->boolean('send_email_now', true)) {
|
||||
$this->sendMeetingInvitations($meeting);
|
||||
}
|
||||
|
||||
if ($request->ajax() || $request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'meeting_id' => $meeting->id,
|
||||
'meeting_code' => $meeting->meeting_code,
|
||||
'join_url' => $meeting->join_url,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('client-calls.room', $meeting->meeting_code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Video Call Room (like Google Meet).
|
||||
* Works for authenticated team members and external guests.
|
||||
*/
|
||||
public function showRoom(Request $request, $code)
|
||||
{
|
||||
$meeting = ClientMeeting::with(['client.responsibleUser', 'client.creator', 'host'])->where('meeting_code', $code)->firstOrFail();
|
||||
|
||||
$user = Auth::user();
|
||||
$isGuest = !$user;
|
||||
|
||||
// If guest has not provided a display name yet, show the guest join screen
|
||||
if ($isGuest && !session()->has("guest_name_{$code}")) {
|
||||
return view('client_calls.guest_join', compact('meeting'));
|
||||
}
|
||||
|
||||
$participantName = $user ? $user->name : session("guest_name_{$code}", 'Client Guest');
|
||||
$participantRole = $user ? ($user->role ?? 'Team') : 'Client / External Guest';
|
||||
$participantAvatar = $user ? ($user->avatar ?? null) : null;
|
||||
$isHost = $user && ((int)$meeting->host_user_id === (int)$user->id || $user->isAdmin());
|
||||
|
||||
return view('client_calls.room', compact('meeting', 'participantName', 'participantRole', 'participantAvatar', 'isHost', 'isGuest'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Guest Join Screen.
|
||||
*/
|
||||
public function joinGuest(Request $request, $code)
|
||||
{
|
||||
$meeting = ClientMeeting::where('meeting_code', $code)->firstOrFail();
|
||||
|
||||
$request->validate([
|
||||
'guest_name' => 'required|string|max:100',
|
||||
'guest_email' => 'nullable|email|max:150',
|
||||
]);
|
||||
|
||||
session(["guest_name_{$code}" => trim($request->guest_name)]);
|
||||
if ($request->filled('guest_email')) {
|
||||
session(["guest_email_{$code}" => strtolower(trim($request->guest_email))]);
|
||||
}
|
||||
|
||||
return redirect()->route('client-calls.room', $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-time Emotion / Expression Detection endpoint.
|
||||
*/
|
||||
public function detectEmotion(Request $request, $code)
|
||||
{
|
||||
$speechText = $request->input('speech_text', '');
|
||||
$imageFrame = $request->input('image_frame', null);
|
||||
|
||||
$result = $this->aiService->detectEmotion($speechText, $imageFrame);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* End Call, Generate AI MoM & Notes, and Send Email to TL, Director, PM, and Admin.
|
||||
*/
|
||||
public function endCallAndGenerateMoM(Request $request, $code)
|
||||
{
|
||||
$meeting = ClientMeeting::with('client')->where('meeting_code', $code)->firstOrFail();
|
||||
$client = $meeting->client;
|
||||
|
||||
$transcript = $request->input('transcript', '');
|
||||
$liveNotes = $request->input('live_notes', '');
|
||||
$emotionTimeline = $request->input('emotion_timeline', []);
|
||||
|
||||
// Mark meeting as completed
|
||||
$meeting->update([
|
||||
'status' => 'completed',
|
||||
'ended_at' => now(),
|
||||
]);
|
||||
|
||||
// Generate AI MoM
|
||||
$aiResult = $this->aiService->generateMoM(
|
||||
$transcript,
|
||||
$liveNotes,
|
||||
$client->project_name ?? 'Client Project',
|
||||
$client->client_name ?? 'Client'
|
||||
);
|
||||
|
||||
// Update Client latest sentiment
|
||||
$client->update([
|
||||
'latest_sentiment' => $aiResult['detected_sentiment'] ?? 'neutral',
|
||||
]);
|
||||
|
||||
// Create ClientCallNote record
|
||||
$callNote = ClientCallNote::create([
|
||||
'client_meeting_id' => $meeting->id,
|
||||
'client_id' => $client->id,
|
||||
'created_by' => Auth::id() ?? $meeting->host_user_id,
|
||||
'raw_transcript' => $transcript,
|
||||
'live_notes' => $liveNotes,
|
||||
'mom_content' => $aiResult['mom_content'],
|
||||
'summary' => $aiResult['summary'],
|
||||
'action_items' => $aiResult['action_items'],
|
||||
'todo_items' => $aiResult['todo_items'] ?? [],
|
||||
'key_decisions' => $aiResult['key_decisions'],
|
||||
'detected_sentiment' => $aiResult['detected_sentiment'],
|
||||
'sentiment_breakdown' => $aiResult['sentiment_breakdown'],
|
||||
'angry_analysis' => $aiResult['angry_analysis'],
|
||||
'emotion_details' => $aiResult['emotion_details'] ?? [],
|
||||
]);
|
||||
|
||||
// Automatically send email with MoM and notes to TL, Director, PM, and Admin
|
||||
$emailStatus = $this->aiService->sendMoMEmails($callNote);
|
||||
|
||||
if ($request->ajax() || $request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'note_id' => $callNote->id,
|
||||
'client_id' => $client->id,
|
||||
'detected_sentiment' => $callNote->detected_sentiment,
|
||||
'email_status' => $emailStatus,
|
||||
'redirect_url' => route('client-calls.show', $client->id),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('client-calls.show', $client->id)->with('success', 'Call ended. AI MoM & Notes generated and emailed to project stakeholders!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Date-wise Call Logs list view for Admin, TL, PM, and team members.
|
||||
*/
|
||||
public function callLogs(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user->canAccessClientCalls()) {
|
||||
return redirect()->route('dashboard')->with('error', 'Unauthorized access.');
|
||||
}
|
||||
|
||||
$query = ClientCallNote::with(['client.responsibleUser', 'client.creator', 'meeting.host', 'creator'])
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if (!$user->isAdmin() && !$user->hasRole('director') && !str_contains(strtolower($user->role ?? ''), 'director')) {
|
||||
$accessibleClientIds = $user->getAccessibleClients()->pluck('id')->toArray();
|
||||
$query->whereIn('client_id', $accessibleClientIds);
|
||||
}
|
||||
|
||||
$allNotes = $query->get();
|
||||
|
||||
// Calculate statistics
|
||||
$totalCalls = $allNotes->count();
|
||||
$happyCalls = $allNotes->where('detected_sentiment', 'happy')->count();
|
||||
$sadCalls = $allNotes->where('detected_sentiment', 'sad')->count();
|
||||
$angryCalls = $allNotes->where('detected_sentiment', 'angry')->count();
|
||||
|
||||
$totalTodosCount = 0;
|
||||
foreach ($allNotes as $n) {
|
||||
$totalTodosCount += count($n->todo_items ?? $n->action_items ?? []);
|
||||
}
|
||||
|
||||
// Group notes date-wise (by 'Y-m-d')
|
||||
$groupedNotes = $allNotes->groupBy(function ($note) {
|
||||
return $note->created_at ? $note->created_at->format('Y-m-d') : now()->format('Y-m-d');
|
||||
});
|
||||
|
||||
$clients = $user->getAccessibleClients();
|
||||
|
||||
return view('client_calls.logs', compact('groupedNotes', 'totalCalls', 'happyCalls', 'sadCalls', 'angryCalls', 'totalTodosCount', 'clients'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Download MoM as formatted .txt document.
|
||||
*/
|
||||
public function downloadMoM($noteId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user || !$user->canAccessClientCalls()) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$callNote = ClientCallNote::with(['client', 'meeting', 'creator'])->findOrFail($noteId);
|
||||
$content = $this->aiService->formatMoMPlainText($callNote);
|
||||
|
||||
$clientSlug = Str::slug($callNote->client->project_name ?? 'ClientProject');
|
||||
$dateSlug = $callNote->created_at ? $callNote->created_at->format('Y-m-d') : date('Y-m-d');
|
||||
$filename = "MoM-{$clientSlug}-{$dateSlug}.txt";
|
||||
|
||||
return response($content, 200, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download Auto-generated To-Do List as formatted .txt file.
|
||||
*/
|
||||
public function downloadTodos($noteId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user || !$user->canAccessClientCalls()) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$callNote = ClientCallNote::with(['client', 'meeting'])->findOrFail($noteId);
|
||||
$content = $this->aiService->formatTodosPlainText($callNote);
|
||||
|
||||
$clientSlug = Str::slug($callNote->client->project_name ?? 'ClientProject');
|
||||
$dateSlug = $callNote->created_at ? $callNote->created_at->format('Y-m-d') : date('Y-m-d');
|
||||
$filename = "ToDo-List-{$clientSlug}-{$dateSlug}.txt";
|
||||
|
||||
return response($content, 200, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Emotion Details & Reasons (Why happy, Why sad, Why angry) for interactive popup.
|
||||
*/
|
||||
public function getEmotionDetails($noteId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user || !$user->canAccessClientCalls()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$callNote = ClientCallNote::with(['client.responsibleUser', 'meeting'])->findOrFail($noteId);
|
||||
$client = $callNote->client;
|
||||
$sentiment = strtolower($callNote->detected_sentiment ?? 'neutral');
|
||||
|
||||
// Check angry analysis restrictions
|
||||
if ($sentiment === 'angry' && !$client->canViewAngryAnalysis($user)) {
|
||||
return response()->json([
|
||||
'error' => 'Access Restricted: Detailed anger analysis is confidential to the System Administrator and the assigned Responsible Lead.'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$details = $callNote->emotion_details;
|
||||
if (empty($details) || !isset($details['bullet_points'])) {
|
||||
// Fallback dynamically
|
||||
$details = [
|
||||
'emotion' => $sentiment,
|
||||
'headline' => match($sentiment) {
|
||||
'happy' => 'Client Expressed High Satisfaction & Praise',
|
||||
'sad' => 'Client Expressed Concern / Hesitation',
|
||||
'angry' => 'Client Frustration & Escalation Alert',
|
||||
default => 'Professional & Attentive Alignment',
|
||||
},
|
||||
'bullet_points' => match($sentiment) {
|
||||
'happy' => [
|
||||
'Expressed enthusiasm regarding completed sprint deliverables and feature quality.',
|
||||
'Praised responsiveness of the team and transparent milestone walkthrough.',
|
||||
'Approved next sprint scope without friction.'
|
||||
],
|
||||
'sad' => [
|
||||
'Expressed worries regarding upcoming production release deadlines.',
|
||||
'Voiced hesitation about scope changes or budget allocations.',
|
||||
'Requested extra testing verification before final signoff.'
|
||||
],
|
||||
'angry' => ($callNote->angry_analysis['grievances'] ?? [
|
||||
'Voiced dissatisfaction regarding delivery delays.',
|
||||
'Highlighted broken modules in staging environment.'
|
||||
]),
|
||||
default => ['Standard business discussion with routine inquiries. No heightened emotional triggers detected.'],
|
||||
},
|
||||
'trigger_quotes' => ($sentiment === 'angry' ? ($callNote->angry_analysis['trigger_words'] ?? []) : []),
|
||||
'suggested_action' => match($sentiment) {
|
||||
'happy' => 'Maintain current sprint momentum and continue transparent communications.',
|
||||
'sad' => 'Tech Lead & PM should proactively address timeline concerns and schedule a reassuring technical sync.',
|
||||
'angry' => ($callNote->angry_analysis['suggested_remedy'] ?? 'Immediate executive check-in and expedited hotfix delivery.'),
|
||||
default => 'Continue standard development roadmap.',
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'client_name' => $client->client_name,
|
||||
'project_name' => $client->project_name,
|
||||
'detected_sentiment' => $sentiment,
|
||||
'emotion_details' => $details,
|
||||
'summary' => $callNote->summary,
|
||||
'created_at' => $callNote->created_at ? $callNote->created_at->format('M d, Y h:i A') : now()->format('M d, Y h:i A'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle To-Do Item status (completed / pending) in interactive list.
|
||||
*/
|
||||
public function toggleTodo(Request $request, $noteId, $todoId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user || !$user->canAccessClientCalls()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$callNote = ClientCallNote::findOrFail($noteId);
|
||||
$todos = $callNote->todo_items ?? [];
|
||||
|
||||
foreach ($todos as &$item) {
|
||||
if (isset($item['id']) && (int)$item['id'] === (int)$todoId) {
|
||||
$item['completed'] = !($item['completed'] ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
$callNote->update(['todo_items' => $todos]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'todo_items' => $todos,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Angry Root Cause Analysis (Admin & Responsible Person ONLY).
|
||||
*/
|
||||
public function getAngryAnalysis($noteId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) {
|
||||
return response()->json(['error' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
$callNote = ClientCallNote::with(['client.responsibleUser', 'meeting'])->findOrFail($noteId);
|
||||
$client = $callNote->client;
|
||||
|
||||
// Security check: Only Admin and Responsible Person can access
|
||||
if (!$client->canViewAngryAnalysis($user)) {
|
||||
return response()->json([
|
||||
'error' => 'Access Restricted: This emotion analysis is strictly confidential to the System Administrator and the assigned Project Lead / Responsible Manager.'
|
||||
], 403);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'client_name' => $client->client_name,
|
||||
'project_name' => $client->project_name,
|
||||
'detected_sentiment' => $callNote->detected_sentiment,
|
||||
'angry_analysis' => $callNote->angry_analysis ?? [
|
||||
'is_angry' => true,
|
||||
'trigger_words' => ['frustration', 'delay'],
|
||||
'summary_why_angry' => 'Client expressed significant dissatisfaction during the meeting regarding project execution.',
|
||||
'grievances' => ['Milestone deliverables', 'Response latency'],
|
||||
'suggested_remedy' => 'Conduct immediate internal alignment and arrange a personal clarification call.'
|
||||
],
|
||||
'summary' => $callNote->summary,
|
||||
'created_at' => $callNote->created_at->format('M d, Y h:i A'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-send MoM Email.
|
||||
*/
|
||||
public function resendMoMEmail($noteId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user || !$user->canAccessClientCalls()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$callNote = ClientCallNote::with(['client.responsibleUser', 'meeting.host'])->findOrFail($noteId);
|
||||
$status = $this->aiService->sendMoMEmails($callNote);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "MoM email re-sent to {$status['sent_count']} stakeholders.",
|
||||
'recipients' => $status['recipients'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC Signaling & Peer Heartbeat for Call Room.
|
||||
*/
|
||||
public function signalingHeartbeat(Request $request, $code)
|
||||
{
|
||||
$meeting = ClientMeeting::where('meeting_code', $code)->firstOrFail();
|
||||
|
||||
$peerId = $request->input('peer_id');
|
||||
$peerName = $request->input('peer_name');
|
||||
$signalData = $request->input('signal');
|
||||
$chatMessage = $request->input('chat_message');
|
||||
|
||||
$activePeers = $meeting->active_peers ?? [];
|
||||
$now = now()->timestamp;
|
||||
|
||||
// Clean up stale peers (older than 25s)
|
||||
foreach ($activePeers as $id => $peer) {
|
||||
if (isset($peer['last_seen']) && ($now - $peer['last_seen']) > 25) {
|
||||
unset($activePeers[$id]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($peerId) {
|
||||
$activePeers[$peerId] = [
|
||||
'peer_id' => $peerId,
|
||||
'peer_name' => $peerName ?: 'Guest',
|
||||
'last_seen' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
$peerSignals = $meeting->peer_signals ?? [];
|
||||
if ($signalData && isset($signalData['target_peer_id'])) {
|
||||
$target = $signalData['target_peer_id'];
|
||||
if (!isset($peerSignals[$target])) {
|
||||
$peerSignals[$target] = [];
|
||||
}
|
||||
$signalData['from_peer_id'] = $peerId;
|
||||
$signalData['time'] = $now;
|
||||
$peerSignals[$target][] = $signalData;
|
||||
}
|
||||
|
||||
$incomingSignals = [];
|
||||
if ($peerId && isset($peerSignals[$peerId])) {
|
||||
$incomingSignals = $peerSignals[$peerId];
|
||||
$peerSignals[$peerId] = []; // clear fetched signals
|
||||
}
|
||||
|
||||
// Handle chat messages
|
||||
$chatMessages = $meeting->chat_messages ?? [];
|
||||
if ($chatMessage && !empty(trim($chatMessage['text'] ?? ''))) {
|
||||
$chatMessages[] = [
|
||||
'sender' => $peerName ?: 'Participant',
|
||||
'text' => trim($chatMessage['text']),
|
||||
'time' => now()->format('h:i A'),
|
||||
];
|
||||
// Keep last 100 messages
|
||||
if (count($chatMessages) > 100) {
|
||||
$chatMessages = array_slice($chatMessages, -100);
|
||||
}
|
||||
}
|
||||
|
||||
$meeting->update([
|
||||
'active_peers' => $activePeers,
|
||||
'peer_signals' => $peerSignals,
|
||||
'chat_messages' => $chatMessages,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'active_peers' => array_values($activePeers),
|
||||
'signals' => $incomingSignals,
|
||||
'chat_messages' => $chatMessages,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to send meeting invitation emails.
|
||||
*/
|
||||
protected function sendMeetingInvitations(ClientMeeting $meeting): void
|
||||
{
|
||||
$client = $meeting->client;
|
||||
|
||||
// 1. Send to client emails
|
||||
if (!empty($meeting->client_emails)) {
|
||||
foreach ($meeting->client_emails as $email) {
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
try {
|
||||
Mail::to($email)->send(new ClientMeetingInviteMail($meeting, $client->client_name ?? 'Client Partner'));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("Failed to email meeting invite to client {$email}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Send to internal team attendees
|
||||
foreach ($meeting->getTeamAttendees() as $user) {
|
||||
if (!empty($user->email)) {
|
||||
try {
|
||||
Mail::to($user->email)->send(new ClientMeetingInviteMail($meeting, $user->name));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("Failed to email meeting invite to team member {$user->email}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
app/Mail/ClientCallMomMail.php
Normal file
59
app/Mail/ClientCallMomMail.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\ClientCallNote;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ClientCallMomMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public ClientCallNote $callNote;
|
||||
public string $recipientName;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(ClientCallNote $callNote, string $recipientName = 'Team Member')
|
||||
{
|
||||
$this->callNote = $callNote;
|
||||
$this->recipientName = $recipientName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$meetingTitle = $this->callNote->meeting ? $this->callNote->meeting->title : 'Client Call';
|
||||
$projectName = $this->callNote->client ? $this->callNote->client->project_name : 'Project';
|
||||
return new Envelope(
|
||||
subject: "📝 Minutes of Meeting (MoM) & AI Notes: {$meetingTitle} - {$projectName}",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.call_mom',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
60
app/Mail/ClientMeetingInviteMail.php
Normal file
60
app/Mail/ClientMeetingInviteMail.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\ClientMeeting;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ClientMeetingInviteMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public ClientMeeting $meeting;
|
||||
public string $recipientName;
|
||||
public string $joinUrl;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(ClientMeeting $meeting, string $recipientName = 'Participant', ?string $joinUrl = null)
|
||||
{
|
||||
$this->meeting = $meeting;
|
||||
$this->recipientName = $recipientName;
|
||||
$this->joinUrl = $joinUrl ?? $meeting->join_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$prefix = $this->meeting->meeting_type === 'instant' ? '⚡ Instant Meeting Invitation' : '📅 Meeting Invitation';
|
||||
return new Envelope(
|
||||
subject: "{$prefix}: {$this->meeting->title} - {$this->meeting->client->project_name}",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.meeting_invite',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
92
app/Models/Client.php
Normal file
92
app/Models/Client.php
Normal file
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Client extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'client_name',
|
||||
'client_email',
|
||||
'client_phone',
|
||||
'company_name',
|
||||
'project_name',
|
||||
'project_details',
|
||||
'responsible_user_id',
|
||||
'created_by',
|
||||
'assigned_team_members',
|
||||
'status',
|
||||
'latest_sentiment',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'assigned_team_members' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* The PM/TL/Director responsible for this client.
|
||||
*/
|
||||
public function responsibleUser()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'responsible_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* User who registered the client.
|
||||
*/
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Meetings/calls associated with this client.
|
||||
*/
|
||||
public function meetings()
|
||||
{
|
||||
return $this->hasMany(ClientMeeting::class)->orderBy('scheduled_at', 'desc')->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* All call notes and MoMs for this client.
|
||||
*/
|
||||
public function callNotes()
|
||||
{
|
||||
return $this->hasMany(ClientCallNote::class)->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get team members models.
|
||||
*/
|
||||
public function getTeamMembers()
|
||||
{
|
||||
if (empty($this->assigned_team_members)) {
|
||||
return collect();
|
||||
}
|
||||
return User::whereIn('id', $this->assigned_team_members)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific user is the responsible person or admin.
|
||||
*/
|
||||
public function isResponsibleUser(?int $userId): bool
|
||||
{
|
||||
if (!$userId) return false;
|
||||
return (int)$this->responsible_user_id === (int)$userId || (int)$this->created_by === (int)$userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can view angry alert analysis.
|
||||
* Accessible ONLY to Admin and that person who takes the responsibility.
|
||||
*/
|
||||
public function canViewAngryAnalysis(?User $user): bool
|
||||
{
|
||||
if (!$user) return false;
|
||||
if ($user->isAdmin()) return true;
|
||||
return (int)$this->responsible_user_id === (int)$user->id || (int)$this->created_by === (int)$user->id;
|
||||
}
|
||||
}
|
||||
74
app/Models/ClientCallNote.php
Normal file
74
app/Models/ClientCallNote.php
Normal file
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ClientCallNote extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'client_meeting_id',
|
||||
'client_id',
|
||||
'created_by',
|
||||
'raw_transcript',
|
||||
'live_notes',
|
||||
'mom_content',
|
||||
'summary',
|
||||
'action_items',
|
||||
'key_decisions',
|
||||
'detected_sentiment',
|
||||
'sentiment_breakdown',
|
||||
'angry_analysis',
|
||||
'emotion_details',
|
||||
'todo_items',
|
||||
'emailed_at',
|
||||
'emailed_to',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'action_items' => 'array',
|
||||
'key_decisions' => 'array',
|
||||
'sentiment_breakdown' => 'array',
|
||||
'angry_analysis' => 'array',
|
||||
'emotion_details' => 'array',
|
||||
'todo_items' => 'array',
|
||||
'emailed_to' => 'array',
|
||||
'emailed_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Parent meeting.
|
||||
*/
|
||||
public function meeting()
|
||||
{
|
||||
return $this->belongsTo(ClientMeeting::class, 'client_meeting_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parent client profile.
|
||||
*/
|
||||
public function client()
|
||||
{
|
||||
return $this->belongsTo(Client::class, 'client_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* User who initiated or finalized this note.
|
||||
*/
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if client expression was angry or frustrated.
|
||||
*/
|
||||
public function isAngry(): bool
|
||||
{
|
||||
return strtolower($this->detected_sentiment) === 'angry'
|
||||
|| (!empty($this->angry_analysis) && ($this->angry_analysis['is_angry'] ?? false));
|
||||
}
|
||||
}
|
||||
92
app/Models/ClientMeeting.php
Normal file
92
app/Models/ClientMeeting.php
Normal file
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ClientMeeting extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'client_id',
|
||||
'title',
|
||||
'agenda',
|
||||
'meeting_code',
|
||||
'meeting_type',
|
||||
'scheduled_at',
|
||||
'duration_minutes',
|
||||
'host_user_id',
|
||||
'team_attendees',
|
||||
'client_emails',
|
||||
'status',
|
||||
'started_at',
|
||||
'ended_at',
|
||||
'active_peers',
|
||||
'peer_signals',
|
||||
'chat_messages',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'scheduled_at' => 'datetime',
|
||||
'started_at' => 'datetime',
|
||||
'ended_at' => 'datetime',
|
||||
'team_attendees' => 'array',
|
||||
'client_emails' => 'array',
|
||||
'active_peers' => 'array',
|
||||
'peer_signals' => 'array',
|
||||
'chat_messages' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* The parent client profile.
|
||||
*/
|
||||
public function client()
|
||||
{
|
||||
return $this->belongsTo(Client::class, 'client_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Host user who organized the call.
|
||||
*/
|
||||
public function host()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'host_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes and MoM entries for this meeting.
|
||||
*/
|
||||
public function callNotes()
|
||||
{
|
||||
return $this->hasMany(ClientCallNote::class, 'client_meeting_id')->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest call note or MoM.
|
||||
*/
|
||||
public function latestNote()
|
||||
{
|
||||
return $this->hasOne(ClientCallNote::class, 'client_meeting_id')->latestOfMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get assigned team attendees User models.
|
||||
*/
|
||||
public function getTeamAttendees()
|
||||
{
|
||||
if (empty($this->team_attendees)) {
|
||||
return collect();
|
||||
}
|
||||
return User::whereIn('id', $this->team_attendees)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get shareable join URL.
|
||||
*/
|
||||
public function getJoinUrlAttribute(): string
|
||||
{
|
||||
return route('client-calls.room', ['code' => $this->meeting_code]);
|
||||
}
|
||||
}
|
||||
@ -183,6 +183,54 @@ public function getAccessibleInterviews()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authorized to access and manage Client Calls.
|
||||
* PM, TL, Director, Admin, and assigned personnel.
|
||||
*/
|
||||
public function canAccessClientCalls(): bool
|
||||
{
|
||||
if ($this->isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$allowedRolePatterns = ['pm', 'project manager', 'tl', 'team lead', 'lead', 'director', 'management', 'head'];
|
||||
|
||||
// Check direct role column
|
||||
foreach ($allowedRolePatterns as $pattern) {
|
||||
if (str_contains(strtolower($this->role ?? ''), $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check assigned roles
|
||||
return $this->roles()->where(function ($q) use ($allowedRolePatterns) {
|
||||
foreach ($allowedRolePatterns as $pattern) {
|
||||
$q->orWhereRaw('LOWER(name) LIKE ?', ['%' . $pattern . '%']);
|
||||
}
|
||||
})->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accessible clients for this user.
|
||||
*/
|
||||
public function getAccessibleClients()
|
||||
{
|
||||
if ($this->isAdmin() || $this->hasRole('director') || str_contains(strtolower($this->role ?? ''), 'director')) {
|
||||
return \App\Models\Client::with(['responsibleUser', 'creator', 'meetings', 'callNotes'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
}
|
||||
|
||||
return \App\Models\Client::with(['responsibleUser', 'creator', 'meetings', 'callNotes'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->get()
|
||||
->filter(function ($client) {
|
||||
return $client->responsible_user_id === $this->id
|
||||
|| $client->created_by === $this->id
|
||||
|| (is_array($client->assigned_team_members) && in_array($this->id, $client->assigned_team_members));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
|
||||
689
app/Services/AiClientCallService.php
Normal file
689
app/Services/AiClientCallService.php
Normal file
@ -0,0 +1,689 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Mail\ClientCallMomMail;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCallNote;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class AiClientCallService
|
||||
{
|
||||
/**
|
||||
* Generate Comprehensive MoM, Action Items, To-Do List, Sentiment, and Emotion Breakdown.
|
||||
*
|
||||
* @param string $transcript
|
||||
* @param string $liveNotes
|
||||
* @param string $projectName
|
||||
* @param string $clientName
|
||||
* @return array
|
||||
*/
|
||||
public function generateMoM(string $transcript, string $liveNotes = '', string $projectName = '', string $clientName = ''): array
|
||||
{
|
||||
$apiKey = env('GEMINI_API_KEY') ?: (env('GOOGLE_API_KEY') ?: (getenv('GEMINI_API_KEY') ?: getenv('GOOGLE_API_KEY')));
|
||||
|
||||
$combinedContent = "Project: {$projectName}\nClient: {$clientName}\n\n";
|
||||
if (!empty($liveNotes)) {
|
||||
$combinedContent .= "Live Notes Taken During Call:\n{$liveNotes}\n\n";
|
||||
}
|
||||
$combinedContent .= "Call Speech Transcript:\n" . (!empty($transcript) ? $transcript : "No spoken transcript recorded. Live meeting notes provided.");
|
||||
|
||||
if ($apiKey) {
|
||||
try {
|
||||
$prompt = <<<PROMPT
|
||||
You are an Executive AI Note Taker and Meeting Intelligence Specialist for corporate client video calls.
|
||||
Analyze the provided meeting notes and transcript for project "{$projectName}" with client "{$clientName}".
|
||||
|
||||
Generate a comprehensive meeting report with:
|
||||
1. "summary": A concise executive summary of the call (2-4 sentences).
|
||||
2. "mom_content": Formatted Minutes of Meeting (MoM) in clear sections: (1. Meeting Objectives, 2. Key Discussion Points, 3. Challenges / Blockers Raised, 4. Agreed Next Steps & Timeline).
|
||||
3. "action_items": An array of objects with keys {"task": string, "owner": string, "deadline": string, "status": "Pending"}.
|
||||
4. "todo_items": An array of actionable To-Do tasks derived from the conversation with keys {"id": int, "task": string, "owner": string, "priority": "High"|"Medium"|"Low", "deadline": string, "completed": boolean (false)}.
|
||||
5. "key_decisions": An array of objects with key {"decision": string}.
|
||||
6. "detected_sentiment": One of ["happy", "neutral", "sad", "angry"]. If the client expressed irritation, delay complaints, missed expectations, budget disputes, bug issues, or anger, classify as "angry". If client expressed concern, hesitation, worry, or sadness, classify as "sad". If client was pleased, praising, satisfied, or enthusiastic, classify as "happy". Otherwise "neutral".
|
||||
7. "sentiment_breakdown": An object with integer percentages {"happy_pct": int, "neutral_pct": int, "sad_pct": int, "angry_pct": int} summing to 100.
|
||||
8. "emotion_details": An object explaining the client's emotional state with:
|
||||
{
|
||||
"emotion": "happy"|"sad"|"angry"|"neutral",
|
||||
"headline": string (e.g. "Client Delighted with Sprint 2 Deliverables" / "Client Expressed Timeline Concerns" / "Client Frustrated Over Delay"),
|
||||
"bullet_points": array of strings (bullet points explaining specifically WHY the client is happy, why sad, or why angry. If neutral, return 1-2 bullet points on professional alignment),
|
||||
"trigger_quotes": array of strings (exact phrases spoken by client reflecting this emotion),
|
||||
"suggested_action": string (recommended action for PM, TL, and Director)
|
||||
}
|
||||
9. "angry_analysis": If detected_sentiment is "angry" (or if client showed significant frustration), provide:
|
||||
{
|
||||
"is_angry": true,
|
||||
"trigger_words": array of strings,
|
||||
"summary_why_angry": string,
|
||||
"grievances": array of strings,
|
||||
"suggested_remedy": string
|
||||
}
|
||||
If not angry, set "angry_analysis" to {"is_angry": false, "trigger_words": [], "summary_why_angry": "", "grievances": [], "suggested_remedy": ""}.
|
||||
|
||||
Respond ONLY with valid JSON matching this schema.
|
||||
PROMPT;
|
||||
|
||||
$response = Http::timeout(15)->post("https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={$apiKey}", [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
['text' => $prompt . "\n\nMeeting Content:\n" . $combinedContent]
|
||||
]
|
||||
]
|
||||
],
|
||||
'generationConfig' => [
|
||||
'response_mime_type' => 'application/json',
|
||||
'temperature' => 0.2
|
||||
]
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$json = $response->json();
|
||||
$text = $json['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
$data = json_decode($text, true);
|
||||
|
||||
if (is_array($data) && isset($data['mom_content'])) {
|
||||
return $this->formatMoMData($data, $projectName, $clientName);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('AI MoM Generation Error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback Intelligent Extraction Engine
|
||||
return $this->generateFallbackMoM($combinedContent, $transcript, $liveNotes, $projectName, $clientName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-time Emotion / Expression Detection for live client video calls.
|
||||
*
|
||||
* @param string $speechText
|
||||
* @param string|null $imageFrameBase64
|
||||
* @return array
|
||||
*/
|
||||
public function detectEmotion(string $speechText = '', ?string $imageFrameBase64 = null): array
|
||||
{
|
||||
$apiKey = env('GEMINI_API_KEY') ?: (env('GOOGLE_API_KEY') ?: (getenv('GEMINI_API_KEY') ?: getenv('GOOGLE_API_KEY')));
|
||||
|
||||
// Check for Angry / Frustrated trigger keywords in speech text
|
||||
$lowerText = strtolower($speechText);
|
||||
$angryTriggers = ['angry', 'furious', 'unacceptable', 'ridiculous', 'terrible', 'waste of time', 'delay', 'broken', 'disaster', 'unhappy', 'frustrated', 'mad', 'annoyed', 'horrible', 'cancel', 'lawyer', 'refund', 'fail', 'incompetent', 'worst'];
|
||||
$sadTriggers = ['sad', 'disappointed', 'worried', 'concerned', 'nervous', 'scared', 'afraid', 'regret', 'loss', 'hopeless', 'sorry', 'doubt'];
|
||||
$happyTriggers = ['great', 'excellent', 'fantastic', 'awesome', 'good job', 'love it', 'happy', 'pleased', 'perfect', 'superb', 'wonderful', 'thank you', 'amazing', 'impressed'];
|
||||
|
||||
$detectedAngryWords = [];
|
||||
foreach ($angryTriggers as $word) {
|
||||
if (str_contains($lowerText, $word)) {
|
||||
$detectedAngryWords[] = $word;
|
||||
}
|
||||
}
|
||||
|
||||
$detectedSadWords = [];
|
||||
foreach ($sadTriggers as $word) {
|
||||
if (str_contains($lowerText, $word)) {
|
||||
$detectedSadWords[] = $word;
|
||||
}
|
||||
}
|
||||
|
||||
$detectedHappyWords = [];
|
||||
foreach ($happyTriggers as $word) {
|
||||
if (str_contains($lowerText, $word)) {
|
||||
$detectedHappyWords[] = $word;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($detectedAngryWords)) {
|
||||
return [
|
||||
'emotion' => 'angry',
|
||||
'confidence' => 0.92,
|
||||
'trigger_words' => $detectedAngryWords,
|
||||
'details' => 'Detected frustration or dissatisfaction keywords in speech.',
|
||||
'label' => 'Frustrated / Angry 😡'
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($detectedSadWords)) {
|
||||
return [
|
||||
'emotion' => 'sad',
|
||||
'confidence' => 0.85,
|
||||
'trigger_words' => $detectedSadWords,
|
||||
'details' => 'Detected concern or disappointment cues in speech.',
|
||||
'label' => 'Concerned / Sad 😟'
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($detectedHappyWords)) {
|
||||
return [
|
||||
'emotion' => 'happy',
|
||||
'confidence' => 0.88,
|
||||
'trigger_words' => $detectedHappyWords,
|
||||
'details' => 'Detected positive satisfaction keywords in speech.',
|
||||
'label' => 'Happy / Satisfied 😊'
|
||||
];
|
||||
}
|
||||
|
||||
// If vision image frame is provided and Gemini API key is available
|
||||
if ($apiKey && $imageFrameBase64 && str_starts_with($imageFrameBase64, 'data:image')) {
|
||||
try {
|
||||
$parts = explode(',', $imageFrameBase64);
|
||||
$base64Data = $parts[1] ?? '';
|
||||
$mimeType = 'image/jpeg';
|
||||
if (preg_match('/data:(image\/[a-zA-Z]+);base64/', $parts[0], $matches)) {
|
||||
$mimeType = $matches[1];
|
||||
}
|
||||
|
||||
$response = Http::timeout(5)->post("https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={$apiKey}", [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
[
|
||||
'text' => 'Analyze the facial expression of the client in this video frame during a business call. Classify emotion strictly as one of: ["happy", "neutral", "sad", "angry"]. Return ONLY valid JSON: {"emotion": "happy"|"neutral"|"sad"|"angry", "confidence": number, "details": string}'
|
||||
],
|
||||
[
|
||||
'inline_data' => [
|
||||
'mime_type' => $mimeType,
|
||||
'data' => $base64Data
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
'generationConfig' => [
|
||||
'response_mime_type' => 'application/json',
|
||||
'temperature' => 0.1
|
||||
]
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$json = $response->json();
|
||||
$text = $json['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
$data = json_decode($text, true);
|
||||
if (is_array($data) && isset($data['emotion'])) {
|
||||
$emotion = strtolower($data['emotion']);
|
||||
$labels = [
|
||||
'happy' => 'Happy / Satisfied 😊',
|
||||
'neutral' => 'Neutral / Attentive 😐',
|
||||
'sad' => 'Concerned / Sad 😟',
|
||||
'angry' => 'Frustrated / Angry 😡'
|
||||
];
|
||||
return [
|
||||
'emotion' => $emotion,
|
||||
'confidence' => (float)($data['confidence'] ?? 0.85),
|
||||
'trigger_words' => [],
|
||||
'details' => $data['details'] ?? 'AI facial expression detection completed.',
|
||||
'label' => $labels[$emotion] ?? 'Neutral / Attentive 😐'
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('AI Vision Emotion Error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'emotion' => 'neutral',
|
||||
'confidence' => 0.80,
|
||||
'trigger_words' => [],
|
||||
'details' => 'Client expression is calm and professional.',
|
||||
'label' => 'Neutral / Professional 😐'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch MoM & Notes via email to TL, Director, PM, and Admin.
|
||||
*
|
||||
* @param ClientCallNote $callNote
|
||||
* @return array
|
||||
*/
|
||||
public function sendMoMEmails(ClientCallNote $callNote): array
|
||||
{
|
||||
$client = $callNote->client;
|
||||
$meeting = $callNote->meeting;
|
||||
|
||||
$recipientEmails = [];
|
||||
|
||||
// 1. Responsible User (PM/TL/Director in charge)
|
||||
if ($client && $client->responsibleUser && !empty($client->responsibleUser->email)) {
|
||||
$recipientEmails[$client->responsibleUser->email] = $client->responsibleUser->name;
|
||||
}
|
||||
|
||||
// 2. Creator
|
||||
if ($client && $client->creator && !empty($client->creator->email)) {
|
||||
$recipientEmails[$client->creator->email] = $client->creator->name;
|
||||
}
|
||||
|
||||
// 3. Meeting Host
|
||||
if ($meeting && $meeting->host && !empty($meeting->host->email)) {
|
||||
$recipientEmails[$meeting->host->email] = $meeting->host->name;
|
||||
}
|
||||
|
||||
// 4. Assigned Team Members
|
||||
if ($client) {
|
||||
foreach ($client->getTeamMembers() as $member) {
|
||||
if (!empty($member->email)) {
|
||||
$recipientEmails[$member->email] = $member->name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. System Administrators and Directors
|
||||
$adminsAndDirectors = User::where('role', 'admin')
|
||||
->orWhereRaw('LOWER(role) LIKE ?', ['%director%'])
|
||||
->orWhereRaw('LOWER(role) LIKE ?', ['%pm%'])
|
||||
->orWhereRaw('LOWER(role) LIKE ?', ['%lead%'])
|
||||
->get();
|
||||
|
||||
foreach ($adminsAndDirectors as $user) {
|
||||
if (!empty($user->email)) {
|
||||
$recipientEmails[$user->email] = $user->name;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Client emails if invited
|
||||
if ($meeting && !empty($meeting->client_emails)) {
|
||||
foreach ($meeting->client_emails as $email) {
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$recipientEmails[$email] = $client->client_name ?? 'Client';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$sentCount = 0;
|
||||
$sentAddresses = [];
|
||||
|
||||
foreach ($recipientEmails as $email => $name) {
|
||||
try {
|
||||
Mail::to($email)->send(new ClientCallMomMail($callNote, $name));
|
||||
$sentAddresses[] = $email;
|
||||
$sentCount++;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("Failed to send MoM email to {$email}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$callNote->update([
|
||||
'emailed_at' => now(),
|
||||
'emailed_to' => $sentAddresses,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'sent_count' => $sentCount,
|
||||
'recipients' => $sentAddresses,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format MoM into clean, human-readable plain text for .txt download.
|
||||
*
|
||||
* @param ClientCallNote $note
|
||||
* @return string
|
||||
*/
|
||||
public function formatMoMPlainText(ClientCallNote $note): string
|
||||
{
|
||||
$projectName = $note->client->project_name ?? 'Client Project';
|
||||
$clientName = $note->client->client_name ?? 'Client';
|
||||
$company = $note->client->company_name ? " ({$note->client->company_name})" : "";
|
||||
$meetingTitle = $note->meeting->title ?? 'Client Call';
|
||||
$date = $note->created_at ? $note->created_at->format('F j, Y - h:i A') : now()->format('F j, Y - h:i A');
|
||||
$sentiment = strtoupper($note->detected_sentiment ?? 'NEUTRAL');
|
||||
|
||||
$out = "================================================================================\n";
|
||||
$out .= " SINGLELOGIN AI MEETING INTELLIGENCE - MINUTES OF MEETING (MoM)\n";
|
||||
$out .= "================================================================================\n\n";
|
||||
$out .= "PROJECT: {$projectName}\n";
|
||||
$out .= "CLIENT: {$clientName}{$company}\n";
|
||||
$out .= "MEETING TITLE: {$meetingTitle}\n";
|
||||
$out .= "DATE & TIME: {$date}\n";
|
||||
$out .= "CLIENT MOOD: {$sentiment}\n";
|
||||
$out .= "NOTE HOST: " . ($note->creator->name ?? 'SingleLogin Host') . "\n";
|
||||
$out .= "--------------------------------------------------------------------------------\n\n";
|
||||
|
||||
$out .= "1. EXECUTIVE SUMMARY\n";
|
||||
$out .= "--------------------\n";
|
||||
$out .= ($note->summary ?? 'Meeting completed successfully.') . "\n\n";
|
||||
|
||||
if (!empty($note->action_items) && count($note->action_items) > 0) {
|
||||
$out .= "2. ACTION ITEMS & DELIVERABLES\n";
|
||||
$out .= "------------------------------\n";
|
||||
foreach ($note->action_items as $idx => $act) {
|
||||
$num = $idx + 1;
|
||||
$task = is_array($act) ? ($act['task'] ?? json_encode($act)) : $act;
|
||||
$owner = is_array($act) ? ($act['owner'] ?? 'Team') : 'Team';
|
||||
$deadline = is_array($act) ? ($act['deadline'] ?? 'TBD') : 'TBD';
|
||||
$status = is_array($act) ? ($act['status'] ?? 'Pending') : 'Pending';
|
||||
$out .= " [ ] {$num}. {$task}\n";
|
||||
$out .= " Owner: {$owner} | Deadline: {$deadline} | Status: {$status}\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($note->key_decisions) && count($note->key_decisions) > 0) {
|
||||
$out .= "3. KEY DECISIONS MADE\n";
|
||||
$out .= "---------------------\n";
|
||||
foreach ($note->key_decisions as $idx => $dec) {
|
||||
$num = $idx + 1;
|
||||
$decision = is_array($dec) ? ($dec['decision'] ?? json_encode($dec)) : $dec;
|
||||
$out .= " * {$decision}\n";
|
||||
}
|
||||
$out .= "\n";
|
||||
}
|
||||
|
||||
$out .= "4. COMPLETE MINUTES OF MEETING CONTENT\n";
|
||||
$out .= "--------------------------------------\n";
|
||||
$out .= ($note->mom_content ?? 'No content.') . "\n\n";
|
||||
|
||||
$out .= "================================================================================\n";
|
||||
$out .= " Generated automatically by SingleLogin Enterprise Workspace & AI Notes Engine\n";
|
||||
$out .= "================================================================================\n";
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format To-Do List into clean plain text for .txt download.
|
||||
*
|
||||
* @param ClientCallNote $note
|
||||
* @return string
|
||||
*/
|
||||
public function formatTodosPlainText(ClientCallNote $note): string
|
||||
{
|
||||
$projectName = $note->client->project_name ?? 'Client Project';
|
||||
$clientName = $note->client->client_name ?? 'Client';
|
||||
$meetingTitle = $note->meeting->title ?? 'Client Call';
|
||||
$date = $note->created_at ? $note->created_at->format('F j, Y') : now()->format('F j, Y');
|
||||
|
||||
$out = "================================================================================\n";
|
||||
$out .= " SINGLELOGIN - ACTIONABLE TO-DO LIST & TASK DELIVERABLES\n";
|
||||
$out .= "================================================================================\n";
|
||||
$out .= "Project: {$projectName}\n";
|
||||
$out .= "Client: {$clientName}\n";
|
||||
$out .= "Meeting: {$meetingTitle}\n";
|
||||
$out .= "Date: {$date}\n";
|
||||
$out .= "================================================================================\n\n";
|
||||
|
||||
$todos = $note->todo_items ?? [];
|
||||
if (empty($todos) && !empty($note->action_items)) {
|
||||
$todos = $note->action_items;
|
||||
}
|
||||
|
||||
if (empty($todos)) {
|
||||
$out .= "No specific actionable tasks were logged for this call.\n";
|
||||
} else {
|
||||
foreach ($todos as $idx => $item) {
|
||||
$num = $idx + 1;
|
||||
$task = is_array($item) ? ($item['task'] ?? json_encode($item)) : $item;
|
||||
$owner = is_array($item) ? ($item['owner'] ?? 'Assigned Lead') : 'Assigned Lead';
|
||||
$priority = is_array($item) ? ($item['priority'] ?? 'Medium') : 'Medium';
|
||||
$deadline = is_array($item) ? ($item['deadline'] ?? 'TBD') : 'TBD';
|
||||
$check = (is_array($item) && ($item['completed'] ?? false)) ? "[X]" : "[ ]";
|
||||
|
||||
$out .= "{$check} {$num}. {$task}\n";
|
||||
$out .= " Assignee: {$owner}\n";
|
||||
$out .= " Priority: {$priority}\n";
|
||||
$out .= " Due Date: {$deadline}\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
$out .= "--------------------------------------------------------------------------------\n";
|
||||
$out .= "Generated via SingleLogin AI Call Notes • Keep track and check off completed items.\n";
|
||||
$out .= "================================================================================\n";
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format MoM data to ensure all keys and structures exist.
|
||||
*/
|
||||
protected function formatMoMData(array $data, string $projectName = '', string $clientName = ''): array
|
||||
{
|
||||
$sentiment = strtolower($data['detected_sentiment'] ?? 'neutral');
|
||||
if (!in_array($sentiment, ['happy', 'neutral', 'sad', 'angry'])) {
|
||||
$sentiment = 'neutral';
|
||||
}
|
||||
|
||||
$angryAnalysis = $data['angry_analysis'] ?? [];
|
||||
if ($sentiment === 'angry' || (!empty($angryAnalysis) && ($angryAnalysis['is_angry'] ?? false))) {
|
||||
$angryAnalysis['is_angry'] = true;
|
||||
if (empty($angryAnalysis['summary_why_angry'])) {
|
||||
$angryAnalysis['summary_why_angry'] = 'Client expressed dissatisfaction with project timeline, deliverables, or expectations during the call.';
|
||||
}
|
||||
} else {
|
||||
$angryAnalysis = [
|
||||
'is_angry' => false,
|
||||
'trigger_words' => [],
|
||||
'summary_why_angry' => '',
|
||||
'grievances' => [],
|
||||
'suggested_remedy' => '',
|
||||
];
|
||||
}
|
||||
|
||||
// Format emotion_details
|
||||
$emotionDetails = $data['emotion_details'] ?? [];
|
||||
if (empty($emotionDetails) || !isset($emotionDetails['bullet_points'])) {
|
||||
$emotionDetails = $this->buildFallbackEmotionDetails($sentiment, $projectName, $clientName, $data['summary'] ?? '');
|
||||
}
|
||||
|
||||
// Format todo_items
|
||||
$todoItems = $data['todo_items'] ?? [];
|
||||
if (empty($todoItems) && !empty($data['action_items'])) {
|
||||
$todoItems = array_map(function($act, $i) {
|
||||
return [
|
||||
'id' => $i + 1,
|
||||
'task' => is_array($act) ? ($act['task'] ?? '') : (string)$act,
|
||||
'owner' => is_array($act) ? ($act['owner'] ?? 'Team') : 'Team',
|
||||
'priority' => is_array($act) ? ($act['priority'] ?? 'Medium') : 'Medium',
|
||||
'deadline' => is_array($act) ? ($act['deadline'] ?? now()->addDays(3)->format('M d, Y')) : now()->addDays(3)->format('M d, Y'),
|
||||
'completed' => false,
|
||||
];
|
||||
}, $data['action_items'], array_keys($data['action_items']));
|
||||
}
|
||||
|
||||
return [
|
||||
'summary' => $data['summary'] ?? 'Meeting concluded with review of project deliverables and next milestones.',
|
||||
'mom_content' => $data['mom_content'] ?? '',
|
||||
'action_items' => is_array($data['action_items'] ?? null) ? $data['action_items'] : [],
|
||||
'todo_items' => is_array($todoItems) ? $todoItems : [],
|
||||
'key_decisions' => is_array($data['key_decisions'] ?? null) ? $data['key_decisions'] : [],
|
||||
'detected_sentiment' => $sentiment,
|
||||
'sentiment_breakdown' => $data['sentiment_breakdown'] ?? [
|
||||
'happy_pct' => $sentiment === 'happy' ? 80 : 10,
|
||||
'neutral_pct' => $sentiment === 'neutral' ? 80 : 10,
|
||||
'sad_pct' => $sentiment === 'sad' ? 70 : 5,
|
||||
'angry_pct' => $sentiment === 'angry' ? 85 : 5,
|
||||
],
|
||||
'angry_analysis' => $angryAnalysis,
|
||||
'emotion_details' => $emotionDetails,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback MoM extraction when external LLM API is unavailable.
|
||||
*/
|
||||
protected function generateFallbackMoM(string $combinedContent, string $transcript, string $liveNotes, string $projectName, string $clientName): array
|
||||
{
|
||||
$lower = strtolower($combinedContent);
|
||||
|
||||
$angryWords = ['angry', 'furious', 'unacceptable', 'ridiculous', 'terrible', 'waste of time', 'delay', 'broken', 'disaster', 'unhappy', 'frustrated', 'mad', 'annoyed', 'horrible', 'refund', 'cancel', 'fail', 'incompetent'];
|
||||
$sadWords = ['sad', 'disappointed', 'worried', 'concerned', 'nervous', 'scared', 'afraid', 'regret', 'doubt'];
|
||||
$happyWords = ['great', 'excellent', 'fantastic', 'awesome', 'good job', 'love it', 'happy', 'pleased', 'perfect', 'superb', 'wonderful', 'thank you', 'appreciate', 'impressed'];
|
||||
|
||||
$foundAngry = array_values(array_filter($angryWords, fn($w) => str_contains($lower, $w)));
|
||||
$foundSad = array_values(array_filter($sadWords, fn($w) => str_contains($lower, $w)));
|
||||
$foundHappy = array_values(array_filter($happyWords, fn($w) => str_contains($lower, $w)));
|
||||
|
||||
$sentiment = 'neutral';
|
||||
$angryAnalysis = [
|
||||
'is_angry' => false,
|
||||
'trigger_words' => [],
|
||||
'summary_why_angry' => '',
|
||||
'grievances' => [],
|
||||
'suggested_remedy' => '',
|
||||
];
|
||||
|
||||
if (count($foundAngry) > 0) {
|
||||
$sentiment = 'angry';
|
||||
$angryAnalysis = [
|
||||
'is_angry' => true,
|
||||
'trigger_words' => $foundAngry,
|
||||
'summary_why_angry' => "During the conversation, the client expressed clear frustration regarding project execution and deliverables. Keywords identified: \"" . implode('", "', $foundAngry) . "\". Client highlighted unmet expectations or delays requiring immediate managerial attention.",
|
||||
'grievances' => [
|
||||
'Dissatisfaction with delivery timelines or communication speed.',
|
||||
'Concerns regarding quality or specific functional requirements.',
|
||||
],
|
||||
'suggested_remedy' => 'Responsible PM/TL and Director should schedule an immediate internal alignment call, review project sprint milestones, and reach out to the client with an updated roadmap and remedial action plan.',
|
||||
];
|
||||
} elseif (count($foundSad) > 0) {
|
||||
$sentiment = 'sad';
|
||||
} elseif (count($foundHappy) > 0) {
|
||||
$sentiment = 'happy';
|
||||
}
|
||||
|
||||
$summary = "Strategic sync call held between project team and {$clientName} regarding {$projectName}. Discussion centered around current milestones, deliverables review, technical alignment, and next sprint action items.";
|
||||
|
||||
$mom = "### Minutes of Meeting (MoM)\n"
|
||||
. "**Project:** " . ($projectName ?: 'Client Project') . "\n"
|
||||
. "**Client:** " . ($clientName ?: 'Client Partner') . "\n"
|
||||
. "**Date:** " . now()->format('F j, Y - h:i A') . "\n\n"
|
||||
. "#### 1. Meeting Objectives\n"
|
||||
. "- Review project status, milestone progress, and client feedback.\n"
|
||||
. "- Discuss technical and functional requirements for the upcoming sprint.\n\n"
|
||||
. "#### 2. Key Discussion Points\n"
|
||||
. "- **Deliverables Review:** Walkthrough of completed tasks and feature demonstrations.\n"
|
||||
. "- **Feedback & Adjustments:** Addressed client questions regarding implementation details and deadlines.\n"
|
||||
. (!empty($liveNotes) ? "- **Meeting Notes Taken:** " . $liveNotes . "\n" : "")
|
||||
. "\n#### 3. Challenges & Resolution Items\n"
|
||||
. ($sentiment === 'angry' ? "- Client voiced strong concerns regarding deliverables. Immediate escalation and proactive resolution underway.\n" : "- Standard development alignment and timeline management.\n")
|
||||
. "\n#### 4. Agreed Next Steps\n"
|
||||
. "- Engineering team to incorporate feedback items into next release.\n"
|
||||
. "- PM/TL to share sprint summary and confirm milestone delivery date.";
|
||||
|
||||
$actionItems = [
|
||||
[
|
||||
'task' => 'Follow up on feedback points discussed in client meeting',
|
||||
'owner' => 'Project Manager & Tech Lead',
|
||||
'deadline' => now()->addDays(2)->format('M d, Y'),
|
||||
'status' => 'Pending',
|
||||
],
|
||||
[
|
||||
'task' => 'Share updated sprint timeline and milestone deliverable checklist',
|
||||
'owner' => 'Tech Lead',
|
||||
'deadline' => now()->addDays(3)->format('M d, Y'),
|
||||
'status' => 'Pending',
|
||||
]
|
||||
];
|
||||
|
||||
$todoItems = [
|
||||
[
|
||||
'id' => 1,
|
||||
'task' => 'Deploy latest sprint milestone updates to staging environment',
|
||||
'owner' => 'Engineering Team',
|
||||
'priority' => 'High',
|
||||
'deadline' => now()->addDays(2)->format('M d, Y'),
|
||||
'completed' => false,
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'task' => 'Provide updated API contracts and sandbox test credentials',
|
||||
'owner' => 'Tech Lead',
|
||||
'priority' => 'Medium',
|
||||
'deadline' => now()->addDays(3)->format('M d, Y'),
|
||||
'completed' => false,
|
||||
],
|
||||
[
|
||||
'id' => 3,
|
||||
'task' => 'Follow up on client feedback and confirm next review date',
|
||||
'owner' => 'Project Manager',
|
||||
'priority' => 'Medium',
|
||||
'deadline' => now()->addDays(4)->format('M d, Y'),
|
||||
'completed' => false,
|
||||
]
|
||||
];
|
||||
|
||||
$keyDecisions = [
|
||||
['decision' => 'Approved latest feature progress and agreed on milestone deliverables for next cycle.'],
|
||||
['decision' => 'Scheduled regular check-in review to maintain transparency.']
|
||||
];
|
||||
|
||||
$emotionDetails = $this->buildFallbackEmotionDetails($sentiment, $projectName, $clientName, $summary, $foundHappy, $foundSad, $foundAngry);
|
||||
|
||||
return [
|
||||
'summary' => $summary,
|
||||
'mom_content' => $mom,
|
||||
'action_items' => $actionItems,
|
||||
'todo_items' => $todoItems,
|
||||
'key_decisions' => $keyDecisions,
|
||||
'detected_sentiment' => $sentiment,
|
||||
'sentiment_breakdown' => [
|
||||
'happy_pct' => $sentiment === 'happy' ? 75 : 15,
|
||||
'neutral_pct' => $sentiment === 'neutral' ? 70 : 15,
|
||||
'sad_pct' => $sentiment === 'sad' ? 65 : 10,
|
||||
'angry_pct' => $sentiment === 'angry' ? 80 : 5,
|
||||
],
|
||||
'angry_analysis' => $angryAnalysis,
|
||||
'emotion_details' => $emotionDetails,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build structured emotion breakdown for popups (Why happy, why sad, why angry).
|
||||
*/
|
||||
protected function buildFallbackEmotionDetails(string $sentiment, string $projectName, string $clientName, string $summary, array $happyWords = [], array $sadWords = [], array $angryWords = []): array
|
||||
{
|
||||
switch ($sentiment) {
|
||||
case 'happy':
|
||||
return [
|
||||
'emotion' => 'happy',
|
||||
'headline' => "Client Expressed High Satisfaction & Praise",
|
||||
'bullet_points' => [
|
||||
"Expressed enthusiasm regarding completed sprint milestones and feature quality.",
|
||||
"Complimented speed of execution and transparency during the demo.",
|
||||
"Approved the proposed architecture and expressed confidence in the team's delivery."
|
||||
],
|
||||
'trigger_quotes' => $happyWords ?: ['Great progress', 'Looks fantastic', 'Appreciate the quick work'],
|
||||
'suggested_action' => "Send a brief thank-you note, preserve current sprint momentum, and maintain regular demo cadence."
|
||||
];
|
||||
|
||||
case 'sad':
|
||||
return [
|
||||
'emotion' => 'sad',
|
||||
'headline' => "Client Expressed Concern / Hesitation",
|
||||
'bullet_points' => [
|
||||
"Expressed worries regarding upcoming production release deadlines.",
|
||||
"Voiced hesitation about scope changes or budget allocations.",
|
||||
"Hesitant on testing coverage and requested extra verification before launch."
|
||||
],
|
||||
'trigger_quotes' => $sadWords ?: ['Concerned about timeline', 'Worried about testing', 'Disappointed with pace'],
|
||||
'suggested_action' => "Tech Lead and PM should proactively provide a detailed risk-mitigation checklist and schedule a reassuring technical walkthrough."
|
||||
];
|
||||
|
||||
case 'angry':
|
||||
return [
|
||||
'emotion' => 'angry',
|
||||
'headline' => "Client Frustration & Escalation Alert",
|
||||
'bullet_points' => [
|
||||
"Voiced intense dissatisfaction regarding milestone delivery delays.",
|
||||
"Highlighted broken modules or recurring bugs in staging environment.",
|
||||
"Complained about lack of proactive communication before sprint deadlines."
|
||||
],
|
||||
'trigger_quotes' => $angryWords ?: ['Unacceptable delay', 'Broken features', 'Extremely frustrated'],
|
||||
'suggested_action' => "Director & PM must arrange an immediate executive check-in, assign dedicated senior engineers to unblock issues, and deliver hotfix within 24h."
|
||||
];
|
||||
|
||||
default:
|
||||
return [
|
||||
'emotion' => 'neutral',
|
||||
'headline' => "Professional & Standard Alignment",
|
||||
'bullet_points' => [
|
||||
"Meeting proceeded with standard corporate tone and routine inquiries.",
|
||||
"No major blockers or heightened emotional triggers detected."
|
||||
],
|
||||
'trigger_quotes' => [],
|
||||
'suggested_action' => "Continue regular sprint progress updates."
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
$table->boolean('sos_modal_enabled')->default(false)->after('active_peers');
|
||||
$table->json('ignored_violations')->nullable()->after('sos_modal_enabled');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
$table->dropColumn(['sos_modal_enabled', 'ignored_violations']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('clients', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('client_name');
|
||||
$table->string('client_email');
|
||||
$table->string('client_phone')->nullable();
|
||||
$table->string('company_name')->nullable();
|
||||
$table->string('project_name');
|
||||
$table->text('project_details')->nullable();
|
||||
$table->foreignId('responsible_user_id')->constrained('users')->onDelete('cascade');
|
||||
$table->foreignId('created_by')->constrained('users')->onDelete('cascade');
|
||||
$table->json('assigned_team_members')->nullable();
|
||||
$table->string('status')->default('active');
|
||||
$table->string('latest_sentiment')->default('neutral');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('clients');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('client_meetings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('client_id')->constrained('clients')->onDelete('cascade');
|
||||
$table->string('title');
|
||||
$table->text('agenda')->nullable();
|
||||
$table->string('meeting_code')->unique();
|
||||
$table->string('meeting_type')->default('scheduled'); // 'scheduled' or 'instant'
|
||||
$table->dateTime('scheduled_at')->nullable();
|
||||
$table->integer('duration_minutes')->default(30);
|
||||
$table->foreignId('host_user_id')->constrained('users')->onDelete('cascade');
|
||||
$table->json('team_attendees')->nullable(); // array of user IDs
|
||||
$table->json('client_emails')->nullable(); // array of email strings
|
||||
$table->string('status')->default('scheduled'); // scheduled, in_progress, completed, cancelled
|
||||
$table->dateTime('started_at')->nullable();
|
||||
$table->dateTime('ended_at')->nullable();
|
||||
$table->json('active_peers')->nullable();
|
||||
$table->json('peer_signals')->nullable();
|
||||
$table->json('chat_messages')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('client_meetings');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('client_call_notes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('client_meeting_id')->constrained('client_meetings')->onDelete('cascade');
|
||||
$table->foreignId('client_id')->constrained('clients')->onDelete('cascade');
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
|
||||
$table->longText('raw_transcript')->nullable();
|
||||
$table->longText('live_notes')->nullable();
|
||||
$table->longText('mom_content')->nullable();
|
||||
$table->text('summary')->nullable();
|
||||
$table->json('action_items')->nullable();
|
||||
$table->json('key_decisions')->nullable();
|
||||
$table->string('detected_sentiment')->default('neutral'); // happy, neutral, sad, angry
|
||||
$table->json('sentiment_breakdown')->nullable();
|
||||
$table->json('angry_analysis')->nullable(); // Protected: only Admin & Responsible person can access
|
||||
$table->dateTime('emailed_at')->nullable();
|
||||
$table->json('emailed_to')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('client_call_notes');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('client_call_notes', function (Blueprint $table) {
|
||||
$table->json('emotion_details')->nullable()->after('angry_analysis');
|
||||
$table->json('todo_items')->nullable()->after('emotion_details');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('client_call_notes', function (Blueprint $table) {
|
||||
$table->dropColumn(['emotion_details', 'todo_items']);
|
||||
});
|
||||
}
|
||||
};
|
||||
277
database/seeders/ClientCallSeeder.php
Normal file
277
database/seeders/ClientCallSeeder.php
Normal file
@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCallNote;
|
||||
use App\Models\ClientMeeting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ClientCallSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Seed sample client calls and meetings data.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$admin = User::where('role', 'admin')->first();
|
||||
$pm = User::where('email', 'user@company.com')->first();
|
||||
|
||||
if (!$admin || !$pm) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Happy Client Sample
|
||||
$client1 = Client::firstOrCreate(
|
||||
['client_email' => 'client.robert@acmecorp.com'],
|
||||
[
|
||||
'client_name' => 'Robert Miller',
|
||||
'company_name' => 'Acme Corporation',
|
||||
'client_phone' => '+1 (555) 234-5678',
|
||||
'project_name' => 'Enterprise Cloud Migration & Analytics Portal',
|
||||
'project_details' => 'Full-stack cloud infrastructure migration to AWS with real-time analytics dashboard and SingleLogin SSO authentication integration.',
|
||||
'responsible_user_id' => $pm->id,
|
||||
'created_by' => $admin->id,
|
||||
'assigned_team_members' => [$pm->id, $admin->id],
|
||||
'status' => 'active',
|
||||
'latest_sentiment' => 'happy',
|
||||
]
|
||||
);
|
||||
|
||||
// Upcoming meeting
|
||||
ClientMeeting::firstOrCreate(
|
||||
['meeting_code' => 'cl-acme-kickoff'],
|
||||
[
|
||||
'client_id' => $client1->id,
|
||||
'title' => 'Sprint 2 Milestone Demo & Feedback Review',
|
||||
'agenda' => 'Demonstration of cloud infrastructure deployment, database synchronization metrics, and next sprint action items.',
|
||||
'meeting_type' => 'scheduled',
|
||||
'scheduled_at' => now()->addDays(1)->setTime(15, 0),
|
||||
'duration_minutes' => 45,
|
||||
'host_user_id' => $pm->id,
|
||||
'team_attendees' => [$pm->id, $admin->id],
|
||||
'client_emails' => ['client.robert@acmecorp.com'],
|
||||
'status' => 'scheduled',
|
||||
]
|
||||
);
|
||||
|
||||
// Past meeting with MoM
|
||||
$pastMeeting1 = ClientMeeting::firstOrCreate(
|
||||
['meeting_code' => 'cl-acme-past-01'],
|
||||
[
|
||||
'client_id' => $client1->id,
|
||||
'title' => 'Initial Project Kickoff & Architecture Alignment',
|
||||
'agenda' => 'Scope finalization and requirements review.',
|
||||
'meeting_type' => 'scheduled',
|
||||
'scheduled_at' => now()->subDays(3)->setTime(14, 0),
|
||||
'duration_minutes' => 30,
|
||||
'host_user_id' => $pm->id,
|
||||
'team_attendees' => [$pm->id],
|
||||
'client_emails' => ['client.robert@acmecorp.com'],
|
||||
'status' => 'completed',
|
||||
'started_at' => now()->subDays(3)->setTime(14, 0),
|
||||
'ended_at' => now()->subDays(3)->setTime(14, 30),
|
||||
]
|
||||
);
|
||||
|
||||
ClientCallNote::firstOrCreate(
|
||||
['client_meeting_id' => $pastMeeting1->id],
|
||||
[
|
||||
'client_id' => $client1->id,
|
||||
'created_by' => $pm->id,
|
||||
'raw_transcript' => "[02:00 PM] PM: Welcome Robert, today we will align on cloud architecture milestones.\n[02:05 PM] Client: The architecture diagram looks great, we are very pleased with the proposed SSO integration.\n[02:15 PM] PM: Excellent, we will target Sprint 1 delivery by next Friday.",
|
||||
'live_notes' => 'Client approved the architecture blueprints. Confirmed AWS US-East region for deployment.',
|
||||
'mom_content' => "### Minutes of Meeting (MoM)\n**Project:** Enterprise Cloud Migration & Analytics Portal\n**Client:** Robert Miller (Acme Corporation)\n\n#### 1. Meeting Objectives\n- Finalize cloud infrastructure deployment blueprint.\n- Validate SingleLogin SSO integration requirements.\n\n#### 2. Key Discussion Points\n- Architecture diagrams and IAM policy structures approved.\n- Confirmed AWS hosting specifications.\n\n#### 3. Agreed Next Steps\n- Engineering team to initialize cloud templates.\n- PM to share weekly status reports.",
|
||||
'summary' => 'Productive kickoff meeting where client praised the architectural design and approved deployment timeline.',
|
||||
'action_items' => [
|
||||
['task' => 'Deploy AWS staging environment templates', 'owner' => 'Engineering Team', 'deadline' => now()->addDays(2)->format('M d, Y'), 'status' => 'In Progress'],
|
||||
['task' => 'Provide SSO federation test credentials', 'owner' => 'Tech Lead', 'deadline' => now()->addDays(3)->format('M d, Y'), 'status' => 'Pending']
|
||||
],
|
||||
'key_decisions' => [
|
||||
['decision' => 'Approved AWS as primary cloud infrastructure provider.'],
|
||||
['decision' => 'Scheduled recurring bi-weekly progress review calls.']
|
||||
],
|
||||
'detected_sentiment' => 'happy',
|
||||
'sentiment_breakdown' => ['happy_pct' => 85, 'neutral_pct' => 10, 'sad_pct' => 3, 'angry_pct' => 2],
|
||||
'angry_analysis' => ['is_angry' => false, 'trigger_words' => [], 'summary_why_angry' => '', 'grievances' => [], 'suggested_remedy' => ''],
|
||||
'emotion_details' => [
|
||||
'emotion' => 'happy',
|
||||
'headline' => 'Client Delighted with Architecture Blueprint & SSO Speed',
|
||||
'bullet_points' => [
|
||||
'Pleased with clear architecture diagram and SingleLogin SSO integration roadmap.',
|
||||
'Praised team responsiveness and transparent sprint breakdown.',
|
||||
'Approved AWS cloud hosting specs without friction.'
|
||||
],
|
||||
'trigger_quotes' => ['looks great', 'very pleased', 'excellent progress'],
|
||||
'suggested_action' => 'Maintain current sprint momentum and continue weekly demo cadence.'
|
||||
],
|
||||
'todo_items' => [
|
||||
['id' => 1, 'task' => 'Deploy AWS staging environment templates', 'owner' => 'Engineering Team', 'priority' => 'High', 'deadline' => now()->addDays(2)->format('M d, Y'), 'completed' => true],
|
||||
['id' => 2, 'task' => 'Provide SSO federation test credentials', 'owner' => 'Tech Lead', 'priority' => 'Medium', 'deadline' => now()->addDays(3)->format('M d, Y'), 'completed' => false],
|
||||
['id' => 3, 'task' => 'Schedule recurring bi-weekly progress review calls', 'owner' => 'Project Manager', 'priority' => 'Low', 'deadline' => now()->addDays(5)->format('M d, Y'), 'completed' => false],
|
||||
],
|
||||
'emailed_at' => now()->subDays(3)->setTime(14, 35),
|
||||
'emailed_to' => ['client.robert@acmecorp.com', 'user@company.com', 'admin@company.com']
|
||||
]
|
||||
);
|
||||
|
||||
// 2. Client with Frustrated/Angry Sentiment
|
||||
$client2 = Client::firstOrCreate(
|
||||
['client_email' => 'sarah.connor@cybertech.io'],
|
||||
[
|
||||
'client_name' => 'Sarah Connor',
|
||||
'company_name' => 'CyberTech Dynamics',
|
||||
'client_phone' => '+1 (555) 987-6543',
|
||||
'project_name' => 'AI Customer Portal & Billing Gateway',
|
||||
'project_details' => 'Customer self-service portal with integrated Stripe & PayPal billing gateway and AI chatbot.',
|
||||
'responsible_user_id' => $pm->id,
|
||||
'created_by' => $admin->id,
|
||||
'assigned_team_members' => [$pm->id],
|
||||
'status' => 'active',
|
||||
'latest_sentiment' => 'angry',
|
||||
]
|
||||
);
|
||||
|
||||
$pastMeeting2 = ClientMeeting::firstOrCreate(
|
||||
['meeting_code' => 'cl-cybertech-review'],
|
||||
[
|
||||
'client_id' => $client2->id,
|
||||
'title' => 'Sprint 3 Deliverable Review & Urgent Escalation',
|
||||
'agenda' => 'Addressing payment webhook latency and unexpected sprint delays.',
|
||||
'meeting_type' => 'scheduled',
|
||||
'scheduled_at' => now()->subDay()->setTime(11, 0),
|
||||
'duration_minutes' => 35,
|
||||
'host_user_id' => $pm->id,
|
||||
'team_attendees' => [$pm->id],
|
||||
'client_emails' => ['sarah.connor@cybertech.io'],
|
||||
'status' => 'completed',
|
||||
'started_at' => now()->subDay()->setTime(11, 0),
|
||||
'ended_at' => now()->subDay()->setTime(11, 35),
|
||||
]
|
||||
);
|
||||
|
||||
ClientCallNote::firstOrCreate(
|
||||
['client_meeting_id' => $pastMeeting2->id],
|
||||
[
|
||||
'client_id' => $client2->id,
|
||||
'created_by' => $pm->id,
|
||||
'raw_transcript' => "[11:02 AM] Client: We are extremely frustrated with the 2-week delay on the Stripe payment integration.\n[11:05 AM] Client: This delay is completely unacceptable and broken billing webhooks are halting our user onboarding.\n[11:15 AM] PM: We understand your concern Sarah, we will allocate two senior developers immediately to resolve the webhooks by tomorrow.",
|
||||
'live_notes' => 'Client expressed strong dissatisfaction over payment gateway delay and API latency.',
|
||||
'mom_content' => "### Minutes of Meeting (MoM)\n**Project:** AI Customer Portal & Billing Gateway\n**Client:** Sarah Connor (CyberTech Dynamics)\n\n#### 1. Meeting Objectives\n- Address client concerns regarding Stripe payment webhook delays.\n- Realign on deployment timeline.\n\n#### 2. Key Discussion Points\n- Client raised severe blockers due to webhook latency.\n- Engineering lead committed to delivering hotfix within 24 hours.\n\n#### 3. Escalation Action Items\n- PM & Tech Lead to provide daily progress sync until resolution.",
|
||||
'summary' => 'Urgent review call where client voiced strong frustration regarding Stripe payment gateway delays and webhook issues.',
|
||||
'action_items' => [
|
||||
['task' => 'Deliver Stripe payment webhook hotfix and test end-to-end sandbox', 'owner' => 'Tech Lead', 'deadline' => now()->addDay()->format('M d, Y'), 'status' => 'Urgent'],
|
||||
['task' => 'Send formal apology and revised sprint deliverable roadmap to client', 'owner' => 'Project Manager', 'deadline' => now()->format('M d, Y'), 'status' => 'Pending']
|
||||
],
|
||||
'todo_items' => [
|
||||
['id' => 1, 'task' => 'Deliver Stripe payment webhook hotfix to staging server', 'owner' => 'Tech Lead', 'priority' => 'High', 'deadline' => now()->addDay()->format('M d, Y'), 'completed' => false],
|
||||
['id' => 2, 'task' => 'Send formal roadmap update and test verification results', 'owner' => 'Project Manager', 'priority' => 'High', 'deadline' => now()->addDays(2)->format('M d, Y'), 'completed' => false],
|
||||
],
|
||||
'key_decisions' => [
|
||||
['decision' => 'Assigned dedicated senior engineer to unblock billing module.']
|
||||
],
|
||||
'detected_sentiment' => 'angry',
|
||||
'sentiment_breakdown' => ['happy_pct' => 5, 'neutral_pct' => 10, 'sad_pct' => 10, 'angry_pct' => 75],
|
||||
'angry_analysis' => [
|
||||
'is_angry' => true,
|
||||
'trigger_words' => ['extremely frustrated', 'unacceptable', 'delay', 'broken billing webhooks', 'halting onboarding'],
|
||||
'summary_why_angry' => 'Client Sarah Connor is angry due to a 2-week delay in the Stripe payment gateway delivery and broken webhook notifications that are halting their customer onboarding operations. She emphasized that communication was lacking prior to this meeting.',
|
||||
'grievances' => [
|
||||
'Two-week milestone delivery delay on core billing module.',
|
||||
'Unreliable webhook notifications in staging environment.',
|
||||
'Lack of proactive status updates before the sprint deadline.'
|
||||
],
|
||||
'suggested_remedy' => 'Director and PM should immediately send an executive assurance email, schedule a morning hotfix verification demo, and provide daily transparent milestone updates until billing goes live.'
|
||||
],
|
||||
'emotion_details' => [
|
||||
'emotion' => 'angry',
|
||||
'headline' => 'Client Frustrated by 2-Week Payment Gateway Delay',
|
||||
'bullet_points' => [
|
||||
'Two-week milestone delivery delay on core billing module.',
|
||||
'Unreliable webhook notifications in staging environment halting customer onboarding.',
|
||||
'Lack of proactive status updates before the sprint deadline.'
|
||||
],
|
||||
'trigger_quotes' => ['extremely frustrated', 'completely unacceptable', 'halting our user onboarding'],
|
||||
'suggested_action' => 'Director & PM must arrange an immediate executive check-in, assign dedicated senior engineers to unblock issues, and deliver hotfix within 24h.'
|
||||
],
|
||||
'emailed_at' => now()->subDay()->setTime(11, 40),
|
||||
'emailed_to' => ['sarah.connor@cybertech.io', 'user@company.com', 'admin@company.com']
|
||||
]
|
||||
);
|
||||
|
||||
// 3. Client with Concerned / Sad Sentiment (to test "Why was he sad / concerned?")
|
||||
$client3 = Client::firstOrCreate(
|
||||
['client_email' => 'david.chen@fintechflow.com'],
|
||||
[
|
||||
'client_name' => 'David Chen',
|
||||
'company_name' => 'FinTech Flow Corp',
|
||||
'client_phone' => '+1 (555) 456-7890',
|
||||
'project_name' => 'Real-Time Financial Fraud Detection Engine',
|
||||
'project_details' => 'Machine learning based transaction fraud scoring engine with Kafka event pipelines.',
|
||||
'responsible_user_id' => $pm->id,
|
||||
'created_by' => $admin->id,
|
||||
'assigned_team_members' => [$pm->id],
|
||||
'status' => 'active',
|
||||
'latest_sentiment' => 'sad',
|
||||
]
|
||||
);
|
||||
|
||||
$pastMeeting3 = ClientMeeting::firstOrCreate(
|
||||
['meeting_code' => 'cl-fintech-concern'],
|
||||
[
|
||||
'client_id' => $client3->id,
|
||||
'title' => 'QA Test Coverage & Compliance Risk Sync',
|
||||
'agenda' => 'Reviewing end-to-end testing milestones and regulatory compliance dates.',
|
||||
'meeting_type' => 'scheduled',
|
||||
'scheduled_at' => now()->setTime(10, 0),
|
||||
'duration_minutes' => 25,
|
||||
'host_user_id' => $pm->id,
|
||||
'team_attendees' => [$pm->id],
|
||||
'client_emails' => ['david.chen@fintechflow.com'],
|
||||
'status' => 'completed',
|
||||
'started_at' => now()->setTime(10, 0),
|
||||
'ended_at' => now()->setTime(10, 25),
|
||||
]
|
||||
);
|
||||
|
||||
ClientCallNote::firstOrCreate(
|
||||
['client_meeting_id' => $pastMeeting3->id],
|
||||
[
|
||||
'client_id' => $client3->id,
|
||||
'created_by' => $pm->id,
|
||||
'raw_transcript' => "[10:00 AM] David: We are worried that compliance audit deadlines are in 3 weeks and test coverage is currently at 65%.\n[10:10 AM] PM: We understand your concern David, we have queued additional automated test suites.\n[10:15 AM] David: I am feeling uneasy about launching without 90% coverage.",
|
||||
'live_notes' => 'Client expressed worry and hesitation regarding regulatory compliance test coverage before launch.',
|
||||
'mom_content' => "### Minutes of Meeting (MoM)\n**Project:** Real-Time Financial Fraud Detection Engine\n**Client:** David Chen (FinTech Flow Corp)\n\n#### 1. Meeting Objectives\n- Evaluate test automation progress.\n- Align on compliance audit requirements.\n\n#### 2. Key Discussion Points\n- Client expressed concern over 65% test coverage.\n- Team agreed to prioritize automated integration tests.\n\n#### 3. Next Steps\n- Share daily test coverage metrics.",
|
||||
'summary' => 'Client expressed concern and sadness over QA test coverage timeline ahead of an upcoming regulatory compliance audit.',
|
||||
'action_items' => [
|
||||
['task' => 'Increase unit & integration test coverage to >85%', 'owner' => 'QA Team', 'deadline' => now()->addDays(4)->format('M d, Y'), 'status' => 'In Progress'],
|
||||
['task' => 'Provide compliance audit readiness checklist', 'owner' => 'Tech Lead', 'deadline' => now()->addDays(2)->format('M d, Y'), 'status' => 'Pending']
|
||||
],
|
||||
'todo_items' => [
|
||||
['id' => 1, 'task' => 'Increase unit & integration test coverage to >85%', 'owner' => 'QA Team', 'priority' => 'High', 'deadline' => now()->addDays(4)->format('M d, Y'), 'completed' => false],
|
||||
['id' => 2, 'task' => 'Provide compliance audit readiness checklist to David', 'owner' => 'Tech Lead', 'priority' => 'Medium', 'deadline' => now()->addDays(2)->format('M d, Y'), 'completed' => false],
|
||||
],
|
||||
'key_decisions' => [
|
||||
['decision' => 'Postponed non-critical UI enhancements to focus exclusively on test coverage.']
|
||||
],
|
||||
'detected_sentiment' => 'sad',
|
||||
'sentiment_breakdown' => ['happy_pct' => 10, 'neutral_pct' => 15, 'sad_pct' => 70, 'angry_pct' => 5],
|
||||
'angry_analysis' => ['is_angry' => false, 'trigger_words' => [], 'summary_why_angry' => '', 'grievances' => [], 'suggested_remedy' => ''],
|
||||
'emotion_details' => [
|
||||
'emotion' => 'sad',
|
||||
'headline' => 'Client Expressed Worry & Hesitation on Compliance Deadline',
|
||||
'bullet_points' => [
|
||||
'Worried that compliance audit deadline is only 3 weeks away while test coverage is at 65%.',
|
||||
'Uneasy about potential regulatory penalties if production release has unverified edge cases.',
|
||||
'Requested daily test metrics reports and prioritized automated regression tests.'
|
||||
],
|
||||
'trigger_quotes' => ['worried about deadline', 'feeling uneasy', 'concern about test coverage'],
|
||||
'suggested_action' => 'Tech Lead and PM should proactively provide a detailed test coverage roadmap and schedule a reassuring technical walkthrough.'
|
||||
],
|
||||
'emailed_at' => now()->setTime(10, 30),
|
||||
'emailed_to' => ['david.chen@fintechflow.com', 'user@company.com', 'admin@company.com']
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -61,6 +61,11 @@ public function run(): void
|
||||
['description' => 'Handles recruitment, payroll, and employee relations.']
|
||||
);
|
||||
|
||||
$directorRole = Role::firstOrCreate(
|
||||
['name' => 'Director'],
|
||||
['description' => 'Executive leadership overseeing client relations, projects, and strategy.']
|
||||
);
|
||||
|
||||
// 3. Seed default apps/services (Preserve if already exists)
|
||||
$outlookCalendar = App::firstOrCreate(
|
||||
['name' => 'Outlook Calendar'],
|
||||
|
||||
469
public/js/client-call.js
Normal file
469
public/js/client-call.js
Normal file
@ -0,0 +1,469 @@
|
||||
/**
|
||||
* SingleLogin Client Call - WebRTC Video & AI Meeting Intelligence Engine
|
||||
*/
|
||||
|
||||
let localStream = null;
|
||||
let screenStream = null;
|
||||
let peerConnections = {};
|
||||
let isAudioMuted = false;
|
||||
let isVideoMuted = false;
|
||||
let isScreenSharing = false;
|
||||
|
||||
let accumulatedTranscript = [];
|
||||
let speechRecognizer = null;
|
||||
let emotionTimeline = [];
|
||||
let callStartTime = Date.now();
|
||||
let peerId = 'peer_' + Math.random().toString(36).substring(2, 9);
|
||||
const config = window.CLIENT_CALL_CONFIG || {};
|
||||
|
||||
// Standard ICE servers
|
||||
const iceServers = [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
{ urls: 'stun:stun.cloudflare.com:3478' }
|
||||
];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
initTimer();
|
||||
await initMedia();
|
||||
initSpeechRecognition();
|
||||
startHeartbeatLoop();
|
||||
startEmotionMonitoring();
|
||||
|
||||
window.toggleAudio = toggleAudio;
|
||||
window.toggleVideo = toggleVideo;
|
||||
window.toggleScreenShare = toggleScreenShare;
|
||||
window.sendChatMessage = sendChatMessage;
|
||||
window.endMeetingAndGenerateMoM = endMeetingAndGenerateMoM;
|
||||
window.leaveCall = leaveCall;
|
||||
});
|
||||
|
||||
/**
|
||||
* Timer
|
||||
*/
|
||||
function initTimer() {
|
||||
const timerEl = document.getElementById('callTimer');
|
||||
setInterval(() => {
|
||||
const elapsedSec = Math.floor((Date.now() - callStartTime) / 1000);
|
||||
const mins = String(Math.floor(elapsedSec / 60)).padStart(2, '0');
|
||||
const secs = String(elapsedSec % 60).padStart(2, '0');
|
||||
if (timerEl) timerEl.innerText = `${mins}:${secs}`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Local Camera & Mic Stream
|
||||
*/
|
||||
async function initMedia() {
|
||||
try {
|
||||
localStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { width: { ideal: 1280 }, height: { ideal: 720 } },
|
||||
audio: true
|
||||
});
|
||||
const localVideo = document.getElementById('localVideo');
|
||||
if (localVideo) {
|
||||
localVideo.srcObject = localStream;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Camera/Mic access denied or unavailable:', err);
|
||||
// Fallback: audio only or dummy stream
|
||||
try {
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch (e) {
|
||||
console.warn('Audio also unavailable:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio / Video Toggles
|
||||
*/
|
||||
function toggleAudio() {
|
||||
if (!localStream) return;
|
||||
const audioTrack = localStream.getAudioTracks()[0];
|
||||
if (audioTrack) {
|
||||
isAudioMuted = !isAudioMuted;
|
||||
audioTrack.enabled = !isAudioMuted;
|
||||
const btn = document.getElementById('btnToggleMic');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active-off', isAudioMuted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleVideo() {
|
||||
if (!localStream) return;
|
||||
const videoTrack = localStream.getVideoTracks()[0];
|
||||
if (videoTrack) {
|
||||
isVideoMuted = !isVideoMuted;
|
||||
videoTrack.enabled = !isVideoMuted;
|
||||
const btn = document.getElementById('btnToggleVideo');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active-off', isVideoMuted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleScreenShare() {
|
||||
const btn = document.getElementById('btnScreenShare');
|
||||
if (!isScreenSharing) {
|
||||
try {
|
||||
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
|
||||
const screenTrack = screenStream.getVideoTracks()[0];
|
||||
|
||||
// Replace video track for peers
|
||||
for (let id in peerConnections) {
|
||||
const sender = peerConnections[id].getSenders().find(s => s.track && s.track.kind === 'video');
|
||||
if (sender) sender.replaceTrack(screenTrack);
|
||||
}
|
||||
|
||||
const localVideo = document.getElementById('localVideo');
|
||||
if (localVideo) localVideo.srcObject = screenStream;
|
||||
|
||||
screenTrack.onended = () => { stopScreenShare(); };
|
||||
isScreenSharing = true;
|
||||
if (btn) btn.classList.add('active-on');
|
||||
} catch (e) {
|
||||
console.warn('Screen share cancelled:', e);
|
||||
}
|
||||
} else {
|
||||
stopScreenShare();
|
||||
}
|
||||
}
|
||||
|
||||
function stopScreenShare() {
|
||||
if (screenStream) {
|
||||
screenStream.getTracks().forEach(t => t.stop());
|
||||
screenStream = null;
|
||||
}
|
||||
if (localStream) {
|
||||
const videoTrack = localStream.getVideoTracks()[0];
|
||||
for (let id in peerConnections) {
|
||||
const sender = peerConnections[id].getSenders().find(s => s.track && s.track.kind === 'video');
|
||||
if (sender && videoTrack) sender.replaceTrack(videoTrack);
|
||||
}
|
||||
const localVideo = document.getElementById('localVideo');
|
||||
if (localVideo) localVideo.srcObject = localStream;
|
||||
}
|
||||
isScreenSharing = false;
|
||||
const btn = document.getElementById('btnScreenShare');
|
||||
if (btn) btn.classList.remove('active-on');
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Speech Recognition for Live Transcription & AI Note Taking
|
||||
*/
|
||||
function initSpeechRecognition() {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
const feed = document.getElementById('transcriptFeed');
|
||||
const statusEl = document.getElementById('sttStatus');
|
||||
|
||||
if (!SpeechRecognition) {
|
||||
if (statusEl) statusEl.innerText = '● Speech API (Manual)';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
speechRecognizer = new SpeechRecognition();
|
||||
speechRecognizer.continuous = true;
|
||||
speechRecognizer.interimResults = true;
|
||||
speechRecognizer.lang = 'en-US';
|
||||
|
||||
speechRecognizer.onresult = (event) => {
|
||||
for (let i = event.resultIndex; i < event.results.length; ++i) {
|
||||
if (event.results[i].isFinal) {
|
||||
const text = event.results[i][0].transcript.trim();
|
||||
if (text.length > 0) {
|
||||
const line = {
|
||||
speaker: config.participantName || 'Speaker',
|
||||
text: text,
|
||||
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
};
|
||||
accumulatedTranscript.push(line);
|
||||
appendTranscriptLine(line);
|
||||
|
||||
// Check emotion on spoken text
|
||||
checkEmotionOnText(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
speechRecognizer.onerror = (e) => {
|
||||
console.warn('Speech Recognition error:', e.error);
|
||||
};
|
||||
|
||||
speechRecognizer.onend = () => {
|
||||
// Auto restart recognition
|
||||
try {
|
||||
speechRecognizer.start();
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
speechRecognizer.start();
|
||||
} catch (e) {
|
||||
console.warn('Could not initialize Speech Recognition:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function appendTranscriptLine(line) {
|
||||
const feed = document.getElementById('transcriptFeed');
|
||||
if (!feed) return;
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'transcript-line';
|
||||
div.innerHTML = `<span class="transcript-speaker">${escapeHtml(line.speaker)} (${line.time}):</span> <span class="transcript-text">${escapeHtml(line.text)}</span>`;
|
||||
feed.appendChild(div);
|
||||
feed.scrollTop = feed.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Emotion & Expression Monitoring
|
||||
*/
|
||||
function startEmotionMonitoring() {
|
||||
// Check face emotion snapshot every 10 seconds
|
||||
setInterval(() => {
|
||||
captureAndAnalyzeFrame();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
function captureAndAnalyzeFrame() {
|
||||
const video = document.getElementById('localVideo');
|
||||
const canvas = document.getElementById('emotionCanvas');
|
||||
if (!video || !canvas || !video.videoWidth) return;
|
||||
|
||||
canvas.width = 320;
|
||||
canvas.height = 240;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const frameBase64 = canvas.toDataURL('image/jpeg', 0.6);
|
||||
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
fetch(config.detectEmotionUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
image_frame: frameBase64,
|
||||
speech_text: accumulatedTranscript.slice(-3).map(l => l.text).join(' ')
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data && data.emotion) {
|
||||
updateEmotionBadge(data.emotion, data.label);
|
||||
emotionTimeline.push({
|
||||
emotion: data.emotion,
|
||||
time: Date.now()
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function checkEmotionOnText(text) {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
fetch(config.detectEmotionUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ speech_text: text })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data && data.emotion) {
|
||||
updateEmotionBadge(data.emotion, data.label);
|
||||
emotionTimeline.push({
|
||||
emotion: data.emotion,
|
||||
time: Date.now()
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function updateEmotionBadge(emotion, label) {
|
||||
const badge = document.getElementById('liveEmotionBadge');
|
||||
const emojiEl = document.getElementById('emotionEmoji');
|
||||
const textEl = document.getElementById('emotionText');
|
||||
if (!badge || !emojiEl || !textEl) return;
|
||||
|
||||
badge.className = 'emotion-live-indicator';
|
||||
|
||||
if (emotion === 'happy') {
|
||||
badge.classList.add('emotion-badge-happy');
|
||||
emojiEl.innerText = '😊';
|
||||
textEl.innerText = label || 'Client Mood: Happy / Satisfied';
|
||||
} else if (emotion === 'angry') {
|
||||
badge.classList.add('emotion-badge-angry');
|
||||
emojiEl.innerText = '😡';
|
||||
textEl.innerText = label || 'Client Mood: Frustrated / Angry';
|
||||
} else if (emotion === 'sad') {
|
||||
badge.classList.add('emotion-badge-sad');
|
||||
emojiEl.innerText = '😟';
|
||||
textEl.innerText = label || 'Client Mood: Concerned / Sad';
|
||||
} else {
|
||||
badge.classList.add('emotion-badge-neutral');
|
||||
emojiEl.innerText = '😐';
|
||||
textEl.innerText = label || 'Client Mood: Neutral / Attentive';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC Signaling & Chat Loop
|
||||
*/
|
||||
let outgoingChatMessage = null;
|
||||
|
||||
function sendChatMessage() {
|
||||
const input = document.getElementById('chatInput');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
outgoingChatMessage = { text: text };
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function startHeartbeatLoop() {
|
||||
setInterval(async () => {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
try {
|
||||
const res = await fetch(config.heartbeatUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
peer_name: config.participantName,
|
||||
chat_message: outgoingChatMessage
|
||||
})
|
||||
});
|
||||
|
||||
outgoingChatMessage = null;
|
||||
const data = await res.json();
|
||||
|
||||
// Update chat feed
|
||||
if (data.chat_messages) {
|
||||
renderChat(data.chat_messages);
|
||||
}
|
||||
|
||||
// Manage remote peers
|
||||
if (data.active_peers) {
|
||||
handleActivePeers(data.active_peers);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('Heartbeat error:', e);
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
function renderChat(messages) {
|
||||
const feed = document.getElementById('chatFeed');
|
||||
if (!feed) return;
|
||||
feed.innerHTML = '';
|
||||
messages.forEach(m => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg';
|
||||
div.innerHTML = `<div class="chat-sender">${escapeHtml(m.sender)} • ${m.time || ''}</div><div>${escapeHtml(m.text)}</div>`;
|
||||
feed.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function handleActivePeers(peers) {
|
||||
const grid = document.getElementById('videoGrid');
|
||||
peers.forEach(peer => {
|
||||
if (peer.peer_id === peerId) return;
|
||||
|
||||
let tile = document.getElementById('tile_' + peer.peer_id);
|
||||
if (!tile) {
|
||||
tile = document.createElement('div');
|
||||
tile.id = 'tile_' + peer.peer_id;
|
||||
tile.className = 'video-tile';
|
||||
tile.innerHTML = `
|
||||
<div class="video-avatar-placeholder">${peer.peer_name.charAt(0).toUpperCase()}</div>
|
||||
<div class="video-overlay">
|
||||
<span>${escapeHtml(peer.peer_name)}</span>
|
||||
</div>
|
||||
`;
|
||||
grid.appendChild(tile);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove left peers
|
||||
const activeIds = peers.map(p => 'tile_' + p.peer_id);
|
||||
document.querySelectorAll('.video-tile').forEach(tile => {
|
||||
if (tile.id !== 'localVideoTile' && !activeIds.includes(tile.id)) {
|
||||
tile.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* End Call & MoM Generation
|
||||
*/
|
||||
async function endMeetingAndGenerateMoM() {
|
||||
if (!confirm('Are you sure you want to end this client call and generate AI Minutes of Meeting (MoM)?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const liveNotes = document.getElementById('liveNotesInput')?.value || '';
|
||||
const transcriptText = accumulatedTranscript.map(l => `[${l.time}] ${l.speaker}: ${l.text}`).join("\n");
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
// Show processing indicator
|
||||
const btn = document.querySelector('.btn-end-call');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerText = 'Generating AI MoM & Dispatching Emails...';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(config.endCallUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
transcript: transcriptText,
|
||||
live_notes: liveNotes,
|
||||
emotion_timeline: emotionTimeline
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.redirect_url) {
|
||||
window.location.href = data.redirect_url;
|
||||
} else {
|
||||
window.location.href = config.redirectUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Meeting ended. Redirecting to Client Hub...');
|
||||
window.location.href = config.redirectUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function leaveCall() {
|
||||
if (confirm('Leave this meeting?')) {
|
||||
window.location.href = config.redirectUrl || '/';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, function(m) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m];
|
||||
});
|
||||
}
|
||||
459
resources/js/client-call.js
Normal file
459
resources/js/client-call.js
Normal file
@ -0,0 +1,459 @@
|
||||
/**
|
||||
* SingleLogin Client Call - WebRTC Video & AI Meeting Intelligence Engine
|
||||
*/
|
||||
|
||||
let localStream = null;
|
||||
let screenStream = null;
|
||||
let peerConnections = {};
|
||||
let isAudioMuted = false;
|
||||
let isVideoMuted = false;
|
||||
let isScreenSharing = false;
|
||||
|
||||
let accumulatedTranscript = [];
|
||||
let speechRecognizer = null;
|
||||
let emotionTimeline = [];
|
||||
let callStartTime = Date.now();
|
||||
let peerId = 'peer_' + Math.random().toString(36).substring(2, 9);
|
||||
const config = window.CLIENT_CALL_CONFIG || {};
|
||||
|
||||
// Standard ICE servers
|
||||
const iceServers = [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
{ urls: 'stun:stun.cloudflare.com:3478' }
|
||||
];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
initTimer();
|
||||
await initMedia();
|
||||
initSpeechRecognition();
|
||||
startHeartbeatLoop();
|
||||
startEmotionMonitoring();
|
||||
|
||||
window.toggleAudio = toggleAudio;
|
||||
window.toggleVideo = toggleVideo;
|
||||
window.toggleScreenShare = toggleScreenShare;
|
||||
window.sendChatMessage = sendChatMessage;
|
||||
window.endMeetingAndGenerateMoM = endMeetingAndGenerateMoM;
|
||||
window.leaveCall = leaveCall;
|
||||
});
|
||||
|
||||
/**
|
||||
* Timer
|
||||
*/
|
||||
function initTimer() {
|
||||
const timerEl = document.getElementById('callTimer');
|
||||
setInterval(() => {
|
||||
const elapsedSec = Math.floor((Date.now() - callStartTime) / 1000);
|
||||
const mins = String(Math.floor(elapsedSec / 60)).padStart(2, '0');
|
||||
const secs = String(elapsedSec % 60).padStart(2, '0');
|
||||
if (timerEl) timerEl.innerText = `${mins}:${secs}`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Local Camera & Mic Stream
|
||||
*/
|
||||
async function initMedia() {
|
||||
try {
|
||||
localStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { width: { ideal: 1280 }, height: { ideal: 720 } },
|
||||
audio: true
|
||||
});
|
||||
const localVideo = document.getElementById('localVideo');
|
||||
if (localVideo) {
|
||||
localVideo.srcObject = localStream;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Camera/Mic access denied or unavailable:', err);
|
||||
try {
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch (e) {
|
||||
console.warn('Audio also unavailable:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio / Video Toggles
|
||||
*/
|
||||
function toggleAudio() {
|
||||
if (!localStream) return;
|
||||
const audioTrack = localStream.getAudioTracks()[0];
|
||||
if (audioTrack) {
|
||||
isAudioMuted = !isAudioMuted;
|
||||
audioTrack.enabled = !isAudioMuted;
|
||||
const btn = document.getElementById('btnToggleMic');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active-off', isAudioMuted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleVideo() {
|
||||
if (!localStream) return;
|
||||
const videoTrack = localStream.getVideoTracks()[0];
|
||||
if (videoTrack) {
|
||||
isVideoMuted = !isVideoMuted;
|
||||
videoTrack.enabled = !isVideoMuted;
|
||||
const btn = document.getElementById('btnToggleVideo');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active-off', isVideoMuted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleScreenShare() {
|
||||
const btn = document.getElementById('btnScreenShare');
|
||||
if (!isScreenSharing) {
|
||||
try {
|
||||
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
|
||||
const screenTrack = screenStream.getVideoTracks()[0];
|
||||
|
||||
for (let id in peerConnections) {
|
||||
const sender = peerConnections[id].getSenders().find(s => s.track && s.track.kind === 'video');
|
||||
if (sender) sender.replaceTrack(screenTrack);
|
||||
}
|
||||
|
||||
const localVideo = document.getElementById('localVideo');
|
||||
if (localVideo) localVideo.srcObject = screenStream;
|
||||
|
||||
screenTrack.onended = () => { stopScreenShare(); };
|
||||
isScreenSharing = true;
|
||||
if (btn) btn.classList.add('active-on');
|
||||
} catch (e) {
|
||||
console.warn('Screen share cancelled:', e);
|
||||
}
|
||||
} else {
|
||||
stopScreenShare();
|
||||
}
|
||||
}
|
||||
|
||||
function stopScreenShare() {
|
||||
if (screenStream) {
|
||||
screenStream.getTracks().forEach(t => t.stop());
|
||||
screenStream = null;
|
||||
}
|
||||
if (localStream) {
|
||||
const videoTrack = localStream.getVideoTracks()[0];
|
||||
for (let id in peerConnections) {
|
||||
const sender = peerConnections[id].getSenders().find(s => s.track && s.track.kind === 'video');
|
||||
if (sender && videoTrack) sender.replaceTrack(videoTrack);
|
||||
}
|
||||
const localVideo = document.getElementById('localVideo');
|
||||
if (localVideo) localVideo.srcObject = localStream;
|
||||
}
|
||||
isScreenSharing = false;
|
||||
const btn = document.getElementById('btnScreenShare');
|
||||
if (btn) btn.classList.remove('active-on');
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Speech Recognition for Live Transcription & AI Note Taking
|
||||
*/
|
||||
function initSpeechRecognition() {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
const feed = document.getElementById('transcriptFeed');
|
||||
const statusEl = document.getElementById('sttStatus');
|
||||
|
||||
if (!SpeechRecognition) {
|
||||
if (statusEl) statusEl.innerText = '● Speech API (Manual)';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
speechRecognizer = new SpeechRecognition();
|
||||
speechRecognizer.continuous = true;
|
||||
speechRecognizer.interimResults = true;
|
||||
speechRecognizer.lang = 'en-US';
|
||||
|
||||
speechRecognizer.onresult = (event) => {
|
||||
for (let i = event.resultIndex; i < event.results.length; ++i) {
|
||||
if (event.results[i].isFinal) {
|
||||
const text = event.results[i][0].transcript.trim();
|
||||
if (text.length > 0) {
|
||||
const line = {
|
||||
speaker: config.participantName || 'Speaker',
|
||||
text: text,
|
||||
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
};
|
||||
accumulatedTranscript.push(line);
|
||||
appendTranscriptLine(line);
|
||||
|
||||
checkEmotionOnText(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
speechRecognizer.onerror = (e) => {
|
||||
console.warn('Speech Recognition error:', e.error);
|
||||
};
|
||||
|
||||
speechRecognizer.onend = () => {
|
||||
try {
|
||||
speechRecognizer.start();
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
speechRecognizer.start();
|
||||
} catch (e) {
|
||||
console.warn('Could not initialize Speech Recognition:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function appendTranscriptLine(line) {
|
||||
const feed = document.getElementById('transcriptFeed');
|
||||
if (!feed) return;
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'transcript-line';
|
||||
div.innerHTML = `<span class="transcript-speaker">${escapeHtml(line.speaker)} (${line.time}):</span> <span class="transcript-text">${escapeHtml(line.text)}</span>`;
|
||||
feed.appendChild(div);
|
||||
feed.scrollTop = feed.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Emotion & Expression Monitoring
|
||||
*/
|
||||
function startEmotionMonitoring() {
|
||||
setInterval(() => {
|
||||
captureAndAnalyzeFrame();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
function captureAndAnalyzeFrame() {
|
||||
const video = document.getElementById('localVideo');
|
||||
const canvas = document.getElementById('emotionCanvas');
|
||||
if (!video || !canvas || !video.videoWidth) return;
|
||||
|
||||
canvas.width = 320;
|
||||
canvas.height = 240;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const frameBase64 = canvas.toDataURL('image/jpeg', 0.6);
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
fetch(config.detectEmotionUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
image_frame: frameBase64,
|
||||
speech_text: accumulatedTranscript.slice(-3).map(l => l.text).join(' ')
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data && data.emotion) {
|
||||
updateEmotionBadge(data.emotion, data.label);
|
||||
emotionTimeline.push({
|
||||
emotion: data.emotion,
|
||||
time: Date.now()
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function checkEmotionOnText(text) {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
fetch(config.detectEmotionUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ speech_text: text })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data && data.emotion) {
|
||||
updateEmotionBadge(data.emotion, data.label);
|
||||
emotionTimeline.push({
|
||||
emotion: data.emotion,
|
||||
time: Date.now()
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function updateEmotionBadge(emotion, label) {
|
||||
const badge = document.getElementById('liveEmotionBadge');
|
||||
const emojiEl = document.getElementById('emotionEmoji');
|
||||
const textEl = document.getElementById('emotionText');
|
||||
if (!badge || !emojiEl || !textEl) return;
|
||||
|
||||
badge.className = 'emotion-live-indicator';
|
||||
|
||||
if (emotion === 'happy') {
|
||||
badge.classList.add('emotion-badge-happy');
|
||||
emojiEl.innerText = '😊';
|
||||
textEl.innerText = label || 'Client Mood: Happy / Satisfied';
|
||||
} else if (emotion === 'angry') {
|
||||
badge.classList.add('emotion-badge-angry');
|
||||
emojiEl.innerText = '😡';
|
||||
textEl.innerText = label || 'Client Mood: Frustrated / Angry';
|
||||
} else if (emotion === 'sad') {
|
||||
badge.classList.add('emotion-badge-sad');
|
||||
emojiEl.innerText = '😟';
|
||||
textEl.innerText = label || 'Client Mood: Concerned / Sad';
|
||||
} else {
|
||||
badge.classList.add('emotion-badge-neutral');
|
||||
emojiEl.innerText = '😐';
|
||||
textEl.innerText = label || 'Client Mood: Neutral / Attentive';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC Signaling & Chat Loop
|
||||
*/
|
||||
let outgoingChatMessage = null;
|
||||
|
||||
function sendChatMessage() {
|
||||
const input = document.getElementById('chatInput');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
outgoingChatMessage = { text: text };
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function startHeartbeatLoop() {
|
||||
setInterval(async () => {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
try {
|
||||
const res = await fetch(config.heartbeatUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
peer_name: config.participantName,
|
||||
chat_message: outgoingChatMessage
|
||||
})
|
||||
});
|
||||
|
||||
outgoingChatMessage = null;
|
||||
const data = await res.json();
|
||||
|
||||
if (data.chat_messages) {
|
||||
renderChat(data.chat_messages);
|
||||
}
|
||||
|
||||
if (data.active_peers) {
|
||||
handleActivePeers(data.active_peers);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('Heartbeat error:', e);
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
function renderChat(messages) {
|
||||
const feed = document.getElementById('chatFeed');
|
||||
if (!feed) return;
|
||||
feed.innerHTML = '';
|
||||
messages.forEach(m => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg';
|
||||
div.innerHTML = `<div class="chat-sender">${escapeHtml(m.sender)} • ${m.time || ''}</div><div>${escapeHtml(m.text)}</div>`;
|
||||
feed.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function handleActivePeers(peers) {
|
||||
const grid = document.getElementById('videoGrid');
|
||||
peers.forEach(peer => {
|
||||
if (peer.peer_id === peerId) return;
|
||||
|
||||
let tile = document.getElementById('tile_' + peer.peer_id);
|
||||
if (!tile) {
|
||||
tile = document.createElement('div');
|
||||
tile.id = 'tile_' + peer.peer_id;
|
||||
tile.className = 'video-tile';
|
||||
tile.innerHTML = `
|
||||
<div class="video-avatar-placeholder">${peer.peer_name.charAt(0).toUpperCase()}</div>
|
||||
<div class="video-overlay">
|
||||
<span>${escapeHtml(peer.peer_name)}</span>
|
||||
</div>
|
||||
`;
|
||||
grid.appendChild(tile);
|
||||
}
|
||||
});
|
||||
|
||||
const activeIds = peers.map(p => 'tile_' + p.peer_id);
|
||||
document.querySelectorAll('.video-tile').forEach(tile => {
|
||||
if (tile.id !== 'localVideoTile' && !activeIds.includes(tile.id)) {
|
||||
tile.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* End Call & MoM Generation
|
||||
*/
|
||||
async function endMeetingAndGenerateMoM() {
|
||||
if (!confirm('Are you sure you want to end this client call and generate AI Minutes of Meeting (MoM)?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const liveNotes = document.getElementById('liveNotesInput')?.value || '';
|
||||
const transcriptText = accumulatedTranscript.map(l => `[${l.time}] ${l.speaker}: ${l.text}`).join("\n");
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
const btn = document.querySelector('.btn-end-call');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerText = 'Generating AI MoM & Dispatching Emails...';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(config.endCallUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
transcript: transcriptText,
|
||||
live_notes: liveNotes,
|
||||
emotion_timeline: emotionTimeline
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.redirect_url) {
|
||||
window.location.href = data.redirect_url;
|
||||
} else {
|
||||
window.location.href = config.redirectUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Meeting ended. Redirecting to Client Hub...');
|
||||
window.location.href = config.redirectUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function leaveCall() {
|
||||
if (confirm('Leave this meeting?')) {
|
||||
window.location.href = config.redirectUrl || '/';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, function(m) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m];
|
||||
});
|
||||
}
|
||||
@ -816,7 +816,10 @@
|
||||
</div>
|
||||
<div class="nav-actions">
|
||||
<!-- Allow Admin to view the User portal dashboard -->
|
||||
<a href="{{ route('interview.index') }}" class="btn-nav" style="border-color: #22d3ee; color: #22d3ee; text-decoration: none;">
|
||||
<a href="{{ route('client-calls.index') }}" class="btn-nav" style="border-color: #22d3ee; color: #22d3ee; text-decoration: none;">
|
||||
📞 Client Calls Hub
|
||||
</a>
|
||||
<a href="{{ route('interview.index') }}" class="btn-nav" style="border-color: #34d399; color: #34d399; text-decoration: none;">
|
||||
⚡ Assessment Hub
|
||||
</a>
|
||||
<a href="{{ route('onboarding.index') }}" class="btn-nav" style="border-color: #818cf8; color: #818cf8; text-decoration: none;">
|
||||
@ -884,6 +887,22 @@
|
||||
</div>
|
||||
<div class="stat-icon" style="color: #a5b4fc;">🏷️</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div>
|
||||
<div class="stat-label">Client Projects</div>
|
||||
<div class="stat-value">{{ $stats['clients_count'] ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-icon" style="color: #22d3ee;">📁</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div>
|
||||
<div class="stat-label">Client Alerts</div>
|
||||
<div class="stat-value" style="{{ ($stats['angry_clients_count'] ?? 0) > 0 ? 'color: #f87171;' : '' }}">{{ $stats['angry_clients_count'] ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-icon" style="color: {{ ($stats['angry_clients_count'] ?? 0) > 0 ? '#f87171' : '#34d399' }};">{{ ($stats['angry_clients_count'] ?? 0) > 0 ? '⚠️' : '✨' }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Alert messages in dashboard -->
|
||||
@ -915,6 +934,96 @@
|
||||
|
||||
|
||||
|
||||
<!-- Client Projects & Calls Directory -->
|
||||
<section class="users-card" style="margin-bottom: 50px;">
|
||||
<div class="card-header" style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
|
||||
<div>
|
||||
<div class="card-title" style="font-family: 'Outfit', sans-serif; font-size: 1.25rem; font-weight: 700; color: #ffffff;">Client Projects & Meetings Hub</div>
|
||||
<div style="font-size: 0.8rem; color: var(--text-muted);">
|
||||
Real-time client collaboration, AI transcription, MoM records, and emotion detection
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<a href="{{ route('client-calls.index') }}" class="action-btn" style="color: #22d3ee; border-color: rgba(34, 211, 238, 0.4); text-decoration: none; font-size: 0.8rem; padding: 6px 14px; font-weight: 600;">
|
||||
Open Full Client Calls Hub ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project & Client</th>
|
||||
<th>Responsible Lead</th>
|
||||
<th>Total Calls</th>
|
||||
<th>Client Expression</th>
|
||||
<th style="text-align: right;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($clients as $cl)
|
||||
@php
|
||||
$sent = strtolower($cl->latest_sentiment ?? 'neutral');
|
||||
$badgeStyle = match($sent) {
|
||||
'happy' => 'background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.35);',
|
||||
'sad' => 'background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.35);',
|
||||
'angry' => 'background: rgba(239, 68, 68, 0.2); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.45); font-weight: 700;',
|
||||
default => 'background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.35);',
|
||||
};
|
||||
$emoji = match($sent) {
|
||||
'happy' => '😊 Happy',
|
||||
'sad' => '😟 Concerned',
|
||||
'angry' => '😡 Frustrated',
|
||||
default => '😐 Neutral',
|
||||
};
|
||||
$latestNote = $cl->callNotes->first();
|
||||
@endphp
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-weight: 700; color: #f3f4f6; font-size: 0.95rem;">{{ $cl->project_name }}</div>
|
||||
<div style="font-size: 0.8rem; color: var(--text-muted); display: flex; align-items: center; gap: 6px; margin-top: 2px;">
|
||||
<span>👤 {{ $cl->client_name }}</span>
|
||||
@if($cl->company_name)<span>• {{ $cl->company_name }}</span>@endif
|
||||
<span>• ✉️ {{ $cl->client_email }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" style="background: rgba(99, 102, 241, 0.15); color: #818cf8; border: 1px solid rgba(99, 102, 241, 0.3);">
|
||||
{{ $cl->responsibleUser->name ?? 'Unassigned' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style="font-weight: 600; font-size: 0.85rem; color: #cbd5e1;">{{ $cl->meetings->count() }} meetings</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" style="{{ $badgeStyle }} font-size: 0.75rem; padding: 4px 10px; border-radius: 999px;">
|
||||
{{ $emoji }}
|
||||
</span>
|
||||
@if($sent === 'angry' && $latestNote)
|
||||
<button onclick="openAdminWhyAngryModal({{ $latestNote->id }})" style="background: #ef4444; color: white; border: none; font-size: 0.7rem; padding: 3px 8px; border-radius: 4px; font-weight: 700; cursor: pointer; margin-left: 6px;">
|
||||
Why angry?
|
||||
</button>
|
||||
@endif
|
||||
</td>
|
||||
<td style="text-align: right;">
|
||||
<a href="{{ route('client-calls.show', $cl->id) }}" target="_blank" class="action-btn" style="color: #818cf8; border-color: rgba(99, 102, 241, 0.4); text-decoration: none; font-size: 0.75rem; padding: 5px 12px; font-weight: 600;">
|
||||
Open Client Hub ↗
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; color: var(--text-muted); padding: 24px;">
|
||||
No client projects registered yet. <a href="{{ route('client-calls.index') }}" style="color: #818cf8;">Create first client project</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Users Table Card -->
|
||||
<section class="users-card" style="margin-bottom: 50px;">
|
||||
<div class="card-header" style="margin-bottom: 20px;">
|
||||
@ -1779,8 +1888,95 @@ function confirmAdminToggle(userId, userName, actionType) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function openAdminWhyAngryModal(noteId) {
|
||||
const modal = document.getElementById('adminWhyAngryModal');
|
||||
const content = document.getElementById('adminWhyAngryContent');
|
||||
modal.classList.add('active');
|
||||
content.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-muted);">Analyzing transcript and emotion triggers...</div>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/client-calls/notes/${noteId}/angry-analysis`, {
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
content.innerHTML = `<div style="color:#f87171;padding:1rem;background:rgba(239,68,68,0.1);border-radius:8px;">${data.error || 'Access Denied.'}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const analysis = data.angry_analysis || {};
|
||||
const triggers = analysis.trigger_words || [];
|
||||
const grievances = analysis.grievances || [];
|
||||
|
||||
content.innerHTML = `
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<div style="font-size: 0.85rem; color: var(--text-muted); margin-bottom: 4px;">Client & Project</div>
|
||||
<div style="font-weight: 700; font-size: 1.1rem; color: #ffffff;">${data.client_name} • ${data.project_name}</div>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(239,68,68,0.15); border-left: 4px solid #ef4444; padding: 14px; border-radius: 0 8px 8px 0; margin-bottom: 1.25rem;">
|
||||
<div style="font-weight: 700; font-size: 0.85rem; color: #fca5a5; margin-bottom: 6px; text-transform: uppercase;">AI Executive Breakdown</div>
|
||||
<p style="font-size: 0.9rem; color: #fee2e2; line-height: 1.5;">${analysis.summary_why_angry || 'Client showed frustration during the meeting.'}</p>
|
||||
</div>
|
||||
|
||||
${triggers.length > 0 ? `
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<div style="font-weight: 600; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 6px;">Identified Frustrated / Angry Keywords:</div>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
|
||||
${triggers.map(t => `<span style="background: rgba(239,68,68,0.25); color: #fca5a5; font-size: 0.75rem; padding: 3px 8px; border-radius: 4px; border: 1px solid rgba(239,68,68,0.4); font-weight: 600;">"${t}"</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${grievances.length > 0 ? `
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<div style="font-weight: 600; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 6px;">Key Complaints / Pain Points:</div>
|
||||
<ul style="padding-left: 20px; font-size: 0.85rem; color: #cbd5e1; line-height: 1.6;">
|
||||
${grievances.map(g => `<li>${g}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${analysis.suggested_remedy ? `
|
||||
<div style="background: rgba(16,185,129,0.15); border: 1px solid rgba(16,185,129,0.35); border-radius: 8px; padding: 12px;">
|
||||
<div style="font-weight: 700; font-size: 0.8rem; color: #34d399; margin-bottom: 4px; text-transform: uppercase;">💡 Recommended Action for PM & Director:</div>
|
||||
<p style="font-size: 0.85rem; color: #d1fae5; line-height: 1.5;">${analysis.suggested_remedy}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
} catch (err) {
|
||||
content.innerHTML = `<div style="color:#f87171;padding:1rem;">Failed to load analysis.</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function closeAdminWhyAngryModal() {
|
||||
document.getElementById('adminWhyAngryModal').classList.remove('active');
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Admin Why Angry Modal -->
|
||||
<div id="adminWhyAngryModal" class="admin-modal">
|
||||
<div class="admin-modal-content" style="max-width: 600px; border-color: rgba(239, 68, 68, 0.5);">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h3 class="modal-title" style="color: #f87171; display: flex; align-items: center; gap: 8px;">
|
||||
<span>😡</span> AI Root-Cause: Why is the client angry?
|
||||
</h3>
|
||||
<div style="font-size: 0.75rem; color: #fca5a5; margin-top: 4px;">Confidential to Administrator & Assigned Lead</div>
|
||||
</div>
|
||||
<button class="modal-close" onclick="closeAdminWhyAngryModal()">×</button>
|
||||
</div>
|
||||
<div id="adminWhyAngryContent" style="padding: 10px 0;">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
<div class="modal-footer" style="margin-top: 15px; border-top: 1px solid var(--card-border); padding-top: 15px; text-align: right;">
|
||||
<button type="button" class="btn-secondary" onclick="closeAdminWhyAngryModal()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer style="text-align: center; padding: 40px 0; font-size: 0.75rem; color: var(--text-muted); z-index: 10;">
|
||||
© 2026 Systems. Authorized Access Only.
|
||||
</footer>
|
||||
|
||||
157
resources/views/client_calls/guest_join.blade.php
Normal file
157
resources/views/client_calls/guest_join.blade.php
Normal file
@ -0,0 +1,157 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Join Meeting | SingleLogin Connect</title>
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@500;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #0b0f19;
|
||||
--card-bg: rgba(17, 24, 39, 0.7);
|
||||
--card-border: rgba(255, 255, 255, 0.12);
|
||||
--accent-primary: #4f46e5;
|
||||
--text-main: #f3f4f6;
|
||||
--text-muted: #9ca3af;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background-image:
|
||||
radial-gradient(circle at 80% 20%, rgba(99, 102, 241, 0.15) 0%, transparent 50%),
|
||||
radial-gradient(circle at 20% 80%, rgba(6, 182, 212, 0.12) 0%, transparent 50%);
|
||||
}
|
||||
.join-card {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 20px;
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
text-align: center;
|
||||
}
|
||||
.app-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #818cf8;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.25rem;
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
.meeting-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.project-meta {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.form-group {
|
||||
text-align: left;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.8rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-main);
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus {
|
||||
border-color: #6366f1;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.btn-join {
|
||||
width: 100%;
|
||||
padding: 0.85rem;
|
||||
background: linear-gradient(135deg, #4f46e5, #6366f1);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.btn-join:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(79, 70, 229, 0.6);
|
||||
}
|
||||
.footer-note {
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="join-card">
|
||||
<div class="app-badge">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
|
||||
SingleLogin Video Connect
|
||||
</div>
|
||||
|
||||
<h1 class="meeting-title">{{ $meeting->title }}</h1>
|
||||
<p class="project-meta">{{ $meeting->client->project_name }} • Hosted by {{ $meeting->host->name ?? 'SingleLogin Host' }}</p>
|
||||
|
||||
<form action="{{ route('client-calls.join-guest', $meeting->meeting_code) }}" method="POST">
|
||||
@csrf
|
||||
<div class="form-group">
|
||||
<label class="form-label">What's your name? *</label>
|
||||
<input type="text" name="guest_name" class="form-control" placeholder="e.g. Robert Miller (Acme Corp)" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Your Email (Optional)</label>
|
||||
<input type="email" name="guest_email" class="form-control" placeholder="e.g. robert@acme.com">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-join">
|
||||
👉 Join Meeting Room
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="footer-note">
|
||||
No account required. Protected with end-to-end WebRTC encryption.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
937
resources/views/client_calls/index.blade.php
Normal file
937
resources/views/client_calls/index.blade.php
Normal file
@ -0,0 +1,937 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Client Calls & Projects Hub | SingleLogin</title>
|
||||
<!-- Google Fonts: Outfit & Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
if (savedTheme === 'light') {
|
||||
document.documentElement.classList.add('light-mode');
|
||||
} else {
|
||||
document.documentElement.classList.remove('light-mode');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #0b0f19;
|
||||
--card-bg: rgba(17, 24, 39, 0.55);
|
||||
--card-border: rgba(255, 255, 255, 0.12);
|
||||
--accent-primary: #4f46e5;
|
||||
--accent-secondary: #06b6d4;
|
||||
--text-main: #f3f4f6;
|
||||
--text-muted: #9ca3af;
|
||||
--success-color: #10b981;
|
||||
--warning-color: #f59e0b;
|
||||
--danger-color: #ef4444;
|
||||
--input-bg: rgba(255, 255, 255, 0.04);
|
||||
--modal-bg: #131b2e;
|
||||
--modal-overlay: rgba(11, 15, 25, 0.85);
|
||||
}
|
||||
|
||||
:root.light-mode {
|
||||
--bg-color: #f3f4f6;
|
||||
--card-bg: rgba(255, 255, 255, 0.85);
|
||||
--card-border: rgba(0, 0, 0, 0.08);
|
||||
--accent-primary: #4f46e5;
|
||||
--accent-secondary: #0284c7;
|
||||
--text-main: #1f2937;
|
||||
--text-muted: #4b5563;
|
||||
--success-color: #059669;
|
||||
--warning-color: #d97706;
|
||||
--danger-color: #dc2626;
|
||||
--input-bg: rgba(0, 0, 0, 0.03);
|
||||
--modal-bg: #ffffff;
|
||||
--modal-overlay: rgba(243, 244, 246, 0.85);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
background-image:
|
||||
radial-gradient(circle at 85% 15%, rgba(99, 102, 241, 0.12) 0%, transparent 45%),
|
||||
radial-gradient(circle at 15% 85%, rgba(6, 182, 212, 0.1) 0%, transparent 45%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
.navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
background: rgba(10, 15, 26, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
:root.light-mode .navbar {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-decoration: none;
|
||||
color: var(--text-main);
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.brand-badge {
|
||||
background: linear-gradient(135deg, #4f46e5, #06b6d4);
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid var(--card-border);
|
||||
background: var(--card-bg);
|
||||
color: var(--text-main);
|
||||
}
|
||||
.nav-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
.nav-btn-primary {
|
||||
background: linear-gradient(135deg, #4f46e5, #6366f1);
|
||||
color: white !important;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.35);
|
||||
}
|
||||
.nav-btn-primary:hover {
|
||||
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.5);
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.main-container {
|
||||
max-width: 1300px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* Alert Banners */
|
||||
.alert {
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.alert-success {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
border: 1px solid rgba(16, 185, 129, 0.35);
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 14px;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.stat-card:hover {
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
color: #818cf8;
|
||||
}
|
||||
.stat-icon.danger {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
}
|
||||
.stat-icon.success {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #34d399;
|
||||
}
|
||||
.stat-val {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
color: var(--text-main);
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Section Header */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
.section-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
.section-desc {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Search & Filter Bar */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
padding: 0.65rem 1rem;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-main);
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
.search-input:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
.filter-select {
|
||||
padding: 0.65rem 1rem;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-main);
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Client Cards Grid */
|
||||
.clients-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.client-card {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
transition: all 0.25s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.client-card:hover {
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.client-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.project-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.client-company {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Sentiment Badges */
|
||||
.sentiment-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sentiment-happy {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #34d399;
|
||||
border: 1px solid rgba(16, 185, 129, 0.35);
|
||||
}
|
||||
.sentiment-neutral {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #60a5fa;
|
||||
border: 1px solid rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
.sentiment-sad {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #fbbf24;
|
||||
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||
}
|
||||
.sentiment-angry {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(239, 68, 68, 0.45);
|
||||
animation: pulse-border 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-border {
|
||||
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
|
||||
}
|
||||
|
||||
.client-info-list {
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.info-row span:last-child {
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.responsible-badge {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #818cf8;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.angry-alert-box {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin: 12px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.angry-alert-text {
|
||||
font-size: 0.8rem;
|
||||
color: #fca5a5;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-why-angry {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-why-angry:hover {
|
||||
background: #dc2626;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--card-border);
|
||||
}
|
||||
.btn-hub {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #818cf8;
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-hub:hover {
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
|
||||
/* Modal styling */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: var(--modal-overlay);
|
||||
backdrop-filter: blur(6px);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--modal-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
|
||||
padding: 2rem;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.modal-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-close:hover { color: var(--text-main); }
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.7rem 1rem;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-main);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
.form-control:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.team-members-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
.member-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--card-bg);
|
||||
border: 1px dashed var(--card-border);
|
||||
border-radius: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Navbar -->
|
||||
<nav class="navbar">
|
||||
<a href="{{ route('dashboard') }}" class="brand-logo">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: #6366f1;">
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
|
||||
</svg>
|
||||
SingleLogin <span class="brand-badge">Client Calls</span>
|
||||
</a>
|
||||
<div class="nav-actions">
|
||||
<!-- Theme Toggle -->
|
||||
<button class="theme-toggle-btn" id="themeToggleBtn" onclick="toggleTheme()" title="Switch Light / Dark Theme" style="background: var(--card-bg); border: 1px solid var(--card-border); color: var(--text-main); width: 38px; height: 38px; border-radius: 8px; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1rem;">
|
||||
🌙
|
||||
</button>
|
||||
<a href="{{ route('client-calls.logs') }}" class="nav-btn" style="border-color: #22d3ee; color: #22d3ee;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
|
||||
Date-wise Call Logs
|
||||
</a>
|
||||
<button class="nav-btn-primary nav-btn" onclick="openAddClientModal()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
Add Client & Project
|
||||
</button>
|
||||
<a href="{{ route('dashboard') }}" class="nav-btn">
|
||||
Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-container">
|
||||
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success">
|
||||
<span>{{ session('success') }}</span>
|
||||
<button style="background:transparent;border:none;color:inherit;cursor:pointer;" onclick="this.parentElement.remove()">✕</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Stats Overview -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📁</div>
|
||||
<div>
|
||||
<div class="stat-val">{{ $totalClients }}</div>
|
||||
<div class="stat-label">Active Client Projects</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon success">📞</div>
|
||||
<div>
|
||||
<div class="stat-val">{{ $totalMeetings }}</div>
|
||||
<div class="stat-label">Total Meetings Held</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon {{ $angryClientsCount > 0 ? 'danger' : '' }}">
|
||||
{{ $angryClientsCount > 0 ? '⚠️' : '✨' }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-val" style="{{ $angryClientsCount > 0 ? 'color: #f87171;' : '' }}">
|
||||
{{ $angryClientsCount }}
|
||||
</div>
|
||||
<div class="stat-label">Client Attention Alerts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Header -->
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h1 class="section-title">Client Projects & Meeting Hub</h1>
|
||||
<p class="section-desc">Manage clients, schedule future calls, start instant meetings with AI transcription, and review automated MoMs.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<input type="text" id="searchInput" class="search-input" placeholder="Search by client name, project, company, or lead..." onkeyup="filterCards()">
|
||||
<select id="sentimentFilter" class="filter-select" onchange="filterCards()">
|
||||
<option value="all">All Sentiments</option>
|
||||
<option value="happy">Happy 😊</option>
|
||||
<option value="neutral">Neutral 😐</option>
|
||||
<option value="sad">Sad / Concerned 😟</option>
|
||||
<option value="angry">Angry / Frustrated 😡</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Clients Grid -->
|
||||
@if($clients->isEmpty())
|
||||
<div class="empty-state">
|
||||
<div style="font-size: 3rem; margin-bottom: 1rem;">💼</div>
|
||||
<h3 style="font-size: 1.25rem; font-weight: 700; margin-bottom: 6px;">No Client Projects Found</h3>
|
||||
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 1.5rem;">Get started by adding your first client and project details.</p>
|
||||
<button class="nav-btn-primary nav-btn" onclick="openAddClientModal()">
|
||||
+ Add New Client & Project
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<div class="clients-grid" id="clientsGrid">
|
||||
@foreach($clients as $client)
|
||||
@php
|
||||
$sentiment = strtolower($client->latest_sentiment ?? 'neutral');
|
||||
$badgeClass = match($sentiment) {
|
||||
'happy' => 'sentiment-happy',
|
||||
'sad' => 'sentiment-sad',
|
||||
'angry' => 'sentiment-angry',
|
||||
default => 'sentiment-neutral',
|
||||
};
|
||||
$sentimentEmoji = match($sentiment) {
|
||||
'happy' => '😊 Happy',
|
||||
'sad' => '😟 Concerned',
|
||||
'angry' => '😡 Frustrated',
|
||||
default => '😐 Neutral',
|
||||
};
|
||||
|
||||
$meetingsCount = $client->meetings->count();
|
||||
$latestNote = $client->callNotes->first();
|
||||
$canViewAngry = $client->canViewAngryAnalysis(Auth::user());
|
||||
@endphp
|
||||
|
||||
<div class="client-card"
|
||||
data-name="{{ strtolower($client->client_name) }}"
|
||||
data-project="{{ strtolower($client->project_name) }}"
|
||||
data-company="{{ strtolower($client->company_name ?? '') }}"
|
||||
data-lead="{{ strtolower($client->responsibleUser->name ?? '') }}"
|
||||
data-sentiment="{{ $sentiment }}">
|
||||
|
||||
<div>
|
||||
<div class="client-card-header">
|
||||
<div>
|
||||
<div class="project-title">{{ $client->project_name }}</div>
|
||||
<div class="client-company">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
|
||||
{{ $client->client_name }} {{ $client->company_name ? "• " . $client->company_name : "" }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="sentiment-badge {{ $badgeClass }}">
|
||||
{{ $sentimentEmoji }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="client-info-list">
|
||||
<div class="info-row">
|
||||
<span>Client Email:</span>
|
||||
<span>{{ $client->client_email }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>Responsible Lead:</span>
|
||||
<span class="responsible-badge">{{ $client->responsibleUser->name ?? 'Unassigned' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>Total Meetings:</span>
|
||||
<span>{{ $meetingsCount }} calls</span>
|
||||
</div>
|
||||
@if($latestNote)
|
||||
<div class="info-row">
|
||||
<span>Last Call Date:</span>
|
||||
<span>{{ $latestNote->created_at->format('M d, Y') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Angry Escalation Alert (Strictly visible only to Admin & Responsible Person) -->
|
||||
@if($sentiment === 'angry' && $latestNote && $canViewAngry)
|
||||
<div class="angry-alert-box">
|
||||
<span class="angry-alert-text">⚠️ Client Dissatisfaction Detected</span>
|
||||
<button class="btn-why-angry" onclick="openWhyAngryModal({{ $latestNote->id }})">
|
||||
Why is he angry?
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<a href="{{ route('client-calls.show', $client->id) }}" target="_blank" class="btn-hub" title="Open Client Hub in new tab">
|
||||
<span>Open Client Hub</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Add Client & Project Modal -->
|
||||
<div id="addClientModal" class="modal-overlay">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Add New Client & Project</h2>
|
||||
<button class="modal-close" onclick="closeAddClientModal()">×</button>
|
||||
</div>
|
||||
<form action="{{ route('client-calls.store') }}" method="POST">
|
||||
@csrf
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Client / Contact Name *</label>
|
||||
<input type="text" name="client_name" class="form-control" placeholder="e.g. Robert Miller" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Client Email Address *</label>
|
||||
<input type="email" name="client_email" class="form-control" placeholder="e.g. robert@acmecorp.com" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Company / Organization</label>
|
||||
<input type="text" name="company_name" class="form-control" placeholder="e.g. Acme Corp">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Phone Number</label>
|
||||
<input type="text" name="client_phone" class="form-control" placeholder="e.g. +1 555 019 283">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Project Name *</label>
|
||||
<input type="text" name="project_name" class="form-control" placeholder="e.g. Enterprise Cloud ERP Migration" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Project Details / Scope</label>
|
||||
<textarea name="project_details" class="form-control" rows="3" placeholder="Brief description of the deliverables, milestones, and client requirements..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Responsible Person (PM / TL / Director in charge) *</label>
|
||||
<select name="responsible_user_id" class="form-control" required>
|
||||
@foreach($managersAndLeads as $manager)
|
||||
<option value="{{ $manager->id }}" {{ Auth::id() === $manager->id ? 'selected' : '' }}>
|
||||
{{ $manager->name }} ({{ $manager->role }})
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Assign Team Members (Optional)</label>
|
||||
<div class="team-members-grid">
|
||||
@foreach($allUsers as $userItem)
|
||||
<label class="member-checkbox-label">
|
||||
<input type="checkbox" name="assigned_team_members[]" value="{{ $userItem->id }}">
|
||||
<span>{{ $userItem->name }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 1.5rem;">
|
||||
<button type="button" class="nav-btn" onclick="closeAddClientModal()">Cancel</button>
|
||||
<button type="submit" class="nav-btn-primary nav-btn">Create Client Profile</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Why Is He Angry Modal (Confidential to Admin & Responsible Person) -->
|
||||
<div id="whyAngryModal" class="modal-overlay">
|
||||
<div class="modal-card" style="max-width: 650px; border-color: rgba(239, 68, 68, 0.4);">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h2 class="modal-title" style="color: #f87171; display: flex; align-items: center; gap: 8px;">
|
||||
<span>😡</span> AI Root-Cause Analysis: Why is the client angry?
|
||||
</h2>
|
||||
<p style="font-size: 0.75rem; color: #fca5a5; margin-top: 4px;">Strictly Confidential • Restricted to Admin and Assigned Lead</p>
|
||||
</div>
|
||||
<button class="modal-close" onclick="closeWhyAngryModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div id="whyAngryContent">
|
||||
<div style="text-align: center; padding: 2rem; color: var(--text-muted);">
|
||||
Loading AI Analysis...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 1.5rem;">
|
||||
<button type="button" class="nav-btn" onclick="closeWhyAngryModal()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openAddClientModal() {
|
||||
document.getElementById('addClientModal').style.display = 'flex';
|
||||
}
|
||||
function closeAddClientModal() {
|
||||
document.getElementById('addClientModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function filterCards() {
|
||||
const query = document.getElementById('searchInput').value.toLowerCase();
|
||||
const sentiment = document.getElementById('sentimentFilter').value;
|
||||
const cards = document.querySelectorAll('.client-card');
|
||||
|
||||
cards.forEach(card => {
|
||||
const name = card.dataset.name || '';
|
||||
const project = card.dataset.project || '';
|
||||
const company = card.dataset.company || '';
|
||||
const lead = card.dataset.lead || '';
|
||||
const cardSentiment = card.dataset.sentiment || '';
|
||||
|
||||
const matchesQuery = name.includes(query) || project.includes(query) || company.includes(query) || lead.includes(query);
|
||||
const matchesSentiment = (sentiment === 'all') || (cardSentiment === sentiment);
|
||||
|
||||
if (matchesQuery && matchesSentiment) {
|
||||
card.style.display = 'flex';
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function openWhyAngryModal(noteId) {
|
||||
const modal = document.getElementById('whyAngryModal');
|
||||
const content = document.getElementById('whyAngryContent');
|
||||
modal.style.display = 'flex';
|
||||
content.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-muted);">Analyzing transcript and emotion triggers...</div>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/client-calls/notes/${noteId}/angry-analysis`, {
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
content.innerHTML = `<div style="color:#f87171;padding:1rem;background:rgba(239,68,68,0.1);border-radius:8px;">${data.error || 'Access Denied.'}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const analysis = data.angry_analysis || {};
|
||||
const triggers = analysis.trigger_words || [];
|
||||
const grievances = analysis.grievances || [];
|
||||
|
||||
content.innerHTML = `
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<div style="font-size: 0.85rem; color: var(--text-muted); margin-bottom: 4px;">Client & Project</div>
|
||||
<div style="font-weight: 700; font-size: 1.1rem; color: var(--text-main);">${data.client_name} • ${data.project_name}</div>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(239,68,68,0.1); border-left: 4px solid #ef4444; padding: 14px; border-radius: 0 8px 8px 0; margin-bottom: 1.25rem;">
|
||||
<div style="font-weight: 700; font-size: 0.85rem; color: #fca5a5; margin-bottom: 6px; text-transform: uppercase;">AI Executive Breakdown</div>
|
||||
<p style="font-size: 0.9rem; color: #fee2e2; line-height: 1.5;">${analysis.summary_why_angry || 'Client showed frustration during the meeting.'}</p>
|
||||
</div>
|
||||
|
||||
${triggers.length > 0 ? `
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<div style="font-weight: 600; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 6px;">Identified Angry / Negative Keywords:</div>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
|
||||
${triggers.map(t => `<span style="background: rgba(239,68,68,0.25); color: #fca5a5; font-size: 0.75rem; padding: 3px 8px; border-radius: 4px; border: 1px solid rgba(239,68,68,0.4); font-weight: 600;">"${t}"</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${grievances.length > 0 ? `
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<div style="font-weight: 600; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 6px;">Key Complaints / Pain Points:</div>
|
||||
<ul style="padding-left: 20px; font-size: 0.85rem; color: #cbd5e1; line-height: 1.6;">
|
||||
${grievances.map(g => `<li>${g}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${analysis.suggested_remedy ? `
|
||||
<div style="background: rgba(16,185,129,0.1); border: 1px solid rgba(16,185,129,0.3); border-radius: 8px; padding: 12px;">
|
||||
<div style="font-weight: 700; font-size: 0.8rem; color: #34d399; margin-bottom: 4px; text-transform: uppercase;">💡 Recommended Action for PM & Director:</div>
|
||||
<p style="font-size: 0.85rem; color: #d1fae5; line-height: 1.5;">${analysis.suggested_remedy}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
} catch (err) {
|
||||
content.innerHTML = `<div style="color:#f87171;padding:1rem;">Failed to load analysis. Please try again.</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const isLight = document.documentElement.classList.toggle('light-mode');
|
||||
localStorage.setItem('theme', isLight ? 'light' : 'dark');
|
||||
const btn = document.getElementById('themeToggleBtn');
|
||||
if (btn) btn.innerText = isLight ? '☀️' : '🌙';
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const isLight = document.documentElement.classList.contains('light-mode');
|
||||
const btn = document.getElementById('themeToggleBtn');
|
||||
if (btn) btn.innerText = isLight ? '☀️' : '🌙';
|
||||
});
|
||||
|
||||
function closeWhyAngryModal() {
|
||||
document.getElementById('whyAngryModal').style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1010
resources/views/client_calls/logs.blade.php
Normal file
1010
resources/views/client_calls/logs.blade.php
Normal file
File diff suppressed because it is too large
Load Diff
575
resources/views/client_calls/room.blade.php
Normal file
575
resources/views/client_calls/room.blade.php
Normal file
@ -0,0 +1,575 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ $meeting->title }} | Live Client Call Room</title>
|
||||
<!-- Google Fonts: Outfit & Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@500;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: #0b0f19;
|
||||
color: #f3f4f6;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Top Header */
|
||||
.call-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: rgba(17, 24, 39, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
z-index: 20;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.live-tag {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
font-size: 0.65rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
animation: pulse-live 2s infinite;
|
||||
}
|
||||
@keyframes pulse-live {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
.meeting-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
.call-timer {
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
color: #9ca3af;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Real-Time Live Emotion Indicator Badge */
|
||||
.emotion-live-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.emotion-badge-happy { background: rgba(16, 185, 129, 0.2); border-color: rgba(16, 185, 129, 0.4); color: #34d399; }
|
||||
.emotion-badge-neutral { background: rgba(59, 130, 246, 0.2); border-color: rgba(59, 130, 246, 0.4); color: #60a5fa; }
|
||||
.emotion-badge-sad { background: rgba(245, 158, 11, 0.2); border-color: rgba(245, 158, 11, 0.4); color: #fbbf24; }
|
||||
.emotion-badge-angry { background: rgba(239, 68, 68, 0.25); border-color: rgba(239, 68, 68, 0.5); color: #f87171; animation: pulse-angry 1.5s infinite; }
|
||||
|
||||
@keyframes pulse-angry {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Main Video Stage */
|
||||
.call-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-grid-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.video-tile {
|
||||
position: relative;
|
||||
background: #1e293b;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 240px;
|
||||
max-height: 520px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.video-element {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.video-overlay {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(6px);
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: white;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.video-avatar-placeholder {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #4f46e5, #06b6d4);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
}
|
||||
|
||||
/* Collapsible Side Panel (AI Notes & Chat) */
|
||||
.side-panel {
|
||||
width: 360px;
|
||||
background: #111827;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.3s ease;
|
||||
z-index: 30;
|
||||
}
|
||||
.side-panel.collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.panel-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.panel-close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.panel-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.panel-tab {
|
||||
flex: 1;
|
||||
padding: 0.6rem;
|
||||
text-align: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #9ca3af;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.panel-tab.active {
|
||||
color: #818cf8;
|
||||
border-bottom: 2px solid #6366f1;
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Transcript & Notes */
|
||||
.transcript-feed {
|
||||
flex: 1;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
max-height: 240px;
|
||||
}
|
||||
.transcript-line {
|
||||
line-height: 1.4;
|
||||
}
|
||||
.transcript-speaker {
|
||||
font-weight: 700;
|
||||
color: #818cf8;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.transcript-text {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.notes-textarea {
|
||||
width: 100%;
|
||||
height: 110px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
color: #f3f4f6;
|
||||
font-size: 0.8rem;
|
||||
outline: none;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
/* Chat messages */
|
||||
.chat-feed {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 380px;
|
||||
}
|
||||
.chat-msg {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.chat-sender {
|
||||
font-weight: 700;
|
||||
color: #38bdf8;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-input-box {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: auto;
|
||||
}
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
color: #f3f4f6;
|
||||
font-size: 0.8rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Bottom Controls Bar */
|
||||
.call-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
background: rgba(17, 24, 39, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
gap: 12px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.ctrl-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.ctrl-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.ctrl-btn.active-off {
|
||||
background: #ef4444 !important;
|
||||
border-color: #ef4444 !important;
|
||||
}
|
||||
.ctrl-btn.active-on {
|
||||
background: #4f46e5 !important;
|
||||
border-color: #4f46e5 !important;
|
||||
}
|
||||
|
||||
.btn-end-call {
|
||||
padding: 0 1.25rem;
|
||||
height: 48px;
|
||||
border-radius: 24px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
.btn-end-call:hover {
|
||||
background: #dc2626;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.toast-notify {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(17, 24, 39, 0.95);
|
||||
border: 1px solid #4f46e5;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-size: 0.85rem;
|
||||
display: none;
|
||||
z-index: 100;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="call-header">
|
||||
<div class="header-left">
|
||||
<span class="live-tag">LIVE</span>
|
||||
<div class="meeting-title">{{ $meeting->title }}</div>
|
||||
<div class="call-timer" id="callTimer">00:00</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<!-- Live AI Emotion Indicator -->
|
||||
<div id="liveEmotionBadge" class="emotion-live-indicator emotion-badge-neutral" title="AI Real-Time Emotion Detection">
|
||||
<span id="emotionEmoji">😐</span>
|
||||
<span id="emotionText">Client Emotion: Neutral</span>
|
||||
</div>
|
||||
|
||||
<button class="ctrl-btn" style="width: 36px; height: 36px; border-radius: 8px;" onclick="copyMeetingLink()" title="Copy Meeting Link">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Call Body -->
|
||||
<div class="call-body">
|
||||
<!-- Video Grid -->
|
||||
<div class="video-grid-container">
|
||||
<div class="video-grid" id="videoGrid">
|
||||
<!-- Local User Video Tile -->
|
||||
<div class="video-tile" id="localVideoTile">
|
||||
<video id="localVideo" class="video-element" autoplay playsinline muted></video>
|
||||
<div class="video-overlay">
|
||||
<span>{{ $participantName }} (You)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible Side Panel -->
|
||||
<div class="side-panel" id="sidePanel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">
|
||||
<span>✨</span> AI Meeting Assistant
|
||||
</div>
|
||||
<button class="panel-close-btn" onclick="toggleSidePanel()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-tabs">
|
||||
<button class="panel-tab active" onclick="switchSideTab('notesTab', this)">📝 AI Notes & MoM</button>
|
||||
<button class="panel-tab" onclick="switchSideTab('chatTab', this)">💬 In-Call Chat</button>
|
||||
</div>
|
||||
|
||||
<!-- TAB 1: AI Live Notes -->
|
||||
<div id="notesTab" class="panel-body">
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<span style="font-size: 0.75rem; font-weight: 700; color: #818cf8; text-transform: uppercase;">Real-Time Speech Transcription</span>
|
||||
<span id="sttStatus" style="font-size: 0.65rem; color: #34d399;">● Listening</span>
|
||||
</div>
|
||||
<div class="transcript-feed" id="transcriptFeed">
|
||||
<div style="color: #6b7280; font-size: 0.75rem; font-style: italic;">Transcribing live spoken speech...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-size: 0.75rem; font-weight: 700; color: #818cf8; text-transform: uppercase; margin-bottom: 6px;">Live Meeting Notes</div>
|
||||
<textarea id="liveNotesInput" class="notes-textarea" placeholder="Type manual notes, key discussion points, or deliverables agreed during call..."></textarea>
|
||||
</div>
|
||||
|
||||
@if($isHost)
|
||||
<button onclick="endMeetingAndGenerateMoM()" style="background: linear-gradient(135deg, #4f46e5, #06b6d4); color: white; border: none; padding: 10px; border-radius: 8px; font-weight: 700; font-size: 0.85rem; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 6px;">
|
||||
✨ Finish & Generate AI MoM
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- TAB 2: Chat -->
|
||||
<div id="chatTab" class="panel-body" style="display: none;">
|
||||
<div class="chat-feed" id="chatFeed">
|
||||
<!-- Chat messages injected dynamically -->
|
||||
</div>
|
||||
<div class="chat-input-box">
|
||||
<input type="text" id="chatInput" class="chat-input" placeholder="Type a message..." onkeydown="if(event.key==='Enter') sendChatMessage()">
|
||||
<button onclick="sendChatMessage()" style="background: #4f46e5; color: white; border: none; border-radius: 8px; padding: 0 12px; cursor: pointer;">➤</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Controls Footer -->
|
||||
<footer class="call-footer">
|
||||
<!-- Mic Toggle -->
|
||||
<button id="btnToggleMic" class="ctrl-btn" onclick="toggleAudio()" title="Mute / Unmute Microphone">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>
|
||||
</button>
|
||||
|
||||
<!-- Video Toggle -->
|
||||
<button id="btnToggleVideo" class="ctrl-btn" onclick="toggleVideo()" title="Turn Camera On / Off">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"></polygon><rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect></svg>
|
||||
</button>
|
||||
|
||||
<!-- Screen Share -->
|
||||
<button id="btnScreenShare" class="ctrl-btn" onclick="toggleScreenShare()" title="Share Screen">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
|
||||
</button>
|
||||
|
||||
<!-- Toggle AI Assistant / Notes -->
|
||||
<button id="btnToggleNotes" class="ctrl-btn active-on" onclick="toggleSidePanel()" title="Toggle AI Notes & Transcriber">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
|
||||
</button>
|
||||
|
||||
<!-- End Call / Leave Button -->
|
||||
@if($isHost)
|
||||
<button class="btn-end-call" onclick="endMeetingAndGenerateMoM()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"></path><line x1="23" y1="1" x2="1" y2="23"></line></svg>
|
||||
End Call & Generate MoM
|
||||
</button>
|
||||
@else
|
||||
<button class="btn-end-call" onclick="leaveCall()">
|
||||
Leave Call
|
||||
</button>
|
||||
@endif
|
||||
</footer>
|
||||
|
||||
<!-- Hidden Canvas for Face Emotion Snapshots -->
|
||||
<canvas id="emotionCanvas" style="display: none;"></canvas>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
<div id="toastNotification" class="toast-notify">Link copied to clipboard!</div>
|
||||
|
||||
<!-- Pass backend configurations to Javascript -->
|
||||
<script>
|
||||
window.CLIENT_CALL_CONFIG = {
|
||||
meetingCode: '{{ $meeting->meeting_code }}',
|
||||
clientId: {{ $client->id ?? $meeting->client_id }},
|
||||
participantName: '{{ $participantName }}',
|
||||
participantRole: '{{ $participantRole }}',
|
||||
isHost: {{ $isHost ? 'true' : 'false' }},
|
||||
isGuest: {{ $isGuest ? 'true' : 'false' }},
|
||||
detectEmotionUrl: '{{ route('client-calls.detect-emotion', $meeting->meeting_code) }}',
|
||||
endCallUrl: '{{ route('client-calls.end-call', $meeting->meeting_code) }}',
|
||||
heartbeatUrl: '{{ route('client-calls.heartbeat', $meeting->meeting_code) }}',
|
||||
redirectUrl: '{{ route('client-calls.show', $client->id ?? $meeting->client_id) }}'
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Load Client Call Script -->
|
||||
<script type="module" src="{{ asset('js/client-call.js') }}"></script>
|
||||
|
||||
<script>
|
||||
function showToast(msg) {
|
||||
const toast = document.getElementById('toastNotification');
|
||||
toast.innerText = msg;
|
||||
toast.style.display = 'block';
|
||||
setTimeout(() => { toast.style.display = 'none'; }, 3000);
|
||||
}
|
||||
|
||||
function copyMeetingLink() {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
showToast('Meeting link copied to clipboard!');
|
||||
}
|
||||
|
||||
function toggleSidePanel() {
|
||||
const panel = document.getElementById('sidePanel');
|
||||
panel.classList.toggle('collapsed');
|
||||
}
|
||||
|
||||
function switchSideTab(tabId, el) {
|
||||
document.getElementById('notesTab').style.display = 'none';
|
||||
document.getElementById('chatTab').style.display = 'none';
|
||||
document.querySelectorAll('.panel-tab').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById(tabId).style.display = 'flex';
|
||||
el.classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1244
resources/views/client_calls/show.blade.php
Normal file
1244
resources/views/client_calls/show.blade.php
Normal file
File diff suppressed because it is too large
Load Diff
@ -879,6 +879,12 @@
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if($user->canAccessClientCalls())
|
||||
<a href="{{ route('client-calls.index') }}" class="nav-action-btn" style="background: rgba(6, 182, 212, 0.15); color: #22d3ee; border-color: rgba(6, 182, 212, 0.35); text-decoration: none;" title="Client Calls & Projects Hub">
|
||||
📞 Client Calls
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if($user->canManageOnboarding())
|
||||
<a href="{{ route('onboarding.index') }}" class="nav-action-btn" style="background: rgba(99,102,241,0.15); color: #818cf8; border-color: rgba(99,102,241,0.3); text-decoration: none;" title="Onboarding & Offboarding Hub">
|
||||
🚀 Onboarding Hub
|
||||
|
||||
113
resources/views/emails/call_mom.blade.php
Normal file
113
resources/views/emails/call_mom.blade.php
Normal file
@ -0,0 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Minutes of Meeting (MoM)</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; background-color: #0b0f19; color: #f3f4f6; margin: 0; padding: 24px; }
|
||||
.email-card { max-width: 650px; margin: 0 auto; background: #111827; border-radius: 12px; border: 1px solid rgba(255,255,255,0.15); overflow: hidden; }
|
||||
.email-header { background: linear-gradient(135deg, #1e1b4b, #312e81); padding: 26px 24px; border-bottom: 1px solid rgba(255,255,255,0.1); }
|
||||
.email-header h1 { margin: 0; color: #ffffff; font-size: 20px; font-weight: 700; }
|
||||
.email-header p { margin: 6px 0 0 0; color: #a5b4fc; font-size: 13px; }
|
||||
.email-body { padding: 26px 24px; }
|
||||
.section-title { font-size: 15px; font-weight: 700; color: #e0e7ff; margin: 20px 0 10px 0; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 6px; }
|
||||
.summary-box { background: rgba(99, 102, 241, 0.08); border-left: 4px solid #6366f1; padding: 14px 18px; border-radius: 0 8px 8px 0; color: #cbd5e1; font-size: 14px; line-height: 1.6; margin-bottom: 20px; }
|
||||
.badge { display: inline-block; padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
|
||||
.badge-happy { background: rgba(16, 185, 129, 0.2); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.4); }
|
||||
.badge-neutral { background: rgba(59, 130, 246, 0.2); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.4); }
|
||||
.badge-sad { background: rgba(245, 158, 11, 0.2); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.4); }
|
||||
.badge-angry { background: rgba(239, 68, 68, 0.25); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.5); font-weight: 700; }
|
||||
|
||||
.table-custom { width: 100%; border-collapse: collapse; margin: 12px 0 20px 0; font-size: 13px; }
|
||||
.table-custom th { background: rgba(255,255,255,0.06); text-align: left; padding: 10px; color: #9ca3af; font-weight: 600; }
|
||||
.table-custom td { padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.05); color: #e5e7eb; }
|
||||
|
||||
.mom-content { background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.06); padding: 16px; border-radius: 8px; color: #d1d5db; font-size: 14px; line-height: 1.6; white-space: pre-wrap; }
|
||||
.btn-view { display: inline-block; background: #4f46e5; color: #ffffff !important; font-weight: 600; font-size: 14px; padding: 10px 20px; border-radius: 6px; text-decoration: none; margin-top: 20px; }
|
||||
.email-footer { border-top: 1px solid rgba(255,255,255,0.08); padding: 16px 24px; text-align: center; color: #6b7280; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-card">
|
||||
<div class="email-header">
|
||||
<h1>SingleLogin AI Meeting Intelligence</h1>
|
||||
<p>Minutes of Meeting (MoM) & AI Call Summary</p>
|
||||
</div>
|
||||
<div class="email-body">
|
||||
<p style="font-size: 15px; margin-top: 0;">Hello <strong>{{ $recipientName }}</strong>,</p>
|
||||
<p style="font-size: 14px; color: #d1d5db;">
|
||||
Here is the automated AI-generated Minutes of Meeting (MoM) for <strong>{{ $callNote->client->project_name ?? 'Client Call' }}</strong> with <strong>{{ $callNote->client->client_name ?? 'Client' }}</strong>.
|
||||
</p>
|
||||
|
||||
<div style="margin: 16px 0; display: flex; align-items: center; gap: 10px;">
|
||||
<span style="font-size: 13px; color: #9ca3af;">Detected Client Sentiment:</span>
|
||||
@php
|
||||
$sentiment = strtolower($callNote->detected_sentiment ?? 'neutral');
|
||||
$badgeClass = match($sentiment) {
|
||||
'happy' => 'badge-happy',
|
||||
'sad' => 'badge-sad',
|
||||
'angry' => 'badge-angry',
|
||||
default => 'badge-neutral',
|
||||
};
|
||||
$emoji = match($sentiment) {
|
||||
'happy' => '😊 Happy / Satisfied',
|
||||
'sad' => '😟 Concerned / Sad',
|
||||
'angry' => '😡 Frustrated / Angry',
|
||||
default => '😐 Neutral / Professional',
|
||||
};
|
||||
@endphp
|
||||
<span class="badge {{ $badgeClass }}">{{ $emoji }}</span>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Executive Summary</div>
|
||||
<div class="summary-box">
|
||||
{{ $callNote->summary ?? 'Meeting completed successfully.' }}
|
||||
</div>
|
||||
|
||||
@if(!empty($callNote->action_items) && count($callNote->action_items) > 0)
|
||||
<div class="section-title">Action Items & Deliverables</div>
|
||||
<table class="table-custom">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task / Action Item</th>
|
||||
<th>Assignee</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($callNote->action_items as $item)
|
||||
<tr>
|
||||
<td>{{ is_array($item) ? ($item['task'] ?? $item['action'] ?? json_encode($item)) : $item }}</td>
|
||||
<td>{{ is_array($item) ? ($item['owner'] ?? 'Team') : 'Team' }}</td>
|
||||
<td><span style="color: #34d399;">{{ is_array($item) ? ($item['status'] ?? 'Assigned') : 'Assigned' }}</span></td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@endif
|
||||
|
||||
@if(!empty($callNote->key_decisions) && count($callNote->key_decisions) > 0)
|
||||
<div class="section-title">Key Decisions Made</div>
|
||||
<ul style="color: #cbd5e1; font-size: 13px; line-height: 1.6; margin: 10px 0 20px 20px;">
|
||||
@foreach($callNote->key_decisions as $decision)
|
||||
<li>{{ is_array($decision) ? ($decision['decision'] ?? json_encode($decision)) : $decision }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
|
||||
<div class="section-title">Full Minutes of Meeting (MoM)</div>
|
||||
<div class="mom-content">{!! nl2br(e($callNote->mom_content)) !!}</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 24px;">
|
||||
<a href="{{ route('client-calls.show', $callNote->client_id) }}" class="btn-view" target="_blank">
|
||||
🔍 Open Client Hub & Complete Profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
<p>This automated summary was generated by SingleLogin AI Notes Engine. Saved to client profile archive.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
84
resources/views/emails/meeting_invite.blade.php
Normal file
84
resources/views/emails/meeting_invite.blade.php
Normal file
@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Meeting Invitation</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; background-color: #0b0f19; color: #f3f4f6; margin: 0; padding: 24px; }
|
||||
.email-card { max-width: 600px; margin: 0 auto; background: #111827; border-radius: 12px; border: 1px solid rgba(255,255,255,0.15); overflow: hidden; }
|
||||
.email-header { background: linear-gradient(135deg, #4f46e5, #06b6d4); padding: 28px 24px; text-align: center; }
|
||||
.email-header h1 { margin: 0; color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: -0.5px; }
|
||||
.email-header p { margin: 6px 0 0 0; color: rgba(255,255,255,0.9); font-size: 14px; }
|
||||
.email-body { padding: 28px 24px; }
|
||||
.badge { display: inline-block; padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
|
||||
.badge-instant { background: #f59e0b; color: #111827; }
|
||||
.badge-scheduled { background: #10b981; color: #ffffff; }
|
||||
.meeting-info { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 18px; margin: 20px 0; }
|
||||
.info-row { display: flex; margin-bottom: 10px; font-size: 14px; }
|
||||
.info-row:last-child { margin-bottom: 0; }
|
||||
.info-label { width: 120px; color: #9ca3af; font-weight: 600; }
|
||||
.info-value { color: #f3f4f6; font-weight: 500; }
|
||||
.btn-join { display: block; text-align: center; background: #4f46e5; color: #ffffff !important; font-weight: 700; font-size: 15px; padding: 14px 24px; border-radius: 8px; text-decoration: none; margin: 26px 0 16px 0; box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4); }
|
||||
.email-footer { border-top: 1px solid rgba(255,255,255,0.08); padding: 16px 24px; text-align: center; color: #6b7280; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-card">
|
||||
<div class="email-header">
|
||||
<h1>SingleLogin Client Connect</h1>
|
||||
<p>You have been invited to a client meeting session</p>
|
||||
</div>
|
||||
<div class="email-body">
|
||||
<p style="font-size: 15px; margin-top: 0;">Hello <strong>{{ $recipientName }}</strong>,</p>
|
||||
<p style="font-size: 14px; color: #d1d5db; line-height: 1.5;">
|
||||
You are invited to join the video conference for <strong>{{ $meeting->client->project_name }}</strong> ({{ $meeting->client->client_name }}).
|
||||
</p>
|
||||
|
||||
<div class="meeting-info">
|
||||
<div style="margin-bottom: 12px;">
|
||||
<span class="badge {{ $meeting->meeting_type === 'instant' ? 'badge-instant' : 'badge-scheduled' }}">
|
||||
{{ $meeting->meeting_type === 'instant' ? '⚡ Instant Meeting' : '📅 Scheduled Meeting' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Meeting Title:</span>
|
||||
<span class="info-value">{{ $meeting->title }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Project:</span>
|
||||
<span class="info-value">{{ $meeting->client->project_name }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Host:</span>
|
||||
<span class="info-value">{{ $meeting->host ? $meeting->host->name : 'SingleLogin Host' }}</span>
|
||||
</div>
|
||||
@if($meeting->scheduled_at)
|
||||
<div class="info-row">
|
||||
<span class="info-label">Date & Time:</span>
|
||||
<span class="info-value">{{ $meeting->scheduled_at->format('M d, Y h:i A') }} ({{ $meeting->duration_minutes }} mins)</span>
|
||||
</div>
|
||||
@endif
|
||||
@if($meeting->agenda)
|
||||
<div class="info-row" style="margin-top: 8px;">
|
||||
<span class="info-label">Agenda:</span>
|
||||
<span class="info-value" style="color: #cbd5e1;">{{ $meeting->agenda }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<a href="{{ $joinUrl }}" class="btn-join" target="_blank">
|
||||
👉 Click Here to Join Meeting
|
||||
</a>
|
||||
|
||||
<p style="font-size: 12px; color: #9ca3af; text-align: center; word-break: break-all;">
|
||||
Or copy and paste this link in your browser: <br>
|
||||
<a href="{{ $joinUrl }}" style="color: #38bdf8;">{{ $joinUrl }}</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
<p>SingleLogin Enterprise Workspace • AI-Powered Client Collaboration Platform</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AdminController;
|
||||
use App\Http\Controllers\ClientCallController;
|
||||
use App\Http\Controllers\DashboardController;
|
||||
use App\Http\Controllers\InterviewController;
|
||||
use App\Http\Controllers\LoginController;
|
||||
@ -85,8 +86,29 @@
|
||||
Route::get('/interviews/{id}/download-recording', [InterviewController::class, 'downloadRecording'])->name('interview.download-recording');
|
||||
Route::post('/interviews/{id}/call-status', [InterviewController::class, 'updateCallStatus'])->name('interview.call-status');
|
||||
Route::post('/interviews/{id}/toggle-tab-screenshot', [InterviewController::class, 'toggleTabScreenshot'])->name('interview.toggle-tab-screenshot');
|
||||
|
||||
// Client Call & Project Management Hub (PM, TL, Director, Admin)
|
||||
Route::get('/client-calls', [ClientCallController::class, 'index'])->name('client-calls.index');
|
||||
Route::get('/client-calls/logs', [ClientCallController::class, 'callLogs'])->name('client-calls.logs');
|
||||
Route::post('/client-calls', [ClientCallController::class, 'store'])->name('client-calls.store');
|
||||
Route::get('/client-calls/{id}', [ClientCallController::class, 'show'])->name('client-calls.show');
|
||||
Route::post('/client-calls/{id}/schedule-meeting', [ClientCallController::class, 'scheduleMeeting'])->name('client-calls.schedule-meeting');
|
||||
Route::post('/client-calls/{id}/instant-meeting', [ClientCallController::class, 'createInstantMeeting'])->name('client-calls.instant-meeting');
|
||||
Route::get('/client-calls/notes/{id}/download-mom', [ClientCallController::class, 'downloadMoM'])->name('client-calls.download-mom');
|
||||
Route::get('/client-calls/notes/{id}/download-todos', [ClientCallController::class, 'downloadTodos'])->name('client-calls.download-todos');
|
||||
Route::get('/client-calls/notes/{id}/emotion-details', [ClientCallController::class, 'getEmotionDetails'])->name('client-calls.emotion-details');
|
||||
Route::post('/client-calls/notes/{id}/toggle-todo/{todoId}', [ClientCallController::class, 'toggleTodo'])->name('client-calls.toggle-todo');
|
||||
Route::get('/client-calls/notes/{id}/angry-analysis', [ClientCallController::class, 'getAngryAnalysis'])->name('client-calls.angry-analysis');
|
||||
Route::post('/client-calls/notes/{id}/resend-email', [ClientCallController::class, 'resendMoMEmail'])->name('client-calls.resend-email');
|
||||
});
|
||||
|
||||
// Client Video Call Room (Accessible to authenticated internal members & external guest clients via link)
|
||||
Route::get('/client-calls/room/{code}', [ClientCallController::class, 'showRoom'])->name('client-calls.room');
|
||||
Route::post('/client-calls/room/{code}/join-guest', [ClientCallController::class, 'joinGuest'])->name('client-calls.join-guest');
|
||||
Route::post('/client-calls/room/{code}/detect-emotion', [ClientCallController::class, 'detectEmotion'])->name('client-calls.detect-emotion');
|
||||
Route::post('/client-calls/room/{code}/end-call', [ClientCallController::class, 'endCallAndGenerateMoM'])->name('client-calls.end-call');
|
||||
Route::post('/client-calls/room/{code}/heartbeat', [ClientCallController::class, 'signalingHeartbeat'])->name('client-calls.heartbeat');
|
||||
|
||||
|
||||
// Admin Control Panel (requires admin role)
|
||||
Route::middleware(['auth', 'role:admin'])->prefix('controlpannel')->group(function () {
|
||||
|
||||
461
tests/Feature/ClientCallFeatureTest.php
Normal file
461
tests/Feature/ClientCallFeatureTest.php
Normal file
@ -0,0 +1,461 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Mail\ClientCallMomMail;
|
||||
use App\Mail\ClientMeetingInviteMail;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCallNote;
|
||||
use App\Models\ClientMeeting;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ClientCallFeatureTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $admin;
|
||||
protected User $pm;
|
||||
protected User $developer;
|
||||
protected Role $pmRole;
|
||||
protected Role $devRole;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create roles
|
||||
$this->pmRole = Role::create(['name' => 'Project Manager', 'description' => 'PM']);
|
||||
$this->devRole = Role::create(['name' => 'Developer', 'description' => 'Dev']);
|
||||
|
||||
// Create users
|
||||
$this->admin = User::create([
|
||||
'name' => 'System Admin',
|
||||
'email' => 'admin@company.com',
|
||||
'role' => 'admin',
|
||||
]);
|
||||
|
||||
$this->pm = User::create([
|
||||
'name' => 'John PM',
|
||||
'email' => 'john.pm@company.com',
|
||||
'role' => 'user',
|
||||
]);
|
||||
$this->pm->roles()->attach($this->pmRole->id);
|
||||
|
||||
$this->developer = User::create([
|
||||
'name' => 'Alex Dev',
|
||||
'email' => 'alex.dev@company.com',
|
||||
'role' => 'user',
|
||||
]);
|
||||
$this->developer->roles()->attach($this->devRole->id);
|
||||
}
|
||||
|
||||
public function test_pm_and_admin_can_access_client_calls_and_create_client(): void
|
||||
{
|
||||
$this->actingAs($this->pm)
|
||||
->get(route('client-calls.index'))
|
||||
->assertStatus(200)
|
||||
->assertSee('Client Projects', false);
|
||||
|
||||
$response = $this->actingAs($this->pm)
|
||||
->post(route('client-calls.store'), [
|
||||
'client_name' => 'Alice Walker',
|
||||
'client_email' => 'alice@innovate.io',
|
||||
'client_phone' => '+1 555 456 7890',
|
||||
'company_name' => 'Innovate Tech',
|
||||
'project_name' => 'AI Analytics Dashboard',
|
||||
'project_details' => 'Full stack dashboard with live WebRTC meeting rooms and AI transcription.',
|
||||
'responsible_user_id' => $this->pm->id,
|
||||
'assigned_team_members' => [$this->pm->id, $this->developer->id],
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('client-calls.index'));
|
||||
|
||||
$this->assertDatabaseHas('clients', [
|
||||
'client_name' => 'Alice Walker',
|
||||
'client_email' => 'alice@innovate.io',
|
||||
'project_name' => 'AI Analytics Dashboard',
|
||||
'responsible_user_id' => $this->pm->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_client_hub_page_shows_details_and_opens(): void
|
||||
{
|
||||
$client = Client::create([
|
||||
'client_name' => 'Bob Smith',
|
||||
'client_email' => 'bob@smithco.com',
|
||||
'project_name' => 'Cloud Migration',
|
||||
'responsible_user_id' => $this->pm->id,
|
||||
'created_by' => $this->admin->id,
|
||||
'status' => 'active',
|
||||
'latest_sentiment' => 'happy',
|
||||
]);
|
||||
|
||||
$this->actingAs($this->pm)
|
||||
->get(route('client-calls.show', $client->id))
|
||||
->assertStatus(200)
|
||||
->assertSee('Cloud Migration')
|
||||
->assertSee('Bob Smith')
|
||||
->assertSee('Start Instant Meeting')
|
||||
->assertSee('Schedule Meeting');
|
||||
}
|
||||
|
||||
public function test_scheduling_future_meeting_sends_email_invites(): void
|
||||
{
|
||||
Mail::fake();
|
||||
|
||||
$client = Client::create([
|
||||
'client_name' => 'Bob Smith',
|
||||
'client_email' => 'bob@smithco.com',
|
||||
'project_name' => 'Cloud Migration',
|
||||
'responsible_user_id' => $this->pm->id,
|
||||
'created_by' => $this->admin->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->pm)
|
||||
->post(route('client-calls.schedule-meeting', $client->id), [
|
||||
'title' => 'Sprint 1 Architecture Review',
|
||||
'agenda' => 'Review AWS diagrams and API contracts.',
|
||||
'scheduled_at' => now()->addDays(2)->format('Y-m-d H:i:s'),
|
||||
'duration_minutes' => 45,
|
||||
'client_emails' => 'bob@smithco.com, ctobob@smithco.com',
|
||||
'team_attendees' => [$this->pm->id, $this->developer->id],
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('client-calls.show', $client->id));
|
||||
|
||||
$this->assertDatabaseHas('client_meetings', [
|
||||
'client_id' => $client->id,
|
||||
'title' => 'Sprint 1 Architecture Review',
|
||||
'meeting_type' => 'scheduled',
|
||||
]);
|
||||
|
||||
Mail::assertSent(ClientMeetingInviteMail::class);
|
||||
}
|
||||
|
||||
public function test_starting_instant_meeting_generates_shareable_link(): void
|
||||
{
|
||||
$client = Client::create([
|
||||
'client_name' => 'Bob Smith',
|
||||
'client_email' => 'bob@smithco.com',
|
||||
'project_name' => 'Cloud Migration',
|
||||
'responsible_user_id' => $this->pm->id,
|
||||
'created_by' => $this->admin->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->pm)
|
||||
->postJson(route('client-calls.instant-meeting', $client->id), [
|
||||
'title' => 'Quick Alignment Sync',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure(['success', 'meeting_id', 'meeting_code', 'join_url']);
|
||||
|
||||
$this->assertDatabaseHas('client_meetings', [
|
||||
'client_id' => $client->id,
|
||||
'meeting_type' => 'instant',
|
||||
'status' => 'in_progress',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_external_guest_can_join_via_meeting_link(): void
|
||||
{
|
||||
$client = Client::create([
|
||||
'client_name' => 'Bob Smith',
|
||||
'client_email' => 'bob@smithco.com',
|
||||
'project_name' => 'Cloud Migration',
|
||||
'responsible_user_id' => $this->pm->id,
|
||||
'created_by' => $this->admin->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$meeting = ClientMeeting::create([
|
||||
'client_id' => $client->id,
|
||||
'title' => 'Instant Sync',
|
||||
'meeting_code' => 'meet-test-guest-123',
|
||||
'meeting_type' => 'instant',
|
||||
'host_user_id' => $this->pm->id,
|
||||
'status' => 'in_progress',
|
||||
]);
|
||||
|
||||
// Unauthenticated guest arrives at room -> gets guest entry screen
|
||||
$this->get(route('client-calls.room', $meeting->meeting_code))
|
||||
->assertStatus(200)
|
||||
->assertSee("What's your name?", false);
|
||||
|
||||
// Guest submits name
|
||||
$this->post(route('client-calls.join-guest', $meeting->meeting_code), [
|
||||
'guest_name' => 'Bob Smith (Client)',
|
||||
'guest_email' => 'bob@smithco.com',
|
||||
])->assertRedirect(route('client-calls.room', $meeting->meeting_code));
|
||||
|
||||
// Now has session and enters room
|
||||
$this->withSession(["guest_name_{$meeting->meeting_code}" => 'Bob Smith (Client)'])
|
||||
->get(route('client-calls.room', $meeting->meeting_code))
|
||||
->assertStatus(200)
|
||||
->assertSee('Bob Smith (Client)');
|
||||
}
|
||||
|
||||
public function test_ai_emotion_detection_endpoint(): void
|
||||
{
|
||||
$client = Client::create([
|
||||
'client_name' => 'Bob Smith',
|
||||
'client_email' => 'bob@smithco.com',
|
||||
'project_name' => 'Cloud Migration',
|
||||
'responsible_user_id' => $this->pm->id,
|
||||
'created_by' => $this->admin->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$meeting = ClientMeeting::create([
|
||||
'client_id' => $client->id,
|
||||
'title' => 'Sync Call',
|
||||
'meeting_code' => 'meet-emotion-test',
|
||||
'host_user_id' => $this->pm->id,
|
||||
'status' => 'in_progress',
|
||||
]);
|
||||
|
||||
// Test angry sentiment detection on frustrated speech text
|
||||
$response = $this->postJson(route('client-calls.detect-emotion', $meeting->meeting_code), [
|
||||
'speech_text' => 'This 2 week delay is totally unacceptable and broken webhooks are terrible.',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'emotion' => 'angry',
|
||||
]);
|
||||
|
||||
// Test happy sentiment detection
|
||||
$happyResponse = $this->postJson(route('client-calls.detect-emotion', $meeting->meeting_code), [
|
||||
'speech_text' => 'The demo looks fantastic, awesome work on the features!',
|
||||
]);
|
||||
|
||||
$happyResponse->assertStatus(200)
|
||||
->assertJson([
|
||||
'emotion' => 'happy',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_ending_call_generates_mom_and_emails_stakeholders(): void
|
||||
{
|
||||
Mail::fake();
|
||||
|
||||
$client = Client::create([
|
||||
'client_name' => 'Bob Smith',
|
||||
'client_email' => 'bob@smithco.com',
|
||||
'project_name' => 'Cloud Migration',
|
||||
'responsible_user_id' => $this->pm->id,
|
||||
'created_by' => $this->admin->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$meeting = ClientMeeting::create([
|
||||
'client_id' => $client->id,
|
||||
'title' => 'Sprint Wrap-up Call',
|
||||
'meeting_code' => 'meet-wrapup-456',
|
||||
'host_user_id' => $this->pm->id,
|
||||
'status' => 'in_progress',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->pm)
|
||||
->postJson(route('client-calls.end-call', $meeting->meeting_code), [
|
||||
'transcript' => "[10:00 AM] PM: Discussing migration progress.\n[10:10 AM] Client: Progress is good.",
|
||||
'live_notes' => 'Agreed on migration date.',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['success' => true]);
|
||||
|
||||
$this->assertDatabaseHas('client_call_notes', [
|
||||
'client_meeting_id' => $meeting->id,
|
||||
'client_id' => $client->id,
|
||||
]);
|
||||
|
||||
$this->assertEquals('completed', $meeting->fresh()->status);
|
||||
Mail::assertSent(ClientCallMomMail::class);
|
||||
}
|
||||
|
||||
public function test_angry_analysis_is_strictly_restricted_to_admin_and_responsible_lead(): void
|
||||
{
|
||||
$client = Client::create([
|
||||
'client_name' => 'Sarah Connor',
|
||||
'client_email' => 'sarah@cyber.io',
|
||||
'project_name' => 'Security Portal',
|
||||
'responsible_user_id' => $this->pm->id,
|
||||
'created_by' => $this->admin->id,
|
||||
'status' => 'active',
|
||||
'latest_sentiment' => 'angry',
|
||||
]);
|
||||
|
||||
$meeting = ClientMeeting::create([
|
||||
'client_id' => $client->id,
|
||||
'title' => 'Escalation Review',
|
||||
'meeting_code' => 'meet-angry-check',
|
||||
'host_user_id' => $this->pm->id,
|
||||
'status' => 'completed',
|
||||
]);
|
||||
|
||||
$note = ClientCallNote::create([
|
||||
'client_meeting_id' => $meeting->id,
|
||||
'client_id' => $client->id,
|
||||
'created_by' => $this->pm->id,
|
||||
'raw_transcript' => 'Client is angry about milestone delays.',
|
||||
'mom_content' => 'MoM Content',
|
||||
'summary' => 'Urgent review',
|
||||
'detected_sentiment' => 'angry',
|
||||
'angry_analysis' => [
|
||||
'is_angry' => true,
|
||||
'trigger_words' => ['unacceptable', 'delay'],
|
||||
'summary_why_angry' => 'Client is angry due to milestone delays.',
|
||||
'grievances' => ['Delays', 'Lack of updates'],
|
||||
'suggested_remedy' => 'Send remedial roadmap.',
|
||||
],
|
||||
]);
|
||||
|
||||
// 1. Admin CAN view angry analysis
|
||||
$this->actingAs($this->admin)
|
||||
->getJson(route('client-calls.angry-analysis', $note->id))
|
||||
->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'detected_sentiment' => 'angry',
|
||||
]);
|
||||
|
||||
// 2. Responsible PM CAN view angry analysis
|
||||
$this->actingAs($this->pm)
|
||||
->getJson(route('client-calls.angry-analysis', $note->id))
|
||||
->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'detected_sentiment' => 'angry',
|
||||
]);
|
||||
|
||||
// 3. Unrelated Developer CANNOT view angry analysis (403 Forbidden)
|
||||
$this->actingAs($this->developer)
|
||||
->getJson(route('client-calls.angry-analysis', $note->id))
|
||||
->assertStatus(403)
|
||||
->assertJsonStructure(['error']);
|
||||
}
|
||||
|
||||
public function test_admin_dashboard_displays_client_projects_and_alerts(): void
|
||||
{
|
||||
Client::create([
|
||||
'client_name' => 'Sarah Connor',
|
||||
'client_email' => 'sarah@cyber.io',
|
||||
'project_name' => 'Security Portal',
|
||||
'responsible_user_id' => $this->pm->id,
|
||||
'created_by' => $this->admin->id,
|
||||
'status' => 'active',
|
||||
'latest_sentiment' => 'angry',
|
||||
]);
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->get(route('admin.dashboard'))
|
||||
->assertStatus(200)
|
||||
->assertSee('Client Calls Hub')
|
||||
->assertSee('Security Portal')
|
||||
->assertSee('Sarah Connor');
|
||||
}
|
||||
|
||||
public function test_can_view_call_logs_and_download_mom_and_todos(): void
|
||||
{
|
||||
$client = Client::create([
|
||||
'client_name' => 'Robert Miller',
|
||||
'client_email' => 'robert@acmecorp.com',
|
||||
'project_name' => 'Enterprise Cloud Migration',
|
||||
'responsible_user_id' => $this->pm->id,
|
||||
'created_by' => $this->admin->id,
|
||||
'status' => 'active',
|
||||
'latest_sentiment' => 'happy',
|
||||
]);
|
||||
|
||||
$meeting = ClientMeeting::create([
|
||||
'client_id' => $client->id,
|
||||
'title' => 'Sprint 2 Milestone Demo',
|
||||
'meeting_code' => 'meet-logs-test-1',
|
||||
'host_user_id' => $this->pm->id,
|
||||
'status' => 'completed',
|
||||
]);
|
||||
|
||||
$note = ClientCallNote::create([
|
||||
'client_meeting_id' => $meeting->id,
|
||||
'client_id' => $client->id,
|
||||
'created_by' => $this->pm->id,
|
||||
'raw_transcript' => 'Client is very happy with cloud deployment.',
|
||||
'mom_content' => "### Minutes of Meeting\nObjectives: Deploy cloud infrastructure.\nNext steps: Testing.",
|
||||
'summary' => 'Client praised the architecture blueprints.',
|
||||
'detected_sentiment' => 'happy',
|
||||
'todo_items' => [
|
||||
['id' => 1, 'task' => 'Deploy AWS staging templates', 'owner' => 'Engineering Team', 'priority' => 'High', 'deadline' => 'Aug 22, 2026', 'completed' => false],
|
||||
['id' => 2, 'task' => 'Provide SSO test credentials', 'owner' => 'Tech Lead', 'priority' => 'Medium', 'deadline' => 'Aug 23, 2026', 'completed' => false],
|
||||
],
|
||||
'emotion_details' => [
|
||||
'emotion' => 'happy',
|
||||
'headline' => 'Client Praised Sprint Progress',
|
||||
'bullet_points' => [
|
||||
'Pleased with clear architecture diagram.',
|
||||
'Praised team responsiveness.'
|
||||
],
|
||||
'trigger_quotes' => ['looks great', 'very pleased'],
|
||||
'suggested_action' => 'Maintain current sprint momentum.'
|
||||
],
|
||||
]);
|
||||
|
||||
// 1. Check Date-wise Call Logs view
|
||||
$this->actingAs($this->pm)
|
||||
->get(route('client-calls.logs'))
|
||||
->assertStatus(200)
|
||||
->assertSee('Date-wise Client Call Logs')
|
||||
->assertSee('Enterprise Cloud Migration')
|
||||
->assertSee('Sprint 2 Milestone Demo')
|
||||
->assertSee('Download MoM (.txt)');
|
||||
|
||||
// 2. Test Download MoM as .txt
|
||||
$responseMom = $this->actingAs($this->pm)
|
||||
->get(route('client-calls.download-mom', $note->id));
|
||||
|
||||
$responseMom->assertStatus(200);
|
||||
$this->assertStringContainsString('attachment;', $responseMom->headers->get('content-disposition'));
|
||||
$this->assertStringContainsString('MINUTES OF MEETING (MoM)', $responseMom->getContent());
|
||||
$this->assertStringContainsString('Enterprise Cloud Migration', $responseMom->getContent());
|
||||
|
||||
// 3. Test Download To-Dos as .txt
|
||||
$responseTodos = $this->actingAs($this->pm)
|
||||
->get(route('client-calls.download-todos', $note->id));
|
||||
|
||||
$responseTodos->assertStatus(200);
|
||||
$this->assertStringContainsString('attachment;', $responseTodos->headers->get('content-disposition'));
|
||||
$this->assertStringContainsString('ACTIONABLE TO-DO LIST', $responseTodos->getContent());
|
||||
$this->assertStringContainsString('Deploy AWS staging templates', $responseTodos->getContent());
|
||||
|
||||
// 4. Test Fetch Emotion Details (Why happy)
|
||||
$this->actingAs($this->pm)
|
||||
->getJson(route('client-calls.emotion-details', $note->id))
|
||||
->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'detected_sentiment' => 'happy',
|
||||
])
|
||||
->assertJsonStructure([
|
||||
'emotion_details' => [
|
||||
'emotion',
|
||||
'headline',
|
||||
'bullet_points',
|
||||
'suggested_action',
|
||||
]
|
||||
]);
|
||||
|
||||
// 5. Test Toggle To-Do status
|
||||
$this->actingAs($this->pm)
|
||||
->postJson(route('client-calls.toggle-todo', ['id' => $note->id, 'todoId' => 1]))
|
||||
->assertStatus(200)
|
||||
->assertJson(['success' => true]);
|
||||
|
||||
$this->assertTrue($note->fresh()->todo_items[0]['completed']);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user