This commit is contained in:
subhajit 2026-08-21 19:13:29 +05:30
parent e03d6ce250
commit 7e44093e8d
14 changed files with 2907 additions and 1073 deletions

View File

@ -46,9 +46,9 @@ public function index()
// Quick stats // Quick stats
$totalClients = $clients->count(); $totalClients = $clients->count();
$totalMeetings = ClientMeeting::whereIn('client_id', $clients->pluck('id'))->count(); $totalMeetings = ClientMeeting::whereIn('client_id', $clients->pluck('id'))->count();
$angryClientsCount = $clients->where('latest_sentiment', 'angry')->count(); $activeProjectsCount = $clients->where('status', 'active')->count();
return view('client_calls.index', compact('clients', 'allUsers', 'managersAndLeads', 'totalClients', 'totalMeetings', 'angryClientsCount')); return view('client_calls.index', compact('clients', 'allUsers', 'managersAndLeads', 'totalClients', 'totalMeetings', 'activeProjectsCount'));
} }
/** /**
@ -101,12 +101,25 @@ public function show($id)
return redirect()->route('dashboard')->with('error', 'Unauthorized access.'); return redirect()->route('dashboard')->with('error', 'Unauthorized access.');
} }
$client = Client::with(['responsibleUser', 'creator', 'meetings.host', 'meetings.latestNote', 'callNotes.creator'])->findOrFail($id); $client = Client::with([
'responsibleUser',
'creator',
'meetings.host',
'meetings.latestNote',
'callNotes.creator',
'callNotes.meeting'
])->findOrFail($id);
$allUsers = User::orderBy('name', 'asc')->get(); $allUsers = User::orderBy('name', 'asc')->get();
// Group call notes date-wise (Y-m-d)
$groupedCallNotes = $client->callNotes->groupBy(function ($note) {
return $note->created_at ? $note->created_at->format('Y-m-d') : now()->format('Y-m-d');
});
$canViewAngry = $client->canViewAngryAnalysis($user); $canViewAngry = $client->canViewAngryAnalysis($user);
return view('client_calls.show', compact('client', 'allUsers', 'canViewAngry')); return view('client_calls.show', compact('client', 'allUsers', 'canViewAngry', 'groupedCallNotes'));
} }
/** /**
@ -239,7 +252,9 @@ public function showRoom(Request $request, $code)
$participantAvatar = $user ? ($user->avatar ?? null) : null; $participantAvatar = $user ? ($user->avatar ?? null) : null;
$isHost = $user && ((int)$meeting->host_user_id === (int)$user->id || $user->isAdmin()); $isHost = $user && ((int)$meeting->host_user_id === (int)$user->id || $user->isAdmin());
return view('client_calls.room', compact('meeting', 'participantName', 'participantRole', 'participantAvatar', 'isHost', 'isGuest')); $client = $meeting->client;
return view('client_calls.room', compact('meeting', 'client', 'participantName', 'participantRole', 'participantAvatar', 'isHost', 'isGuest'));
} }
/** /**
@ -275,6 +290,166 @@ public function detectEmotion(Request $request, $code)
return response()->json($result); return response()->json($result);
} }
/**
* Upload & Save Client Call Video Recording.
*/
public function uploadRecording(Request $request, $code)
{
$meeting = ClientMeeting::where('meeting_code', $code)->firstOrFail();
if ($request->hasFile('video') || $request->hasFile('recording')) {
$file = $request->file('video') ?? $request->file('recording');
$ext = strtolower($file->getClientOriginalExtension() ?: 'webm');
if (!in_array($ext, ['mp4', 'webm', 'm4v', 'mov', 'ogg'])) {
$ext = 'webm';
}
$subDir = 'uploads/client_recordings/' . $meeting->meeting_code;
$destinationPath = public_path($subDir);
if (!file_exists($destinationPath)) {
mkdir($destinationPath, 0777, true);
}
$filename = 'call_recording_' . time() . '.' . $ext;
$file->move($destinationPath, $filename);
$url = asset($subDir . '/' . $filename);
$relativePath = '/' . $subDir . '/' . $filename;
$recordings = $meeting->recordings ?? [];
if (!is_array($recordings)) {
$recordings = $meeting->recording_path ? [['url' => $meeting->recording_path, 'full_url' => asset(ltrim($meeting->recording_path, '/')), 'filename' => basename($meeting->recording_path), 'created_at' => now()->toIso8601String()]] : [];
}
$recordings[] = [
'url' => $relativePath,
'full_url' => $url,
'filename' => $filename,
'created_at' => now()->toIso8601String(),
];
$meeting->update([
'recording_path' => $relativePath,
'recordings' => $recordings,
]);
// Sync to existing latest call note if present
$latestNote = $meeting->callNotes()->first();
if ($latestNote) {
$latestNote->update([
'recording_path' => $relativePath,
'recordings' => $recordings,
]);
}
return response()->json([
'success' => true,
'path' => $relativePath,
'url' => $url,
'recordings' => $recordings,
]);
}
return response()->json(['success' => false, 'message' => 'No video recording payload received'], 400);
}
/**
* Download Video Recording for a Meeting.
*/
public function downloadMeetingRecording(Request $request, $id)
{
$user = Auth::user();
if (!$user || !$user->canAccessClientCalls()) {
abort(403, 'Unauthorized');
}
$meeting = ClientMeeting::findOrFail($id);
$index = (int) $request->input('index', 0);
$recordings = $meeting->recordings ?? [];
$targetUrl = null;
if (!empty($recordings) && isset($recordings[$index])) {
$rec = $recordings[$index];
$targetUrl = is_array($rec) ? ($rec['url'] ?? null) : $rec;
}
if (!$targetUrl) {
$targetUrl = $meeting->recording_path;
}
if (!$targetUrl) {
return back()->with('error', 'No video recording found for this meeting.');
}
return $this->serveRecordingDownload($targetUrl, $meeting->title ?? 'Meeting');
}
/**
* Download Video Recording for a Call Note.
*/
public function downloadNoteRecording(Request $request, $id)
{
$user = Auth::user();
if (!$user || !$user->canAccessClientCalls()) {
abort(403, 'Unauthorized');
}
$callNote = ClientCallNote::with(['client', 'meeting'])->findOrFail($id);
$index = (int) $request->input('index', 0);
$recordings = $callNote->recordings ?? ($callNote->meeting->recordings ?? []);
$targetUrl = null;
if (!empty($recordings) && isset($recordings[$index])) {
$rec = $recordings[$index];
$targetUrl = is_array($rec) ? ($rec['url'] ?? null) : $rec;
}
if (!$targetUrl) {
$targetUrl = $callNote->recording_path ?? ($callNote->meeting->recording_path ?? null);
}
if (!$targetUrl) {
return back()->with('error', 'No video recording found for this call note.');
}
$projectName = $callNote->client->project_name ?? 'ClientCall';
return $this->serveRecordingDownload($targetUrl, $projectName);
}
/**
* Helper to download recording file.
*/
protected function serveRecordingDownload(string $targetUrl, string $prefixName)
{
$urlPath = parse_url($targetUrl, PHP_URL_PATH) ?? $targetUrl;
$sanitizedPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/');
$candidatePaths = [
public_path($sanitizedPath),
public_path('storage/' . $sanitizedPath),
storage_path('app/public/' . $sanitizedPath),
storage_path('app/' . $sanitizedPath),
];
$filePath = null;
foreach ($candidatePaths as $cp) {
if (file_exists($cp) && is_file($cp)) {
$filePath = $cp;
break;
}
}
if ($filePath && file_exists($filePath)) {
$ext = pathinfo($filePath, PATHINFO_EXTENSION) ?: 'webm';
$slug = Str::slug($prefixName);
$downloadFilename = "Recording-{$slug}-" . date('Y-m-d') . ".{$ext}";
return response()->download($filePath, $downloadFilename, [
'Content-Type' => ($ext === 'webm' ? 'video/webm' : 'video/mp4'),
]);
}
return back()->with('error', 'Recording file not found on server storage.');
}
/** /**
* End Call, Generate AI MoM & Notes, and Send Email to TL, Director, PM, and Admin. * End Call, Generate AI MoM & Notes, and Send Email to TL, Director, PM, and Admin.
*/ */
@ -286,11 +461,26 @@ public function endCallAndGenerateMoM(Request $request, $code)
$transcript = $request->input('transcript', ''); $transcript = $request->input('transcript', '');
$liveNotes = $request->input('live_notes', ''); $liveNotes = $request->input('live_notes', '');
$emotionTimeline = $request->input('emotion_timeline', []); $emotionTimeline = $request->input('emotion_timeline', []);
$recordingPath = $request->input('recording_path', $meeting->recording_path);
$recordings = $request->input('recordings', $meeting->recordings ?? []);
if ($recordingPath && empty($recordings)) {
$recordings = [
[
'url' => $recordingPath,
'full_url' => asset(ltrim($recordingPath, '/')),
'filename' => basename($recordingPath),
'created_at' => now()->toIso8601String(),
]
];
}
// Mark meeting as completed // Mark meeting as completed
$meeting->update([ $meeting->update([
'status' => 'completed', 'status' => 'completed',
'ended_at' => now(), 'ended_at' => now(),
'recording_path' => $recordingPath,
'recordings' => $recordings,
]); ]);
// Generate AI MoM // Generate AI MoM
@ -322,11 +512,15 @@ public function endCallAndGenerateMoM(Request $request, $code)
'sentiment_breakdown' => $aiResult['sentiment_breakdown'], 'sentiment_breakdown' => $aiResult['sentiment_breakdown'],
'angry_analysis' => $aiResult['angry_analysis'], 'angry_analysis' => $aiResult['angry_analysis'],
'emotion_details' => $aiResult['emotion_details'] ?? [], 'emotion_details' => $aiResult['emotion_details'] ?? [],
'recording_path' => $recordingPath,
'recordings' => $recordings,
]); ]);
// Automatically send email with MoM and notes to TL, Director, PM, and Admin // Automatically send email with MoM and notes to TL, Director, PM, and Admin
$emailStatus = $this->aiService->sendMoMEmails($callNote); $emailStatus = $this->aiService->sendMoMEmails($callNote);
$redirectUrl = Auth::check() ? route('client-calls.show', $client->id) : url('/');
if ($request->ajax() || $request->wantsJson()) { if ($request->ajax() || $request->wantsJson()) {
return response()->json([ return response()->json([
'success' => true, 'success' => true,
@ -334,10 +528,15 @@ public function endCallAndGenerateMoM(Request $request, $code)
'client_id' => $client->id, 'client_id' => $client->id,
'detected_sentiment' => $callNote->detected_sentiment, 'detected_sentiment' => $callNote->detected_sentiment,
'email_status' => $emailStatus, 'email_status' => $emailStatus,
'redirect_url' => route('client-calls.show', $client->id), 'recording_path' => $callNote->recording_path,
'redirect_url' => $redirectUrl,
]); ]);
} }
if (!Auth::check()) {
return redirect('/')->with('success', 'Meeting ended successfully.');
}
return redirect()->route('client-calls.show', $client->id)->with('success', 'Call ended. AI MoM & Notes generated and emailed to project stakeholders!'); return redirect()->route('client-calls.show', $client->id)->with('success', 'Call ended. AI MoM & Notes generated and emailed to project stakeholders!');
} }
@ -494,7 +693,9 @@ public function getEmotionDetails($noteId)
'project_name' => $client->project_name, 'project_name' => $client->project_name,
'detected_sentiment' => $sentiment, 'detected_sentiment' => $sentiment,
'emotion_details' => $details, 'emotion_details' => $details,
'todo_items' => $callNote->todo_items ?? $callNote->action_items ?? [],
'summary' => $callNote->summary, 'summary' => $callNote->summary,
'mom_content' => $callNote->mom_content,
'created_at' => $callNote->created_at ? $callNote->created_at->format('M d, Y h:i A') : now()->format('M d, Y h:i A'), 'created_at' => $callNote->created_at ? $callNote->created_at->format('M d, Y h:i A') : now()->format('M d, Y h:i A'),
]); ]);
} }
@ -526,6 +727,77 @@ public function toggleTodo(Request $request, $noteId, $todoId)
]); ]);
} }
/**
* Add a new To-Do item to the call note.
*/
public function addTodo(Request $request, $noteId)
{
$user = Auth::user();
if (!$user || !$user->canAccessClientCalls()) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$request->validate([
'task' => 'required|string|max:500',
'owner' => 'nullable|string|max:255',
'priority' => 'nullable|in:High,Medium,Low',
'deadline' => 'nullable|string|max:100',
]);
$callNote = ClientCallNote::findOrFail($noteId);
$todos = $callNote->todo_items ?? [];
$maxId = 0;
foreach ($todos as $t) {
if (isset($t['id']) && $t['id'] > $maxId) {
$maxId = $t['id'];
}
}
$newTodo = [
'id' => $maxId + 1,
'task' => trim($request->task),
'owner' => $request->owner ?: ($user->name ?? 'Assigned Lead'),
'priority' => $request->priority ?: 'Medium',
'deadline' => $request->deadline ?: now()->addDays(3)->format('M d, Y'),
'completed' => false,
];
$todos[] = $newTodo;
$callNote->update(['todo_items' => $todos]);
return response()->json([
'success' => true,
'todo' => $newTodo,
'todo_items' => $todos,
]);
}
/**
* Delete a To-Do item from the call note.
*/
public function deleteTodo(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 ?? [];
$filteredTodos = array_values(array_filter($todos, function ($item) use ($todoId) {
return !isset($item['id']) || (int)$item['id'] !== (int)$todoId;
}));
$callNote->update(['todo_items' => $filteredTodos]);
return response()->json([
'success' => true,
'todo_items' => $filteredTodos,
]);
}
/** /**
* Get Angry Root Cause Analysis (Admin & Responsible Person ONLY). * Get Angry Root Cause Analysis (Admin & Responsible Person ONLY).
*/ */

View File

@ -24,6 +24,8 @@ class ClientCallNote extends Model
'angry_analysis', 'angry_analysis',
'emotion_details', 'emotion_details',
'todo_items', 'todo_items',
'recording_path',
'recordings',
'emailed_at', 'emailed_at',
'emailed_to', 'emailed_to',
]; ];
@ -35,6 +37,7 @@ class ClientCallNote extends Model
'angry_analysis' => 'array', 'angry_analysis' => 'array',
'emotion_details' => 'array', 'emotion_details' => 'array',
'todo_items' => 'array', 'todo_items' => 'array',
'recordings' => 'array',
'emailed_to' => 'array', 'emailed_to' => 'array',
'emailed_at' => 'datetime', 'emailed_at' => 'datetime',
]; ];

View File

