fix it
This commit is contained in:
parent
e03d6ce250
commit
7e44093e8d
@ -46,9 +46,9 @@ public function index()
|
||||
// Quick stats
|
||||
$totalClients = $clients->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.');
|
||||
}
|
||||
|
||||
$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();
|
||||
|
||||
// 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);
|
||||
|
||||
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;
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@ -286,11 +461,26 @@ public function endCallAndGenerateMoM(Request $request, $code)
|
||||
$transcript = $request->input('transcript', '');
|
||||
$liveNotes = $request->input('live_notes', '');
|
||||
$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
|
||||
$meeting->update([
|
||||
'status' => 'completed',
|
||||
'ended_at' => now(),
|
||||
'recording_path' => $recordingPath,
|
||||
'recordings' => $recordings,
|
||||
]);
|
||||
|
||||
// Generate AI MoM
|
||||
@ -322,11 +512,15 @@ public function endCallAndGenerateMoM(Request $request, $code)
|
||||
'sentiment_breakdown' => $aiResult['sentiment_breakdown'],
|
||||
'angry_analysis' => $aiResult['angry_analysis'],
|
||||
'emotion_details' => $aiResult['emotion_details'] ?? [],
|
||||
'recording_path' => $recordingPath,
|
||||
'recordings' => $recordings,
|
||||
]);
|
||||
|
||||
// Automatically send email with MoM and notes to TL, Director, PM, and Admin
|
||||
$emailStatus = $this->aiService->sendMoMEmails($callNote);
|
||||
|
||||
$redirectUrl = Auth::check() ? route('client-calls.show', $client->id) : url('/');
|
||||
|
||||
if ($request->ajax() || $request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@ -334,10 +528,15 @@ public function endCallAndGenerateMoM(Request $request, $code)
|
||||
'client_id' => $client->id,
|
||||
'detected_sentiment' => $callNote->detected_sentiment,
|
||||
'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!');
|
||||
}
|
||||
|
||||
@ -494,7 +693,9 @@ public function getEmotionDetails($noteId)
|
||||
'project_name' => $client->project_name,
|
||||
'detected_sentiment' => $sentiment,
|
||||
'emotion_details' => $details,
|
||||
'todo_items' => $callNote->todo_items ?? $callNote->action_items ?? [],
|
||||
'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'),
|
||||
]);
|
||||
}
|
||||
@ -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).
|
||||
*/
|
||||
|
||||
@ -24,6 +24,8 @@ class ClientCallNote extends Model
|
||||
'angry_analysis',
|
||||
'emotion_details',
|
||||
'todo_items',
|
||||
'recording_path',
|
||||
'recordings',
|
||||
'emailed_at',
|
||||
'emailed_to',
|
||||
];
|
||||
@ -35,6 +37,7 @@ class ClientCallNote extends Model
|
||||
'angry_analysis' => 'array',
|
||||
'emotion_details' => 'array',
|
||||
'todo_items' => 'array',
|
||||
'recordings' => 'array',
|
||||
'emailed_to' => 'array',
|
||||
'emailed_at' => 'datetime',
|
||||
];
|
||||
|
||||
@ -26,6 +26,8 @@ class ClientMeeting extends Model
|
||||
'active_peers',
|
||||
'peer_signals',
|
||||
'chat_messages',
|
||||
'recording_path',
|
||||
'recordings',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@ -37,6 +39,7 @@ class ClientMeeting extends Model
|
||||
'active_peers' => 'array',
|
||||
'peer_signals' => 'array',
|
||||
'chat_messages' => 'array',
|
||||
'recordings' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -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')));
|
||||
|
||||
// Check for Angry / Frustrated trigger keywords in speech text
|
||||
$lowerText = strtolower($speechText);
|
||||
$angryTriggers = ['angry', 'furious', 'unacceptable', 'ridiculous', 'terrible', 'waste of time', 'delay', 'broken', 'disaster', 'unhappy', 'frustrated', 'mad', 'annoyed', 'horrible', 'cancel', 'lawyer', 'refund', 'fail', 'incompetent', 'worst'];
|
||||
$sadTriggers = ['sad', 'disappointed', 'worried', 'concerned', 'nervous', 'scared', 'afraid', 'regret', 'loss', 'hopeless', 'sorry', 'doubt'];
|
||||
$happyTriggers = ['great', 'excellent', 'fantastic', 'awesome', 'good job', 'love it', 'happy', 'pleased', 'perfect', 'superb', 'wonderful', 'thank you', 'amazing', 'impressed'];
|
||||
$lowerText = strtolower(trim($speechText));
|
||||
|
||||
// 1. Check for negative polarity / negations that invert positive sentiment
|
||||
$negatedHappyPhrases = [
|
||||
'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 = [];
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (!empty($foundNegations)) {
|
||||
$detectedAngryWords = array_unique(array_merge($detectedAngryWords, $foundNegations));
|
||||
}
|
||||
|
||||
$detectedSadWords = [];
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
$detectedHappyWords = [];
|
||||
foreach ($happyTriggers as $word) {
|
||||
if (str_contains($lowerText, $word)) {
|
||||
$detectedHappyWords[] = $word;
|
||||
// Only consider happy words if there are no strong negations of happiness
|
||||
if (empty($foundNegations)) {
|
||||
foreach ($happyTriggers as $word) {
|
||||
if (preg_match('/\b' . preg_quote($word, '/') . '\b/i', $lowerText) || str_contains($lowerText, $word)) {
|
||||
$detectedHappyWords[] = $word;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($detectedAngryWords)) {
|
||||
return [
|
||||
'emotion' => 'angry',
|
||||
'confidence' => 0.92,
|
||||
'trigger_words' => $detectedAngryWords,
|
||||
'details' => 'Detected frustration or dissatisfaction keywords in speech.',
|
||||
'confidence' => 0.94,
|
||||
'trigger_words' => array_values(array_unique($detectedAngryWords)),
|
||||
'details' => 'Detected clear frustration, complaint, or dissatisfaction cues in conversation.',
|
||||
'label' => 'Frustrated / Angry 😡'
|
||||
];
|
||||
}
|
||||
@ -149,9 +192,9 @@ public function detectEmotion(string $speechText = '', ?string $imageFrameBase64
|
||||
if (!empty($detectedSadWords)) {
|
||||
return [
|
||||
'emotion' => 'sad',
|
||||
'confidence' => 0.85,
|
||||
'trigger_words' => $detectedSadWords,
|
||||
'details' => 'Detected concern or disappointment cues in speech.',
|
||||
'confidence' => 0.88,
|
||||
'trigger_words' => array_values(array_unique($detectedSadWords)),
|
||||
'details' => 'Detected concern, hesitation, or worry in speech dialogue.',
|
||||
'label' => 'Concerned / Sad 😟'
|
||||
];
|
||||
}
|
||||
@ -159,9 +202,9 @@ public function detectEmotion(string $speechText = '', ?string $imageFrameBase64
|
||||
if (!empty($detectedHappyWords)) {
|
||||
return [
|
||||
'emotion' => 'happy',
|
||||
'confidence' => 0.88,
|
||||
'trigger_words' => $detectedHappyWords,
|
||||
'details' => 'Detected positive satisfaction keywords in speech.',
|
||||
'confidence' => 0.90,
|
||||
'trigger_words' => array_values(array_unique($detectedHappyWords)),
|
||||
'details' => 'Detected positive satisfaction, appreciation, or praise in speech.',
|
||||
'label' => 'Happy / Satisfied 😊'
|
||||
];
|
||||
}
|
||||
@ -507,13 +550,23 @@ protected function generateFallbackMoM(string $combinedContent, string $transcri
|
||||
{
|
||||
$lower = strtolower($combinedContent);
|
||||
|
||||
$angryWords = ['angry', 'furious', 'unacceptable', 'ridiculous', 'terrible', 'waste of time', 'delay', 'broken', 'disaster', 'unhappy', 'frustrated', 'mad', 'annoyed', 'horrible', 'refund', 'cancel', 'fail', 'incompetent'];
|
||||
$sadWords = ['sad', 'disappointed', 'worried', 'concerned', 'nervous', 'scared', 'afraid', 'regret', 'doubt'];
|
||||
$happyWords = ['great', 'excellent', 'fantastic', 'awesome', 'good job', 'love it', 'happy', 'pleased', 'perfect', 'superb', 'wonderful', 'thank you', 'appreciate', 'impressed'];
|
||||
$negatedHappyPhrases = ['not happy', 'not satisfied', 'not good', 'not great', 'not working', 'not impressed', 'not pleased', 'no progress', 'displeased', 'unhappy', 'dissatisfied', 'cannot accept', 'unacceptable'];
|
||||
$foundNegations = array_values(array_filter($negatedHappyPhrases, fn($w) => str_contains($lower, $w)));
|
||||
|
||||
$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)));
|
||||
$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';
|
||||
$angryAnalysis = [
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,5 +1,7 @@
|
||||
/**
|
||||
* 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;
|
||||
@ -9,6 +11,16 @@ let isAudioMuted = false;
|
||||
let isVideoMuted = 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 speechRecognizer = null;
|
||||
let emotionTimeline = [];
|
||||
@ -33,6 +45,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
window.toggleAudio = toggleAudio;
|
||||
window.toggleVideo = toggleVideo;
|
||||
window.toggleScreenShare = toggleScreenShare;
|
||||
window.toggleCallRecording = toggleCallRecording;
|
||||
window.sendChatMessage = sendChatMessage;
|
||||
window.endMeetingAndGenerateMoM = endMeetingAndGenerateMoM;
|
||||
window.leaveCall = leaveCall;
|
||||
@ -87,6 +100,7 @@ function toggleAudio() {
|
||||
const btn = document.getElementById('btnToggleMic');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active-off', isAudioMuted);
|
||||
btn.title = isAudioMuted ? 'Unmute Microphone' : 'Mute Microphone';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -100,6 +114,7 @@ function toggleVideo() {
|
||||
const btn = document.getElementById('btnToggleVideo');
|
||||
if (btn) {
|
||||
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');
|
||||
if (!isScreenSharing) {
|
||||
try {
|
||||
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
|
||||
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
|
||||
const screenTrack = screenStream.getVideoTracks()[0];
|
||||
|
||||
// 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
|
||||
* =========================================================================
|
||||
*/
|
||||
function initSpeechRecognition() {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
@ -324,6 +574,7 @@ let outgoingChatMessage = null;
|
||||
|
||||
function sendChatMessage() {
|
||||
const input = document.getElementById('chatInput');
|
||||
if (!input) return;
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
@ -382,6 +633,8 @@ function renderChat(messages) {
|
||||
|
||||
function handleActivePeers(peers) {
|
||||
const grid = document.getElementById('videoGrid');
|
||||
if (!grid) return;
|
||||
|
||||
peers.forEach(peer => {
|
||||
if (peer.peer_id === peerId) return;
|
||||
|
||||
@ -391,7 +644,7 @@ function handleActivePeers(peers) {
|
||||
tile.id = 'tile_' + peer.peer_id;
|
||||
tile.className = 'video-tile';
|
||||
tile.innerHTML = `
|
||||
<div class="video-avatar-placeholder">${peer.peer_name.charAt(0).toUpperCase()}</div>
|
||||
<div class="video-avatar-placeholder">${(peer.peer_name || 'U').charAt(0).toUpperCase()}</div>
|
||||
<div class="video-overlay">
|
||||
<span>${escapeHtml(peer.peer_name)}</span>
|
||||
</div>
|
||||
@ -417,6 +670,12 @@ async function endMeetingAndGenerateMoM() {
|
||||
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 transcriptText = accumulatedTranscript.map(l => `[${l.time}] ${l.speaker}: ${l.text}`).join("\n");
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
@ -425,7 +684,7 @@ async function endMeetingAndGenerateMoM() {
|
||||
const btn = document.querySelector('.btn-end-call');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerText = 'Generating AI MoM & Dispatching Emails...';
|
||||
btn.innerText = 'Generating AI MoM & Saving Notes...';
|
||||
}
|
||||
|
||||
try {
|
||||
@ -439,7 +698,8 @@ async function endMeetingAndGenerateMoM() {
|
||||
body: JSON.stringify({
|
||||
transcript: transcriptText,
|
||||
live_notes: liveNotes,
|
||||
emotion_timeline: emotionTimeline
|
||||
emotion_timeline: emotionTimeline,
|
||||
recording_path: recordedVideoPath
|
||||
})
|
||||
});
|
||||
|
||||
@ -450,20 +710,23 @@ async function endMeetingAndGenerateMoM() {
|
||||
window.location.href = config.redirectUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Meeting ended. Redirecting to Client Hub...');
|
||||
alert('Meeting completed. Redirecting to Client Hub...');
|
||||
window.location.href = config.redirectUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function leaveCall() {
|
||||
if (confirm('Leave this meeting?')) {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
}
|
||||
window.location.href = config.redirectUrl || '/';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, function(m) {
|
||||
return String(str).replace(/[&<>"']/g, function(m) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m];
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
/**
|
||||
* 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;
|
||||
@ -9,6 +11,16 @@ let isAudioMuted = false;
|
||||
let isVideoMuted = 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 speechRecognizer = null;
|
||||
let emotionTimeline = [];
|
||||
@ -33,6 +45,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
window.toggleAudio = toggleAudio;
|
||||
window.toggleVideo = toggleVideo;
|
||||
window.toggleScreenShare = toggleScreenShare;
|
||||
window.toggleCallRecording = toggleCallRecording;
|
||||
window.sendChatMessage = sendChatMessage;
|
||||
window.endMeetingAndGenerateMoM = endMeetingAndGenerateMoM;
|
||||
window.leaveCall = leaveCall;
|
||||
@ -86,6 +99,7 @@ function toggleAudio() {
|
||||
const btn = document.getElementById('btnToggleMic');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active-off', isAudioMuted);
|
||||
btn.title = isAudioMuted ? 'Unmute Microphone' : 'Mute Microphone';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -99,6 +113,7 @@ function toggleVideo() {
|
||||
const btn = document.getElementById('btnToggleVideo');
|
||||
if (btn) {
|
||||
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');
|
||||
if (!isScreenSharing) {
|
||||
try {
|
||||
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
|
||||
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
|
||||
const screenTrack = screenStream.getVideoTracks()[0];
|
||||
|
||||
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
|
||||
* =========================================================================
|
||||
*/
|
||||
function initSpeechRecognition() {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
@ -318,6 +568,7 @@ let outgoingChatMessage = null;
|
||||
|
||||
function sendChatMessage() {
|
||||
const input = document.getElementById('chatInput');
|
||||
if (!input) return;
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
@ -374,6 +625,8 @@ function renderChat(messages) {
|
||||
|
||||
function handleActivePeers(peers) {
|
||||
const grid = document.getElementById('videoGrid');
|
||||
if (!grid) return;
|
||||
|
||||
peers.forEach(peer => {
|
||||
if (peer.peer_id === peerId) return;
|
||||
|
||||
@ -383,7 +636,7 @@ function handleActivePeers(peers) {
|
||||
tile.id = 'tile_' + peer.peer_id;
|
||||
tile.className = 'video-tile';
|
||||
tile.innerHTML = `
|
||||
<div class="video-avatar-placeholder">${peer.peer_name.charAt(0).toUpperCase()}</div>
|
||||
<div class="video-avatar-placeholder">${(peer.peer_name || 'U').charAt(0).toUpperCase()}</div>
|
||||
<div class="video-overlay">
|
||||
<span>${escapeHtml(peer.peer_name)}</span>
|
||||
</div>
|
||||
@ -408,6 +661,12 @@ async function endMeetingAndGenerateMoM() {
|
||||
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 transcriptText = accumulatedTranscript.map(l => `[${l.time}] ${l.speaker}: ${l.text}`).join("\n");
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
@ -415,7 +674,7 @@ async function endMeetingAndGenerateMoM() {
|
||||
const btn = document.querySelector('.btn-end-call');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerText = 'Generating AI MoM & Dispatching Emails...';
|
||||
btn.innerText = 'Generating AI MoM & Saving Notes...';
|
||||
}
|
||||
|
||||
try {
|
||||
@ -429,7 +688,8 @@ async function endMeetingAndGenerateMoM() {
|
||||
body: JSON.stringify({
|
||||
transcript: transcriptText,
|
||||
live_notes: liveNotes,
|
||||
emotion_timeline: emotionTimeline
|
||||
emotion_timeline: emotionTimeline,
|
||||
recording_path: recordedVideoPath
|
||||
})
|
||||
});
|
||||
|
||||
@ -440,20 +700,23 @@ async function endMeetingAndGenerateMoM() {
|
||||
window.location.href = config.redirectUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Meeting ended. Redirecting to Client Hub...');
|
||||
alert('Meeting completed. Redirecting to Client Hub...');
|
||||
window.location.href = config.redirectUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function leaveCall() {
|
||||
if (confirm('Leave this meeting?')) {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
}
|
||||
window.location.href = config.redirectUrl || '/';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, function(m) {
|
||||
return String(str).replace(/[&<>"']/g, function(m) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m];
|
||||
});
|
||||
}
|
||||
|
||||
@ -118,6 +118,10 @@
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.join-card { padding: 1.75rem 1.25rem; }
|
||||
.meeting-title { font-size: 1.25rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@ -3,11 +3,12 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
<!-- Google Fonts: Outfit & Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
<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>
|
||||
(function () {
|
||||
@ -23,63 +24,72 @@
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #0b0f19;
|
||||
--card-bg: rgba(17, 24, 39, 0.55);
|
||||
--card-border: rgba(255, 255, 255, 0.12);
|
||||
--card-bg: rgba(17, 24, 39, 0.6);
|
||||
--card-border: rgba(255, 255, 255, 0.1);
|
||||
--card-hover-border: rgba(99, 102, 241, 0.45);
|
||||
--accent-primary: #4f46e5;
|
||||
--accent-secondary: #06b6d4;
|
||||
--accent-glow: rgba(99, 102, 241, 0.25);
|
||||
--text-main: #f3f4f6;
|
||||
--text-muted: #9ca3af;
|
||||
--success-color: #10b981;
|
||||
--warning-color: #f59e0b;
|
||||
--danger-color: #ef4444;
|
||||
--input-bg: rgba(255, 255, 255, 0.04);
|
||||
--modal-bg: #131b2e;
|
||||
--input-bg: rgba(255, 255, 255, 0.05);
|
||||
--modal-bg: rgba(15, 23, 42, 0.95);
|
||||
--modal-overlay: rgba(11, 15, 25, 0.85);
|
||||
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||
}
|
||||
|
||||
:root.light-mode {
|
||||
--bg-color: #f3f4f6;
|
||||
--card-bg: rgba(255, 255, 255, 0.85);
|
||||
--bg-color: #f0f4f8;
|
||||
--card-bg: rgba(255, 255, 255, 0.8);
|
||||
--card-border: rgba(0, 0, 0, 0.08);
|
||||
--card-hover-border: rgba(79, 70, 229, 0.4);
|
||||
--accent-primary: #4f46e5;
|
||||
--accent-secondary: #0284c7;
|
||||
--text-main: #1f2937;
|
||||
--text-muted: #4b5563;
|
||||
--accent-glow: rgba(79, 70, 229, 0.15);
|
||||
--text-main: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
--success-color: #059669;
|
||||
--warning-color: #d97706;
|
||||
--danger-color: #dc2626;
|
||||
--input-bg: rgba(0, 0, 0, 0.03);
|
||||
--modal-bg: #ffffff;
|
||||
--modal-overlay: rgba(243, 244, 246, 0.85);
|
||||
--modal-bg: rgba(255, 255, 255, 0.96);
|
||||
--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; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
background-image:
|
||||
radial-gradient(circle at 85% 15%, rgba(99, 102, 241, 0.12) 0%, transparent 45%),
|
||||
radial-gradient(circle at 15% 85%, rgba(6, 182, 212, 0.1) 0%, transparent 45%);
|
||||
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.12) 0%, transparent 45%);
|
||||
background-attachment: fixed;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
/* Glass Navbar */
|
||||
.navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
background: rgba(10, 15, 26, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(11, 15, 25, 0.7);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
:root.light-mode .navbar {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
@ -96,62 +106,86 @@
|
||||
background: linear-gradient(135deg, #4f46e5, #06b6d4);
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.1rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
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);
|
||||
background: var(--card-bg);
|
||||
color: var(--text-main);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.nav-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
transform: translateY(-2px);
|
||||
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: none;
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.35);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
.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 */
|
||||
.main-container {
|
||||
max-width: 1300px;
|
||||
max-width: 1320px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* Alert Banners */
|
||||
/* Alert */
|
||||
.alert {
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 10px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.9rem;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.alert-success {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
@ -162,49 +196,56 @@
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 14px;
|
||||
padding: 1.25rem;
|
||||
border-radius: 16px;
|
||||
padding: 1.35rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
gap: 1.2rem;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
.stat-card:hover {
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--card-hover-border);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 28px var(--accent-glow);
|
||||
}
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.6rem;
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
color: #818cf8;
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
.stat-icon.danger {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
.stat-icon.cyan {
|
||||
background: rgba(6, 182, 212, 0.12);
|
||||
color: #22d3ee;
|
||||
border-color: rgba(6, 182, 212, 0.2);
|
||||
}
|
||||
.stat-icon.success {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
.stat-icon.emerald {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #34d399;
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
.stat-val {
|
||||
font-size: 1.6rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
color: var(--text-main);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
@ -212,6 +253,7 @@
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Section Header */
|
||||
@ -225,12 +267,12 @@
|
||||
}
|
||||
.section-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
.section-desc {
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
@ -239,32 +281,37 @@
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.search-input {
|
||||
.search-box {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
padding: 0.65rem 1rem;
|
||||
min-width: 280px;
|
||||
}
|
||||
.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);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
color: var(--text-main);
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.search-input:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
.filter-select {
|
||||
padding: 0.65rem 1rem;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-main);
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
/* Client Cards Grid */
|
||||
@ -273,23 +320,34 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.clients-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.navbar {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.client-card {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 16px;
|
||||
border-radius: 18px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
transition: all 0.25s ease;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
.client-card:hover {
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.25);
|
||||
border-color: var(--card-hover-border);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 16px 36px var(--accent-glow);
|
||||
}
|
||||
|
||||
.client-card-header {
|
||||
@ -300,64 +358,30 @@
|
||||
}
|
||||
.project-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.2rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.client-company {
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Sentiment Badges */
|
||||
.sentiment-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sentiment-happy {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #34d399;
|
||||
border: 1px solid rgba(16, 185, 129, 0.35);
|
||||
}
|
||||
.sentiment-neutral {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #60a5fa;
|
||||
border: 1px solid rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
.sentiment-sad {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #fbbf24;
|
||||
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||
}
|
||||
.sentiment-angry {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(239, 68, 68, 0.45);
|
||||
animation: pulse-border 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-border {
|
||||
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
|
||||
}
|
||||
|
||||
.client-info-list {
|
||||
margin: 1rem 0;
|
||||
margin: 1.25rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
gap: 10px;
|
||||
font-size: 0.875rem;
|
||||
background: var(--input-bg);
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--card-border);
|
||||
}
|
||||
.info-row {
|
||||
display: flex;
|
||||
@ -367,76 +391,48 @@
|
||||
}
|
||||
.info-row span:last-child {
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.responsible-badge {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #818cf8;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.angry-alert-box {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
padding: 3px 10px;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin: 12px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.angry-alert-text {
|
||||
font-size: 0.8rem;
|
||||
color: #fca5a5;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-why-angry {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-why-angry:hover {
|
||||
background: #dc2626;
|
||||
transform: scale(1.03);
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
gap: 10px;
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--card-border);
|
||||
}
|
||||
.btn-hub {
|
||||
.btn-open-hub {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
gap: 8px;
|
||||
padding: 0.7rem 1.2rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
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;
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.btn-hub:hover {
|
||||
background: #4f46e5;
|
||||
.btn-open-hub:hover {
|
||||
background: linear-gradient(135deg, #4f46e5, #06b6d4);
|
||||
color: white;
|
||||
border-color: #4f46e5;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Modal styling */
|
||||
@ -444,22 +440,25 @@
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: var(--modal-overlay);
|
||||
backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
padding: 1rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--modal-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 16px;
|
||||
border-radius: 20px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-width: 620px;
|
||||
max-height: 90vh;
|
||||
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;
|
||||
}
|
||||
.modal-header {
|
||||
@ -470,7 +469,7 @@
|
||||
}
|
||||
.modal-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.35rem;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.modal-close {
|
||||
@ -479,8 +478,18 @@
|
||||
color: var(--text-muted);
|
||||
font-size: 1.5rem;
|
||||
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 {
|
||||
margin-bottom: 1.25rem;
|
||||
@ -494,16 +503,18 @@
|
||||
}
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.7rem 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
color: var(--text-main);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus {
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
.form-row {
|
||||
display: grid;
|
||||
@ -519,14 +530,14 @@
|
||||
overflow-y: auto;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
.member-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-main);
|
||||
}
|
||||
@ -535,27 +546,43 @@
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Navbar -->
|
||||
<!-- Glass Navbar -->
|
||||
<nav class="navbar">
|
||||
<a href="{{ route('dashboard') }}" class="brand-logo">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: #6366f1;">
|
||||
<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"/>
|
||||
</svg>
|
||||
SingleLogin <span class="brand-badge">Client Calls</span>
|
||||
</a>
|
||||
<div class="nav-actions">
|
||||
<!-- Theme Toggle -->
|
||||
<button class="theme-toggle-btn" id="themeToggleBtn" onclick="toggleTheme()" title="Switch Light / Dark Theme" style="background: var(--card-bg); border: 1px solid var(--card-border); color: var(--text-main); width: 38px; height: 38px; border-radius: 8px; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1rem;">
|
||||
<button class="theme-toggle-btn" id="themeToggleBtn" onclick="toggleTheme()" title="Switch Light / Dark Theme">
|
||||
🌙
|
||||
</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>
|
||||
Date-wise Call Logs
|
||||
</a>
|
||||
@ -564,7 +591,7 @@
|
||||
Add Client & Project
|
||||
</button>
|
||||
<a href="{{ route('dashboard') }}" class="nav-btn">
|
||||
Back to Dashboard
|
||||
Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
@ -575,7 +602,7 @@
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success">
|
||||
<span>{{ session('success') }}</span>
|
||||
<button style="background:transparent;border:none;color:inherit;cursor:pointer;" onclick="this.parentElement.remove()">✕</button>
|
||||
<button style="background:transparent;border:none;color:inherit;cursor:pointer;font-size:1.1rem;" onclick="this.parentElement.remove()">✕</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@ -585,25 +612,21 @@
|
||||
<div class="stat-icon">📁</div>
|
||||
<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 class="stat-card">
|
||||
<div class="stat-icon success">📞</div>
|
||||
<div class="stat-icon emerald">📞</div>
|
||||
<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 class="stat-card">
|
||||
<div class="stat-icon {{ $angryClientsCount > 0 ? 'danger' : '' }}">
|
||||
{{ $angryClientsCount > 0 ? '⚠️' : '✨' }}
|
||||
</div>
|
||||
<div class="stat-icon cyan">🚀</div>
|
||||
<div>
|
||||
<div class="stat-val" style="{{ $angryClientsCount > 0 ? 'color: #f87171;' : '' }}">
|
||||
{{ $angryClientsCount }}
|
||||
</div>
|
||||
<div class="stat-label">Client Attention Alerts</div>
|
||||
<div class="stat-val">{{ $activeProjectsCount ?? $totalClients }}</div>
|
||||
<div class="stat-label">Active Projects</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -612,28 +635,24 @@
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h1 class="section-title">Client Projects & Meeting Hub</h1>
|
||||
<p class="section-desc">Manage clients, schedule future calls, start instant meetings with AI transcription, and review automated MoMs.</p>
|
||||
<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>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<!-- Search Bar -->
|
||||
<div class="filter-bar">
|
||||
<input type="text" id="searchInput" class="search-input" placeholder="Search by client name, project, company, or lead..." onkeyup="filterCards()">
|
||||
<select id="sentimentFilter" class="filter-select" onchange="filterCards()">
|
||||
<option value="all">All Sentiments</option>
|
||||
<option value="happy">Happy 😊</option>
|
||||
<option value="neutral">Neutral 😐</option>
|
||||
<option value="sad">Sad / Concerned 😟</option>
|
||||
<option value="angry">Angry / Frustrated 😡</option>
|
||||
</select>
|
||||
<div class="search-box">
|
||||
<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>
|
||||
<input type="text" id="searchInput" class="search-input" placeholder="Search by client name, project, company, or lead..." onkeyup="filterCards()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clients Grid -->
|
||||
@if($clients->isEmpty())
|
||||
<div class="empty-state">
|
||||
<div style="font-size: 3rem; margin-bottom: 1rem;">💼</div>
|
||||
<h3 style="font-size: 1.25rem; font-weight: 700; margin-bottom: 6px;">No Client Projects Found</h3>
|
||||
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 1.5rem;">Get started by adding your first client and project details.</p>
|
||||
<div style="font-size: 3.5rem; margin-bottom: 1rem;">💼</div>
|
||||
<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 registering your first client and project profile.</p>
|
||||
<button class="nav-btn-primary nav-btn" onclick="openAddClientModal()">
|
||||
+ Add New Client & Project
|
||||
</button>
|
||||
@ -642,31 +661,15 @@
|
||||
<div class="clients-grid" id="clientsGrid">
|
||||
@foreach($clients as $client)
|
||||
@php
|
||||
$sentiment = strtolower($client->latest_sentiment ?? 'neutral');
|
||||
$badgeClass = match($sentiment) {
|
||||
'happy' => 'sentiment-happy',
|
||||
'sad' => 'sentiment-sad',
|
||||
'angry' => 'sentiment-angry',
|
||||
default => 'sentiment-neutral',
|
||||
};
|
||||
$sentimentEmoji = match($sentiment) {
|
||||
'happy' => '😊 Happy',
|
||||
'sad' => '😟 Concerned',
|
||||
'angry' => '😡 Frustrated',
|
||||
default => '😐 Neutral',
|
||||
};
|
||||
|
||||
$meetingsCount = $client->meetings->count();
|
||||
$latestNote = $client->callNotes->first();
|
||||
$canViewAngry = $client->canViewAngryAnalysis(Auth::user());
|
||||
@endphp
|
||||
|
||||
<div class="client-card"
|
||||
data-name="{{ strtolower($client->client_name) }}"
|
||||
data-project="{{ strtolower($client->project_name) }}"
|
||||
data-company="{{ strtolower($client->company_name ?? '') }}"
|
||||
data-lead="{{ strtolower($client->responsibleUser->name ?? '') }}"
|
||||
data-sentiment="{{ $sentiment }}">
|
||||
data-lead="{{ strtolower($client->responsibleUser->name ?? '') }}">
|
||||
|
||||
<div>
|
||||
<div class="client-card-header">
|
||||
@ -677,9 +680,6 @@
|
||||
{{ $client->client_name }} {{ $client->company_name ? "• " . $client->company_name : "" }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="sentiment-badge {{ $badgeClass }}">
|
||||
{{ $sentimentEmoji }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="client-info-list">
|
||||
@ -692,8 +692,8 @@
|
||||
<span class="responsible-badge">{{ $client->responsibleUser->name ?? 'Unassigned' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>Total Meetings:</span>
|
||||
<span>{{ $meetingsCount }} calls</span>
|
||||
<span>Total Calls:</span>
|
||||
<span>{{ $meetingsCount }} {{ $meetingsCount === 1 ? 'call' : 'calls' }}</span>
|
||||
</div>
|
||||
@if($latestNote)
|
||||
<div class="info-row">
|
||||
@ -702,22 +702,12 @@
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Angry Escalation Alert (Strictly visible only to Admin & Responsible Person) -->
|
||||
@if($sentiment === 'angry' && $latestNote && $canViewAngry)
|
||||
<div class="angry-alert-box">
|
||||
<span class="angry-alert-text">⚠️ Client Dissatisfaction Detected</span>
|
||||
<button class="btn-why-angry" onclick="openWhyAngryModal({{ $latestNote->id }})">
|
||||
Why is he angry?
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<a href="{{ route('client-calls.show', $client->id) }}" target="_blank" class="btn-hub" title="Open Client Hub in new tab">
|
||||
<span>Open Client Hub</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>
|
||||
<a href="{{ route('client-calls.show', $client->id) }}" target="_blank" class="btn-open-hub" title="Open Client Hub in new tab">
|
||||
<span>Open Client Hub & Call Details</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -799,31 +789,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Why Is He Angry Modal (Confidential to Admin & Responsible Person) -->
|
||||
<div id="whyAngryModal" class="modal-overlay">
|
||||
<div class="modal-card" style="max-width: 650px; border-color: rgba(239, 68, 68, 0.4);">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h2 class="modal-title" style="color: #f87171; display: flex; align-items: center; gap: 8px;">
|
||||
<span>😡</span> AI Root-Cause Analysis: Why is the client angry?
|
||||
</h2>
|
||||
<p style="font-size: 0.75rem; color: #fca5a5; margin-top: 4px;">Strictly Confidential • Restricted to Admin and Assigned Lead</p>
|
||||
</div>
|
||||
<button class="modal-close" onclick="closeWhyAngryModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div id="whyAngryContent">
|
||||
<div style="text-align: center; padding: 2rem; color: var(--text-muted);">
|
||||
Loading AI Analysis...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 1.5rem;">
|
||||
<button type="button" class="nav-btn" onclick="closeWhyAngryModal()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openAddClientModal() {
|
||||
document.getElementById('addClientModal').style.display = 'flex';
|
||||
@ -834,7 +799,6 @@ function closeAddClientModal() {
|
||||
|
||||
function filterCards() {
|
||||
const query = document.getElementById('searchInput').value.toLowerCase();
|
||||
const sentiment = document.getElementById('sentimentFilter').value;
|
||||
const cards = document.querySelectorAll('.client-card');
|
||||
|
||||
cards.forEach(card => {
|
||||
@ -842,12 +806,10 @@ function filterCards() {
|
||||
const project = card.dataset.project || '';
|
||||
const company = card.dataset.company || '';
|
||||
const lead = card.dataset.lead || '';
|
||||
const cardSentiment = card.dataset.sentiment || '';
|
||||
|
||||
const matchesQuery = name.includes(query) || project.includes(query) || company.includes(query) || lead.includes(query);
|
||||
const matchesSentiment = (sentiment === 'all') || (cardSentiment === sentiment);
|
||||
|
||||
if (matchesQuery && matchesSentiment) {
|
||||
if (matchesQuery) {
|
||||
card.style.display = 'flex';
|
||||
} else {
|
||||
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} • ${data.project_name}</div>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(239,68,68,0.1); border-left: 4px solid #ef4444; padding: 14px; border-radius: 0 8px 8px 0; margin-bottom: 1.25rem;">
|
||||
<div style="font-weight: 700; font-size: 0.85rem; color: #fca5a5; margin-bottom: 6px; text-transform: uppercase;">AI Executive Breakdown</div>
|
||||
<p style="font-size: 0.9rem; color: #fee2e2; line-height: 1.5;">${analysis.summary_why_angry || 'Client showed frustration during the meeting.'}</p>
|
||||
</div>
|
||||
|
||||
${triggers.length > 0 ? `
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<div style="font-weight: 600; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 6px;">Identified Angry / Negative Keywords:</div>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
|
||||
${triggers.map(t => `<span style="background: rgba(239,68,68,0.25); color: #fca5a5; font-size: 0.75rem; padding: 3px 8px; border-radius: 4px; border: 1px solid rgba(239,68,68,0.4); font-weight: 600;">"${t}"</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${grievances.length > 0 ? `
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<div style="font-weight: 600; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 6px;">Key Complaints / Pain Points:</div>
|
||||
<ul style="padding-left: 20px; font-size: 0.85rem; color: #cbd5e1; line-height: 1.6;">
|
||||
${grievances.map(g => `<li>${g}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${analysis.suggested_remedy ? `
|
||||
<div style="background: rgba(16,185,129,0.1); border: 1px solid rgba(16,185,129,0.3); border-radius: 8px; padding: 12px;">
|
||||
<div style="font-weight: 700; font-size: 0.8rem; color: #34d399; margin-bottom: 4px; text-transform: uppercase;">💡 Recommended Action for PM & Director:</div>
|
||||
<p style="font-size: 0.85rem; color: #d1fae5; line-height: 1.5;">${analysis.suggested_remedy}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
} catch (err) {
|
||||
content.innerHTML = `<div style="color:#f87171;padding:1rem;">Failed to load analysis. Please try again.</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const isLight = document.documentElement.classList.toggle('light-mode');
|
||||
localStorage.setItem('theme', isLight ? 'light' : 'dark');
|
||||
@ -928,10 +828,6 @@ function toggleTheme() {
|
||||
const btn = document.getElementById('themeToggleBtn');
|
||||
if (btn) btn.innerText = isLight ? '☀️' : '🌙';
|
||||
});
|
||||
|
||||
function closeWhyAngryModal() {
|
||||
document.getElementById('whyAngryModal').style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
<!-- Google Fonts: Outfit & Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
<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>
|
||||
(function () {
|
||||
@ -24,34 +24,40 @@
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #0b0f19;
|
||||
--card-bg: rgba(17, 24, 39, 0.55);
|
||||
--card-border: rgba(255, 255, 255, 0.12);
|
||||
--card-bg: rgba(17, 24, 39, 0.6);
|
||||
--card-border: rgba(255, 255, 255, 0.1);
|
||||
--card-hover-border: rgba(99, 102, 241, 0.45);
|
||||
--accent-primary: #4f46e5;
|
||||
--accent-secondary: #06b6d4;
|
||||
--accent-glow: rgba(99, 102, 241, 0.25);
|
||||
--text-main: #f3f4f6;
|
||||
--text-muted: #9ca3af;
|
||||
--success-color: #10b981;
|
||||
--warning-color: #f59e0b;
|
||||
--danger-color: #ef4444;
|
||||
--input-bg: rgba(255, 255, 255, 0.04);
|
||||
--modal-bg: #131b2e;
|
||||
--input-bg: rgba(255, 255, 255, 0.05);
|
||||
--modal-bg: rgba(15, 23, 42, 0.96);
|
||||
--modal-overlay: rgba(11, 15, 25, 0.85);
|
||||
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||
}
|
||||
|
||||
:root.light-mode {
|
||||
--bg-color: #f3f4f6;
|
||||
--card-bg: rgba(255, 255, 255, 0.85);
|
||||
--bg-color: #f0f4f8;
|
||||
--card-bg: rgba(255, 255, 255, 0.82);
|
||||
--card-border: rgba(0, 0, 0, 0.08);
|
||||
--card-hover-border: rgba(79, 70, 229, 0.4);
|
||||
--accent-primary: #4f46e5;
|
||||
--accent-secondary: #0284c7;
|
||||
--text-main: #1f2937;
|
||||
--text-muted: #4b5563;
|
||||
--accent-glow: rgba(79, 70, 229, 0.15);
|
||||
--text-main: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
--success-color: #059669;
|
||||
--warning-color: #d97706;
|
||||
--danger-color: #dc2626;
|
||||
--input-bg: rgba(0, 0, 0, 0.03);
|
||||
--modal-bg: #ffffff;
|
||||
--modal-overlay: rgba(243, 244, 246, 0.85);
|
||||
--modal-bg: rgba(255, 255, 255, 0.96);
|
||||
--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; }
|
||||
@ -61,8 +67,8 @@
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
background-image:
|
||||
radial-gradient(circle at 85% 15%, rgba(99, 102, 241, 0.12) 0%, transparent 45%),
|
||||
radial-gradient(circle at 15% 85%, rgba(6, 182, 212, 0.1) 0%, transparent 45%);
|
||||
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.12) 0%, transparent 45%);
|
||||
background-attachment: fixed;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
@ -73,14 +79,15 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
background: rgba(10, 15, 26, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(11, 15, 25, 0.7);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
:root.light-mode .navbar { background: rgba(255, 255, 255, 0.8); }
|
||||
:root.light-mode .navbar { background: rgba(255, 255, 255, 0.82); }
|
||||
|
||||
.brand-logo {
|
||||
display: flex;
|
||||
@ -95,8 +102,8 @@
|
||||
.brand-badge {
|
||||
background: linear-gradient(135deg, #06b6d4, #3b82f6);
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75rem;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
@ -106,44 +113,53 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.1rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
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);
|
||||
background: var(--card-bg);
|
||||
color: var(--text-main);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.nav-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
transform: translateY(-2px);
|
||||
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 {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
color: var(--text-main);
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 1.1rem;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.main-container {
|
||||
max-width: 1300px;
|
||||
max-width: 1320px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
@ -157,31 +173,34 @@
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 14px;
|
||||
padding: 1.25rem;
|
||||
border-radius: 16px;
|
||||
padding: 1.25rem 1.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
.stat-card:hover {
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--card-hover-border);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 10px 24px var(--accent-glow);
|
||||
}
|
||||
.stat-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.3rem;
|
||||
font-size: 1.4rem;
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
.stat-val {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
color: var(--text-main);
|
||||
@ -191,6 +210,7 @@
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Section Header */
|
||||
@ -204,7 +224,7 @@
|
||||
}
|
||||
.section-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
@ -219,27 +239,32 @@
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
padding: 0.65rem 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--input-bg);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
color: var(--text-main);
|
||||
font-size: 0.875rem;
|
||||
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 {
|
||||
padding: 0.65rem 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--input-bg);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
color: var(--text-main);
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Date Group Header */
|
||||
/* Date Group */
|
||||
.date-group {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
@ -249,19 +274,18 @@
|
||||
gap: 12px;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.date-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
.date-badge {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
.date-title-badge {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(6, 182, 212, 0.2));
|
||||
color: #818cf8;
|
||||
font-size: 0.75rem;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 10px;
|
||||
padding: 4px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.date-line {
|
||||
flex: 1;
|
||||
@ -272,20 +296,22 @@
|
||||
/* Call Log Entry Card */
|
||||
.log-entry-card {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
border-radius: 18px;
|
||||
padding: 1.6rem;
|
||||
margin-bottom: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
transition: all 0.25s ease;
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
.log-entry-card:hover {
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.25);
|
||||
border-color: var(--card-hover-border);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 30px var(--accent-glow);
|
||||
}
|
||||
|
||||
.log-card-top {
|
||||
@ -297,12 +323,22 @@
|
||||
}
|
||||
.log-project-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.2rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
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 {
|
||||
font-size: 0.85rem;
|
||||
@ -314,21 +350,21 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Interactive Clickable Emotion Badges */
|
||||
/* Clickable Emotion Badges */
|
||||
.emotion-clickable {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.emotion-clickable:hover {
|
||||
transform: scale(1.05);
|
||||
transform: scale(1.04);
|
||||
}
|
||||
.emotion-happy {
|
||||
background: rgba(16, 185, 129, 0.18);
|
||||
@ -358,11 +394,11 @@
|
||||
}
|
||||
|
||||
.summary-preview-box {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-left: 3px solid #6366f1;
|
||||
padding: 10px 14px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-size: 0.875rem;
|
||||
padding: 12px 14px;
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-size: 0.9rem;
|
||||
color: #cbd5e1;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@ -371,7 +407,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--card-border);
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
@ -381,9 +417,9 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
@ -408,38 +444,41 @@
|
||||
border-color: rgba(16, 185, 129, 0.35);
|
||||
}
|
||||
|
||||
/* Modal styling */
|
||||
/* Modals */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: var(--modal-overlay);
|
||||
backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
padding: 1rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--modal-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 16px;
|
||||
border-radius: 20px;
|
||||
width: 100%;
|
||||
max-width: 650px;
|
||||
max-width: 660px;
|
||||
max-height: 90vh;
|
||||
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;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.modal-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.3rem;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.modal-close {
|
||||
@ -448,36 +487,41 @@
|
||||
color: var(--text-muted);
|
||||
font-size: 1.5rem;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.todo-item-row.completed {
|
||||
opacity: 0.6;
|
||||
opacity: 0.55;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.todo-checkbox {
|
||||
margin-top: 3px;
|
||||
cursor: pointer;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
accent-color: #10b981;
|
||||
}
|
||||
.todo-content {
|
||||
flex: 1;
|
||||
}
|
||||
.todo-content { flex: 1; }
|
||||
.todo-text {
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
}
|
||||
@ -492,7 +536,7 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Navbar -->
|
||||
<!-- Glass Navbar -->
|
||||
<nav class="navbar">
|
||||
<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>
|
||||
@ -549,7 +593,7 @@
|
||||
<div class="stat-icon" style="color: #22d3ee;">📋</div>
|
||||
<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>
|
||||
@ -558,8 +602,8 @@
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h1 class="section-title">Date-wise Client Call Logs</h1>
|
||||
<p style="color: var(--text-muted); font-size: 0.875rem; margin-top: 4px;">
|
||||
Review date-wise meeting transcripts, click client expressions to inspect emotional drivers, and export MoMs & To-Do lists in .txt format.
|
||||
<p style="color: var(--text-muted); font-size: 0.9rem; margin-top: 4px;">
|
||||
Review date-wise meeting transcripts, inspect emotional sentiment drivers, and export MoMs & actionable tasks in .txt format.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -584,7 +628,7 @@
|
||||
|
||||
<!-- Grouped Date-wise List -->
|
||||
@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>
|
||||
<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>
|
||||
@ -593,8 +637,12 @@
|
||||
@foreach($groupedNotes as $dateKey => $notes)
|
||||
<div class="date-group" data-date="{{ $dateKey }}">
|
||||
<div class="date-header">
|
||||
<span class="date-title">📅 {{ \Carbon\Carbon::parse($dateKey)->format('F j, Y') }}</span>
|
||||
<span class="date-badge">{{ $notes->count() }} {{ $notes->count() === 1 ? 'call' : 'calls' }}</span>
|
||||
<div class="date-title-badge">
|
||||
<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>
|
||||
|
||||
@ -614,6 +662,17 @@
|
||||
default => '😐 Neutral',
|
||||
};
|
||||
$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
|
||||
|
||||
<div class="log-entry-card"
|
||||
@ -626,9 +685,14 @@
|
||||
<div>
|
||||
<div class="log-project-title">
|
||||
{{ $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' }}
|
||||
</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 class="log-client-meta">
|
||||
<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 -->
|
||||
<button class="emotion-clickable {{ $badgeClass }}" onclick="openEmotionModal({{ $note->id }})" title="Click to view why the client felt this way">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -652,23 +716,29 @@
|
||||
<!-- Action Buttons Bar -->
|
||||
<div class="log-actions-bar">
|
||||
<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">
|
||||
📥 Download MoM (.txt)
|
||||
</a>
|
||||
|
||||
<!-- Interactive To-Do List Button -->
|
||||
<button class="btn-action-small btn-download-todos" onclick="openTodoModal({{ $note->id }}, '{{ addslashes($note->client->project_name ?? '') }}')">
|
||||
📋 To-Do Tasks ({{ $todoCount }})
|
||||
📋 Tasks ({{ $todoCount }})
|
||||
</button>
|
||||
|
||||
<!-- Download To-Do .txt directly -->
|
||||
<a href="{{ route('client-calls.download-todos', $note->id) }}" class="btn-action-small" title="Direct download To-Do List in text format">
|
||||
📄 To-Do (.txt)
|
||||
<a href="{{ route('client-calls.download-todos', $note->id) }}" class="btn-action-small" title="Direct download Tasks in text format">
|
||||
📄 Tasks (.txt)
|
||||
</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 style="display: flex; gap: 8px;">
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<button class="btn-action-small" onclick="openFullMomModal({{ $note->id }})">
|
||||
View Full MoM
|
||||
</button>
|
||||
@ -685,12 +755,12 @@
|
||||
|
||||
</main>
|
||||
|
||||
<!-- 1. Interactive Emotion Intelligence Modal (Why Happy, Why Sad, Why Angry) -->
|
||||
<!-- 1. Interactive Emotion Intelligence Modal -->
|
||||
<div id="emotionModal" class="modal-overlay">
|
||||
<div class="modal-card" id="emotionModalCard">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<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
|
||||
</h2>
|
||||
<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>
|
||||
<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>
|
||||
<p id="todoModalSubmeta" style="font-size: 0.75rem; color: var(--text-muted); margin-top: 4px;"></p>
|
||||
</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;">
|
||||
<a id="btnDownloadTodoFile" href="#" class="nav-btn-primary nav-btn" style="font-size: 0.85rem;">
|
||||
📥 Download To-Do List (.txt)
|
||||
📥 Download Tasks (.txt)
|
||||
</a>
|
||||
<button type="button" class="nav-btn" onclick="closeTodoModal()">Done</button>
|
||||
</div>
|
||||
@ -747,7 +817,7 @@
|
||||
<button class="modal-close" onclick="closeFullMomModal()">×</button>
|
||||
</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 style="display: flex; justify-content: space-between; align-items: center; margin-top: 1.25rem;">
|
||||
@ -759,8 +829,30 @@
|
||||
</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()">×</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>
|
||||
// Theme Toggle
|
||||
function toggleTheme() {
|
||||
const isLight = document.documentElement.classList.toggle('light-mode');
|
||||
localStorage.setItem('theme', isLight ? 'light' : 'dark');
|
||||
@ -772,7 +864,6 @@ function toggleTheme() {
|
||||
if (btn) btn.innerText = isLight ? '☀️' : '🌙';
|
||||
});
|
||||
|
||||
// Filter Logs
|
||||
function filterLogs() {
|
||||
const query = document.getElementById('searchInput').value.toLowerCase();
|
||||
const sentiment = document.getElementById('sentimentFilter').value;
|
||||
@ -795,7 +886,6 @@ function filterLogs() {
|
||||
}
|
||||
});
|
||||
|
||||
// Hide empty date groups
|
||||
document.querySelectorAll('.date-group').forEach(group => {
|
||||
const visibleCards = group.querySelectorAll('.log-entry-card[style="display: flex;"]');
|
||||
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) {
|
||||
const modal = document.getElementById('emotionModal');
|
||||
const body = document.getElementById('emotionModalBody');
|
||||
const submeta = document.getElementById('emotionModalSubmeta');
|
||||
const card = document.getElementById('emotionModalCard');
|
||||
|
||||
modal.style.display = 'flex';
|
||||
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 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 = '😐';
|
||||
if (sentiment === 'happy') {
|
||||
bannerStyle = 'background: rgba(16,185,129,0.12); border-left: 4px solid #10b981; color: #34d399;';
|
||||
@ -848,9 +936,9 @@ function filterLogs() {
|
||||
}
|
||||
|
||||
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;">
|
||||
<span>${icon}</span> ${emo.headline || 'Client Emotional Analysis'}
|
||||
<span>${icon}</span> ${escapeHtml(emo.headline || 'Client Emotional Analysis')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -859,7 +947,7 @@ function filterLogs() {
|
||||
Key Reasons & Emotional Triggers:
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -869,17 +957,17 @@ function filterLogs() {
|
||||
Spoken Key Phrases / Quotes:
|
||||
</div>
|
||||
<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>
|
||||
` : ''}
|
||||
|
||||
${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;">
|
||||
💡 Recommended Action for Project Team:
|
||||
</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>
|
||||
` : ''}
|
||||
`;
|
||||
@ -889,10 +977,7 @@ function filterLogs() {
|
||||
}
|
||||
function closeEmotionModal() { document.getElementById('emotionModal').style.display = 'none'; }
|
||||
|
||||
// 2. Interactive To-Do Modal
|
||||
let activeNoteId = null;
|
||||
async function openTodoModal(noteId, projectName) {
|
||||
activeNoteId = noteId;
|
||||
const modal = document.getElementById('todoModal');
|
||||
const submeta = document.getElementById('todoModalSubmeta');
|
||||
const listEl = document.getElementById('todoModalList');
|
||||
@ -901,76 +986,51 @@ function closeEmotionModal() { document.getElementById('emotionModal').style.dis
|
||||
submeta.innerText = projectName ? `Project: ${projectName}` : '';
|
||||
downloadBtn.href = `/client-calls/notes/${noteId}/download-todos`;
|
||||
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 {
|
||||
const res = await fetch(`/client-calls/notes/${noteId}/emotion-details`, {
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
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) {
|
||||
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) {
|
||||
const row = document.getElementById(`todo_${todoId}`);
|
||||
const row = document.getElementById(`todo_${noteId}_${todoId}`);
|
||||
if (row) row.classList.toggle('completed', cb.checked);
|
||||
|
||||
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'; }
|
||||
|
||||
// 3. Full MoM Modal
|
||||
async function openFullMomModal(noteId) {
|
||||
const modal = document.getElementById('fullMomModal');
|
||||
const content = document.getElementById('fullMomContent');
|
||||
const submeta = document.getElementById('fullMomSubmeta');
|
||||
const downloadBtn = document.getElementById('btnDownloadMomFile');
|
||||
|
||||
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 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m];
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -28,15 +28,19 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: rgba(17, 24, 39, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(17, 24, 39, 0.75);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
z-index: 20;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.live-tag {
|
||||
background: #ef4444;
|
||||
@ -46,6 +50,7 @@
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
animation: pulse-live 2s infinite;
|
||||
}
|
||||
@keyframes pulse-live {
|
||||
@ -61,11 +66,38 @@
|
||||
}
|
||||
.call-timer {
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
color: #9ca3af;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 3px 8px;
|
||||
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 */
|
||||
@ -116,7 +148,7 @@
|
||||
width: 100%;
|
||||
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;
|
||||
align-items: center;
|
||||
}
|
||||
@ -127,8 +159,8 @@
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 240px;
|
||||
max-height: 520px;
|
||||
min-height: 220px;
|
||||
max-height: 540px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -148,7 +180,7 @@
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(6px);
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
@ -159,6 +191,7 @@
|
||||
gap: 6px;
|
||||
color: white;
|
||||
z-index: 10;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.video-avatar-placeholder {
|
||||
@ -173,6 +206,7 @@
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
|
||||
/* Collapsible Side Panel (AI Notes & Chat) */
|
||||
@ -210,6 +244,16 @@
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
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 {
|
||||
@ -226,6 +270,7 @@
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.panel-tab.active {
|
||||
color: #818cf8;
|
||||
@ -280,6 +325,9 @@
|
||||
outline: none;
|
||||
resize: none;
|
||||
}
|
||||
.notes-textarea:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
/* Chat messages */
|
||||
.chat-feed {
|
||||
@ -318,18 +366,23 @@
|
||||
font-size: 0.8rem;
|
||||
outline: none;
|
||||
}
|
||||
.chat-input:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
/* Bottom Controls Bar */
|
||||
.call-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
background: rgba(17, 24, 39, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 0.85rem 1.5rem;
|
||||
background: rgba(17, 24, 39, 0.88);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
gap: 12px;
|
||||
z-index: 20;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ctrl-btn {
|
||||
@ -352,11 +405,23 @@
|
||||
.ctrl-btn.active-off {
|
||||
background: #ef4444 !important;
|
||||
border-color: #ef4444 !important;
|
||||
color: white !important;
|
||||
}
|
||||
.ctrl-btn.active-on {
|
||||
background: #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 {
|
||||
padding: 0 1.25rem;
|
||||
@ -377,23 +442,78 @@
|
||||
.btn-end-call:hover {
|
||||
background: #dc2626;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.6);
|
||||
}
|
||||
|
||||
/* Toast & Modal */
|
||||
.toast-notify {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
bottom: 85px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(17, 24, 39, 0.95);
|
||||
border: 1px solid #4f46e5;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 0.85rem;
|
||||
display: none;
|
||||
z-index: 100;
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
@ -402,13 +522,19 @@
|
||||
<header class="call-header">
|
||||
<div class="header-left">
|
||||
<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>
|
||||
|
||||
<!-- 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 style="display: flex; align-items: center; gap: 12px;">
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||||
<!-- 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="emotionText">Client Emotion: Neutral</span>
|
||||
</div>
|
||||
@ -440,12 +566,12 @@
|
||||
<div class="panel-title">
|
||||
<span>✨</span> AI Meeting Assistant
|
||||
</div>
|
||||
<button class="panel-close-btn" onclick="toggleSidePanel()">×</button>
|
||||
<button class="panel-close-btn" onclick="toggleSidePanel()" title="Close side panel">×</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-tabs">
|
||||
<button class="panel-tab active" onclick="switchSideTab('notesTab', this)">📝 AI Notes & MoM</button>
|
||||
<button class="panel-tab" onclick="switchSideTab('chatTab', this)">💬 In-Call Chat</button>
|
||||
<button class="panel-tab" onclick="switchSideTab('chatTab', this)">💬 Chat</button>
|
||||
</div>
|
||||
|
||||
<!-- TAB 1: AI Live Notes -->
|
||||
@ -466,7 +592,7 @@
|
||||
</div>
|
||||
|
||||
@if($isHost)
|
||||
<button onclick="endMeetingAndGenerateMoM()" style="background: linear-gradient(135deg, #4f46e5, #06b6d4); color: white; border: none; padding: 10px; border-radius: 8px; font-weight: 700; font-size: 0.85rem; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 6px;">
|
||||
<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
|
||||
</button>
|
||||
@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>
|
||||
</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 -->
|
||||
<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>
|
||||
@ -520,6 +657,25 @@
|
||||
@endif
|
||||
</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 -->
|
||||
<canvas id="emotionCanvas" style="display: none;"></canvas>
|
||||
|
||||
@ -536,9 +692,10 @@
|
||||
isHost: {{ $isHost ? 'true' : 'false' }},
|
||||
isGuest: {{ $isGuest ? 'true' : 'false' }},
|
||||
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) }}',
|
||||
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>
|
||||
|
||||
@ -548,9 +705,11 @@
|
||||
<script>
|
||||
function showToast(msg) {
|
||||
const toast = document.getElementById('toastNotification');
|
||||
toast.innerText = msg;
|
||||
toast.style.display = 'block';
|
||||
setTimeout(() => { toast.style.display = 'none'; }, 3000);
|
||||
if (toast) {
|
||||
toast.innerText = msg;
|
||||
toast.style.display = 'block';
|
||||
setTimeout(() => { toast.style.display = 'none'; }, 3500);
|
||||
}
|
||||
}
|
||||
|
||||
function copyMeetingLink() {
|
||||
@ -560,15 +719,20 @@ function copyMeetingLink() {
|
||||
|
||||
function toggleSidePanel() {
|
||||
const panel = document.getElementById('sidePanel');
|
||||
panel.classList.toggle('collapsed');
|
||||
if (panel) {
|
||||
panel.classList.toggle('collapsed');
|
||||
}
|
||||
}
|
||||
|
||||
function switchSideTab(tabId, el) {
|
||||
document.getElementById('notesTab').style.display = 'none';
|
||||
document.getElementById('chatTab').style.display = 'none';
|
||||
const nTab = document.getElementById('notesTab');
|
||||
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.getElementById(tabId).style.display = 'flex';
|
||||
el.classList.add('active');
|
||||
const target = document.getElementById(tabId);
|
||||
if (target) target.style.display = 'flex';
|
||||
if (el) el.classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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}/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}/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::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)
|
||||
Route::get('/client-calls/room/{code}', [ClientCallController::class, 'showRoom'])->name('client-calls.room');
|
||||
Route::post('/client-calls/room/{code}/join-guest', [ClientCallController::class, 'joinGuest'])->name('client-calls.join-guest');
|
||||
Route::post('/client-calls/room/{code}/detect-emotion', [ClientCallController::class, 'detectEmotion'])->name('client-calls.detect-emotion');
|
||||
Route::post('/client-calls/room/{code}/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}/heartbeat', [ClientCallController::class, 'signalingHeartbeat'])->name('client-calls.heartbeat');
|
||||
|
||||
|
||||
@ -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']);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user