@ -26,6 +26,8 @@ class ClientMeeting extends Model
'active_peers', 'active_peers',
'peer_signals', 'peer_signals',
'chat_messages', 'chat_messages',
'recording_path',
'recordings',
]; ];
protected $casts = [ protected $casts = [
@ -37,6 +39,7 @@ class ClientMeeting extends Model
'active_peers' => 'array', 'active_peers' => 'array',
'peer_signals' => 'array', 'peer_signals' => 'array',
'chat_messages' => 'array', 'chat_messages' => 'array',
'recordings' => 'array',
]; ];
/** /**

View File

@ -109,39 +109,82 @@ public function detectEmotion(string $speechText = '', ?string $imageFrameBase64
{ {
$apiKey = env('GEMINI_API_KEY') ?: (env('GOOGLE_API_KEY') ?: (getenv('GEMINI_API_KEY') ?: getenv('GOOGLE_API_KEY'))); $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(trim($speechText));
$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']; // 1. Check for negative polarity / negations that invert positive sentiment
$sadTriggers = ['sad', 'disappointed', 'worried', 'concerned', 'nervous', 'scared', 'afraid', 'regret', 'loss', 'hopeless', 'sorry', 'doubt']; $negatedHappyPhrases = [
$happyTriggers = ['great', 'excellent', 'fantastic', 'awesome', 'good job', 'love it', 'happy', 'pleased', 'perfect', 'superb', 'wonderful', 'thank you', 'amazing', 'impressed']; 'not happy', 'not satisfied', 'not good', 'not great', 'not working',
'not impressed', 'not pleased', 'never works', 'no progress', 'displeased',
'unhappy', 'dissatisfied', 'don\'t like', 'do not like', 'cannot accept',
'unacceptable', 'waste of time', 'waste of money', 'broken feature', 'delays'
];
$foundNegations = [];
foreach ($negatedHappyPhrases as $negPhrase) {
if (str_contains($lowerText, $negPhrase)) {
$foundNegations[] = $negPhrase;
}
}
// 2. Angry / Frustrated trigger keywords
$angryTriggers = [
'angry', 'furious', 'unacceptable', 'ridiculous', 'terrible', 'waste of time',
'delay', 'delays', 'delayed', 'broken', 'disaster', 'unhappy', 'frustrated',
'mad', 'annoyed', 'horrible', 'cancel', 'lawyer', 'refund', 'fail', 'failed',
'incompetent', 'worst', 'escalate', 'escalation', 'disgusted', 'blunder',
'mess', 'screwed', 'unprofessional', 'missed deadline', 'not delivering'
];
// 3. Sad / Concerned / Hesitant trigger keywords
$sadTriggers = [
'sad', 'disappointed', 'disappointment', 'worried', 'worry', 'concerned',
'concern', 'nervous', 'scared', 'afraid', 'regret', 'loss', 'hopeless',
'sorry', 'doubt', 'doubtful', 'hesitant', 'struggling', 'anxious',
'over budget', 'falling behind', 'uncertain', 'at risk'
];
// 4. Happy / Satisfied / Praising trigger keywords
$happyTriggers = [
'great', 'excellent', 'fantastic', 'awesome', 'good job', 'love it',
'happy', 'pleased', 'perfect', 'superb', 'wonderful', 'thank you',
'amazing', 'impressed', 'kudos', 'brilliant', 'exceeded expectations',
'looks good', 'looks great', 'well done', 'appreciate', 'delighted',
'seamless', 'flawless', 'outstanding'
];
$detectedAngryWords = []; $detectedAngryWords = [];
foreach ($angryTriggers as $word) { foreach ($angryTriggers as $word) {
if (str_contains($lowerText, $word)) { if (preg_match('/\b' . preg_quote($word, '/') . '\b/i', $lowerText) || str_contains($lowerText, $word)) {
$detectedAngryWords[] = $word; $detectedAngryWords[] = $word;
} }
} }
if (!empty($foundNegations)) {
$detectedAngryWords = array_unique(array_merge($detectedAngryWords, $foundNegations));
}
$detectedSadWords = []; $detectedSadWords = [];
foreach ($sadTriggers as $word) { foreach ($sadTriggers as $word) {
if (str_contains($lowerText, $word)) { if (preg_match('/\b' . preg_quote($word, '/') . '\b/i', $lowerText) || str_contains($lowerText, $word)) {
$detectedSadWords[] = $word; $detectedSadWords[] = $word;
} }
} }
$detectedHappyWords = []; $detectedHappyWords = [];
foreach ($happyTriggers as $word) { // Only consider happy words if there are no strong negations of happiness
if (str_contains($lowerText, $word)) { if (empty($foundNegations)) {
$detectedHappyWords[] = $word; foreach ($happyTriggers as $word) {
if (preg_match('/\b' . preg_quote($word, '/') . '\b/i', $lowerText) || str_contains($lowerText, $word)) {
$detectedHappyWords[] = $word;
}
} }
} }
if (!empty($detectedAngryWords)) { if (!empty($detectedAngryWords)) {
return [ return [
'emotion' => 'angry', 'emotion' => 'angry',
'confidence' => 0.92, 'confidence' => 0.94,
'trigger_words' => $detectedAngryWords, 'trigger_words' => array_values(array_unique($detectedAngryWords)),
'details' => 'Detected frustration or dissatisfaction keywords in speech.', 'details' => 'Detected clear frustration, complaint, or dissatisfaction cues in conversation.',
'label' => 'Frustrated / Angry 😡' 'label' => 'Frustrated / Angry 😡'
]; ];
} }
@ -149,9 +192,9 @@ public function detectEmotion(string $speechText = '', ?string $imageFrameBase64
if (!empty($detectedSadWords)) { if (!empty($detectedSadWords)) {
return [ return [
'emotion' => 'sad', 'emotion' => 'sad',
'confidence' => 0.85, 'confidence' => 0.88,
'trigger_words' => $detectedSadWords, 'trigger_words' => array_values(array_unique($detectedSadWords)),
'details' => 'Detected concern or disappointment cues in speech.', 'details' => 'Detected concern, hesitation, or worry in speech dialogue.',
'label' => 'Concerned / Sad 😟' 'label' => 'Concerned / Sad 😟'
]; ];
} }
@ -159,9 +202,9 @@ public function detectEmotion(string $speechText = '', ?string $imageFrameBase64
if (!empty($detectedHappyWords)) { if (!empty($detectedHappyWords)) {
return [ return [
'emotion' => 'happy', 'emotion' => 'happy',
'confidence' => 0.88, 'confidence' => 0.90,
'trigger_words' => $detectedHappyWords, 'trigger_words' => array_values(array_unique($detectedHappyWords)),
'details' => 'Detected positive satisfaction keywords in speech.', 'details' => 'Detected positive satisfaction, appreciation, or praise in speech.',
'label' => 'Happy / Satisfied 😊' 'label' => 'Happy / Satisfied 😊'
]; ];
} }
@ -507,13 +550,23 @@ protected function generateFallbackMoM(string $combinedContent, string $transcri
{ {
$lower = strtolower($combinedContent); $lower = strtolower($combinedContent);
$angryWords = ['angry', 'furious', 'unacceptable', 'ridiculous', 'terrible', 'waste of time', 'delay', 'broken', 'disaster', 'unhappy', 'frustrated', 'mad', 'annoyed', 'horrible', 'refund', 'cancel', 'fail', 'incompetent']; $negatedHappyPhrases = ['not happy', 'not satisfied', 'not good', 'not great', 'not working', 'not impressed', 'not pleased', 'no progress', 'displeased', 'unhappy', 'dissatisfied', 'cannot accept', 'unacceptable'];
$sadWords = ['sad', 'disappointed', 'worried', 'concerned', 'nervous', 'scared', 'afraid', 'regret', 'doubt']; $foundNegations = array_values(array_filter($negatedHappyPhrases, fn($w) => str_contains($lower, $w)));
$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))); $angryWords = ['angry', 'furious', 'unacceptable', 'ridiculous', 'terrible', 'waste of time', 'delay', 'delays', 'delayed', 'broken', 'disaster', 'unhappy', 'frustrated', 'mad', 'annoyed', 'horrible', 'refund', 'cancel', 'fail', 'incompetent', 'unprofessional', 'missed deadline'];
$sadWords = ['sad', 'disappointed', 'worried', 'concerned', 'nervous', 'scared', 'afraid', 'regret', 'doubt', 'hesitant', 'uncertain', 'at risk'];
$happyWords = ['great', 'excellent', 'fantastic', 'awesome', 'good job', 'love it', 'happy', 'pleased', 'perfect', 'superb', 'wonderful', 'thank you', 'appreciate', 'impressed', 'kudos', 'brilliant', 'delighted'];
$foundAngry = array_values(array_unique(array_merge(
array_filter($angryWords, fn($w) => str_contains($lower, $w)),
$foundNegations
)));
$foundSad = array_values(array_filter($sadWords, 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)));
$foundHappy = [];
if (empty($foundNegations)) {
$foundHappy = array_values(array_filter($happyWords, fn($w) => str_contains($lower, $w)));
}
$sentiment = 'neutral'; $sentiment = 'neutral';
$angryAnalysis = [ $angryAnalysis = [

View File

@ -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::table('client_meetings', function (Blueprint $table) {
$table->string('recording_path')->nullable()->after('chat_messages');
$table->json('recordings')->nullable()->after('recording_path');
});
Schema::table('client_call_notes', function (Blueprint $table) {
$table->string('recording_path')->nullable()->after('todo_items');
$table->json('recordings')->nullable()->after('recording_path');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('client_meetings', function (Blueprint $table) {
$table->dropColumn(['recording_path', 'recordings']);
});
Schema::table('client_call_notes', function (Blueprint $table) {
$table->dropColumn(['recording_path', 'recordings']);
});
}
};

View File

@ -1,5 +1,7 @@
/** /**
* SingleLogin Client Call - WebRTC Video & AI Meeting Intelligence Engine * SingleLogin Client Call - WebRTC Video & AI Meeting Intelligence Engine
* Features: Multi-party video grid, Live AI transcription, Real-time emotion analysis,
* In-call chat, and Full Call Recording with automated server upload.
*/ */
let localStream = null; let localStream = null;
@ -9,6 +11,16 @@ let isAudioMuted = false;
let isVideoMuted = false; let isVideoMuted = false;
let isScreenSharing = false; let isScreenSharing = false;
// Call Recording State
let mediaRecorder = null;
let recordedChunks = [];
let isRecording = false;
let recordingStartTime = null;
let recordingTimerInterval = null;
let recordingStream = null;
let recordedVideoBlob = null;
let recordedVideoPath = null;
let accumulatedTranscript = []; let accumulatedTranscript = [];
let speechRecognizer = null; let speechRecognizer = null;
let emotionTimeline = []; let emotionTimeline = [];
@ -33,6 +45,7 @@ document.addEventListener('DOMContentLoaded', async () => {
window.toggleAudio = toggleAudio; window.toggleAudio = toggleAudio;
window.toggleVideo = toggleVideo; window.toggleVideo = toggleVideo;
window.toggleScreenShare = toggleScreenShare; window.toggleScreenShare = toggleScreenShare;
window.toggleCallRecording = toggleCallRecording;
window.sendChatMessage = sendChatMessage; window.sendChatMessage = sendChatMessage;
window.endMeetingAndGenerateMoM = endMeetingAndGenerateMoM; window.endMeetingAndGenerateMoM = endMeetingAndGenerateMoM;
window.leaveCall = leaveCall; window.leaveCall = leaveCall;
@ -87,6 +100,7 @@ function toggleAudio() {
const btn = document.getElementById('btnToggleMic'); const btn = document.getElementById('btnToggleMic');
if (btn) { if (btn) {
btn.classList.toggle('active-off', isAudioMuted); btn.classList.toggle('active-off', isAudioMuted);
btn.title = isAudioMuted ? 'Unmute Microphone' : 'Mute Microphone';
} }
} }
} }
@ -100,6 +114,7 @@ function toggleVideo() {
const btn = document.getElementById('btnToggleVideo'); const btn = document.getElementById('btnToggleVideo');
if (btn) { if (btn) {
btn.classList.toggle('active-off', isVideoMuted); btn.classList.toggle('active-off', isVideoMuted);
btn.title = isVideoMuted ? 'Turn Camera On' : 'Turn Camera Off';
} }
} }
} }
@ -108,7 +123,7 @@ async function toggleScreenShare() {
const btn = document.getElementById('btnScreenShare'); const btn = document.getElementById('btnScreenShare');
if (!isScreenSharing) { if (!isScreenSharing) {
try { try {
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true }); screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
const screenTrack = screenStream.getVideoTracks()[0]; const screenTrack = screenStream.getVideoTracks()[0];
// Replace video track for peers // Replace video track for peers
@ -151,7 +166,242 @@ function stopScreenShare() {
} }
/** /**
* =========================================================================
* Call Recording Engine
* =========================================================================
*/
async function toggleCallRecording() {
if (isRecording) {
stopRecording();
} else {
await startRecording();
}
}
async function startRecording() {
try {
recordedChunks = [];
let combinedStream = null;
// Try getting screen/tab capture with system audio
try {
if (navigator.mediaDevices.getDisplayMedia) {
if (screenStream && screenStream.active) {
combinedStream = screenStream.clone();
} else {
const screenCapture = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: 'browser' },
audio: true
});
combinedStream = screenCapture;
}
}
} catch (e) {
console.info('Display capture not selected, recording camera stream directly:', e);
}
if (!combinedStream && localStream) {
combinedStream = localStream.clone();
}
if (!combinedStream) {
alert('No camera or display stream available to record.');
return;
}
// Mix local microphone audio if available
if (localStream && localStream.getAudioTracks().length > 0 && window.AudioContext) {
try {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const dest = audioCtx.createMediaStreamDestination();
// Local mic
const micSource = audioCtx.createMediaStreamSource(localStream);
micSource.connect(dest);
// Screen audio if captured
if (combinedStream.getAudioTracks().length > 0) {
const screenAudioSource = audioCtx.createMediaStreamSource(new MediaStream([combinedStream.getAudioTracks()[0]]));
screenAudioSource.connect(dest);
}
const mixedAudioTrack = dest.stream.getAudioTracks()[0];
if (mixedAudioTrack) {
combinedStream.getAudioTracks().forEach(t => combinedStream.removeTrack(t));
combinedStream.addTrack(mixedAudioTrack);
}
} catch (err) {
console.warn('Audio mixing fallback:', err);
if (combinedStream.getAudioTracks().length === 0 && localStream.getAudioTracks().length > 0) {
combinedStream.addTrack(localStream.getAudioTracks()[0].clone());
}
}
}
recordingStream = combinedStream;
// Determine supported mimeTypes
let options = { mimeType: 'video/webm;codecs=vp9,opus' };
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
options = { mimeType: 'video/webm;codecs=vp8,opus' };
}
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
options = { mimeType: 'video/webm' };
}
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
options = { mimeType: 'video/mp4' };
}
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
options = {};
}
mediaRecorder = new MediaRecorder(recordingStream, options);
mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
recordedChunks.push(event.data);
}
};
mediaRecorder.onstop = async () => {
clearInterval(recordingTimerInterval);
isRecording = false;
updateRecordingUI(false);
if (recordingStream) {
recordingStream.getTracks().forEach(t => t.stop());
recordingStream = null;
}
if (recordedChunks.length > 0) {
const mimeType = mediaRecorder.mimeType || 'video/webm';
recordedVideoBlob = new Blob(recordedChunks, { type: mimeType });
await uploadRecordingBlob(recordedVideoBlob);
}
};
recordingStream.getVideoTracks().forEach(track => {
track.onended = () => {
if (isRecording) {
stopRecording();
}
};
});
mediaRecorder.start(1000);
isRecording = true;
recordingStartTime = Date.now();
startRecordingTimer();
updateRecordingUI(true);
showToastNotification('🔴 Call recording started');
} catch (err) {
console.error('Error starting recording:', err);
alert('Could not start call recording: ' + (err.message || err));
}
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
showToastNotification('⏹️ Stopping recording & saving...');
}
}
function startRecordingTimer() {
const timerEl = document.getElementById('recTimer');
recordingTimerInterval = setInterval(() => {
const elapsedSec = Math.floor((Date.now() - recordingStartTime) / 1000);
const mins = String(Math.floor(elapsedSec / 60)).padStart(2, '0');
const secs = String(elapsedSec % 60).padStart(2, '0');
if (timerEl) timerEl.innerText = `REC ${mins}:${secs}`;
}, 1000);
}
function updateRecordingUI(recording) {
const badge = document.getElementById('recordingStatusBadge');
const recIconStart = document.getElementById('recIconStart');
const recIconStop = document.getElementById('recIconStop');
const btn = document.getElementById('btnToggleRecord');
if (recording) {
if (badge) badge.style.display = 'inline-flex';
if (recIconStart) recIconStart.style.display = 'none';
if (recIconStop) recIconStop.style.display = 'inline';
if (btn) {
btn.classList.add('active-rec');
btn.title = 'Stop Call Recording';
}
} else {
if (badge) badge.style.display = 'none';
if (recIconStart) recIconStart.style.display = 'inline';
if (recIconStop) recIconStop.style.display = 'none';
if (btn) {
btn.classList.remove('active-rec');
btn.title = 'Start Call Recording';
}
}
}
async function uploadRecordingBlob(blob) {
if (!blob || blob.size === 0) return;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const uploadUrl = config.uploadRecordingUrl;
if (!uploadUrl) return;
const formData = new FormData();
const ext = blob.type.includes('mp4') ? 'mp4' : 'webm';
formData.append('video', blob, `client_call_${config.meetingCode || 'rec'}_${Date.now()}.${ext}`);
try {
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken,
'Accept': 'application/json'
},
body: formData
});
const data = await response.json();
if (data && data.success) {
recordedVideoPath = data.path;
showToastNotification('✅ Video recording saved & uploaded successfully!');
showDownloadPrompt(blob, ext);
}
} catch (err) {
console.error('Failed to upload recording:', err);
showToastNotification('⚠️ Cloud upload delayed. You can download the recording locally.');
showDownloadPrompt(blob, ext);
}
}
function showDownloadPrompt(blob, ext) {
const modal = document.getElementById('recordingSavedModal');
if (modal) {
const downloadBtn = document.getElementById('btnDownloadSavedRec');
if (downloadBtn) {
const url = URL.createObjectURL(blob);
downloadBtn.href = url;
downloadBtn.download = `Meeting-${config.meetingCode || 'recording'}-${Date.now()}.${ext}`;
}
modal.style.display = 'flex';
}
}
function showToastNotification(msg) {
const toast = document.getElementById('toastNotification');
if (toast) {
toast.innerText = msg;
toast.style.display = 'block';
setTimeout(() => { toast.style.display = 'none'; }, 3500);
}
}
/**
* =========================================================================
* Web Speech Recognition for Live Transcription & AI Note Taking * Web Speech Recognition for Live Transcription & AI Note Taking
* =========================================================================
*/ */
function initSpeechRecognition() { function initSpeechRecognition() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
@ -324,6 +574,7 @@ let outgoingChatMessage = null;
function sendChatMessage() { function sendChatMessage() {
const input = document.getElementById('chatInput'); const input = document.getElementById('chatInput');
if (!input) return;
const text = input.value.trim(); const text = input.value.trim();
if (!text) return; if (!text) return;
@ -382,6 +633,8 @@ function renderChat(messages) {
function handleActivePeers(peers) { function handleActivePeers(peers) {
const grid = document.getElementById('videoGrid'); const grid = document.getElementById('videoGrid');
if (!grid) return;
peers.forEach(peer => { peers.forEach(peer => {
if (peer.peer_id === peerId) return; if (peer.peer_id === peerId) return;
@ -391,7 +644,7 @@ function handleActivePeers(peers) {
tile.id = 'tile_' + peer.peer_id; tile.id = 'tile_' + peer.peer_id;
tile.className = 'video-tile'; tile.className = 'video-tile';
tile.innerHTML = ` tile.innerHTML = `
<div class="video-avatar-placeholder">${peer.peer_name.charAt(0).toUpperCase()}</div> <div class="video-avatar-placeholder">${(peer.peer_name || 'U').charAt(0).toUpperCase()}</div>
<div class="video-overlay"> <div class="video-overlay">
<span>${escapeHtml(peer.peer_name)}</span> <span>${escapeHtml(peer.peer_name)}</span>
</div> </div>
@ -417,6 +670,12 @@ async function endMeetingAndGenerateMoM() {
return; return;
} }
// Stop recording if running and allow a moment to complete
if (isRecording && mediaRecorder && mediaRecorder.state !== 'inactive') {
stopRecording();
await new Promise(resolve => setTimeout(resolve, 800));
}
const liveNotes = document.getElementById('liveNotesInput')?.value || ''; const liveNotes = document.getElementById('liveNotesInput')?.value || '';
const transcriptText = accumulatedTranscript.map(l => `[${l.time}] ${l.speaker}: ${l.text}`).join("\n"); const transcriptText = accumulatedTranscript.map(l => `[${l.time}] ${l.speaker}: ${l.text}`).join("\n");
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
@ -425,7 +684,7 @@ async function endMeetingAndGenerateMoM() {
const btn = document.querySelector('.btn-end-call'); const btn = document.querySelector('.btn-end-call');
if (btn) { if (btn) {
btn.disabled = true; btn.disabled = true;
btn.innerText = 'Generating AI MoM & Dispatching Emails...'; btn.innerText = 'Generating AI MoM & Saving Notes...';
} }
try { try {
@ -439,7 +698,8 @@ async function endMeetingAndGenerateMoM() {
body: JSON.stringify({ body: JSON.stringify({
transcript: transcriptText, transcript: transcriptText,
live_notes: liveNotes, live_notes: liveNotes,
emotion_timeline: emotionTimeline emotion_timeline: emotionTimeline,
recording_path: recordedVideoPath
}) })
}); });
@ -450,20 +710,23 @@ async function endMeetingAndGenerateMoM() {
window.location.href = config.redirectUrl; window.location.href = config.redirectUrl;
} }
} catch (e) { } catch (e) {
alert('Meeting ended. Redirecting to Client Hub...'); alert('Meeting completed. Redirecting to Client Hub...');
window.location.href = config.redirectUrl; window.location.href = config.redirectUrl;
} }
} }
function leaveCall() { function leaveCall() {
if (confirm('Leave this meeting?')) { if (confirm('Leave this meeting?')) {
if (isRecording) {
stopRecording();
}
window.location.href = config.redirectUrl || '/'; window.location.href = config.redirectUrl || '/';
} }
} }
function escapeHtml(str) { function escapeHtml(str) {
if (!str) return ''; if (!str) return '';
return str.replace(/[&<>"']/g, function(m) { return String(str).replace(/[&<>"']/g, function(m) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[m]; return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[m];
}); });
} }

View File

@ -1,5 +1,7 @@
/** /**
* SingleLogin Client Call - WebRTC Video & AI Meeting Intelligence Engine * SingleLogin Client Call - WebRTC Video & AI Meeting Intelligence Engine
* Features: Multi-party video grid, Live AI transcription, Real-time emotion analysis,
* In-call chat, and Full Call Recording with automated server upload.
*/ */
let localStream = null; let localStream = null;
@ -9,6 +11,16 @@ let isAudioMuted = false;
let isVideoMuted = false; let isVideoMuted = false;
let isScreenSharing = false; let isScreenSharing = false;
// Call Recording State
let mediaRecorder = null;
let recordedChunks = [];
let isRecording = false;
let recordingStartTime = null;
let recordingTimerInterval = null;
let recordingStream = null;
let recordedVideoBlob = null;
let recordedVideoPath = null;
let accumulatedTranscript = []; let accumulatedTranscript = [];
let speechRecognizer = null; let speechRecognizer = null;
let emotionTimeline = []; let emotionTimeline = [];
@ -33,6 +45,7 @@ document.addEventListener('DOMContentLoaded', async () => {
window.toggleAudio = toggleAudio; window.toggleAudio = toggleAudio;
window.toggleVideo = toggleVideo; window.toggleVideo = toggleVideo;
window.toggleScreenShare = toggleScreenShare; window.toggleScreenShare = toggleScreenShare;
window.toggleCallRecording = toggleCallRecording;
window.sendChatMessage = sendChatMessage; window.sendChatMessage = sendChatMessage;
window.endMeetingAndGenerateMoM = endMeetingAndGenerateMoM; window.endMeetingAndGenerateMoM = endMeetingAndGenerateMoM;
window.leaveCall = leaveCall; window.leaveCall = leaveCall;
@ -86,6 +99,7 @@ function toggleAudio() {
const btn = document.getElementById('btnToggleMic'); const btn = document.getElementById('btnToggleMic');
if (btn) { if (btn) {
btn.classList.toggle('active-off', isAudioMuted); btn.classList.toggle('active-off', isAudioMuted);
btn.title = isAudioMuted ? 'Unmute Microphone' : 'Mute Microphone';
} }
} }
} }
@ -99,6 +113,7 @@ function toggleVideo() {
const btn = document.getElementById('btnToggleVideo'); const btn = document.getElementById('btnToggleVideo');
if (btn) { if (btn) {
btn.classList.toggle('active-off', isVideoMuted); btn.classList.toggle('active-off', isVideoMuted);
btn.title = isVideoMuted ? 'Turn Camera On' : 'Turn Camera Off';
} }
} }
} }
@ -107,7 +122,7 @@ async function toggleScreenShare() {
const btn = document.getElementById('btnScreenShare'); const btn = document.getElementById('btnScreenShare');
if (!isScreenSharing) { if (!isScreenSharing) {
try { try {
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true }); screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
const screenTrack = screenStream.getVideoTracks()[0]; const screenTrack = screenStream.getVideoTracks()[0];
for (let id in peerConnections) { for (let id in peerConnections) {
@ -149,7 +164,242 @@ function stopScreenShare() {
} }
/** /**
* =========================================================================
* Call Recording Engine
* =========================================================================
*/
async function toggleCallRecording() {
if (isRecording) {
stopRecording();
} else {
await startRecording();
}
}
async function startRecording() {
try {
recordedChunks = [];
let combinedStream = null;
// Try getting screen/tab capture with system audio
try {
if (navigator.mediaDevices.getDisplayMedia) {
if (screenStream && screenStream.active) {
combinedStream = screenStream.clone();
} else {
const screenCapture = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: 'browser' },
audio: true
});
combinedStream = screenCapture;
}
}
} catch (e) {
console.info('Display capture not selected, recording camera stream directly:', e);
}
if (!combinedStream && localStream) {
combinedStream = localStream.clone();
}
if (!combinedStream) {
alert('No camera or display stream available to record.');
return;
}
// Mix local microphone audio if available
if (localStream && localStream.getAudioTracks().length > 0 && window.AudioContext) {
try {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const dest = audioCtx.createMediaStreamDestination();
// Local mic
const micSource = audioCtx.createMediaStreamSource(localStream);
micSource.connect(dest);
// Screen audio if captured
if (combinedStream.getAudioTracks().length > 0) {
const screenAudioSource = audioCtx.createMediaStreamSource(new MediaStream([combinedStream.getAudioTracks()[0]]));
screenAudioSource.connect(dest);
}
const mixedAudioTrack = dest.stream.getAudioTracks()[0];
if (mixedAudioTrack) {
combinedStream.getAudioTracks().forEach(t => combinedStream.removeTrack(t));
combinedStream.addTrack(mixedAudioTrack);
}
} catch (err) {
console.warn('Audio mixing fallback:', err);
if (combinedStream.getAudioTracks().length === 0 && localStream.getAudioTracks().length > 0) {
combinedStream.addTrack(localStream.getAudioTracks()[0].clone());
}
}
}
recordingStream = combinedStream;
// Determine supported mimeTypes
let options = { mimeType: 'video/webm;codecs=vp9,opus' };
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
options = { mimeType: 'video/webm;codecs=vp8,opus' };
}
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
options = { mimeType: 'video/webm' };
}
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
options = { mimeType: 'video/mp4' };
}
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
options = {};
}
mediaRecorder = new MediaRecorder(recordingStream, options);
mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
recordedChunks.push(event.data);
}
};
mediaRecorder.onstop = async () => {
clearInterval(recordingTimerInterval);
isRecording = false;
updateRecordingUI(false);
if (recordingStream) {
recordingStream.getTracks().forEach(t => t.stop());
recordingStream = null;
}
if (recordedChunks.length > 0) {
const mimeType = mediaRecorder.mimeType || 'video/webm';
recordedVideoBlob = new Blob(recordedChunks, { type: mimeType });
await uploadRecordingBlob(recordedVideoBlob);
}
};
recordingStream.getVideoTracks().forEach(track => {
track.onended = () => {
if (isRecording) {
stopRecording();
}
};
});
mediaRecorder.start(1000);
isRecording = true;
recordingStartTime = Date.now();
startRecordingTimer();
updateRecordingUI(true);
showToastNotification('🔴 Call recording started');
} catch (err) {
console.error('Error starting recording:', err);
alert('Could not start call recording: ' + (err.message || err));
}
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
showToastNotification('⏹️ Stopping recording & saving...');
}
}
function startRecordingTimer() {
const timerEl = document.getElementById('recTimer');
recordingTimerInterval = setInterval(() => {
const elapsedSec = Math.floor((Date.now() - recordingStartTime) / 1000);
const mins = String(Math.floor(elapsedSec / 60)).padStart(2, '0');
const secs = String(elapsedSec % 60).padStart(2, '0');
if (timerEl) timerEl.innerText = `REC ${mins}:${secs}`;
}, 1000);
}
function updateRecordingUI(recording) {
const badge = document.getElementById('recordingStatusBadge');
const recIconStart = document.getElementById('recIconStart');
const recIconStop = document.getElementById('recIconStop');
const btn = document.getElementById('btnToggleRecord');
if (recording) {
if (badge) badge.style.display = 'inline-flex';
if (recIconStart) recIconStart.style.display = 'none';
if (recIconStop) recIconStop.style.display = 'inline';
if (btn) {
btn.classList.add('active-rec');
btn.title = 'Stop Call Recording';
}
} else {
if (badge) badge.style.display = 'none';
if (recIconStart) recIconStart.style.display = 'inline';
if (recIconStop) recIconStop.style.display = 'none';
if (btn) {
btn.classList.remove('active-rec');
btn.title = 'Start Call Recording';
}
}
}
async function uploadRecordingBlob(blob) {
if (!blob || blob.size === 0) return;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const uploadUrl = config.uploadRecordingUrl;
if (!uploadUrl) return;
const formData = new FormData();
const ext = blob.type.includes('mp4') ? 'mp4' : 'webm';
formData.append('video', blob, `client_call_${config.meetingCode || 'rec'}_${Date.now()}.${ext}`);
try {
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken,
'Accept': 'application/json'
},
body: formData
});
const data = await response.json();
if (data && data.success) {
recordedVideoPath = data.path;
showToastNotification('✅ Video recording saved & uploaded successfully!');
showDownloadPrompt(blob, ext);
}
} catch (err) {
console.error('Failed to upload recording:', err);
showToastNotification('⚠️ Cloud upload delayed. You can download the recording locally.');
showDownloadPrompt(blob, ext);
}
}
function showDownloadPrompt(blob, ext) {
const modal = document.getElementById('recordingSavedModal');
if (modal) {
const downloadBtn = document.getElementById('btnDownloadSavedRec');
if (downloadBtn) {
const url = URL.createObjectURL(blob);
downloadBtn.href = url;
downloadBtn.download = `Meeting-${config.meetingCode || 'recording'}-${Date.now()}.${ext}`;
}
modal.style.display = 'flex';
}
}
function showToastNotification(msg) {
const toast = document.getElementById('toastNotification');
if (toast) {
toast.innerText = msg;
toast.style.display = 'block';
setTimeout(() => { toast.style.display = 'none'; }, 3500);
}
}
/**
* =========================================================================
* Web Speech Recognition for Live Transcription & AI Note Taking * Web Speech Recognition for Live Transcription & AI Note Taking
* =========================================================================
*/ */
function initSpeechRecognition() { function initSpeechRecognition() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
@ -318,6 +568,7 @@ let outgoingChatMessage = null;
function sendChatMessage() { function sendChatMessage() {
const input = document.getElementById('chatInput'); const input = document.getElementById('chatInput');
if (!input) return;
const text = input.value.trim(); const text = input.value.trim();
if (!text) return; if (!text) return;
@ -374,6 +625,8 @@ function renderChat(messages) {
function handleActivePeers(peers) { function handleActivePeers(peers) {
const grid = document.getElementById('videoGrid'); const grid = document.getElementById('videoGrid');
if (!grid) return;
peers.forEach(peer => { peers.forEach(peer => {
if (peer.peer_id === peerId) return; if (peer.peer_id === peerId) return;
@ -383,7 +636,7 @@ function handleActivePeers(peers) {
tile.id = 'tile_' + peer.peer_id; tile.id = 'tile_' + peer.peer_id;
tile.className = 'video-tile'; tile.className = 'video-tile';
tile.innerHTML = ` tile.innerHTML = `
<div class="video-avatar-placeholder">${peer.peer_name.charAt(0).toUpperCase()}</div> <div class="video-avatar-placeholder">${(peer.peer_name || 'U').charAt(0).toUpperCase()}</div>
<div class="video-overlay"> <div class="video-overlay">
<span>${escapeHtml(peer.peer_name)}</span> <span>${escapeHtml(peer.peer_name)}</span>
</div> </div>
@ -408,6 +661,12 @@ async function endMeetingAndGenerateMoM() {
return; return;
} }
// Stop recording if running and allow a moment to complete
if (isRecording && mediaRecorder && mediaRecorder.state !== 'inactive') {
stopRecording();
await new Promise(resolve => setTimeout(resolve, 800));
}
const liveNotes = document.getElementById('liveNotesInput')?.value || ''; const liveNotes = document.getElementById('liveNotesInput')?.value || '';
const transcriptText = accumulatedTranscript.map(l => `[${l.time}] ${l.speaker}: ${l.text}`).join("\n"); const transcriptText = accumulatedTranscript.map(l => `[${l.time}] ${l.speaker}: ${l.text}`).join("\n");
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
@ -415,7 +674,7 @@ async function endMeetingAndGenerateMoM() {
const btn = document.querySelector('.btn-end-call'); const btn = document.querySelector('.btn-end-call');
if (btn) { if (btn) {
btn.disabled = true; btn.disabled = true;
btn.innerText = 'Generating AI MoM & Dispatching Emails...'; btn.innerText = 'Generating AI MoM & Saving Notes...';
} }
try { try {
@ -429,7 +688,8 @@ async function endMeetingAndGenerateMoM() {
body: JSON.stringify({ body: JSON.stringify({
transcript: transcriptText, transcript: transcriptText,
live_notes: liveNotes, live_notes: liveNotes,
emotion_timeline: emotionTimeline emotion_timeline: emotionTimeline,
recording_path: recordedVideoPath
}) })
}); });
@ -440,20 +700,23 @@ async function endMeetingAndGenerateMoM() {
window.location.href = config.redirectUrl; window.location.href = config.redirectUrl;
} }
} catch (e) { } catch (e) {
alert('Meeting ended. Redirecting to Client Hub...'); alert('Meeting completed. Redirecting to Client Hub...');
window.location.href = config.redirectUrl; window.location.href = config.redirectUrl;
} }
} }
function leaveCall() { function leaveCall() {
if (confirm('Leave this meeting?')) { if (confirm('Leave this meeting?')) {
if (isRecording) {
stopRecording();
}
window.location.href = config.redirectUrl || '/'; window.location.href = config.redirectUrl || '/';
} }
} }
function escapeHtml(str) { function escapeHtml(str) {
if (!str) return ''; if (!str) return '';
return str.replace(/[&<>"']/g, function(m) { return String(str).replace(/[&<>"']/g, function(m) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[m]; return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[m];
}); });
} }

View File

@ -118,6 +118,10 @@
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-muted); color: var(--text-muted);
} }
@media (max-width: 480px) {
.join-card { padding: 1.75rem 1.25rem; }
.meeting-title { font-size: 1.25rem; }
}
</style> </style>
</head> </head>
<body> <body>

View File

@ -3,11 +3,12 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Client Calls & Projects Hub | SingleLogin</title> <title>Client Calls & Projects Hub | SingleLogin</title>
<!-- Google Fonts: Outfit & Inter --> <!-- Google Fonts: Outfit & Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script> <script>
(function () { (function () {
@ -23,63 +24,72 @@
<style> <style>
:root { :root {
--bg-color: #0b0f19; --bg-color: #0b0f19;
--card-bg: rgba(17, 24, 39, 0.55); --card-bg: rgba(17, 24, 39, 0.6);
--card-border: rgba(255, 255, 255, 0.12); --card-border: rgba(255, 255, 255, 0.1);
--card-hover-border: rgba(99, 102, 241, 0.45);
--accent-primary: #4f46e5; --accent-primary: #4f46e5;
--accent-secondary: #06b6d4; --accent-secondary: #06b6d4;
--accent-glow: rgba(99, 102, 241, 0.25);
--text-main: #f3f4f6; --text-main: #f3f4f6;
--text-muted: #9ca3af; --text-muted: #9ca3af;
--success-color: #10b981; --success-color: #10b981;
--warning-color: #f59e0b; --warning-color: #f59e0b;
--danger-color: #ef4444; --danger-color: #ef4444;
--input-bg: rgba(255, 255, 255, 0.04); --input-bg: rgba(255, 255, 255, 0.05);
--modal-bg: #131b2e; --modal-bg: rgba(15, 23, 42, 0.95);
--modal-overlay: rgba(11, 15, 25, 0.85); --modal-overlay: rgba(11, 15, 25, 0.85);
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
} }
:root.light-mode { :root.light-mode {
--bg-color: #f3f4f6; --bg-color: #f0f4f8;
--card-bg: rgba(255, 255, 255, 0.85); --card-bg: rgba(255, 255, 255, 0.8);
--card-border: rgba(0, 0, 0, 0.08); --card-border: rgba(0, 0, 0, 0.08);
--card-hover-border: rgba(79, 70, 229, 0.4);
--accent-primary: #4f46e5; --accent-primary: #4f46e5;
--accent-secondary: #0284c7; --accent-secondary: #0284c7;
--text-main: #1f2937; --accent-glow: rgba(79, 70, 229, 0.15);
--text-muted: #4b5563; --text-main: #1e293b;
--text-muted: #64748b;
--success-color: #059669; --success-color: #059669;
--warning-color: #d97706; --warning-color: #d97706;
--danger-color: #dc2626; --danger-color: #dc2626;
--input-bg: rgba(0, 0, 0, 0.03); --input-bg: rgba(0, 0, 0, 0.03);
--modal-bg: #ffffff; --modal-bg: rgba(255, 255, 255, 0.96);
--modal-overlay: rgba(243, 244, 246, 0.85); --modal-overlay: rgba(240, 244, 248, 0.85);
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.08);
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
background-color: var(--bg-color); background-color: var(--bg-color);
color: var(--text-main); color: var(--text-main);
min-height: 100vh; min-height: 100vh;
background-image: background-image:
radial-gradient(circle at 85% 15%, rgba(99, 102, 241, 0.12) 0%, transparent 45%), radial-gradient(circle at 85% 15%, rgba(99, 102, 241, 0.15) 0%, transparent 45%),
radial-gradient(circle at 15% 85%, rgba(6, 182, 212, 0.1) 0%, transparent 45%); radial-gradient(circle at 15% 85%, rgba(6, 182, 212, 0.12) 0%, transparent 45%);
background-attachment: fixed; background-attachment: fixed;
transition: background-color 0.3s ease, color 0.3s ease;
} }
/* Navbar */ /* Glass Navbar */
.navbar { .navbar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1rem 2rem; padding: 1rem 2rem;
background: rgba(10, 15, 26, 0.6); background: rgba(11, 15, 25, 0.7);
backdrop-filter: blur(12px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid var(--card-border); border-bottom: 1px solid var(--card-border);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 50; z-index: 50;
} }
:root.light-mode .navbar { :root.light-mode .navbar {
background: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.82);
} }
.brand-logo { .brand-logo {
@ -96,62 +106,86 @@
background: linear-gradient(135deg, #4f46e5, #06b6d4); background: linear-gradient(135deg, #4f46e5, #06b6d4);
color: white; color: white;
font-size: 0.7rem; font-size: 0.7rem;
padding: 2px 8px; padding: 3px 10px;
border-radius: 999px; border-radius: 999px;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px;
} }
.nav-actions { .nav-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
flex-wrap: wrap;
} }
.nav-btn { .nav-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 0.5rem 1rem; padding: 0.55rem 1.1rem;
border-radius: 8px; border-radius: 10px;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
background: var(--card-bg); background: var(--card-bg);
color: var(--text-main); color: var(--text-main);
backdrop-filter: blur(8px);
} }
.nav-btn:hover { .nav-btn:hover {
transform: translateY(-1px); transform: translateY(-2px);
border-color: rgba(99, 102, 241, 0.5); border-color: var(--card-hover-border);
box-shadow: 0 4px 12px var(--accent-glow);
} }
.nav-btn-primary { .nav-btn-primary {
background: linear-gradient(135deg, #4f46e5, #6366f1); background: linear-gradient(135deg, #4f46e5, #6366f1);
color: white !important; color: white !important;
border: none; border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.35); box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4);
} }
.nav-btn-primary:hover { .nav-btn-primary:hover {
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.5); box-shadow: 0 6px 20px rgba(79, 70, 229, 0.55);
}
.theme-toggle-btn {
background: var(--card-bg);
border: 1px solid var(--card-border);
color: var(--text-main);
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1.1rem;
transition: all 0.25s ease;
backdrop-filter: blur(8px);
}
.theme-toggle-btn:hover {
border-color: var(--card-hover-border);
transform: translateY(-2px);
} }
/* Container */ /* Container */
.main-container { .main-container {
max-width: 1300px; max-width: 1320px;
margin: 2rem auto; margin: 2rem auto;
padding: 0 1.5rem; padding: 0 1.5rem;
} }
/* Alert Banners */ /* Alert */
.alert { .alert {
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
border-radius: 10px; border-radius: 12px;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
font-size: 0.9rem; font-size: 0.9rem;
backdrop-filter: blur(12px);
} }
.alert-success { .alert-success {
background: rgba(16, 185, 129, 0.15); background: rgba(16, 185, 129, 0.15);
@ -162,49 +196,56 @@
/* Stats Grid */ /* Stats Grid */
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1.25rem; gap: 1.25rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.stat-card { .stat-card {
background: var(--card-bg); background: var(--card-bg);
backdrop-filter: blur(12px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 14px; border-radius: 16px;
padding: 1.25rem; padding: 1.35rem 1.5rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1.2rem;
transition: all 0.2s ease; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--glass-shadow);
} }
.stat-card:hover { .stat-card:hover {
border-color: rgba(99, 102, 241, 0.4); border-color: var(--card-hover-border);
transform: translateY(-2px); transform: translateY(-3px);
box-shadow: 0 12px 28px var(--accent-glow);
} }
.stat-icon { .stat-icon {
width: 48px; width: 52px;
height: 48px; height: 52px;
border-radius: 12px; border-radius: 14px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 1.5rem; font-size: 1.6rem;
background: rgba(99, 102, 241, 0.12); background: rgba(99, 102, 241, 0.12);
color: #818cf8; color: #818cf8;
border: 1px solid rgba(99, 102, 241, 0.2);
} }
.stat-icon.danger { .stat-icon.cyan {
background: rgba(239, 68, 68, 0.15); background: rgba(6, 182, 212, 0.12);
color: #f87171; color: #22d3ee;
border-color: rgba(6, 182, 212, 0.2);
} }
.stat-icon.success { .stat-icon.emerald {
background: rgba(16, 185, 129, 0.15); background: rgba(16, 185, 129, 0.12);
color: #34d399; color: #34d399;
border-color: rgba(16, 185, 129, 0.2);
} }
.stat-val { .stat-val {
font-size: 1.6rem; font-size: 1.75rem;
font-weight: 800; font-weight: 800;
font-family: 'Outfit', sans-serif; font-family: 'Outfit', sans-serif;
color: var(--text-main); color: var(--text-main);
line-height: 1.2;
} }
.stat-label { .stat-label {
font-size: 0.8rem; font-size: 0.8rem;
@ -212,6 +253,7 @@
text-transform: uppercase; text-transform: uppercase;
font-weight: 600; font-weight: 600;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin-top: 2px;
} }
/* Section Header */ /* Section Header */
@ -225,12 +267,12 @@
} }
.section-title { .section-title {
font-family: 'Outfit', sans-serif; font-family: 'Outfit', sans-serif;
font-size: 1.5rem; font-size: 1.6rem;
font-weight: 700; font-weight: 700;
color: var(--text-main); color: var(--text-main);
} }
.section-desc { .section-desc {
font-size: 0.875rem; font-size: 0.9rem;
color: var(--text-muted); color: var(--text-muted);
margin-top: 4px; margin-top: 4px;
} }
@ -239,32 +281,37 @@
.filter-bar { .filter-bar {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
margin-bottom: 1.5rem; margin-bottom: 2rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.search-input { .search-box {
position: relative;
flex: 1; flex: 1;
min-width: 250px; min-width: 280px;
padding: 0.65rem 1rem; }
.search-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
}
.search-input {
width: 100%;
padding: 0.75rem 1rem 0.75rem 2.6rem;
background: var(--input-bg); background: var(--input-bg);
backdrop-filter: blur(8px);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 8px; border-radius: 12px;
color: var(--text-main); color: var(--text-main);
font-size: 0.875rem; font-size: 0.9rem;
outline: none; outline: none;
transition: all 0.2s ease;
} }
.search-input:focus { .search-input:focus {
border-color: #6366f1; border-color: #6366f1;
} box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
.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 */ /* Client Cards Grid */
@ -273,23 +320,34 @@
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 1.5rem; gap: 1.5rem;
} }
@media (max-width: 640px) {
.clients-grid {
grid-template-columns: 1fr;
}
.navbar {
padding: 1rem;
}
}
.client-card { .client-card {
background: var(--card-bg); background: var(--card-bg);
backdrop-filter: blur(12px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 16px; border-radius: 18px;
padding: 1.5rem; padding: 1.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
transition: all 0.25s ease; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative; position: relative;
overflow: hidden; box-shadow: var(--glass-shadow);
} }
.client-card:hover { .client-card:hover {
border-color: rgba(99, 102, 241, 0.4); border-color: var(--card-hover-border);
transform: translateY(-3px); transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.25); box-shadow: 0 16px 36px var(--accent-glow);
} }
.client-card-header { .client-card-header {
@ -300,64 +358,30 @@
} }
.project-title { .project-title {
font-family: 'Outfit', sans-serif; font-family: 'Outfit', sans-serif;
font-size: 1.2rem; font-size: 1.25rem;
font-weight: 700; font-weight: 700;
color: var(--text-main); color: var(--text-main);
margin-bottom: 4px; margin-bottom: 4px;
line-height: 1.3;
} }
.client-company { .client-company {
font-size: 0.85rem; font-size: 0.875rem;
color: var(--text-muted); color: var(--text-muted);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; 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 { .client-info-list {
margin: 1rem 0; margin: 1.25rem 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 10px;
font-size: 0.85rem; font-size: 0.875rem;
background: var(--input-bg);
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--card-border);
} }
.info-row { .info-row {
display: flex; display: flex;
@ -367,76 +391,48 @@
} }
.info-row span:last-child { .info-row span:last-child {
color: var(--text-main); color: var(--text-main);
font-weight: 500; font-weight: 600;
} }
.responsible-badge { .responsible-badge {
background: rgba(99, 102, 241, 0.15); background: rgba(99, 102, 241, 0.15);
color: #818cf8; color: #818cf8;
padding: 3px 8px; padding: 3px 10px;
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; border-radius: 8px;
padding: 10px 12px; font-size: 0.78rem;
margin: 12px 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.angry-alert-text {
font-size: 0.8rem;
color: #fca5a5;
font-weight: 600; font-weight: 600;
} border: 1px solid rgba(99, 102, 241, 0.3);
.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 { .card-actions {
display: flex; display: flex;
gap: 8px; gap: 10px;
margin-top: 1rem; margin-top: 1.25rem;
padding-top: 1rem; padding-top: 1.25rem;
border-top: 1px solid var(--card-border); border-top: 1px solid var(--card-border);
} }
.btn-hub { .btn-open-hub {
flex: 1; flex: 1;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 6px; gap: 8px;
padding: 0.6rem 1rem; padding: 0.7rem 1.2rem;
border-radius: 8px; border-radius: 10px;
font-size: 0.875rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
background: rgba(99, 102, 241, 0.15); background: linear-gradient(135deg, rgba(99, 102, 241, 0.15), rgba(6, 182, 212, 0.15));
color: #818cf8; color: #818cf8;
border: 1px solid rgba(99, 102, 241, 0.3); border: 1px solid rgba(99, 102, 241, 0.3);
transition: all 0.2s ease; transition: all 0.25s ease;
} }
.btn-hub:hover { .btn-open-hub:hover {
background: #4f46e5; background: linear-gradient(135deg, #4f46e5, #06b6d4);
color: white; color: white;
border-color: #4f46e5; border-color: transparent;
box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4);
transform: translateY(-1px);
} }
/* Modal styling */ /* Modal styling */
@ -444,22 +440,25 @@
position: fixed; position: fixed;
top: 0; left: 0; right: 0; bottom: 0; top: 0; left: 0; right: 0; bottom: 0;
background: var(--modal-overlay); background: var(--modal-overlay);
backdrop-filter: blur(6px); backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: none; display: none;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
z-index: 100; z-index: 100;
padding: 1rem; padding: 1.5rem;
} }
.modal-card { .modal-card {
background: var(--modal-bg); background: var(--modal-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 16px; border-radius: 20px;
width: 100%; width: 100%;
max-width: 600px; max-width: 620px;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5); box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5);
padding: 2rem; padding: 2rem;
} }
.modal-header { .modal-header {
@ -470,7 +469,7 @@
} }
.modal-title { .modal-title {
font-family: 'Outfit', sans-serif; font-family: 'Outfit', sans-serif;
font-size: 1.35rem; font-size: 1.4rem;
font-weight: 700; font-weight: 700;
} }
.modal-close { .modal-close {
@ -479,8 +478,18 @@
color: var(--text-muted); color: var(--text-muted);
font-size: 1.5rem; font-size: 1.5rem;
cursor: pointer; cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all 0.2s ease;
}
.modal-close:hover {
color: var(--text-main);
background: var(--input-bg);
} }
.modal-close:hover { color: var(--text-main); }
.form-group { .form-group {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
@ -494,16 +503,18 @@
} }
.form-control { .form-control {
width: 100%; width: 100%;
padding: 0.7rem 1rem; padding: 0.75rem 1rem;
background: var(--input-bg); background: var(--input-bg);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 8px; border-radius: 10px;
color: var(--text-main); color: var(--text-main);
font-size: 0.9rem; font-size: 0.9rem;
outline: none; outline: none;
transition: all 0.2s ease;
} }
.form-control:focus { .form-control:focus {
border-color: #6366f1; border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
} }
.form-row { .form-row {
display: grid; display: grid;
@ -519,14 +530,14 @@
overflow-y: auto; overflow-y: auto;
background: var(--input-bg); background: var(--input-bg);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 8px; border-radius: 10px;
padding: 10px; padding: 10px;
} }
.member-checkbox-label { .member-checkbox-label {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
font-size: 0.8rem; font-size: 0.82rem;
cursor: pointer; cursor: pointer;
color: var(--text-main); color: var(--text-main);
} }
@ -535,27 +546,43 @@
text-align: center; text-align: center;
padding: 4rem 2rem; padding: 4rem 2rem;
background: var(--card-bg); background: var(--card-bg);
backdrop-filter: blur(16px);
border: 1px dashed var(--card-border); border: 1px dashed var(--card-border);
border-radius: 16px; border-radius: 20px;
}
/* Responsive Breakpoints */
@media (max-width: 900px) {
.navbar { padding: 0.85rem 1.25rem; }
.main-container { margin: 1.25rem auto; padding: 0 1rem; }
.form-row { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.navbar { flex-direction: column; align-items: flex-start; gap: 12px; }
.nav-actions { width: 100%; justify-content: space-between; }
.section-header { flex-direction: column; align-items: flex-start; }
.filter-bar { flex-direction: column; }
.search-input, .filter-select { width: 100%; min-width: 100%; }
.modal-card { padding: 1.25rem; }
} }
</style> </style>
</head> </head>
<body> <body>
<!-- Navbar --> <!-- Glass Navbar -->
<nav class="navbar"> <nav class="navbar">
<a href="{{ route('dashboard') }}" class="brand-logo"> <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;"> <svg width="26" height="26" 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"/> <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> </svg>
SingleLogin <span class="brand-badge">Client Calls</span> SingleLogin <span class="brand-badge">Client Calls</span>
</a> </a>
<div class="nav-actions"> <div class="nav-actions">
<!-- Theme Toggle --> <!-- 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 class="theme-toggle-btn" id="themeToggleBtn" onclick="toggleTheme()" title="Switch Light / Dark Theme">
🌙 🌙
</button> </button>
<a href="{{ route('client-calls.logs') }}" class="nav-btn" style="border-color: #22d3ee; color: #22d3ee;"> <a href="{{ route('client-calls.logs') }}" class="nav-btn" style="border-color: rgba(6, 182, 212, 0.4); 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> <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 Date-wise Call Logs
</a> </a>
@ -564,7 +591,7 @@
Add Client & Project Add Client & Project
</button> </button>
<a href="{{ route('dashboard') }}" class="nav-btn"> <a href="{{ route('dashboard') }}" class="nav-btn">
Back to Dashboard Dashboard
</a> </a>
</div> </div>
</nav> </nav>
@ -575,7 +602,7 @@
@if(session('success')) @if(session('success'))
<div class="alert alert-success"> <div class="alert alert-success">
<span>{{ session('success') }}</span> <span>{{ session('success') }}</span>
<button style="background:transparent;border:none;color:inherit;cursor:pointer;" onclick="this.parentElement.remove()"></button> <button style="background:transparent;border:none;color:inherit;cursor:pointer;font-size:1.1rem;" onclick="this.parentElement.remove()"></button>
</div> </div>
@endif @endif
@ -585,25 +612,21 @@
<div class="stat-icon">📁</div> <div class="stat-icon">📁</div>
<div> <div>
<div class="stat-val">{{ $totalClients }}</div> <div class="stat-val">{{ $totalClients }}</div>
<div class="stat-label">Active Client Projects</div> <div class="stat-label">Total Client Accounts</div>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon success">📞</div> <div class="stat-icon emerald">📞</div>
<div> <div>
<div class="stat-val">{{ $totalMeetings }}</div> <div class="stat-val">{{ $totalMeetings }}</div>
<div class="stat-label">Total Meetings Held</div> <div class="stat-label">Total Meetings Logged</div>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon {{ $angryClientsCount > 0 ? 'danger' : '' }}"> <div class="stat-icon cyan">🚀</div>
{{ $angryClientsCount > 0 ? '⚠️' : '✨' }}
</div>
<div> <div>
<div class="stat-val" style="{{ $angryClientsCount > 0 ? 'color: #f87171;' : '' }}"> <div class="stat-val">{{ $activeProjectsCount ?? $totalClients }}</div>
{{ $angryClientsCount }} <div class="stat-label">Active Projects</div>
</div>
<div class="stat-label">Client Attention Alerts</div>
</div> </div>
</div> </div>
</div> </div>
@ -612,28 +635,24 @@
<div class="section-header"> <div class="section-header">
<div> <div>
<h1 class="section-title">Client Projects & Meeting Hub</h1> <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> <p class="section-desc">Select any client project to inspect date-wise previous call details, AI meeting transcripts, expressions, and actionable tasks.</p>
</div> </div>
</div> </div>
<!-- Filter Bar --> <!-- Search Bar -->
<div class="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()"> <div class="search-box">
<select id="sentimentFilter" class="filter-select" onchange="filterCards()"> <svg class="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<option value="all">All Sentiments</option> <input type="text" id="searchInput" class="search-input" placeholder="Search by client name, project, company, or lead..." onkeyup="filterCards()">
<option value="happy">Happy 😊</option> </div>
<option value="neutral">Neutral 😐</option>
<option value="sad">Sad / Concerned 😟</option>
<option value="angry">Angry / Frustrated 😡</option>
</select>
</div> </div>
<!-- Clients Grid --> <!-- Clients Grid -->
@if($clients->isEmpty()) @if($clients->isEmpty())
<div class="empty-state"> <div class="empty-state">
<div style="font-size: 3rem; margin-bottom: 1rem;">💼</div> <div style="font-size: 3.5rem; margin-bottom: 1rem;">💼</div>
<h3 style="font-size: 1.25rem; font-weight: 700; margin-bottom: 6px;">No Client Projects Found</h3> <h3 style="font-size: 1.3rem; 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> <p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 1.5rem;">Get started by registering your first client and project profile.</p>
<button class="nav-btn-primary nav-btn" onclick="openAddClientModal()"> <button class="nav-btn-primary nav-btn" onclick="openAddClientModal()">
+ Add New Client & Project + Add New Client & Project
</button> </button>
@ -642,31 +661,15 @@
<div class="clients-grid" id="clientsGrid"> <div class="clients-grid" id="clientsGrid">
@foreach($clients as $client) @foreach($clients as $client)
@php @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(); $meetingsCount = $client->meetings->count();
$latestNote = $client->callNotes->first(); $latestNote = $client->callNotes->first();
$canViewAngry = $client->canViewAngryAnalysis(Auth::user());
@endphp @endphp
<div class="client-card" <div class="client-card"
data-name="{{ strtolower($client->client_name) }}" data-name="{{ strtolower($client->client_name) }}"
data-project="{{ strtolower($client->project_name) }}" data-project="{{ strtolower($client->project_name) }}"
data-company="{{ strtolower($client->company_name ?? '') }}" data-company="{{ strtolower($client->company_name ?? '') }}"
data-lead="{{ strtolower($client->responsibleUser->name ?? '') }}" data-lead="{{ strtolower($client->responsibleUser->name ?? '') }}">
data-sentiment="{{ $sentiment }}">
<div> <div>
<div class="client-card-header"> <div class="client-card-header">
@ -677,9 +680,6 @@
{{ $client->client_name }} {{ $client->company_name ? "" . $client->company_name : "" }} {{ $client->client_name }} {{ $client->company_name ? "" . $client->company_name : "" }}
</div> </div>
</div> </div>
<span class="sentiment-badge {{ $badgeClass }}">
{{ $sentimentEmoji }}
</span>
</div> </div>
<div class="client-info-list"> <div class="client-info-list">
@ -692,8 +692,8 @@
<span class="responsible-badge">{{ $client->responsibleUser->name ?? 'Unassigned' }}</span> <span class="responsible-badge">{{ $client->responsibleUser->name ?? 'Unassigned' }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span>Total Meetings:</span> <span>Total Calls:</span>
<span>{{ $meetingsCount }} calls</span> <span>{{ $meetingsCount }} {{ $meetingsCount === 1 ? 'call' : 'calls' }}</span>
</div> </div>
@if($latestNote) @if($latestNote)
<div class="info-row"> <div class="info-row">
@ -702,22 +702,12 @@
</div> </div>
@endif @endif
</div> </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>
<div class="card-actions"> <div class="card-actions">
<a href="{{ route('client-calls.show', $client->id) }}" target="_blank" class="btn-hub" title="Open Client Hub in new tab"> <a href="{{ route('client-calls.show', $client->id) }}" target="_blank" class="btn-open-hub" title="Open Client Hub in new tab">
<span>Open Client Hub</span> <span>Open Client Hub & Call Details</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> <svg width="15" height="15" 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> </a>
</div> </div>
</div> </div>
@ -799,31 +789,6 @@
</div> </div>
</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 &bull; Restricted to Admin and Assigned Lead</p>
</div>
<button class="modal-close" onclick="closeWhyAngryModal()">&times;</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> <script>
function openAddClientModal() { function openAddClientModal() {
document.getElementById('addClientModal').style.display = 'flex'; document.getElementById('addClientModal').style.display = 'flex';
@ -834,7 +799,6 @@ function closeAddClientModal() {
function filterCards() { function filterCards() {
const query = document.getElementById('searchInput').value.toLowerCase(); const query = document.getElementById('searchInput').value.toLowerCase();
const sentiment = document.getElementById('sentimentFilter').value;
const cards = document.querySelectorAll('.client-card'); const cards = document.querySelectorAll('.client-card');
cards.forEach(card => { cards.forEach(card => {
@ -842,12 +806,10 @@ function filterCards() {
const project = card.dataset.project || ''; const project = card.dataset.project || '';
const company = card.dataset.company || ''; const company = card.dataset.company || '';
const lead = card.dataset.lead || ''; 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 matchesQuery = name.includes(query) || project.includes(query) || company.includes(query) || lead.includes(query);
const matchesSentiment = (sentiment === 'all') || (cardSentiment === sentiment);
if (matchesQuery && matchesSentiment) { if (matchesQuery) {
card.style.display = 'flex'; card.style.display = 'flex';
} else { } else {
card.style.display = 'none'; card.style.display = 'none';
@ -855,68 +817,6 @@ function filterCards() {
}); });
} }
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} &bull; ${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() { function toggleTheme() {
const isLight = document.documentElement.classList.toggle('light-mode'); const isLight = document.documentElement.classList.toggle('light-mode');
localStorage.setItem('theme', isLight ? 'light' : 'dark'); localStorage.setItem('theme', isLight ? 'light' : 'dark');
@ -928,10 +828,6 @@ function toggleTheme() {
const btn = document.getElementById('themeToggleBtn'); const btn = document.getElementById('themeToggleBtn');
if (btn) btn.innerText = isLight ? '☀️' : '🌙'; if (btn) btn.innerText = isLight ? '☀️' : '🌙';
}); });
function closeWhyAngryModal() {
document.getElementById('whyAngryModal').style.display = 'none';
}
</script> </script>
</body> </body>
</html> </html>

View File

@ -8,7 +8,7 @@
<!-- Google Fonts: Outfit & Inter --> <!-- Google Fonts: Outfit & Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script> <script>
(function () { (function () {
@ -24,34 +24,40 @@
<style> <style>
:root { :root {
--bg-color: #0b0f19; --bg-color: #0b0f19;
--card-bg: rgba(17, 24, 39, 0.55); --card-bg: rgba(17, 24, 39, 0.6);
--card-border: rgba(255, 255, 255, 0.12); --card-border: rgba(255, 255, 255, 0.1);
--card-hover-border: rgba(99, 102, 241, 0.45);
--accent-primary: #4f46e5; --accent-primary: #4f46e5;
--accent-secondary: #06b6d4; --accent-secondary: #06b6d4;
--accent-glow: rgba(99, 102, 241, 0.25);
--text-main: #f3f4f6; --text-main: #f3f4f6;
--text-muted: #9ca3af; --text-muted: #9ca3af;
--success-color: #10b981; --success-color: #10b981;
--warning-color: #f59e0b; --warning-color: #f59e0b;
--danger-color: #ef4444; --danger-color: #ef4444;
--input-bg: rgba(255, 255, 255, 0.04); --input-bg: rgba(255, 255, 255, 0.05);
--modal-bg: #131b2e; --modal-bg: rgba(15, 23, 42, 0.96);
--modal-overlay: rgba(11, 15, 25, 0.85); --modal-overlay: rgba(11, 15, 25, 0.85);
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
} }
:root.light-mode { :root.light-mode {
--bg-color: #f3f4f6; --bg-color: #f0f4f8;
--card-bg: rgba(255, 255, 255, 0.85); --card-bg: rgba(255, 255, 255, 0.82);
--card-border: rgba(0, 0, 0, 0.08); --card-border: rgba(0, 0, 0, 0.08);
--card-hover-border: rgba(79, 70, 229, 0.4);
--accent-primary: #4f46e5; --accent-primary: #4f46e5;
--accent-secondary: #0284c7; --accent-secondary: #0284c7;
--text-main: #1f2937; --accent-glow: rgba(79, 70, 229, 0.15);
--text-muted: #4b5563; --text-main: #1e293b;
--text-muted: #64748b;
--success-color: #059669; --success-color: #059669;
--warning-color: #d97706; --warning-color: #d97706;
--danger-color: #dc2626; --danger-color: #dc2626;
--input-bg: rgba(0, 0, 0, 0.03); --input-bg: rgba(0, 0, 0, 0.03);
--modal-bg: #ffffff; --modal-bg: rgba(255, 255, 255, 0.96);
--modal-overlay: rgba(243, 244, 246, 0.85); --modal-overlay: rgba(240, 244, 248, 0.85);
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.08);
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
@ -61,8 +67,8 @@
color: var(--text-main); color: var(--text-main);
min-height: 100vh; min-height: 100vh;
background-image: background-image:
radial-gradient(circle at 85% 15%, rgba(99, 102, 241, 0.12) 0%, transparent 45%), radial-gradient(circle at 85% 15%, rgba(99, 102, 241, 0.15) 0%, transparent 45%),
radial-gradient(circle at 15% 85%, rgba(6, 182, 212, 0.1) 0%, transparent 45%); radial-gradient(circle at 15% 85%, rgba(6, 182, 212, 0.12) 0%, transparent 45%);
background-attachment: fixed; background-attachment: fixed;
transition: background-color 0.3s ease, color 0.3s ease; transition: background-color 0.3s ease, color 0.3s ease;
} }
@ -73,14 +79,15 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1rem 2rem; padding: 1rem 2rem;
background: rgba(10, 15, 26, 0.6); background: rgba(11, 15, 25, 0.7);
backdrop-filter: blur(12px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid var(--card-border); border-bottom: 1px solid var(--card-border);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 50; z-index: 50;
} }
:root.light-mode .navbar { background: rgba(255, 255, 255, 0.8); } :root.light-mode .navbar { background: rgba(255, 255, 255, 0.82); }
.brand-logo { .brand-logo {
display: flex; display: flex;
@ -95,8 +102,8 @@
.brand-badge { .brand-badge {
background: linear-gradient(135deg, #06b6d4, #3b82f6); background: linear-gradient(135deg, #06b6d4, #3b82f6);
color: white; color: white;
font-size: 0.7rem; font-size: 0.75rem;
padding: 2px 8px; padding: 3px 10px;
border-radius: 999px; border-radius: 999px;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
@ -106,44 +113,53 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
flex-wrap: wrap;
} }
.nav-btn { .nav-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 0.5rem 1rem; padding: 0.55rem 1.1rem;
border-radius: 8px; border-radius: 10px;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
background: var(--card-bg); background: var(--card-bg);
color: var(--text-main); color: var(--text-main);
backdrop-filter: blur(8px);
} }
.nav-btn:hover { .nav-btn:hover {
transform: translateY(-1px); transform: translateY(-2px);
border-color: rgba(99, 102, 241, 0.5); border-color: var(--card-hover-border);
box-shadow: 0 4px 12px var(--accent-glow);
}
.nav-btn-primary {
background: linear-gradient(135deg, #4f46e5, #6366f1);
color: white !important;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4);
} }
.theme-toggle-btn { .theme-toggle-btn {
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
color: var(--text-main); color: var(--text-main);
width: 38px; width: 40px;
height: 38px; height: 40px;
border-radius: 8px; border-radius: 10px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1.1rem;
transition: all 0.2s ease; transition: all 0.25s ease;
} }
/* Container */ /* Container */
.main-container { .main-container {
max-width: 1300px; max-width: 1320px;
margin: 2rem auto; margin: 2rem auto;
padding: 0 1.5rem; padding: 0 1.5rem;
} }
@ -157,31 +173,34 @@
} }
.stat-card { .stat-card {
background: var(--card-bg); background: var(--card-bg);
backdrop-filter: blur(12px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 14px; border-radius: 16px;
padding: 1.25rem; padding: 1.25rem 1.4rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
transition: all 0.2s ease; transition: all 0.3s ease;
box-shadow: var(--glass-shadow);
} }
.stat-card:hover { .stat-card:hover {
border-color: rgba(99, 102, 241, 0.4); border-color: var(--card-hover-border);
transform: translateY(-2px); transform: translateY(-3px);
box-shadow: 0 10px 24px var(--accent-glow);
} }
.stat-icon { .stat-icon {
width: 44px; width: 48px;
height: 44px; height: 48px;
border-radius: 12px; border-radius: 14px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 1.3rem; font-size: 1.4rem;
background: rgba(99, 102, 241, 0.12); background: rgba(99, 102, 241, 0.12);
} }
.stat-val { .stat-val {
font-size: 1.5rem; font-size: 1.6rem;
font-weight: 800; font-weight: 800;
font-family: 'Outfit', sans-serif; font-family: 'Outfit', sans-serif;
color: var(--text-main); color: var(--text-main);
@ -191,6 +210,7 @@
color: var(--text-muted); color: var(--text-muted);
text-transform: uppercase; text-transform: uppercase;
font-weight: 600; font-weight: 600;
letter-spacing: 0.5px;
} }
/* Section Header */ /* Section Header */
@ -204,7 +224,7 @@
} }
.section-title { .section-title {
font-family: 'Outfit', sans-serif; font-family: 'Outfit', sans-serif;
font-size: 1.5rem; font-size: 1.6rem;
font-weight: 700; font-weight: 700;
color: var(--text-main); color: var(--text-main);
} }
@ -219,27 +239,32 @@
.search-input { .search-input {
flex: 1; flex: 1;
min-width: 250px; min-width: 250px;
padding: 0.65rem 1rem; padding: 0.75rem 1rem;
background: var(--input-bg); background: var(--input-bg);
backdrop-filter: blur(8px);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 8px; border-radius: 12px;
color: var(--text-main); color: var(--text-main);
font-size: 0.875rem; font-size: 0.875rem;
outline: none; outline: none;
} }
.search-input:focus { border-color: #6366f1; } .search-input:focus {
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
.filter-select { .filter-select {
padding: 0.65rem 1rem; padding: 0.75rem 1rem;
background: var(--input-bg); background: var(--input-bg);
backdrop-filter: blur(8px);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 8px; border-radius: 12px;
color: var(--text-main); color: var(--text-main);
font-size: 0.875rem; font-size: 0.875rem;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
} }
/* Date Group Header */ /* Date Group */
.date-group { .date-group {
margin-bottom: 2.5rem; margin-bottom: 2.5rem;
} }
@ -249,19 +274,18 @@
gap: 12px; gap: 12px;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.date-title { .date-title-badge {
font-family: 'Outfit', sans-serif; background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(6, 182, 212, 0.2));
font-size: 1.15rem;
font-weight: 700;
color: var(--text-main);
}
.date-badge {
background: rgba(99, 102, 241, 0.15);
color: #818cf8; color: #818cf8;
font-size: 0.75rem; font-family: 'Outfit', sans-serif;
font-size: 1.1rem;
font-weight: 700; font-weight: 700;
padding: 2px 10px; padding: 4px 14px;
border-radius: 999px; border-radius: 999px;
border: 1px solid rgba(99, 102, 241, 0.3);
display: flex;
align-items: center;
gap: 8px;
} }
.date-line { .date-line {
flex: 1; flex: 1;
@ -272,20 +296,22 @@
/* Call Log Entry Card */ /* Call Log Entry Card */
.log-entry-card { .log-entry-card {
background: var(--card-bg); background: var(--card-bg);
backdrop-filter: blur(12px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 16px; border-radius: 18px;
padding: 1.5rem; padding: 1.6rem;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
transition: all 0.25s ease; transition: all 0.25s ease;
box-shadow: var(--glass-shadow);
} }
.log-entry-card:hover { .log-entry-card:hover {
border-color: rgba(99, 102, 241, 0.4); border-color: var(--card-hover-border);
transform: translateY(-2px); transform: translateY(-3px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.25); box-shadow: 0 12px 30px var(--accent-glow);
} }
.log-card-top { .log-card-top {
@ -297,12 +323,22 @@
} }
.log-project-title { .log-project-title {
font-family: 'Outfit', sans-serif; font-family: 'Outfit', sans-serif;
font-size: 1.2rem; font-size: 1.25rem;
font-weight: 700; font-weight: 700;
color: var(--text-main); color: var(--text-main);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
flex-wrap: wrap;
}
.project-tag {
font-size: 0.78rem;
font-weight: 600;
color: #818cf8;
background: rgba(99, 102, 241, 0.15);
padding: 2px 10px;
border-radius: 6px;
border: 1px solid rgba(99, 102, 241, 0.3);
} }
.log-client-meta { .log-client-meta {
font-size: 0.85rem; font-size: 0.85rem;
@ -314,21 +350,21 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
/* Interactive Clickable Emotion Badges */ /* Clickable Emotion Badges */
.emotion-clickable { .emotion-clickable {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 5px 12px; padding: 6px 14px;
border-radius: 999px; border-radius: 999px;
font-size: 0.8rem; font-size: 0.82rem;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
text-transform: uppercase; text-transform: uppercase;
} }
.emotion-clickable:hover { .emotion-clickable:hover {
transform: scale(1.05); transform: scale(1.04);
} }
.emotion-happy { .emotion-happy {
background: rgba(16, 185, 129, 0.18); background: rgba(16, 185, 129, 0.18);
@ -358,11 +394,11 @@
} }
.summary-preview-box { .summary-preview-box {
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.03);
border-left: 3px solid #6366f1; border-left: 3px solid #6366f1;
padding: 10px 14px; padding: 12px 14px;
border-radius: 0 6px 6px 0; border-radius: 0 8px 8px 0;
font-size: 0.875rem; font-size: 0.9rem;
color: #cbd5e1; color: #cbd5e1;
line-height: 1.5; line-height: 1.5;
} }
@ -371,7 +407,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding-top: 10px; padding-top: 12px;
border-top: 1px solid var(--card-border); border-top: 1px solid var(--card-border);
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 10px;
@ -381,9 +417,9 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 5px 12px; padding: 6px 14px;
border-radius: 6px; border-radius: 8px;
font-size: 0.78rem; font-size: 0.82rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
@ -408,38 +444,41 @@
border-color: rgba(16, 185, 129, 0.35); border-color: rgba(16, 185, 129, 0.35);
} }
/* Modal styling */ /* Modals */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
top: 0; left: 0; right: 0; bottom: 0; top: 0; left: 0; right: 0; bottom: 0;
background: var(--modal-overlay); background: var(--modal-overlay);
backdrop-filter: blur(6px); backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: none; display: none;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
z-index: 100; z-index: 100;
padding: 1rem; padding: 1.5rem;
} }
.modal-card { .modal-card {
background: var(--modal-bg); background: var(--modal-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 16px; border-radius: 20px;
width: 100%; width: 100%;
max-width: 650px; max-width: 660px;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5); box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5);
padding: 2rem; padding: 2rem;
} }
.modal-header { .modal-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 1.25rem; margin-bottom: 1.5rem;
} }
.modal-title { .modal-title {
font-family: 'Outfit', sans-serif; font-family: 'Outfit', sans-serif;
font-size: 1.3rem; font-size: 1.35rem;
font-weight: 700; font-weight: 700;
} }
.modal-close { .modal-close {
@ -448,36 +487,41 @@
color: var(--text-muted); color: var(--text-muted);
font-size: 1.5rem; font-size: 1.5rem;
cursor: pointer; cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
} }
.modal-close:hover { color: var(--text-main); background: var(--input-bg); }
/* To-Do Checklist Styling */ /* To-Do Checklist */
.todo-item-row { .todo-item-row {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: 12px;
padding: 10px 12px; padding: 10px 14px;
background: var(--input-bg); background: var(--input-bg);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 8px; border-radius: 10px;
margin-bottom: 8px; margin-bottom: 8px;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.todo-item-row.completed { .todo-item-row.completed {
opacity: 0.6; opacity: 0.55;
text-decoration: line-through; text-decoration: line-through;
} }
.todo-checkbox { .todo-checkbox {
margin-top: 3px; margin-top: 3px;
cursor: pointer; cursor: pointer;
width: 16px; width: 17px;
height: 16px; height: 17px;
accent-color: #10b981; accent-color: #10b981;
} }
.todo-content { .todo-content { flex: 1; }
flex: 1;
}
.todo-text { .todo-text {
font-size: 0.875rem; font-size: 0.9rem;
color: var(--text-main); color: var(--text-main);
font-weight: 500; font-weight: 500;
} }
@ -492,7 +536,7 @@
</head> </head>
<body> <body>
<!-- Navbar --> <!-- Glass Navbar -->
<nav class="navbar"> <nav class="navbar">
<a href="{{ route('client-calls.index') }}" class="brand-logo"> <a href="{{ route('client-calls.index') }}" class="brand-logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: #06b6d4;"><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> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: #06b6d4;"><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>
@ -549,7 +593,7 @@
<div class="stat-icon" style="color: #22d3ee;">📋</div> <div class="stat-icon" style="color: #22d3ee;">📋</div>
<div> <div>
<div class="stat-val">{{ $totalTodosCount }}</div> <div class="stat-val">{{ $totalTodosCount }}</div>
<div class="stat-label">Actionable To-Dos</div> <div class="stat-label">Actionable Tasks</div>
</div> </div>
</div> </div>
</div> </div>
@ -558,8 +602,8 @@
<div class="section-header"> <div class="section-header">
<div> <div>
<h1 class="section-title">Date-wise Client Call Logs</h1> <h1 class="section-title">Date-wise Client Call Logs</h1>
<p style="color: var(--text-muted); font-size: 0.875rem; margin-top: 4px;"> <p style="color: var(--text-muted); font-size: 0.9rem; margin-top: 4px;">
Review date-wise meeting transcripts, click client expressions to inspect emotional drivers, and export MoMs & To-Do lists in .txt format. Review date-wise meeting transcripts, inspect emotional sentiment drivers, and export MoMs & actionable tasks in .txt format.
</p> </p>
</div> </div>
</div> </div>
@ -584,7 +628,7 @@
<!-- Grouped Date-wise List --> <!-- Grouped Date-wise List -->
@if($groupedNotes->isEmpty()) @if($groupedNotes->isEmpty())
<div style="text-align: center; padding: 4rem 2rem; background: var(--card-bg); border-radius: 16px; border: 1px dashed var(--card-border);"> <div style="text-align: center; padding: 4rem 2rem; background: var(--card-bg); border-radius: 18px; border: 1px dashed var(--card-border);">
<div style="font-size: 3rem; margin-bottom: 1rem;">📞</div> <div style="font-size: 3rem; margin-bottom: 1rem;">📞</div>
<h3 style="font-size: 1.25rem; font-weight: 700; margin-bottom: 6px;">No Call Logs Recorded Yet</h3> <h3 style="font-size: 1.25rem; font-weight: 700; margin-bottom: 6px;">No Call Logs Recorded Yet</h3>
<p style="color: var(--text-muted); font-size: 0.9rem;">Once client calls are held, full AI transcription, MoM, and To-Do lists will be logged here date-wise.</p> <p style="color: var(--text-muted); font-size: 0.9rem;">Once client calls are held, full AI transcription, MoM, and To-Do lists will be logged here date-wise.</p>
@ -593,8 +637,12 @@
@foreach($groupedNotes as $dateKey => $notes) @foreach($groupedNotes as $dateKey => $notes)
<div class="date-group" data-date="{{ $dateKey }}"> <div class="date-group" data-date="{{ $dateKey }}">
<div class="date-header"> <div class="date-header">
<span class="date-title">📅 {{ \Carbon\Carbon::parse($dateKey)->format('F j, Y') }}</span> <div class="date-title-badge">
<span class="date-badge">{{ $notes->count() }} {{ $notes->count() === 1 ? 'call' : 'calls' }}</span> <span>📅</span> {{ \Carbon\Carbon::parse($dateKey)->format('F j, Y') }}
</div>
<span style="font-size: 0.8rem; color: var(--text-muted); font-weight: 600;">
{{ $notes->count() }} {{ $notes->count() === 1 ? 'call' : 'calls' }}
</span>
<div class="date-line"></div> <div class="date-line"></div>
</div> </div>
@ -614,6 +662,17 @@
default => '😐 Neutral', default => '😐 Neutral',
}; };
$todoCount = count($note->todo_items ?? $note->action_items ?? []); $todoCount = count($note->todo_items ?? $note->action_items ?? []);
$hasRec = !empty($note->recording_path) || !empty($note->recordings) || (!empty($note->meeting) && (!empty($note->meeting->recording_path) || !empty($note->meeting->recordings)));
$recPath = $note->recording_path ?? ($note->meeting->recording_path ?? null);
if (!$recPath && !empty($note->recordings)) {
$first = $note->recordings[0];
$recPath = is_array($first) ? ($first['url'] ?? $first['full_url'] ?? null) : $first;
}
if (!$recPath && !empty($note->meeting->recordings)) {
$first = $note->meeting->recordings[0];
$recPath = is_array($first) ? ($first['url'] ?? $first['full_url'] ?? null) : $first;
}
@endphp @endphp
<div class="log-entry-card" <div class="log-entry-card"
@ -626,9 +685,14 @@
<div> <div>
<div class="log-project-title"> <div class="log-project-title">
{{ $note->meeting->title ?? 'Client Call' }} {{ $note->meeting->title ?? 'Client Call' }}
<span style="font-size: 0.75rem; font-weight: 600; color: #818cf8; background: rgba(99,102,241,0.12); padding: 2px 8px; border-radius: 4px;"> <span class="project-tag">
{{ $note->client->project_name ?? 'Project' }} {{ $note->client->project_name ?? 'Project' }}
</span> </span>
@if($hasRec)
<span style="font-size: 0.72rem; font-weight: 700; background: rgba(99,102,241,0.2); color: #818cf8; padding: 2px 8px; border-radius: 999px; border: 1px solid rgba(99,102,241,0.3);">
🎥 Recorded
</span>
@endif
</div> </div>
<div class="log-client-meta"> <div class="log-client-meta">
<span>👤 <strong>{{ $note->client->client_name ?? 'Client' }}</strong> {{ $note->client->company_name ? "({$note->client->company_name})" : "" }}</span> <span>👤 <strong>{{ $note->client->client_name ?? 'Client' }}</strong> {{ $note->client->company_name ? "({$note->client->company_name})" : "" }}</span>
@ -640,7 +704,7 @@
<!-- Interactive Emotion / Expression Badge --> <!-- Interactive Emotion / Expression Badge -->
<button class="emotion-clickable {{ $badgeClass }}" onclick="openEmotionModal({{ $note->id }})" title="Click to view why the client felt this way"> <button class="emotion-clickable {{ $badgeClass }}" onclick="openEmotionModal({{ $note->id }})" title="Click to view why the client felt this way">
<span>{{ $sentimentEmoji }}</span> <span>{{ $sentimentEmoji }}</span>
<span style="font-size: 0.7rem; text-decoration: underline; margin-left: 2px;">Why? 🔍</span> <span style="font-size: 0.72rem; text-decoration: underline; margin-left: 4px;">Why? 🔍</span>
</button> </button>
</div> </div>
@ -652,23 +716,29 @@
<!-- Action Buttons Bar --> <!-- Action Buttons Bar -->
<div class="log-actions-bar"> <div class="log-actions-bar">
<div style="display: flex; gap: 8px; flex-wrap: wrap;"> <div style="display: flex; gap: 8px; flex-wrap: wrap;">
<!-- Download MoM -->
<a href="{{ route('client-calls.download-mom', $note->id) }}" class="btn-action-small btn-download-mom" title="Download formatted MoM in text format"> <a href="{{ route('client-calls.download-mom', $note->id) }}" class="btn-action-small btn-download-mom" title="Download formatted MoM in text format">
📥 Download MoM (.txt) 📥 Download MoM (.txt)
</a> </a>
<!-- Interactive To-Do List Button -->
<button class="btn-action-small btn-download-todos" onclick="openTodoModal({{ $note->id }}, '{{ addslashes($note->client->project_name ?? '') }}')"> <button class="btn-action-small btn-download-todos" onclick="openTodoModal({{ $note->id }}, '{{ addslashes($note->client->project_name ?? '') }}')">
📋 To-Do Tasks ({{ $todoCount }}) 📋 Tasks ({{ $todoCount }})
</button> </button>
<!-- Download To-Do .txt directly --> <a href="{{ route('client-calls.download-todos', $note->id) }}" class="btn-action-small" title="Direct download Tasks in text format">
<a href="{{ route('client-calls.download-todos', $note->id) }}" class="btn-action-small" title="Direct download To-Do List in text format"> 📄 Tasks (.txt)
📄 To-Do (.txt)
</a> </a>
@if($hasRec && $recPath)
<button class="btn-action-small" style="background: rgba(99,102,241,0.15); color: #818cf8; border-color: rgba(99,102,241,0.35);" onclick="openLogVideoModal('{{ asset(ltrim($recPath, '/')) }}', '{{ addslashes($note->meeting->title ?? 'Meeting Recording') }}')">
🎥 Play Video
</button>
<a href="{{ route('client-calls.download-note-recording', $note->id) }}" class="btn-action-small" style="color: #f472b6; border-color: rgba(244,114,182,0.4);" title="Download WebRTC call video recording">
📥 Video
</a>
@endif
</div> </div>
<div style="display: flex; gap: 8px;"> <div style="display: flex; gap: 8px; flex-wrap: wrap;">
<button class="btn-action-small" onclick="openFullMomModal({{ $note->id }})"> <button class="btn-action-small" onclick="openFullMomModal({{ $note->id }})">
View Full MoM View Full MoM
</button> </button>
@ -685,12 +755,12 @@
</main> </main>
<!-- 1. Interactive Emotion Intelligence Modal (Why Happy, Why Sad, Why Angry) --> <!-- 1. Interactive Emotion Intelligence Modal -->
<div id="emotionModal" class="modal-overlay"> <div id="emotionModal" class="modal-overlay">
<div class="modal-card" id="emotionModalCard"> <div class="modal-card">
<div class="modal-header"> <div class="modal-header">
<div> <div>
<h2 class="modal-title" id="emotionModalTitle" style="display: flex; align-items: center; gap: 8px;"> <h2 class="modal-title" style="display: flex; align-items: center; gap: 8px;">
<span>🧠</span> Emotion Root-Cause Intelligence <span>🧠</span> Emotion Root-Cause Intelligence
</h2> </h2>
<p id="emotionModalSubmeta" style="font-size: 0.75rem; color: var(--text-muted); margin-top: 4px;"></p> <p id="emotionModalSubmeta" style="font-size: 0.75rem; color: var(--text-muted); margin-top: 4px;"></p>
@ -716,7 +786,7 @@
<div class="modal-header"> <div class="modal-header">
<div> <div>
<h2 class="modal-title" style="display: flex; align-items: center; gap: 8px;"> <h2 class="modal-title" style="display: flex; align-items: center; gap: 8px;">
<span>📋</span> AI Auto-Generated To-Do Tasks <span>📋</span> AI Auto-Generated Actionable Tasks
</h2> </h2>
<p id="todoModalSubmeta" style="font-size: 0.75rem; color: var(--text-muted); margin-top: 4px;"></p> <p id="todoModalSubmeta" style="font-size: 0.75rem; color: var(--text-muted); margin-top: 4px;"></p>
</div> </div>
@ -729,7 +799,7 @@
<div style="display: flex; justify-content: space-between; align-items: center; border-top: 1px solid var(--card-border); padding-top: 1rem;"> <div style="display: flex; justify-content: space-between; align-items: center; border-top: 1px solid var(--card-border); padding-top: 1rem;">
<a id="btnDownloadTodoFile" href="#" class="nav-btn-primary nav-btn" style="font-size: 0.85rem;"> <a id="btnDownloadTodoFile" href="#" class="nav-btn-primary nav-btn" style="font-size: 0.85rem;">
📥 Download To-Do List (.txt) 📥 Download Tasks (.txt)
</a> </a>
<button type="button" class="nav-btn" onclick="closeTodoModal()">Done</button> <button type="button" class="nav-btn" onclick="closeTodoModal()">Done</button>
</div> </div>
@ -747,7 +817,7 @@
<button class="modal-close" onclick="closeFullMomModal()">&times;</button> <button class="modal-close" onclick="closeFullMomModal()">&times;</button>
</div> </div>
<div id="fullMomContent" style="background: var(--input-bg); border: 1px solid var(--card-border); border-radius: 8px; padding: 1.25rem; font-size: 0.875rem; line-height: 1.6; color: #cbd5e1; max-height: 440px; overflow-y: auto; white-space: pre-wrap; font-family: inherit;"> <div id="fullMomContent" style="background: var(--input-bg); border: 1px solid var(--card-border); border-radius: 12px; padding: 1.25rem; font-size: 0.875rem; line-height: 1.6; color: #cbd5e1; max-height: 440px; overflow-y: auto; white-space: pre-wrap;">
</div> </div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1.25rem;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1.25rem;">
@ -759,8 +829,30 @@
</div> </div>
</div> </div>
<!-- 4. Video Player Modal -->
<div id="logVideoPlayerModal" class="modal-overlay">
<div class="modal-card" style="max-width: 800px; padding: 1.5rem;">
<div class="modal-header">
<div>
<h2 id="logVideoModalTitle" class="modal-title" style="display: flex; align-items: center; gap: 8px;">
<span>🎥</span> Meeting Video Recording
</h2>
</div>
<button class="modal-close" onclick="closeLogVideoModal()">&times;</button>
</div>
<div style="background: #000; border-radius: 12px; overflow: hidden; margin-bottom: 1.25rem;">
<video id="logRecordingVideoPlayer" controls playsinline style="width: 100%; max-height: 460px; display: block; border-radius: 12px;"></video>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
<a id="btnDownloadLogVideoModal" href="#" class="nav-btn-primary nav-btn" style="font-size: 0.85rem;" download>
📥 Download Video Recording
</a>
<button type="button" class="nav-btn" onclick="closeLogVideoModal()">Close</button>
</div>
</div>
</div>
<script> <script>
// Theme Toggle
function toggleTheme() { function toggleTheme() {
const isLight = document.documentElement.classList.toggle('light-mode'); const isLight = document.documentElement.classList.toggle('light-mode');
localStorage.setItem('theme', isLight ? 'light' : 'dark'); localStorage.setItem('theme', isLight ? 'light' : 'dark');
@ -772,7 +864,6 @@ function toggleTheme() {
if (btn) btn.innerText = isLight ? '☀️' : '🌙'; if (btn) btn.innerText = isLight ? '☀️' : '🌙';
}); });
// Filter Logs
function filterLogs() { function filterLogs() {
const query = document.getElementById('searchInput').value.toLowerCase(); const query = document.getElementById('searchInput').value.toLowerCase();
const sentiment = document.getElementById('sentimentFilter').value; const sentiment = document.getElementById('sentimentFilter').value;
@ -795,7 +886,6 @@ function filterLogs() {
} }
}); });
// Hide empty date groups
document.querySelectorAll('.date-group').forEach(group => { document.querySelectorAll('.date-group').forEach(group => {
const visibleCards = group.querySelectorAll('.log-entry-card[style="display: flex;"]'); const visibleCards = group.querySelectorAll('.log-entry-card[style="display: flex;"]');
const allCards = group.querySelectorAll('.log-entry-card'); const allCards = group.querySelectorAll('.log-entry-card');
@ -807,12 +897,10 @@ function filterLogs() {
}); });
} }
// 1. Emotion Breakdown Modal (Why happy, Why sad, Why angry)
async function openEmotionModal(noteId) { async function openEmotionModal(noteId) {
const modal = document.getElementById('emotionModal'); const modal = document.getElementById('emotionModal');
const body = document.getElementById('emotionModalBody'); const body = document.getElementById('emotionModalBody');
const submeta = document.getElementById('emotionModalSubmeta'); const submeta = document.getElementById('emotionModalSubmeta');
const card = document.getElementById('emotionModalCard');
modal.style.display = 'flex'; modal.style.display = 'flex';
body.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-muted);">Analyzing client emotional sentiment...</div>'; body.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-muted);">Analyzing client emotional sentiment...</div>';
@ -834,7 +922,7 @@ function filterLogs() {
const bullets = emo.bullet_points || []; const bullets = emo.bullet_points || [];
const quotes = emo.trigger_quotes || []; const quotes = emo.trigger_quotes || [];
let bannerStyle = 'background: rgba(59,130,246,0.1); border-left: 4px solid #3b82f6; color: #93c5fd;'; let bannerStyle = 'background: rgba(59,130,246,0.12); border-left: 4px solid #3b82f6; color: #93c5fd;';
let icon = '😐'; let icon = '😐';
if (sentiment === 'happy') { if (sentiment === 'happy') {
bannerStyle = 'background: rgba(16,185,129,0.12); border-left: 4px solid #10b981; color: #34d399;'; bannerStyle = 'background: rgba(16,185,129,0.12); border-left: 4px solid #10b981; color: #34d399;';
@ -848,9 +936,9 @@ function filterLogs() {
} }
body.innerHTML = ` body.innerHTML = `
<div style="${bannerStyle} padding: 14px; border-radius: 0 8px 8px 0; margin-bottom: 1.25rem;"> <div style="${bannerStyle} padding: 14px; border-radius: 0 10px 10px 0; margin-bottom: 1.25rem;">
<div style="font-weight: 700; font-size: 0.95rem; display: flex; align-items: center; gap: 6px;"> <div style="font-weight: 700; font-size: 0.95rem; display: flex; align-items: center; gap: 6px;">
<span>${icon}</span> ${emo.headline || 'Client Emotional Analysis'} <span>${icon}</span> ${escapeHtml(emo.headline || 'Client Emotional Analysis')}
</div> </div>
</div> </div>
@ -859,7 +947,7 @@ function filterLogs() {
Key Reasons & Emotional Triggers: Key Reasons & Emotional Triggers:
</div> </div>
<ul style="padding-left: 20px; font-size: 0.875rem; color: #cbd5e1; line-height: 1.6;"> <ul style="padding-left: 20px; font-size: 0.875rem; color: #cbd5e1; line-height: 1.6;">
${bullets.map(b => `<li style="margin-bottom: 4px;">${b}</li>`).join('')} ${bullets.map(b => `<li style="margin-bottom: 4px;">${escapeHtml(b)}</li>`).join('')}
</ul> </ul>
</div> </div>
@ -869,17 +957,17 @@ function filterLogs() {
Spoken Key Phrases / Quotes: Spoken Key Phrases / Quotes:
</div> </div>
<div style="display: flex; flex-wrap: wrap; gap: 6px;"> <div style="display: flex; flex-wrap: wrap; gap: 6px;">
${quotes.map(q => `<span style="background: var(--input-bg); border: 1px solid var(--card-border); padding: 4px 10px; border-radius: 6px; font-size: 0.8rem; font-style: italic; color: #cbd5e1;">"${q}"</span>`).join('')} ${quotes.map(q => `<span style="background: var(--input-bg); border: 1px solid var(--card-border); padding: 4px 10px; border-radius: 6px; font-size: 0.8rem; font-style: italic; color: #cbd5e1;">"${escapeHtml(q)}"</span>`).join('')}
</div> </div>
</div> </div>
` : ''} ` : ''}
${emo.suggested_action ? ` ${emo.suggested_action ? `
<div style="background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.3); border-radius: 8px; padding: 12px;"> <div style="background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.3); border-radius: 10px; padding: 12px 14px;">
<div style="font-size: 0.75rem; font-weight: 700; color: #818cf8; text-transform: uppercase; margin-bottom: 4px;"> <div style="font-size: 0.75rem; font-weight: 700; color: #818cf8; text-transform: uppercase; margin-bottom: 4px;">
💡 Recommended Action for Project Team: 💡 Recommended Action for Project Team:
</div> </div>
<p style="font-size: 0.85rem; color: #e0e7ff; line-height: 1.5;">${emo.suggested_action}</p> <p style="font-size: 0.85rem; color: #e0e7ff; line-height: 1.5;">${escapeHtml(emo.suggested_action)}</p>
</div> </div>
` : ''} ` : ''}
`; `;
@ -889,10 +977,7 @@ function filterLogs() {
} }
function closeEmotionModal() { document.getElementById('emotionModal').style.display = 'none'; } function closeEmotionModal() { document.getElementById('emotionModal').style.display = 'none'; }
// 2. Interactive To-Do Modal
let activeNoteId = null;
async function openTodoModal(noteId, projectName) { async function openTodoModal(noteId, projectName) {
activeNoteId = noteId;
const modal = document.getElementById('todoModal'); const modal = document.getElementById('todoModal');
const submeta = document.getElementById('todoModalSubmeta'); const submeta = document.getElementById('todoModalSubmeta');
const listEl = document.getElementById('todoModalList'); const listEl = document.getElementById('todoModalList');
@ -901,76 +986,51 @@ function closeEmotionModal() { document.getElementById('emotionModal').style.dis
submeta.innerText = projectName ? `Project: ${projectName}` : ''; submeta.innerText = projectName ? `Project: ${projectName}` : '';
downloadBtn.href = `/client-calls/notes/${noteId}/download-todos`; downloadBtn.href = `/client-calls/notes/${noteId}/download-todos`;
modal.style.display = 'flex'; modal.style.display = 'flex';
listEl.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-muted);">Loading To-Do tasks...</div>'; listEl.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-muted);">Loading tasks...</div>';
try { try {
const res = await fetch(`/client-calls/notes/${noteId}/emotion-details`, { const res = await fetch(`/client-calls/notes/${noteId}/emotion-details`, {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await res.json(); const data = await res.json();
const todos = data.todo_items || [];
if (todos.length === 0) {
listEl.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-muted);">No specific tasks found for this call.</div>';
return;
}
listEl.innerHTML = todos.map((item, idx) => {
const id = item.id || (idx + 1);
const completed = item.completed ? 'checked' : '';
const completedClass = item.completed ? 'completed' : '';
const taskText = item.task || '';
const owner = item.owner || 'Assigned Lead';
const priority = item.priority || 'Medium';
const deadline = item.deadline || 'TBD';
return `
<div class="todo-item-row ${completedClass}" id="todo_${noteId}_${id}">
<input type="checkbox" class="todo-checkbox" ${completed} onchange="toggleTodoCheck(this, ${noteId}, ${id})">
<div class="todo-content">
<div class="todo-text">${escapeHtml(taskText)}</div>
<div class="todo-meta">
<span>👤 ${escapeHtml(owner)}</span>
<span> Priority: ${escapeHtml(priority)}</span>
<span>📅 Due: ${escapeHtml(deadline)}</span>
</div>
</div>
</div>
`;
}).join('');
// Fetch note details via download endpoint or direct
fetchFullNoteAndRenderTodos(noteId);
} catch (e) { } catch (e) {
listEl.innerHTML = '<div style="color:#f87171;padding:1rem;">Failed to load tasks.</div>'; listEl.innerHTML = '<div style="color:#f87171;padding:1rem;">Failed to load tasks.</div>';
} }
} }
async function fetchFullNoteAndRenderTodos(noteId) {
const listEl = document.getElementById('todoModalList');
try {
const res = await fetch(`/client-calls/notes/${noteId}/emotion-details`, {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
});
const noteData = await res.json();
// Mock/fetch todos
renderTodoList(noteId);
} catch (e) {}
}
async function renderTodoList(noteId) {
const listEl = document.getElementById('todoModalList');
// Mock sample items if not present
listEl.innerHTML = `
<div class="todo-item-row" id="todo_1">
<input type="checkbox" class="todo-checkbox" onchange="toggleTodoCheck(this, ${noteId}, 1)">
<div class="todo-content">
<div class="todo-text">Deploy latest sprint milestone updates to staging environment</div>
<div class="todo-meta">
<span>👤 Owner: Engineering Team</span>
<span> Priority: High</span>
<span>📅 Due: in 2 days</span>
</div>
</div>
</div>
<div class="todo-item-row" id="todo_2">
<input type="checkbox" class="todo-checkbox" onchange="toggleTodoCheck(this, ${noteId}, 2)">
<div class="todo-content">
<div class="todo-text">Provide updated API contracts and sandbox test credentials</div>
<div class="todo-meta">
<span>👤 Owner: Tech Lead</span>
<span> Priority: Medium</span>
<span>📅 Due: in 3 days</span>
</div>
</div>
</div>
<div class="todo-item-row" id="todo_3">
<input type="checkbox" class="todo-checkbox" onchange="toggleTodoCheck(this, ${noteId}, 3)">
<div class="todo-content">
<div class="todo-text">Follow up on client feedback and confirm next review date</div>
<div class="todo-meta">
<span>👤 Owner: Project Manager</span>
<span> Priority: Medium</span>
<span>📅 Due: in 4 days</span>
</div>
</div>
</div>
`;
}
function toggleTodoCheck(cb, noteId, todoId) { function toggleTodoCheck(cb, noteId, todoId) {
const row = document.getElementById(`todo_${todoId}`); const row = document.getElementById(`todo_${noteId}_${todoId}`);
if (row) row.classList.toggle('completed', cb.checked); if (row) row.classList.toggle('completed', cb.checked);
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
@ -985,11 +1045,9 @@ function toggleTodoCheck(cb, noteId, todoId) {
} }
function closeTodoModal() { document.getElementById('todoModal').style.display = 'none'; } function closeTodoModal() { document.getElementById('todoModal').style.display = 'none'; }
// 3. Full MoM Modal
async function openFullMomModal(noteId) { async function openFullMomModal(noteId) {
const modal = document.getElementById('fullMomModal'); const modal = document.getElementById('fullMomModal');
const content = document.getElementById('fullMomContent'); const content = document.getElementById('fullMomContent');
const submeta = document.getElementById('fullMomSubmeta');
const downloadBtn = document.getElementById('btnDownloadMomFile'); const downloadBtn = document.getElementById('btnDownloadMomFile');
downloadBtn.href = `/client-calls/notes/${noteId}/download-mom`; downloadBtn.href = `/client-calls/notes/${noteId}/download-mom`;
@ -1005,6 +1063,47 @@ function closeTodoModal() { document.getElementById('todoModal').style.display =
} }
} }
function closeFullMomModal() { document.getElementById('fullMomModal').style.display = 'none'; } function closeFullMomModal() { document.getElementById('fullMomModal').style.display = 'none'; }
function openLogVideoModal(videoUrl, title) {
const modal = document.getElementById('logVideoPlayerModal');
const video = document.getElementById('logRecordingVideoPlayer');
const titleEl = document.getElementById('logVideoModalTitle');
const dlBtn = document.getElementById('btnDownloadLogVideoModal');
if (titleEl && title) {
titleEl.innerHTML = `<span>🎥</span> ${escapeHtml(title)}`;
}
if (video) {
video.src = videoUrl;
video.load();
video.play().catch(() => {});
}
if (dlBtn) {
dlBtn.href = videoUrl;
}
if (modal) {
modal.style.display = 'flex';
}
}
function closeLogVideoModal() {
const modal = document.getElementById('logVideoPlayerModal');
const video = document.getElementById('logRecordingVideoPlayer');
if (video) {
video.pause();
video.src = '';
}
if (modal) {
modal.style.display = 'none';
}
}
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/[&<>"']/g, function(m) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[m];
});
}
</script> </script>
</body> </body>
</html> </html>

View File

@ -28,15 +28,19 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background: rgba(17, 24, 39, 0.7); background: rgba(17, 24, 39, 0.75);
backdrop-filter: blur(12px); backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1); border-bottom: 1px solid rgba(255, 255, 255, 0.1);
z-index: 20; z-index: 20;
flex-wrap: wrap;
gap: 10px;
} }
.header-left { .header-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
flex-wrap: wrap;
} }
.live-tag { .live-tag {
background: #ef4444; background: #ef4444;
@ -46,6 +50,7 @@
border-radius: 999px; border-radius: 999px;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px;
animation: pulse-live 2s infinite; animation: pulse-live 2s infinite;
} }
@keyframes pulse-live { @keyframes pulse-live {
@ -61,11 +66,38 @@
} }
.call-timer { .call-timer {
font-family: monospace; font-family: monospace;
font-size: 0.9rem; font-size: 0.85rem;
color: #9ca3af; color: #9ca3af;
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
padding: 3px 8px; padding: 3px 8px;
border-radius: 6px; border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
/* Live Recording Status Badge */
.rec-badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.5);
color: #f87171;
padding: 3px 10px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.5px;
}
.rec-dot {
width: 8px;
height: 8px;
background: #ef4444;
border-radius: 50%;
animation: pulse-rec 1s infinite alternate;
}
@keyframes pulse-rec {
0% { opacity: 0.3; transform: scale(0.85); }
100% { opacity: 1; transform: scale(1.2); }
} }
/* Real-Time Live Emotion Indicator Badge */ /* Real-Time Live Emotion Indicator Badge */
@ -116,7 +148,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
max-height: 100%; max-height: 100%;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
justify-items: center; justify-items: center;
align-items: center; align-items: center;
} }
@ -127,8 +159,8 @@
border-radius: 16px; border-radius: 16px;
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 240px; min-height: 220px;
max-height: 520px; max-height: 540px;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
align-items: center; align-items: center;
@ -148,7 +180,7 @@
position: absolute; position: absolute;
bottom: 12px; bottom: 12px;
left: 12px; left: 12px;
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
padding: 4px 10px; padding: 4px 10px;
border-radius: 8px; border-radius: 8px;
@ -159,6 +191,7 @@
gap: 6px; gap: 6px;
color: white; color: white;
z-index: 10; z-index: 10;
border: 1px solid rgba(255, 255, 255, 0.1);
} }
.video-avatar-placeholder { .video-avatar-placeholder {
@ -173,6 +206,7 @@
font-size: 2rem; font-size: 2rem;
font-weight: 800; font-weight: 800;
font-family: 'Outfit', sans-serif; font-family: 'Outfit', sans-serif;
box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4);
} }
/* Collapsible Side Panel (AI Notes & Chat) */ /* Collapsible Side Panel (AI Notes & Chat) */
@ -210,6 +244,16 @@
color: #9ca3af; color: #9ca3af;
cursor: pointer; cursor: pointer;
font-size: 1.2rem; font-size: 1.2rem;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
}
.panel-close-btn:hover {
color: white;
background: rgba(255, 255, 255, 0.1);
} }
.panel-tabs { .panel-tabs {
@ -226,6 +270,7 @@
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease;
} }
.panel-tab.active { .panel-tab.active {
color: #818cf8; color: #818cf8;
@ -280,6 +325,9 @@
outline: none; outline: none;
resize: none; resize: none;
} }
.notes-textarea:focus {
border-color: #6366f1;
}
/* Chat messages */ /* Chat messages */
.chat-feed { .chat-feed {
@ -318,18 +366,23 @@
font-size: 0.8rem; font-size: 0.8rem;
outline: none; outline: none;
} }
.chat-input:focus {
border-color: #6366f1;
}
/* Bottom Controls Bar */ /* Bottom Controls Bar */
.call-footer { .call-footer {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 1rem 1.5rem; padding: 0.85rem 1.5rem;
background: rgba(17, 24, 39, 0.85); background: rgba(17, 24, 39, 0.88);
backdrop-filter: blur(12px); backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-top: 1px solid rgba(255, 255, 255, 0.1); border-top: 1px solid rgba(255, 255, 255, 0.1);
gap: 12px; gap: 12px;
z-index: 20; z-index: 20;
flex-wrap: wrap;
} }
.ctrl-btn { .ctrl-btn {
@ -352,11 +405,23 @@
.ctrl-btn.active-off { .ctrl-btn.active-off {
background: #ef4444 !important; background: #ef4444 !important;
border-color: #ef4444 !important; border-color: #ef4444 !important;
color: white !important;
} }
.ctrl-btn.active-on { .ctrl-btn.active-on {
background: #4f46e5 !important; background: #4f46e5 !important;
border-color: #4f46e5 !important; border-color: #4f46e5 !important;
} }
.ctrl-btn.active-rec {
background: rgba(239, 68, 68, 0.25) !important;
border-color: #ef4444 !important;
color: #f87171 !important;
animation: pulse-rec-btn 1.5s infinite;
}
@keyframes pulse-rec-btn {
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5); }
70% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
}
.btn-end-call { .btn-end-call {
padding: 0 1.25rem; padding: 0 1.25rem;
@ -377,23 +442,78 @@
.btn-end-call:hover { .btn-end-call:hover {
background: #dc2626; background: #dc2626;
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.6);
} }
/* Toast & Modal */
.toast-notify { .toast-notify {
position: fixed; position: fixed;
bottom: 80px; bottom: 85px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
background: rgba(17, 24, 39, 0.95); background: rgba(17, 24, 39, 0.95);
border: 1px solid #4f46e5; border: 1px solid #4f46e5;
padding: 10px 20px; padding: 10px 20px;
border-radius: 8px; border-radius: 10px;
color: white; color: white;
font-size: 0.85rem; font-size: 0.85rem;
display: none; display: none;
z-index: 100; z-index: 100;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
} }
/* Modal Overlay */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(11, 15, 25, 0.85);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: none;
justify-content: center;
align-items: center;
z-index: 120;
padding: 1.5rem;
}
.modal-card {
background: rgba(17, 24, 39, 0.96);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 20px;
width: 100%;
max-width: 480px;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.6);
padding: 1.75rem;
text-align: center;
}
/* Responsive Breakpoints */
@media (max-width: 768px) {
.call-header { padding: 0.5rem 1rem; }
.meeting-title { font-size: 0.95rem; max-width: 150px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.emotion-live-indicator { font-size: 0.72rem; padding: 3px 8px; }
.side-panel {
position: absolute;
top: 0; right: 0; bottom: 0;
width: 100%;
max-width: 320px;
background: rgba(17, 24, 39, 0.97);
backdrop-filter: blur(20px);
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.7);
}
.ctrl-btn { width: 42px; height: 42px; }
.btn-end-call { height: 42px; font-size: 0.8rem; padding: 0 1rem; }
.call-footer { gap: 8px; padding: 0.65rem 0.75rem; }
.video-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.meeting-title { max-width: 110px; }
.call-timer { font-size: 0.75rem; padding: 2px 6px; }
.ctrl-btn { width: 38px; height: 38px; }
.btn-end-call { height: 38px; font-size: 0.75rem; padding: 0 0.8rem; }
}
</style> </style>
</head> </head>
<body> <body>
@ -402,13 +522,19 @@
<header class="call-header"> <header class="call-header">
<div class="header-left"> <div class="header-left">
<span class="live-tag">LIVE</span> <span class="live-tag">LIVE</span>
<div class="meeting-title">{{ $meeting->title }}</div> <div class="meeting-title" title="{{ $meeting->title }}">{{ $meeting->title }}</div>
<div class="call-timer" id="callTimer">00:00</div> <div class="call-timer" id="callTimer">00:00</div>
<!-- Live Call Recording Status Indicator -->
<div id="recordingStatusBadge" class="rec-badge" style="display: none;" title="Call is currently being recorded">
<span class="rec-dot"></span>
<span id="recTimer">REC 00:00</span>
</div>
</div> </div>
<div style="display: flex; align-items: center; gap: 12px;"> <div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<!-- Live AI Emotion Indicator --> <!-- Live AI Emotion Indicator -->
<div id="liveEmotionBadge" class="emotion-live-indicator emotion-badge-neutral" title="AI Real-Time Emotion Detection"> <div id="liveEmotionBadge" class="emotion-live-indicator emotion-badge-neutral" title="AI Real-Time Emotion & Expression Intelligence">
<span id="emotionEmoji">😐</span> <span id="emotionEmoji">😐</span>
<span id="emotionText">Client Emotion: Neutral</span> <span id="emotionText">Client Emotion: Neutral</span>
</div> </div>
@ -440,12 +566,12 @@
<div class="panel-title"> <div class="panel-title">
<span></span> AI Meeting Assistant <span></span> AI Meeting Assistant
</div> </div>
<button class="panel-close-btn" onclick="toggleSidePanel()">&times;</button> <button class="panel-close-btn" onclick="toggleSidePanel()" title="Close side panel">&times;</button>
</div> </div>
<div class="panel-tabs"> <div class="panel-tabs">
<button class="panel-tab active" onclick="switchSideTab('notesTab', this)">📝 AI Notes & MoM</button> <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> <button class="panel-tab" onclick="switchSideTab('chatTab', this)">💬 Chat</button>
</div> </div>
<!-- TAB 1: AI Live Notes --> <!-- TAB 1: AI Live Notes -->
@ -466,7 +592,7 @@
</div> </div>
@if($isHost) @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;"> <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; box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4);">
Finish & Generate AI MoM Finish & Generate AI MoM
</button> </button>
@endif @endif
@ -502,6 +628,17 @@
<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> <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> </button>
<!-- Call Recording Feature Button -->
<button id="btnToggleRecord" class="ctrl-btn" onclick="toggleCallRecording()" title="Start / Stop Call Recording">
<svg id="recIconStart" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="4" fill="#ef4444"></circle>
</svg>
<svg id="recIconStop" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: none; color: #ef4444;">
<rect x="7" y="7" width="10" height="10" rx="2" fill="#ef4444"></rect>
</svg>
</button>
<!-- Toggle AI Assistant / Notes --> <!-- Toggle AI Assistant / Notes -->
<button id="btnToggleNotes" class="ctrl-btn active-on" onclick="toggleSidePanel()" title="Toggle AI Notes & Transcriber"> <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> <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>
@ -520,6 +657,25 @@
@endif @endif
</footer> </footer>
<!-- Recording Saved / Download Modal -->
<div id="recordingSavedModal" class="modal-overlay">
<div class="modal-card">
<div style="font-size: 2.5rem; margin-bottom: 8px;">🎥</div>
<h3 style="font-size: 1.25rem; font-weight: 700; margin-bottom: 6px;">Call Recording Ready</h3>
<p style="color: #9ca3af; font-size: 0.875rem; margin-bottom: 1.5rem;">
The video recording has been captured and uploaded to the meeting profile. You can also save a copy to your device right now.
</p>
<div style="display: flex; gap: 10px; justify-content: center;">
<a id="btnDownloadSavedRec" href="#" class="btn-end-call" style="background: linear-gradient(135deg, #4f46e5, #06b6d4); text-decoration: none;">
📥 Download Recording
</a>
<button type="button" class="ctrl-btn" style="width: auto; height: 48px; border-radius: 24px; padding: 0 1.25rem;" onclick="document.getElementById('recordingSavedModal').style.display='none'">
Dismiss
</button>
</div>
</div>
</div>
<!-- Hidden Canvas for Face Emotion Snapshots --> <!-- Hidden Canvas for Face Emotion Snapshots -->
<canvas id="emotionCanvas" style="display: none;"></canvas> <canvas id="emotionCanvas" style="display: none;"></canvas>
@ -536,9 +692,10 @@
isHost: {{ $isHost ? 'true' : 'false' }}, isHost: {{ $isHost ? 'true' : 'false' }},
isGuest: {{ $isGuest ? 'true' : 'false' }}, isGuest: {{ $isGuest ? 'true' : 'false' }},
detectEmotionUrl: '{{ route('client-calls.detect-emotion', $meeting->meeting_code) }}', detectEmotionUrl: '{{ route('client-calls.detect-emotion', $meeting->meeting_code) }}',
uploadRecordingUrl: '{{ route('client-calls.upload-recording', $meeting->meeting_code) }}',
endCallUrl: '{{ route('client-calls.end-call', $meeting->meeting_code) }}', endCallUrl: '{{ route('client-calls.end-call', $meeting->meeting_code) }}',
heartbeatUrl: '{{ route('client-calls.heartbeat', $meeting->meeting_code) }}', heartbeatUrl: '{{ route('client-calls.heartbeat', $meeting->meeting_code) }}',
redirectUrl: '{{ route('client-calls.show', $client->id ?? $meeting->client_id) }}' redirectUrl: '{{ $isGuest ? url('/') : route('client-calls.show', $client->id ?? $meeting->client_id) }}'
}; };
</script> </script>
@ -548,9 +705,11 @@
<script> <script>
function showToast(msg) { function showToast(msg) {
const toast = document.getElementById('toastNotification'); const toast = document.getElementById('toastNotification');
toast.innerText = msg; if (toast) {
toast.style.display = 'block'; toast.innerText = msg;
setTimeout(() => { toast.style.display = 'none'; }, 3000); toast.style.display = 'block';
setTimeout(() => { toast.style.display = 'none'; }, 3500);
}
} }
function copyMeetingLink() { function copyMeetingLink() {
@ -560,15 +719,20 @@ function copyMeetingLink() {
function toggleSidePanel() { function toggleSidePanel() {
const panel = document.getElementById('sidePanel'); const panel = document.getElementById('sidePanel');
panel.classList.toggle('collapsed'); if (panel) {
panel.classList.toggle('collapsed');
}
} }
function switchSideTab(tabId, el) { function switchSideTab(tabId, el) {
document.getElementById('notesTab').style.display = 'none'; const nTab = document.getElementById('notesTab');
document.getElementById('chatTab').style.display = 'none'; const cTab = document.getElementById('chatTab');
if (nTab) nTab.style.display = 'none';
if (cTab) cTab.style.display = 'none';
document.querySelectorAll('.panel-tab').forEach(b => b.classList.remove('active')); document.querySelectorAll('.panel-tab').forEach(b => b.classList.remove('active'));
document.getElementById(tabId).style.display = 'flex'; const target = document.getElementById(tabId);
el.classList.add('active'); if (target) target.style.display = 'flex';
if (el) el.classList.add('active');
} }
</script> </script>
</body> </body>

File diff suppressed because it is too large Load Diff

View File

@ -98,14 +98,19 @@
Route::get('/client-calls/notes/{id}/download-todos', [ClientCallController::class, 'downloadTodos'])->name('client-calls.download-todos'); 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::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::post('/client-calls/notes/{id}/toggle-todo/{todoId}', [ClientCallController::class, 'toggleTodo'])->name('client-calls.toggle-todo');
Route::post('/client-calls/notes/{id}/add-todo', [ClientCallController::class, 'addTodo'])->name('client-calls.add-todo');
Route::delete('/client-calls/notes/{id}/delete-todo/{todoId}', [ClientCallController::class, 'deleteTodo'])->name('client-calls.delete-todo');
Route::get('/client-calls/notes/{id}/angry-analysis', [ClientCallController::class, 'getAngryAnalysis'])->name('client-calls.angry-analysis'); 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'); Route::post('/client-calls/notes/{id}/resend-email', [ClientCallController::class, 'resendMoMEmail'])->name('client-calls.resend-email');
Route::get('/client-calls/meetings/{id}/download-recording', [ClientCallController::class, 'downloadMeetingRecording'])->name('client-calls.download-meeting-recording');
Route::get('/client-calls/notes/{id}/download-recording', [ClientCallController::class, 'downloadNoteRecording'])->name('client-calls.download-note-recording');
}); });
// Client Video Call Room (Accessible to authenticated internal members & external guest clients via link) // 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::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}/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}/detect-emotion', [ClientCallController::class, 'detectEmotion'])->name('client-calls.detect-emotion');
Route::post('/client-calls/room/{code}/upload-recording', [ClientCallController::class, 'uploadRecording'])->name('client-calls.upload-recording');
Route::post('/client-calls/room/{code}/end-call', [ClientCallController::class, 'endCallAndGenerateMoM'])->name('client-calls.end-call'); 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'); Route::post('/client-calls/room/{code}/heartbeat', [ClientCallController::class, 'signalingHeartbeat'])->name('client-calls.heartbeat');

View File

@ -458,4 +458,328 @@ public function test_can_view_call_logs_and_download_mom_and_todos(): void
$this->assertTrue($note->fresh()->todo_items[0]['completed']); $this->assertTrue($note->fresh()->todo_items[0]['completed']);
} }
public function test_client_list_does_not_display_sentiment_badges_on_cards(): void
{
$client = Client::create([
'client_name' => 'Alice Wonder',
'client_email' => 'alice@wonderland.com',
'project_name' => 'Wonder App',
'responsible_user_id' => $this->pm->id,
'created_by' => $this->admin->id,
'status' => 'active',
'latest_sentiment' => 'angry',
]);
$response = $this->actingAs($this->pm)
->get(route('client-calls.index'));
$response->assertStatus(200)
->assertSee('Wonder App')
->assertSee('Alice Wonder')
->assertDontSee('sentiment-angry')
->assertDontSee('sentiment-happy');
}
public function test_client_hub_shows_date_wise_calls_section_and_tasks(): void
{
$client = Client::create([
'client_name' => 'David Warner',
'client_email' => 'david@cricket.io',
'project_name' => 'Live Score System',
'responsible_user_id' => $this->pm->id,
'created_by' => $this->admin->id,
'status' => 'active',
]);
$meeting = ClientMeeting::create([
'client_id' => $client->id,
'title' => 'Initial Sprint Review',
'meeting_code' => 'meet-score-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,
'summary' => 'Reviewed live scoring latency.',
'mom_content' => 'Full MoM details here.',
'detected_sentiment' => 'happy',
'todo_items' => [
['id' => 1, 'task' => 'Optimize websocket latency', 'owner' => 'Dev Team', 'priority' => 'High', 'deadline' => 'Aug 24, 2026', 'completed' => false],
],
]);
// Access Client Hub / Show page
$response = $this->actingAs($this->pm)
->get(route('client-calls.show', $client->id));
$response->assertStatus(200)
->assertSee('Live Score System')
->assertSee('Date-wise Call History & MoM Details', false)
->assertSee('Initial Sprint Review')
->assertSee('Optimize websocket latency')
->assertSee('Happy / Satisfied');
}
public function test_add_and_delete_todo_items(): void
{
$client = Client::create([
'client_name' => 'Emma Watson',
'client_email' => 'emma@cinema.com',
'project_name' => 'Film Portal',
'responsible_user_id' => $this->pm->id,
'created_by' => $this->admin->id,
'status' => 'active',
]);
$meeting = ClientMeeting::create([
'client_id' => $client->id,
'title' => 'Post-Production Sync',
'meeting_code' => 'meet-film-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,
'summary' => 'Sync on release roadmap.',
'mom_content' => 'MoM Content.',
'detected_sentiment' => 'neutral',
'todo_items' => [
['id' => 1, 'task' => 'First Task', 'owner' => 'PM', 'priority' => 'Medium', 'deadline' => 'TBD', 'completed' => false]
],
]);
// 1. Add new To-Do
$addRes = $this->actingAs($this->pm)
->postJson(route('client-calls.add-todo', $note->id), [
'task' => 'Deploy video CDN endpoints',
'owner' => 'Alex Dev',
'priority' => 'High',
'deadline' => 'Aug 26, 2026',
]);
$addRes->assertStatus(200)
->assertJson([
'success' => true,
'todo' => [
'task' => 'Deploy video CDN endpoints',
'owner' => 'Alex Dev',
'priority' => 'High',
]
]);
$this->assertCount(2, $note->fresh()->todo_items);
// 2. Delete the first To-Do
$delRes = $this->actingAs($this->pm)
->deleteJson(route('client-calls.delete-todo', ['id' => $note->id, 'todoId' => 1]));
$delRes->assertStatus(200)
->assertJson(['success' => true]);
$this->assertCount(1, $note->fresh()->todo_items);
$this->assertEquals('Deploy video CDN endpoints', $note->fresh()->todo_items[0]['task']);
}
public function test_upload_recording_endpoint_saves_file_and_updates_database(): void
{
\Illuminate\Support\Facades\Storage::fake('public');
$client = Client::create([
'client_name' => 'Sara Connor',
'client_email' => 'sara@cyberdyne.com',
'project_name' => 'AI Defense System',
'responsible_user_id' => $this->pm->id,
'created_by' => $this->admin->id,
'status' => 'active',
]);
$meeting = ClientMeeting::create([
'client_id' => $client->id,
'title' => 'Weekly Sync with Cyberdyne',
'meeting_code' => 'meet-cyber-rec-1',
'host_user_id' => $this->pm->id,
'status' => 'in_progress',
]);
$note = ClientCallNote::create([
'client_meeting_id' => $meeting->id,
'client_id' => $client->id,
'created_by' => $this->pm->id,
'summary' => 'Ongoing call notes',
'mom_content' => 'MoM in progress',
'detected_sentiment' => 'happy',
]);
$fakeVideo = \Illuminate\Http\UploadedFile::fake()->create('call_recording.webm', 2048, 'video/webm');
$response = $this->post(route('client-calls.upload-recording', $meeting->meeting_code), [
'video' => $fakeVideo,
]);
$response->assertStatus(200)
->assertJson([
'success' => true,
]);
$meeting->refresh();
$note->refresh();
$this->assertNotNull($meeting->recording_path);
$this->assertNotEmpty($meeting->recordings);
$this->assertNotNull($note->recording_path);
$this->assertNotEmpty($note->recordings);
$this->assertFileExists(public_path(ltrim($meeting->recording_path, '/')));
// Clean up created test recording file
@unlink(public_path(ltrim($meeting->recording_path, '/')));
}
public function test_meeting_and_note_recording_downloads(): void
{
$client = Client::create([
'client_name' => 'Thomas Anderson',
'client_email' => 'neo@matrix.io',
'project_name' => 'Matrix Simulator',
'responsible_user_id' => $this->pm->id,
'created_by' => $this->admin->id,
'status' => 'active',
]);
$meeting = ClientMeeting::create([
'client_id' => $client->id,
'title' => 'Matrix Review',
'meeting_code' => 'meet-matrix-99',
'host_user_id' => $this->pm->id,
'status' => 'completed',
]);
// Create a dummy video file in public storage
$dir = public_path('uploads/client_recordings/' . $meeting->meeting_code);
if (!file_exists($dir)) {
mkdir($dir, 0777, true);
}
$testFilePath = $dir . '/test_rec.webm';
file_put_contents($testFilePath, 'dummy webm content');
$relPath = 'uploads/client_recordings/' . $meeting->meeting_code . '/test_rec.webm';
$meeting->update(['recording_path' => $relPath]);
$note = ClientCallNote::create([
'client_meeting_id' => $meeting->id,
'client_id' => $client->id,
'created_by' => $this->pm->id,
'summary' => 'Discussion on simulated physics.',
'mom_content' => 'MoM details here.',
'recording_path' => $relPath,
]);
// 1. Download meeting recording
$resMeeting = $this->actingAs($this->pm)
->get(route('client-calls.download-meeting-recording', $meeting->id));
$resMeeting->assertStatus(200);
// 2. Download note recording
$resNote = $this->actingAs($this->pm)
->get(route('client-calls.download-note-recording', $note->id));
$resNote->assertStatus(200);
// Clean up
@unlink($testFilePath);
@rmdir($dir);
}
public function test_ending_meeting_links_recording_path_to_call_notes(): void
{
Mail::fake();
$client = Client::create([
'client_name' => 'Bruce Wayne',
'client_email' => 'bruce@waynecorp.com',
'project_name' => 'Batmobile OS',
'responsible_user_id' => $this->pm->id,
'created_by' => $this->admin->id,
'status' => 'active',
]);
$meeting = ClientMeeting::create([
'client_id' => $client->id,
'title' => 'Batmobile Specs Call',
'meeting_code' => 'meet-bat-101',
'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: Testing client recording flow.',
'live_notes' => 'Armor upgrades agreed.',
'emotion_timeline' => [['emotion' => 'happy', 'time' => time()]],
'recording_path' => 'uploads/client_recordings/meet-bat-101/rec.webm',
]);
$response->assertStatus(200)
->assertJsonStructure(['success', 'redirect_url', 'note_id']);
$note = ClientCallNote::where('client_meeting_id', $meeting->id)->first();
$this->assertNotNull($note);
$this->assertEquals('uploads/client_recordings/meet-bat-101/rec.webm', $note->recording_path);
$this->assertNotEmpty($note->recordings);
$meeting->refresh();
$this->assertEquals('uploads/client_recordings/meet-bat-101/rec.webm', $meeting->recording_path);
}
public function test_client_hub_and_logs_pages_render_recording_actions_when_present(): void
{
$client = Client::create([
'client_name' => 'Diana Prince',
'client_email' => 'diana@themyscira.org',
'project_name' => 'Shield Security',
'responsible_user_id' => $this->pm->id,
'created_by' => $this->admin->id,
'status' => 'active',
]);
$meeting = ClientMeeting::create([
'client_id' => $client->id,
'title' => 'Defense Protocol Sync',
'meeting_code' => 'meet-shield-202',
'host_user_id' => $this->pm->id,
'status' => 'completed',
'recording_path' => 'uploads/client_recordings/meet-shield-202/rec.webm',
]);
$note = ClientCallNote::create([
'client_meeting_id' => $meeting->id,
'client_id' => $client->id,
'created_by' => $this->pm->id,
'summary' => 'Reviewed aegis defense protocols.',
'mom_content' => 'Full MoM details here.',
'detected_sentiment' => 'happy',
'recording_path' => 'uploads/client_recordings/meet-shield-202/rec.webm',
]);
// 1. Check Show page renders Video Call Recording
$showRes = $this->actingAs($this->pm)
->get(route('client-calls.show', $client->id));
$showRes->assertStatus(200)
->assertSee('Video Call Recording')
->assertSee('Watch Recording')
->assertSee('Download Video');
// 2. Check Logs page renders Recorded badge & Play Video button
$logsRes = $this->actingAs($this->pm)
->get(route('client-calls.logs'));
$logsRes->assertStatus(200)
->assertSee('Recorded')
->assertSee('Play Video')
->assertSee('Download Video Recording', false);
}
} }