Compare commits
4 Commits
main
...
new_client
| Author | SHA1 | Date | |
|---|---|---|---|
| 4803f86074 | |||
| 80b772bc8b | |||
| 7e44093e8d | |||
| e03d6ce250 |
@ -2,9 +2,12 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\App;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCallNote;
|
||||
use App\Models\ClientMeeting;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Models\UserAppOverride;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@ -30,6 +33,9 @@ public function index()
|
||||
// Fetch all overrides
|
||||
$overrides = UserAppOverride::with(['user', 'app'])->orderBy('created_at', 'desc')->get();
|
||||
|
||||
// Fetch all client projects & meetings
|
||||
$clients = Client::with(['responsibleUser', 'creator', 'meetings', 'callNotes'])->orderBy('created_at', 'desc')->get();
|
||||
|
||||
// Calculate statistics
|
||||
$stats = [
|
||||
'total_users' => User::count(),
|
||||
@ -37,11 +43,14 @@ public function index()
|
||||
'blocked_count' => User::where('is_blocked', true)->count(),
|
||||
'apps_count' => App::count(),
|
||||
'roles_count' => Role::count(),
|
||||
'clients_count' => Client::count(),
|
||||
'client_meetings_count' => ClientMeeting::count(),
|
||||
'angry_clients_count' => Client::where('latest_sentiment', 'angry')->count(),
|
||||
];
|
||||
|
||||
$defaultRoleName = \App\Models\Setting::get('default_role', 'Developer');
|
||||
|
||||
return view('admin.dashboard', compact('admin', 'users', 'apps', 'roles', 'overrides', 'stats', 'defaultRoleName'));
|
||||
return view('admin.dashboard', compact('admin', 'users', 'apps', 'roles', 'overrides', 'clients', 'stats', 'defaultRoleName'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
963
app/Http/Controllers/ClientCallController.php
Normal file
963
app/Http/Controllers/ClientCallController.php
Normal file
@ -0,0 +1,963 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Mail\ClientMeetingInviteMail;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCallNote;
|
||||
use App\Models\ClientMeeting;
|
||||
use App\Models\User;
|
||||
use App\Services\AiClientCallService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ClientCallController extends Controller
|
||||
{
|
||||
protected AiClientCallService $aiService;
|
||||
|
||||
public function __construct(AiClientCallService $aiService)
|
||||
{
|
||||
$this->aiService = $aiService;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all client calls and projects.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user->canAccessClientCalls()) {
|
||||
return redirect()->route('dashboard')->with('error', 'Client Calls management is reserved for PMs, Team Leads, Directors, and Administrators.');
|
||||
}
|
||||
|
||||
$clients = $user->getAccessibleClients();
|
||||
$allUsers = User::orderBy('name', 'asc')->get();
|
||||
|
||||
// Filter managers / leads for dropdowns
|
||||
$managersAndLeads = $allUsers->filter(function ($u) {
|
||||
return $u->canAccessClientCalls();
|
||||
});
|
||||
|
||||
// Quick stats
|
||||
$totalClients = $clients->count();
|
||||
$totalMeetings = ClientMeeting::whereIn('client_id', $clients->pluck('id'))->count();
|
||||
$activeProjectsCount = $clients->where('status', 'active')->count();
|
||||
|
||||
return view('client_calls.index', compact('clients', 'allUsers', 'managersAndLeads', 'totalClients', 'totalMeetings', 'activeProjectsCount'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new Client & Project profile.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user->canAccessClientCalls()) {
|
||||
return redirect()->route('dashboard')->with('error', 'Unauthorized action.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'client_name' => 'required|string|max:255',
|
||||
'client_email' => 'required|email|max:255',
|
||||
'client_phone' => 'nullable|string|max:50',
|
||||
'company_name' => 'nullable|string|max:255',
|
||||
'project_name' => 'required|string|max:255',
|
||||
'project_details' => 'nullable|string',
|
||||
'responsible_user_id' => 'required|exists:users,id',
|
||||
'assigned_team_members' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$client = Client::create([
|
||||
'client_name' => $request->client_name,
|
||||
'client_email' => strtolower(trim($request->client_email)),
|
||||
'client_phone' => $request->client_phone,
|
||||
'company_name' => $request->company_name,
|
||||
'project_name' => $request->project_name,
|
||||
'project_details' => $request->project_details,
|
||||
'responsible_user_id' => $request->responsible_user_id,
|
||||
'created_by' => $user->id,
|
||||
'assigned_team_members' => $request->assigned_team_members ?? [],
|
||||
'status' => 'active',
|
||||
'latest_sentiment' => 'neutral',
|
||||
]);
|
||||
|
||||
return redirect()->route('client-calls.index')->with('success', "Client '{$client->client_name}' & Project '{$client->project_name}' created successfully!");
|
||||
}
|
||||
|
||||
/**
|
||||
* Show Client Call Hub (opens in new tab).
|
||||
*/
|
||||
public function show($id)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user->canAccessClientCalls()) {
|
||||
return redirect()->route('dashboard')->with('error', 'Unauthorized access.');
|
||||
}
|
||||
|
||||
$client = Client::with([
|
||||
'responsibleUser',
|
||||
'creator',
|
||||
'meetings.host',
|
||||
'meetings.latestNote',
|
||||
'callNotes.creator',
|
||||
'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', 'groupedCallNotes'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a future meeting.
|
||||
*/
|
||||
public function scheduleMeeting(Request $request, $clientId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$client = Client::findOrFail($clientId);
|
||||
|
||||
$request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'agenda' => 'nullable|string',
|
||||
'scheduled_at' => 'required|date',
|
||||
'duration_minutes' => 'required|integer|min:5|max:480',
|
||||
'team_attendees' => 'nullable|array',
|
||||
'client_emails' => 'nullable|string', // comma separated emails
|
||||
]);
|
||||
|
||||
// Parse client emails
|
||||
$clientEmails = [];
|
||||
if (!empty($request->client_emails)) {
|
||||
$rawEmails = explode(',', $request->client_emails);
|
||||
foreach ($rawEmails as $email) {
|
||||
$trimmed = strtolower(trim($email));
|
||||
if (filter_var($trimmed, FILTER_VALIDATE_EMAIL)) {
|
||||
$clientEmails[] = $trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Always include primary client email
|
||||
if (!in_array(strtolower(trim($client->client_email)), $clientEmails)) {
|
||||
$clientEmails[] = strtolower(trim($client->client_email));
|
||||
}
|
||||
|
||||
$meetingCode = 'call-' . Str::lower(Str::random(9));
|
||||
|
||||
$meeting = ClientMeeting::create([
|
||||
'client_id' => $client->id,
|
||||
'title' => $request->title,
|
||||
'agenda' => $request->agenda,
|
||||
'meeting_code' => $meetingCode,
|
||||
'meeting_type' => 'scheduled',
|
||||
'scheduled_at' => Carbon::parse($request->scheduled_at),
|
||||
'duration_minutes' => (int)$request->duration_minutes,
|
||||
'host_user_id' => $user->id,
|
||||
'team_attendees' => $request->team_attendees ?? [],
|
||||
'client_emails' => $clientEmails,
|
||||
'status' => 'scheduled',
|
||||
]);
|
||||
|
||||
// Send meeting invitation email to clients & internal team attendees
|
||||
$this->sendMeetingInvitations($meeting);
|
||||
|
||||
return redirect()->route('client-calls.show', $client->id)->with('success', "Meeting '{$meeting->title}' scheduled! Invitations with join link have been emailed to attendees.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an instant meeting room immediately.
|
||||
*/
|
||||
public function createInstantMeeting(Request $request, $clientId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$client = Client::findOrFail($clientId);
|
||||
|
||||
$title = $request->input('title') ?: ("Instant Sync - " . $client->project_name);
|
||||
$meetingCode = 'quick-' . Str::lower(Str::random(9));
|
||||
|
||||
$clientEmails = [strtolower(trim($client->client_email))];
|
||||
if ($request->filled('client_emails')) {
|
||||
$raw = explode(',', $request->client_emails);
|
||||
foreach ($raw as $e) {
|
||||
$t = strtolower(trim($e));
|
||||
if (filter_var($t, FILTER_VALIDATE_EMAIL) && !in_array($t, $clientEmails)) {
|
||||
$clientEmails[] = $t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$meeting = ClientMeeting::create([
|
||||
'client_id' => $client->id,
|
||||
'title' => $title,
|
||||
'agenda' => $request->input('agenda', 'Instant ad-hoc collaboration call'),
|
||||
'meeting_code' => $meetingCode,
|
||||
'meeting_type' => 'instant',
|
||||
'scheduled_at' => now(),
|
||||
'duration_minutes' => 45,
|
||||
'host_user_id' => $user->id,
|
||||
'team_attendees' => $request->team_attendees ?? [$user->id],
|
||||
'client_emails' => $clientEmails,
|
||||
'status' => 'in_progress',
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
// If requested to send invite email immediately
|
||||
if ($request->boolean('send_email_now', true)) {
|
||||
$this->sendMeetingInvitations($meeting);
|
||||
}
|
||||
|
||||
if ($request->ajax() || $request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'meeting_id' => $meeting->id,
|
||||
'meeting_code' => $meeting->meeting_code,
|
||||
'join_url' => $meeting->join_url,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('client-calls.room', $meeting->meeting_code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Video Call Room (like Google Meet).
|
||||
* Works for authenticated team members and external guests.
|
||||
*/
|
||||
public function showRoom(Request $request, $code)
|
||||
{
|
||||
$meeting = ClientMeeting::with(['client.responsibleUser', 'client.creator', 'host'])->where('meeting_code', $code)->firstOrFail();
|
||||
|
||||
$user = Auth::user();
|
||||
$isGuest = !$user;
|
||||
|
||||
// If guest has not provided a display name yet, show the guest join screen
|
||||
if ($isGuest && !session()->has("guest_name_{$code}")) {
|
||||
return view('client_calls.guest_join', compact('meeting'));
|
||||
}
|
||||
|
||||
$participantName = $user ? $user->name : session("guest_name_{$code}", 'Client Guest');
|
||||
$participantRole = $user ? ($user->role ?? 'Team') : 'Client / External Guest';
|
||||
$participantAvatar = $user ? ($user->avatar ?? null) : null;
|
||||
$isHost = $user && ((int)$meeting->host_user_id === (int)$user->id || $user->isAdmin());
|
||||
|
||||
$client = $meeting->client;
|
||||
|
||||
return view('client_calls.room', compact('meeting', 'client', 'participantName', 'participantRole', 'participantAvatar', 'isHost', 'isGuest'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Guest Join Screen.
|
||||
*/
|
||||
public function joinGuest(Request $request, $code)
|
||||
{
|
||||
$meeting = ClientMeeting::where('meeting_code', $code)->firstOrFail();
|
||||
|
||||
$request->validate([
|
||||
'guest_name' => 'required|string|max:100',
|
||||
'guest_email' => 'nullable|email|max:150',
|
||||
]);
|
||||
|
||||
session(["guest_name_{$code}" => trim($request->guest_name)]);
|
||||
if ($request->filled('guest_email')) {
|
||||
session(["guest_email_{$code}" => strtolower(trim($request->guest_email))]);
|
||||
}
|
||||
|
||||
return redirect()->route('client-calls.room', $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-time Emotion / Expression Detection endpoint.
|
||||
*/
|
||||
public function detectEmotion(Request $request, $code)
|
||||
{
|
||||
$speechText = (string)($request->input('speech_text') ?? '');
|
||||
$imageFrame = $request->input('image_frame');
|
||||
|
||||
$result = $this->aiService->detectEmotion($speechText, $imageFrame);
|
||||
|
||||
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.
|
||||
*/
|
||||
public function endCallAndGenerateMoM(Request $request, $code)
|
||||
{
|
||||
$meeting = ClientMeeting::with('client')->where('meeting_code', $code)->firstOrFail();
|
||||
$client = $meeting->client;
|
||||
|
||||
$transcript = $request->input('transcript', '');
|
||||
$liveNotes = $request->input('live_notes', '');
|
||||
$emotionTimeline = $request->input('emotion_timeline', []);
|
||||
$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
|
||||
$aiResult = $this->aiService->generateMoM(
|
||||
$transcript,
|
||||
$liveNotes,
|
||||
$client->project_name ?? 'Client Project',
|
||||
$client->client_name ?? 'Client'
|
||||
);
|
||||
|
||||
// Update Client latest sentiment
|
||||
$client->update([
|
||||
'latest_sentiment' => $aiResult['detected_sentiment'] ?? 'neutral',
|
||||
]);
|
||||
|
||||
// Create ClientCallNote record
|
||||
$callNote = ClientCallNote::create([
|
||||
'client_meeting_id' => $meeting->id,
|
||||
'client_id' => $client->id,
|
||||
'created_by' => Auth::id() ?? $meeting->host_user_id,
|
||||
'raw_transcript' => $transcript,
|
||||
'live_notes' => $liveNotes,
|
||||
'mom_content' => $aiResult['mom_content'],
|
||||
'summary' => $aiResult['summary'],
|
||||
'action_items' => $aiResult['action_items'],
|
||||
'todo_items' => $aiResult['todo_items'] ?? [],
|
||||
'key_decisions' => $aiResult['key_decisions'],
|
||||
'detected_sentiment' => $aiResult['detected_sentiment'],
|
||||
'sentiment_breakdown' => $aiResult['sentiment_breakdown'],
|
||||
'angry_analysis' => $aiResult['angry_analysis'],
|
||||
'emotion_details' => $aiResult['emotion_details'] ?? [],
|
||||
'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,
|
||||
'note_id' => $callNote->id,
|
||||
'client_id' => $client->id,
|
||||
'detected_sentiment' => $callNote->detected_sentiment,
|
||||
'email_status' => $emailStatus,
|
||||
'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!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Date-wise Call Logs list view for Admin, TL, PM, and team members.
|
||||
*/
|
||||
public function callLogs(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user->canAccessClientCalls()) {
|
||||
return redirect()->route('dashboard')->with('error', 'Unauthorized access.');
|
||||
}
|
||||
|
||||
$query = ClientCallNote::with(['client.responsibleUser', 'client.creator', 'meeting.host', 'creator'])
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if (!$user->isAdmin() && !$user->hasRole('director') && !str_contains(strtolower($user->role ?? ''), 'director')) {
|
||||
$accessibleClientIds = $user->getAccessibleClients()->pluck('id')->toArray();
|
||||
$query->whereIn('client_id', $accessibleClientIds);
|
||||
}
|
||||
|
||||
$allNotes = $query->get();
|
||||
|
||||
// Calculate statistics
|
||||
$totalCalls = $allNotes->count();
|
||||
$happyCalls = $allNotes->where('detected_sentiment', 'happy')->count();
|
||||
$sadCalls = $allNotes->where('detected_sentiment', 'sad')->count();
|
||||
$angryCalls = $allNotes->where('detected_sentiment', 'angry')->count();
|
||||
|
||||
$totalTodosCount = 0;
|
||||
foreach ($allNotes as $n) {
|
||||
$totalTodosCount += count($n->todo_items ?? $n->action_items ?? []);
|
||||
}
|
||||
|
||||
// Group notes date-wise (by 'Y-m-d')
|
||||
$groupedNotes = $allNotes->groupBy(function ($note) {
|
||||
return $note->created_at ? $note->created_at->format('Y-m-d') : now()->format('Y-m-d');
|
||||
});
|
||||
|
||||
$clients = $user->getAccessibleClients();
|
||||
|
||||
return view('client_calls.logs', compact('groupedNotes', 'totalCalls', 'happyCalls', 'sadCalls', 'angryCalls', 'totalTodosCount', 'clients'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Download MoM as formatted .txt document.
|
||||
*/
|
||||
public function downloadMoM($noteId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user || !$user->canAccessClientCalls()) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$callNote = ClientCallNote::with(['client', 'meeting', 'creator'])->findOrFail($noteId);
|
||||
$content = $this->aiService->formatMoMPlainText($callNote);
|
||||
|
||||
$clientSlug = Str::slug($callNote->client->project_name ?? 'ClientProject');
|
||||
$dateSlug = $callNote->created_at ? $callNote->created_at->format('Y-m-d') : date('Y-m-d');
|
||||
$filename = "MoM-{$clientSlug}-{$dateSlug}.txt";
|
||||
|
||||
return response($content, 200, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download Auto-generated To-Do List as formatted .txt file.
|
||||
*/
|
||||
public function downloadTodos($noteId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user || !$user->canAccessClientCalls()) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$callNote = ClientCallNote::with(['client', 'meeting'])->findOrFail($noteId);
|
||||
$content = $this->aiService->formatTodosPlainText($callNote);
|
||||
|
||||
$clientSlug = Str::slug($callNote->client->project_name ?? 'ClientProject');
|
||||
$dateSlug = $callNote->created_at ? $callNote->created_at->format('Y-m-d') : date('Y-m-d');
|
||||
$filename = "ToDo-List-{$clientSlug}-{$dateSlug}.txt";
|
||||
|
||||
return response($content, 200, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Emotion Details & Reasons (Why happy, Why sad, Why angry) for interactive popup.
|
||||
*/
|
||||
public function getEmotionDetails($noteId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user || !$user->canAccessClientCalls()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$callNote = ClientCallNote::with(['client.responsibleUser', 'meeting'])->findOrFail($noteId);
|
||||
$client = $callNote->client;
|
||||
$sentiment = strtolower($callNote->detected_sentiment ?? 'neutral');
|
||||
|
||||
// Check angry analysis restrictions
|
||||
if ($sentiment === 'angry' && !$client->canViewAngryAnalysis($user)) {
|
||||
return response()->json([
|
||||
'error' => 'Access Restricted: Detailed anger analysis is confidential to the System Administrator and the assigned Responsible Lead.'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$details = $callNote->emotion_details;
|
||||
if (empty($details) || !isset($details['bullet_points'])) {
|
||||
// Fallback dynamically
|
||||
$details = [
|
||||
'emotion' => $sentiment,
|
||||
'headline' => match($sentiment) {
|
||||
'happy' => 'Client Expressed High Satisfaction & Praise',
|
||||
'sad' => 'Client Expressed Concern / Hesitation',
|
||||
'angry' => 'Client Frustration & Escalation Alert',
|
||||
default => 'Professional & Attentive Alignment',
|
||||
},
|
||||
'bullet_points' => match($sentiment) {
|
||||
'happy' => [
|
||||
'Expressed enthusiasm regarding completed sprint deliverables and feature quality.',
|
||||
'Praised responsiveness of the team and transparent milestone walkthrough.',
|
||||
'Approved next sprint scope without friction.'
|
||||
],
|
||||
'sad' => [
|
||||
'Expressed worries regarding upcoming production release deadlines.',
|
||||
'Voiced hesitation about scope changes or budget allocations.',
|
||||
'Requested extra testing verification before final signoff.'
|
||||
],
|
||||
'angry' => ($callNote->angry_analysis['grievances'] ?? [
|
||||
'Voiced dissatisfaction regarding delivery delays.',
|
||||
'Highlighted broken modules in staging environment.'
|
||||
]),
|
||||
default => ['Standard business discussion with routine inquiries. No heightened emotional triggers detected.'],
|
||||
},
|
||||
'trigger_quotes' => ($sentiment === 'angry' ? ($callNote->angry_analysis['trigger_words'] ?? []) : []),
|
||||
'suggested_action' => match($sentiment) {
|
||||
'happy' => 'Maintain current sprint momentum and continue transparent communications.',
|
||||
'sad' => 'Tech Lead & PM should proactively address timeline concerns and schedule a reassuring technical sync.',
|
||||
'angry' => ($callNote->angry_analysis['suggested_remedy'] ?? 'Immediate executive check-in and expedited hotfix delivery.'),
|
||||
default => 'Continue standard development roadmap.',
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'client_name' => $client->client_name,
|
||||
'project_name' => $client->project_name,
|
||||
'detected_sentiment' => $sentiment,
|
||||
'emotion_details' => $details,
|
||||
'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'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle To-Do Item status (completed / pending) in interactive list.
|
||||
*/
|
||||
public function toggleTodo(Request $request, $noteId, $todoId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user || !$user->canAccessClientCalls()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$callNote = ClientCallNote::findOrFail($noteId);
|
||||
$todos = $callNote->todo_items ?? [];
|
||||
|
||||
foreach ($todos as &$item) {
|
||||
if (isset($item['id']) && (int)$item['id'] === (int)$todoId) {
|
||||
$item['completed'] = !($item['completed'] ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
$callNote->update(['todo_items' => $todos]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'todo_items' => $todos,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
public function getAngryAnalysis($noteId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) {
|
||||
return response()->json(['error' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
$callNote = ClientCallNote::with(['client.responsibleUser', 'meeting'])->findOrFail($noteId);
|
||||
$client = $callNote->client;
|
||||
|
||||
// Security check: Only Admin and Responsible Person can access
|
||||
if (!$client->canViewAngryAnalysis($user)) {
|
||||
return response()->json([
|
||||
'error' => 'Access Restricted: This emotion analysis is strictly confidential to the System Administrator and the assigned Project Lead / Responsible Manager.'
|
||||
], 403);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'client_name' => $client->client_name,
|
||||
'project_name' => $client->project_name,
|
||||
'detected_sentiment' => $callNote->detected_sentiment,
|
||||
'angry_analysis' => $callNote->angry_analysis ?? [
|
||||
'is_angry' => true,
|
||||
'trigger_words' => ['frustration', 'delay'],
|
||||
'summary_why_angry' => 'Client expressed significant dissatisfaction during the meeting regarding project execution.',
|
||||
'grievances' => ['Milestone deliverables', 'Response latency'],
|
||||
'suggested_remedy' => 'Conduct immediate internal alignment and arrange a personal clarification call.'
|
||||
],
|
||||
'summary' => $callNote->summary,
|
||||
'created_at' => $callNote->created_at->format('M d, Y h:i A'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-send MoM Email.
|
||||
*/
|
||||
public function resendMoMEmail($noteId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user || !$user->canAccessClientCalls()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$callNote = ClientCallNote::with(['client.responsibleUser', 'meeting.host'])->findOrFail($noteId);
|
||||
$status = $this->aiService->sendMoMEmails($callNote);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "MoM email re-sent to {$status['sent_count']} stakeholders.",
|
||||
'recipients' => $status['recipients'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC Signaling & Peer Heartbeat for Call Room.
|
||||
*/
|
||||
public function signalingHeartbeat(Request $request, $code)
|
||||
{
|
||||
$meeting = ClientMeeting::where('meeting_code', $code)->firstOrFail();
|
||||
|
||||
$peerId = $request->input('peer_id');
|
||||
$peerName = $request->input('peer_name');
|
||||
$signalData = $request->input('signal');
|
||||
$chatMessage = $request->input('chat_message');
|
||||
|
||||
$activePeers = $meeting->active_peers ?? [];
|
||||
$now = now()->timestamp;
|
||||
|
||||
// Clean up stale peers (older than 25s)
|
||||
foreach ($activePeers as $id => $peer) {
|
||||
if (isset($peer['last_seen']) && ($now - $peer['last_seen']) > 25) {
|
||||
unset($activePeers[$id]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($peerId) {
|
||||
$activePeers[$peerId] = [
|
||||
'peer_id' => $peerId,
|
||||
'peer_name' => $peerName ?: 'Guest',
|
||||
'last_seen' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
$peerSignals = $meeting->peer_signals ?? [];
|
||||
if ($signalData && isset($signalData['target_peer_id'])) {
|
||||
$target = $signalData['target_peer_id'];
|
||||
if (!isset($peerSignals[$target])) {
|
||||
$peerSignals[$target] = [];
|
||||
}
|
||||
$signalData['from_peer_id'] = $peerId;
|
||||
$signalData['time'] = $now;
|
||||
$peerSignals[$target][] = $signalData;
|
||||
}
|
||||
|
||||
$incomingSignals = [];
|
||||
if ($peerId && isset($peerSignals[$peerId])) {
|
||||
$incomingSignals = $peerSignals[$peerId];
|
||||
$peerSignals[$peerId] = []; // clear fetched signals
|
||||
}
|
||||
|
||||
// Handle chat messages
|
||||
$chatMessages = $meeting->chat_messages ?? [];
|
||||
if ($chatMessage && !empty(trim($chatMessage['text'] ?? ''))) {
|
||||
$chatMessages[] = [
|
||||
'sender' => $peerName ?: 'Participant',
|
||||
'text' => trim($chatMessage['text']),
|
||||
'time' => now()->format('h:i A'),
|
||||
];
|
||||
// Keep last 100 messages
|
||||
if (count($chatMessages) > 100) {
|
||||
$chatMessages = array_slice($chatMessages, -100);
|
||||
}
|
||||
}
|
||||
|
||||
$meeting->update([
|
||||
'active_peers' => $activePeers,
|
||||
'peer_signals' => $peerSignals,
|
||||
'chat_messages' => $chatMessages,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'active_peers' => array_values($activePeers),
|
||||
'signals' => $incomingSignals,
|
||||
'chat_messages' => $chatMessages,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to send meeting invitation emails.
|
||||
*/
|
||||
protected function sendMeetingInvitations(ClientMeeting $meeting): void
|
||||
{
|
||||
$client = $meeting->client;
|
||||
|
||||
// 1. Send to client emails
|
||||
if (!empty($meeting->client_emails)) {
|
||||
foreach ($meeting->client_emails as $email) {
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
try {
|
||||
Mail::to($email)->send(new ClientMeetingInviteMail($meeting, $client->client_name ?? 'Client Partner'));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("Failed to email meeting invite to client {$email}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Send to internal team attendees
|
||||
foreach ($meeting->getTeamAttendees() as $user) {
|
||||
if (!empty($user->email)) {
|
||||
try {
|
||||
Mail::to($user->email)->send(new ClientMeetingInviteMail($meeting, $user->name));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("Failed to email meeting invite to team member {$user->email}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
app/Mail/ClientCallMomMail.php
Normal file
59
app/Mail/ClientCallMomMail.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\ClientCallNote;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ClientCallMomMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public ClientCallNote $callNote;
|
||||
public string $recipientName;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(ClientCallNote $callNote, string $recipientName = 'Team Member')
|
||||
{
|
||||
$this->callNote = $callNote;
|
||||
$this->recipientName = $recipientName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$meetingTitle = $this->callNote->meeting ? $this->callNote->meeting->title : 'Client Call';
|
||||
$projectName = $this->callNote->client ? $this->callNote->client->project_name : 'Project';
|
||||
return new Envelope(
|
||||
subject: "📝 Minutes of Meeting (MoM) & AI Notes: {$meetingTitle} - {$projectName}",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.call_mom',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
60
app/Mail/ClientMeetingInviteMail.php
Normal file
60
app/Mail/ClientMeetingInviteMail.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\ClientMeeting;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ClientMeetingInviteMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public ClientMeeting $meeting;
|
||||
public string $recipientName;
|
||||
public string $joinUrl;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(ClientMeeting $meeting, string $recipientName = 'Participant', ?string $joinUrl = null)
|
||||
{
|
||||
$this->meeting = $meeting;
|
||||
$this->recipientName = $recipientName;
|
||||
$this->joinUrl = $joinUrl ?? $meeting->join_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$prefix = $this->meeting->meeting_type === 'instant' ? '⚡ Instant Meeting Invitation' : '📅 Meeting Invitation';
|
||||
return new Envelope(
|
||||
subject: "{$prefix}: {$this->meeting->title} - {$this->meeting->client->project_name}",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.meeting_invite',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
92
app/Models/Client.php
Normal file
92
app/Models/Client.php
Normal file
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Client extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'client_name',
|
||||
'client_email',
|
||||
'client_phone',
|
||||
'company_name',
|
||||
'project_name',
|
||||
'project_details',
|
||||
'responsible_user_id',
|
||||
'created_by',
|
||||
'assigned_team_members',
|
||||
'status',
|
||||
'latest_sentiment',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'assigned_team_members' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* The PM/TL/Director responsible for this client.
|
||||
*/
|
||||
public function responsibleUser()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'responsible_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* User who registered the client.
|
||||
*/
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Meetings/calls associated with this client.
|
||||
*/
|
||||
public function meetings()
|
||||
{
|
||||
return $this->hasMany(ClientMeeting::class)->orderBy('scheduled_at', 'desc')->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* All call notes and MoMs for this client.
|
||||
*/
|
||||
public function callNotes()
|
||||
{
|
||||
return $this->hasMany(ClientCallNote::class)->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get team members models.
|
||||
*/
|
||||
public function getTeamMembers()
|
||||
{
|
||||
if (empty($this->assigned_team_members)) {
|
||||
return collect();
|
||||
}
|
||||
return User::whereIn('id', $this->assigned_team_members)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific user is the responsible person or admin.
|
||||
*/
|
||||
public function isResponsibleUser(?int $userId): bool
|
||||
{
|
||||
if (!$userId) return false;
|
||||
return (int)$this->responsible_user_id === (int)$userId || (int)$this->created_by === (int)$userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can view angry alert analysis.
|
||||
* Accessible ONLY to Admin and that person who takes the responsibility.
|
||||
*/
|
||||
public function canViewAngryAnalysis(?User $user): bool
|
||||
{
|
||||
if (!$user) return false;
|
||||
if ($user->isAdmin()) return true;
|
||||
return (int)$this->responsible_user_id === (int)$user->id || (int)$this->created_by === (int)$user->id;
|
||||
}
|
||||
}
|
||||
77
app/Models/ClientCallNote.php
Normal file
77
app/Models/ClientCallNote.php
Normal file
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ClientCallNote extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'client_meeting_id',
|
||||
'client_id',
|
||||
'created_by',
|
||||
'raw_transcript',
|
||||
'live_notes',
|
||||
'mom_content',
|
||||
'summary',
|
||||
'action_items',
|
||||
'key_decisions',
|
||||
'detected_sentiment',
|
||||
'sentiment_breakdown',
|
||||
'angry_analysis',
|
||||
'emotion_details',
|
||||
'todo_items',
|
||||
'recording_path',
|
||||
'recordings',
|
||||
'emailed_at',
|
||||
'emailed_to',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'action_items' => 'array',
|
||||
'key_decisions' => 'array',
|
||||
'sentiment_breakdown' => 'array',
|
||||
'angry_analysis' => 'array',
|
||||
'emotion_details' => 'array',
|
||||
'todo_items' => 'array',
|
||||
'recordings' => 'array',
|
||||
'emailed_to' => 'array',
|
||||
'emailed_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Parent meeting.
|
||||
*/
|
||||
public function meeting()
|
||||
{
|
||||
return $this->belongsTo(ClientMeeting::class, 'client_meeting_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parent client profile.
|
||||
*/
|
||||
public function client()
|
||||
{
|
||||
return $this->belongsTo(Client::class, 'client_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* User who initiated or finalized this note.
|
||||
*/
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if client expression was angry or frustrated.
|
||||
*/
|
||||
public function isAngry(): bool
|
||||
{
|
||||
return strtolower($this->detected_sentiment) === 'angry'
|
||||
|| (!empty($this->angry_analysis) && ($this->angry_analysis['is_angry'] ?? false));
|
||||
}
|
||||
}
|
||||
95
app/Models/ClientMeeting.php
Normal file
95
app/Models/ClientMeeting.php
Normal file
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ClientMeeting extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'client_id',
|
||||
'title',
|
||||
'agenda',
|
||||
'meeting_code',
|
||||
'meeting_type',
|
||||
'scheduled_at',
|
||||
'duration_minutes',
|
||||
'host_user_id',
|
||||
'team_attendees',
|
||||
'client_emails',
|
||||
'status',
|
||||
'started_at',
|
||||
'ended_at',
|
||||
'active_peers',
|
||||
'peer_signals',
|
||||
'chat_messages',
|
||||
'recording_path',
|
||||
'recordings',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'scheduled_at' => 'datetime',
|
||||
'started_at' => 'datetime',
|
||||
'ended_at' => 'datetime',
|
||||
'team_attendees' => 'array',
|
||||
'client_emails' => 'array',
|
||||
'active_peers' => 'array',
|
||||
'peer_signals' => 'array',
|
||||
'chat_messages' => 'array',
|
||||
'recordings' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* The parent client profile.
|
||||
*/
|
||||
public function client()
|
||||
{
|
||||
return $this->belongsTo(Client::class, 'client_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Host user who organized the call.
|
||||
*/
|
||||
public function host()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'host_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes and MoM entries for this meeting.
|
||||
*/
|
||||
public function callNotes()
|
||||
{
|
||||
return $this->hasMany(ClientCallNote::class, 'client_meeting_id')->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest call note or MoM.
|
||||
*/
|
||||
public function latestNote()
|
||||
{
|
||||
return $this->hasOne(ClientCallNote::class, 'client_meeting_id')->latestOfMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get assigned team attendees User models.
|
||||
*/
|
||||
public function getTeamAttendees()
|
||||
{
|
||||
if (empty($this->team_attendees)) {
|
||||
return collect();
|
||||
}
|
||||
return User::whereIn('id', $this->team_attendees)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get shareable join URL.
|
||||
*/
|
||||
public function getJoinUrlAttribute(): string
|
||||
{
|
||||
return route('client-calls.room', ['code' => $this->meeting_code]);
|
||||
}
|
||||
}
|
||||
@ -183,6 +183,54 @@ public function getAccessibleInterviews()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authorized to access and manage Client Calls.
|
||||
* PM, TL, Director, Admin, and assigned personnel.
|
||||
*/
|
||||
public function canAccessClientCalls(): bool
|
||||
{
|
||||
if ($this->isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$allowedRolePatterns = ['pm', 'project manager', 'tl', 'team lead', 'lead', 'director', 'management', 'head'];
|
||||
|
||||
// Check direct role column
|
||||
foreach ($allowedRolePatterns as $pattern) {
|
||||
if (str_contains(strtolower($this->role ?? ''), $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check assigned roles
|
||||
return $this->roles()->where(function ($q) use ($allowedRolePatterns) {
|
||||
foreach ($allowedRolePatterns as $pattern) {
|
||||
$q->orWhereRaw('LOWER(name) LIKE ?', ['%' . $pattern . '%']);
|
||||
}
|
||||
})->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accessible clients for this user.
|
||||
*/
|
||||
public function getAccessibleClients()
|
||||
{
|
||||
if ($this->isAdmin() || $this->hasRole('director') || str_contains(strtolower($this->role ?? ''), 'director')) {
|
||||
return \App\Models\Client::with(['responsibleUser', 'creator', 'meetings', 'callNotes'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
}
|
||||
|
||||
return \App\Models\Client::with(['responsibleUser', 'creator', 'meetings', 'callNotes'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->get()
|
||||
->filter(function ($client) {
|
||||
return $client->responsible_user_id === $this->id
|
||||
|| $client->created_by === $this->id
|
||||
|| (is_array($client->assigned_team_members) && in_array($this->id, $client->assigned_team_members));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
|
||||
1035
app/Services/AiClientCallService.php
Normal file
1035
app/Services/AiClientCallService.php
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
$table->boolean('sos_modal_enabled')->default(false)->after('active_peers');
|
||||
$table->json('ignored_violations')->nullable()->after('sos_modal_enabled');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
$table->dropColumn(['sos_modal_enabled', 'ignored_violations']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('clients', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('client_name');
|
||||
$table->string('client_email');
|
||||
$table->string('client_phone')->nullable();
|
||||
$table->string('company_name')->nullable();
|
||||
$table->string('project_name');
|
||||
$table->text('project_details')->nullable();
|
||||
$table->foreignId('responsible_user_id')->constrained('users')->onDelete('cascade');
|
||||
$table->foreignId('created_by')->constrained('users')->onDelete('cascade');
|
||||
$table->json('assigned_team_members')->nullable();
|
||||
$table->string('status')->default('active');
|
||||
$table->string('latest_sentiment')->default('neutral');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('clients');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('client_meetings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('client_id')->constrained('clients')->onDelete('cascade');
|
||||
$table->string('title');
|
||||
$table->text('agenda')->nullable();
|
||||
$table->string('meeting_code')->unique();
|
||||
$table->string('meeting_type')->default('scheduled'); // 'scheduled' or 'instant'
|
||||
$table->dateTime('scheduled_at')->nullable();
|
||||
$table->integer('duration_minutes')->default(30);
|
||||
$table->foreignId('host_user_id')->constrained('users')->onDelete('cascade');
|
||||
$table->json('team_attendees')->nullable(); // array of user IDs
|
||||
$table->json('client_emails')->nullable(); // array of email strings
|
||||
$table->string('status')->default('scheduled'); // scheduled, in_progress, completed, cancelled
|
||||
$table->dateTime('started_at')->nullable();
|
||||
$table->dateTime('ended_at')->nullable();
|
||||
$table->json('active_peers')->nullable();
|
||||
$table->json('peer_signals')->nullable();
|
||||
$table->json('chat_messages')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('client_meetings');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('client_call_notes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('client_meeting_id')->constrained('client_meetings')->onDelete('cascade');
|
||||
$table->foreignId('client_id')->constrained('clients')->onDelete('cascade');
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
|
||||
$table->longText('raw_transcript')->nullable();
|
||||
$table->longText('live_notes')->nullable();
|
||||
$table->longText('mom_content')->nullable();
|
||||
$table->text('summary')->nullable();
|
||||
$table->json('action_items')->nullable();
|
||||
$table->json('key_decisions')->nullable();
|
||||
$table->string('detected_sentiment')->default('neutral'); // happy, neutral, sad, angry
|
||||
$table->json('sentiment_breakdown')->nullable();
|
||||
$table->json('angry_analysis')->nullable(); // Protected: only Admin & Responsible person can access
|
||||
$table->dateTime('emailed_at')->nullable();
|
||||
$table->json('emailed_to')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('client_call_notes');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('client_call_notes', function (Blueprint $table) {
|
||||
$table->json('emotion_details')->nullable()->after('angry_analysis');
|
||||
$table->json('todo_items')->nullable()->after('emotion_details');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('client_call_notes', function (Blueprint $table) {
|
||||
$table->dropColumn(['emotion_details', 'todo_items']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
277
database/seeders/ClientCallSeeder.php
Normal file
277
database/seeders/ClientCallSeeder.php
Normal file
@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCallNote;
|
||||
use App\Models\ClientMeeting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ClientCallSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Seed sample client calls and meetings data.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$admin = User::where('role', 'admin')->first();
|
||||
$pm = User::where('email', 'user@company.com')->first();
|
||||
|
||||
if (!$admin || !$pm) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Happy Client Sample
|
||||
$client1 = Client::firstOrCreate(
|
||||
['client_email' => 'client.robert@acmecorp.com'],
|
||||
[
|
||||
'client_name' => 'Robert Miller',
|
||||
'company_name' => 'Acme Corporation',
|
||||
'client_phone' => '+1 (555) 234-5678',
|
||||
'project_name' => 'Enterprise Cloud Migration & Analytics Portal',
|
||||
'project_details' => 'Full-stack cloud infrastructure migration to AWS with real-time analytics dashboard and SingleLogin SSO authentication integration.',
|
||||
'responsible_user_id' => $pm->id,
|
||||
'created_by' => $admin->id,
|
||||
'assigned_team_members' => [$pm->id, $admin->id],
|
||||
'status' => 'active',
|
||||
'latest_sentiment' => 'happy',
|
||||
]
|
||||
);
|
||||
|
||||
// Upcoming meeting
|
||||
ClientMeeting::firstOrCreate(
|
||||
['meeting_code' => 'cl-acme-kickoff'],
|
||||
[
|
||||
'client_id' => $client1->id,
|
||||
'title' => 'Sprint 2 Milestone Demo & Feedback Review',
|
||||
'agenda' => 'Demonstration of cloud infrastructure deployment, database synchronization metrics, and next sprint action items.',
|
||||
'meeting_type' => 'scheduled',
|
||||
'scheduled_at' => now()->addDays(1)->setTime(15, 0),
|
||||
'duration_minutes' => 45,
|
||||
'host_user_id' => $pm->id,
|
||||
'team_attendees' => [$pm->id, $admin->id],
|
||||
'client_emails' => ['client.robert@acmecorp.com'],
|
||||
'status' => 'scheduled',
|
||||
]
|
||||
);
|
||||
|
||||
// Past meeting with MoM
|
||||
$pastMeeting1 = ClientMeeting::firstOrCreate(
|
||||
['meeting_code' => 'cl-acme-past-01'],
|
||||
[
|
||||
'client_id' => $client1->id,
|
||||
'title' => 'Initial Project Kickoff & Architecture Alignment',
|
||||
'agenda' => 'Scope finalization and requirements review.',
|
||||
'meeting_type' => 'scheduled',
|
||||
'scheduled_at' => now()->subDays(3)->setTime(14, 0),
|
||||
'duration_minutes' => 30,
|
||||
'host_user_id' => $pm->id,
|
||||
'team_attendees' => [$pm->id],
|
||||
'client_emails' => ['client.robert@acmecorp.com'],
|
||||
'status' => 'completed',
|
||||
'started_at' => now()->subDays(3)->setTime(14, 0),
|
||||
'ended_at' => now()->subDays(3)->setTime(14, 30),
|
||||
]
|
||||
);
|
||||
|
||||
ClientCallNote::firstOrCreate(
|
||||
['client_meeting_id' => $pastMeeting1->id],
|
||||
[
|
||||
'client_id' => $client1->id,
|
||||
'created_by' => $pm->id,
|
||||
'raw_transcript' => "[02:00 PM] PM: Welcome Robert, today we will align on cloud architecture milestones.\n[02:05 PM] Client: The architecture diagram looks great, we are very pleased with the proposed SSO integration.\n[02:15 PM] PM: Excellent, we will target Sprint 1 delivery by next Friday.",
|
||||
'live_notes' => 'Client approved the architecture blueprints. Confirmed AWS US-East region for deployment.',
|
||||
'mom_content' => "### Minutes of Meeting (MoM)\n**Project:** Enterprise Cloud Migration & Analytics Portal\n**Client:** Robert Miller (Acme Corporation)\n\n#### 1. Meeting Objectives\n- Finalize cloud infrastructure deployment blueprint.\n- Validate SingleLogin SSO integration requirements.\n\n#### 2. Key Discussion Points\n- Architecture diagrams and IAM policy structures approved.\n- Confirmed AWS hosting specifications.\n\n#### 3. Agreed Next Steps\n- Engineering team to initialize cloud templates.\n- PM to share weekly status reports.",
|
||||
'summary' => 'Productive kickoff meeting where client praised the architectural design and approved deployment timeline.',
|
||||
'action_items' => [
|
||||
['task' => 'Deploy AWS staging environment templates', 'owner' => 'Engineering Team', 'deadline' => now()->addDays(2)->format('M d, Y'), 'status' => 'In Progress'],
|
||||
['task' => 'Provide SSO federation test credentials', 'owner' => 'Tech Lead', 'deadline' => now()->addDays(3)->format('M d, Y'), 'status' => 'Pending']
|
||||
],
|
||||
'key_decisions' => [
|
||||
['decision' => 'Approved AWS as primary cloud infrastructure provider.'],
|
||||
['decision' => 'Scheduled recurring bi-weekly progress review calls.']
|
||||
],
|
||||
'detected_sentiment' => 'happy',
|
||||
'sentiment_breakdown' => ['happy_pct' => 85, 'neutral_pct' => 10, 'sad_pct' => 3, 'angry_pct' => 2],
|
||||
'angry_analysis' => ['is_angry' => false, 'trigger_words' => [], 'summary_why_angry' => '', 'grievances' => [], 'suggested_remedy' => ''],
|
||||
'emotion_details' => [
|
||||
'emotion' => 'happy',
|
||||
'headline' => 'Client Delighted with Architecture Blueprint & SSO Speed',
|
||||
'bullet_points' => [
|
||||
'Pleased with clear architecture diagram and SingleLogin SSO integration roadmap.',
|
||||
'Praised team responsiveness and transparent sprint breakdown.',
|
||||
'Approved AWS cloud hosting specs without friction.'
|
||||
],
|
||||
'trigger_quotes' => ['looks great', 'very pleased', 'excellent progress'],
|
||||
'suggested_action' => 'Maintain current sprint momentum and continue weekly demo cadence.'
|
||||
],
|
||||
'todo_items' => [
|
||||
['id' => 1, 'task' => 'Deploy AWS staging environment templates', 'owner' => 'Engineering Team', 'priority' => 'High', 'deadline' => now()->addDays(2)->format('M d, Y'), 'completed' => true],
|
||||
['id' => 2, 'task' => 'Provide SSO federation test credentials', 'owner' => 'Tech Lead', 'priority' => 'Medium', 'deadline' => now()->addDays(3)->format('M d, Y'), 'completed' => false],
|
||||
['id' => 3, 'task' => 'Schedule recurring bi-weekly progress review calls', 'owner' => 'Project Manager', 'priority' => 'Low', 'deadline' => now()->addDays(5)->format('M d, Y'), 'completed' => false],
|
||||
],
|
||||
'emailed_at' => now()->subDays(3)->setTime(14, 35),
|
||||
'emailed_to' => ['client.robert@acmecorp.com', 'user@company.com', 'admin@company.com']
|
||||
]
|
||||
);
|
||||
|
||||
// 2. Client with Frustrated/Angry Sentiment
|
||||
$client2 = Client::firstOrCreate(
|
||||
['client_email' => 'sarah.connor@cybertech.io'],
|
||||
[
|
||||
'client_name' => 'Sarah Connor',
|
||||
'company_name' => 'CyberTech Dynamics',
|
||||
'client_phone' => '+1 (555) 987-6543',
|
||||
'project_name' => 'AI Customer Portal & Billing Gateway',
|
||||
'project_details' => 'Customer self-service portal with integrated Stripe & PayPal billing gateway and AI chatbot.',
|
||||
'responsible_user_id' => $pm->id,
|
||||
'created_by' => $admin->id,
|
||||
'assigned_team_members' => [$pm->id],
|
||||
'status' => 'active',
|
||||
'latest_sentiment' => 'angry',
|
||||
]
|
||||
);
|
||||
|
||||
$pastMeeting2 = ClientMeeting::firstOrCreate(
|
||||
['meeting_code' => 'cl-cybertech-review'],
|
||||
[
|
||||
'client_id' => $client2->id,
|
||||
'title' => 'Sprint 3 Deliverable Review & Urgent Escalation',
|
||||
'agenda' => 'Addressing payment webhook latency and unexpected sprint delays.',
|
||||
'meeting_type' => 'scheduled',
|
||||
'scheduled_at' => now()->subDay()->setTime(11, 0),
|
||||
'duration_minutes' => 35,
|
||||
'host_user_id' => $pm->id,
|
||||
'team_attendees' => [$pm->id],
|
||||
'client_emails' => ['sarah.connor@cybertech.io'],
|
||||
'status' => 'completed',
|
||||
'started_at' => now()->subDay()->setTime(11, 0),
|
||||
'ended_at' => now()->subDay()->setTime(11, 35),
|
||||
]
|
||||
);
|
||||
|
||||
ClientCallNote::firstOrCreate(
|
||||
['client_meeting_id' => $pastMeeting2->id],
|
||||
[
|
||||
'client_id' => $client2->id,
|
||||
'created_by' => $pm->id,
|
||||
'raw_transcript' => "[11:02 AM] Client: We are extremely frustrated with the 2-week delay on the Stripe payment integration.\n[11:05 AM] Client: This delay is completely unacceptable and broken billing webhooks are halting our user onboarding.\n[11:15 AM] PM: We understand your concern Sarah, we will allocate two senior developers immediately to resolve the webhooks by tomorrow.",
|
||||
'live_notes' => 'Client expressed strong dissatisfaction over payment gateway delay and API latency.',
|
||||
'mom_content' => "### Minutes of Meeting (MoM)\n**Project:** AI Customer Portal & Billing Gateway\n**Client:** Sarah Connor (CyberTech Dynamics)\n\n#### 1. Meeting Objectives\n- Address client concerns regarding Stripe payment webhook delays.\n- Realign on deployment timeline.\n\n#### 2. Key Discussion Points\n- Client raised severe blockers due to webhook latency.\n- Engineering lead committed to delivering hotfix within 24 hours.\n\n#### 3. Escalation Action Items\n- PM & Tech Lead to provide daily progress sync until resolution.",
|
||||
'summary' => 'Urgent review call where client voiced strong frustration regarding Stripe payment gateway delays and webhook issues.',
|
||||
'action_items' => [
|
||||
['task' => 'Deliver Stripe payment webhook hotfix and test end-to-end sandbox', 'owner' => 'Tech Lead', 'deadline' => now()->addDay()->format('M d, Y'), 'status' => 'Urgent'],
|
||||
['task' => 'Send formal apology and revised sprint deliverable roadmap to client', 'owner' => 'Project Manager', 'deadline' => now()->format('M d, Y'), 'status' => 'Pending']
|
||||
],
|
||||
'todo_items' => [
|
||||
['id' => 1, 'task' => 'Deliver Stripe payment webhook hotfix to staging server', 'owner' => 'Tech Lead', 'priority' => 'High', 'deadline' => now()->addDay()->format('M d, Y'), 'completed' => false],
|
||||
['id' => 2, 'task' => 'Send formal roadmap update and test verification results', 'owner' => 'Project Manager', 'priority' => 'High', 'deadline' => now()->addDays(2)->format('M d, Y'), 'completed' => false],
|
||||
],
|
||||
'key_decisions' => [
|
||||
['decision' => 'Assigned dedicated senior engineer to unblock billing module.']
|
||||
],
|
||||
'detected_sentiment' => 'angry',
|
||||
'sentiment_breakdown' => ['happy_pct' => 5, 'neutral_pct' => 10, 'sad_pct' => 10, 'angry_pct' => 75],
|
||||
'angry_analysis' => [
|
||||
'is_angry' => true,
|
||||
'trigger_words' => ['extremely frustrated', 'unacceptable', 'delay', 'broken billing webhooks', 'halting onboarding'],
|
||||
'summary_why_angry' => 'Client Sarah Connor is angry due to a 2-week delay in the Stripe payment gateway delivery and broken webhook notifications that are halting their customer onboarding operations. She emphasized that communication was lacking prior to this meeting.',
|
||||
'grievances' => [
|
||||
'Two-week milestone delivery delay on core billing module.',
|
||||
'Unreliable webhook notifications in staging environment.',
|
||||
'Lack of proactive status updates before the sprint deadline.'
|
||||
],
|
||||
'suggested_remedy' => 'Director and PM should immediately send an executive assurance email, schedule a morning hotfix verification demo, and provide daily transparent milestone updates until billing goes live.'
|
||||
],
|
||||
'emotion_details' => [
|
||||
'emotion' => 'angry',
|
||||
'headline' => 'Client Frustrated by 2-Week Payment Gateway Delay',
|
||||
'bullet_points' => [
|
||||
'Two-week milestone delivery delay on core billing module.',
|
||||
'Unreliable webhook notifications in staging environment halting customer onboarding.',
|
||||
'Lack of proactive status updates before the sprint deadline.'
|
||||
],
|
||||
'trigger_quotes' => ['extremely frustrated', 'completely unacceptable', 'halting our user onboarding'],
|
||||
'suggested_action' => 'Director & PM must arrange an immediate executive check-in, assign dedicated senior engineers to unblock issues, and deliver hotfix within 24h.'
|
||||
],
|
||||
'emailed_at' => now()->subDay()->setTime(11, 40),
|
||||
'emailed_to' => ['sarah.connor@cybertech.io', 'user@company.com', 'admin@company.com']
|
||||
]
|
||||
);
|
||||
|
||||
// 3. Client with Concerned / Sad Sentiment (to test "Why was he sad / concerned?")
|
||||
$client3 = Client::firstOrCreate(
|
||||
['client_email' => 'david.chen@fintechflow.com'],
|
||||
[
|
||||
'client_name' => 'David Chen',
|
||||
'company_name' => 'FinTech Flow Corp',
|
||||
'client_phone' => '+1 (555) 456-7890',
|
||||
'project_name' => 'Real-Time Financial Fraud Detection Engine',
|
||||
'project_details' => 'Machine learning based transaction fraud scoring engine with Kafka event pipelines.',
|
||||
'responsible_user_id' => $pm->id,
|
||||
'created_by' => $admin->id,
|
||||
'assigned_team_members' => [$pm->id],
|
||||
'status' => 'active',
|
||||
'latest_sentiment' => 'sad',
|
||||
]
|
||||
);
|
||||
|
||||
$pastMeeting3 = ClientMeeting::firstOrCreate(
|
||||
['meeting_code' => 'cl-fintech-concern'],
|
||||
[
|
||||
'client_id' => $client3->id,
|
||||
'title' => 'QA Test Coverage & Compliance Risk Sync',
|
||||
'agenda' => 'Reviewing end-to-end testing milestones and regulatory compliance dates.',
|
||||
'meeting_type' => 'scheduled',
|
||||
'scheduled_at' => now()->setTime(10, 0),
|
||||
'duration_minutes' => 25,
|
||||
'host_user_id' => $pm->id,
|
||||
'team_attendees' => [$pm->id],
|
||||
'client_emails' => ['david.chen@fintechflow.com'],
|
||||
'status' => 'completed',
|
||||
'started_at' => now()->setTime(10, 0),
|
||||
'ended_at' => now()->setTime(10, 25),
|
||||
]
|
||||
);
|
||||
|
||||
ClientCallNote::firstOrCreate(
|
||||
['client_meeting_id' => $pastMeeting3->id],
|
||||
[
|
||||
'client_id' => $client3->id,
|
||||
'created_by' => $pm->id,
|
||||
'raw_transcript' => "[10:00 AM] David: We are worried that compliance audit deadlines are in 3 weeks and test coverage is currently at 65%.\n[10:10 AM] PM: We understand your concern David, we have queued additional automated test suites.\n[10:15 AM] David: I am feeling uneasy about launching without 90% coverage.",
|
||||
'live_notes' => 'Client expressed worry and hesitation regarding regulatory compliance test coverage before launch.',
|
||||
'mom_content' => "### Minutes of Meeting (MoM)\n**Project:** Real-Time Financial Fraud Detection Engine\n**Client:** David Chen (FinTech Flow Corp)\n\n#### 1. Meeting Objectives\n- Evaluate test automation progress.\n- Align on compliance audit requirements.\n\n#### 2. Key Discussion Points\n- Client expressed concern over 65% test coverage.\n- Team agreed to prioritize automated integration tests.\n\n#### 3. Next Steps\n- Share daily test coverage metrics.",
|
||||
'summary' => 'Client expressed concern and sadness over QA test coverage timeline ahead of an upcoming regulatory compliance audit.',
|
||||
'action_items' => [
|
||||
['task' => 'Increase unit & integration test coverage to >85%', 'owner' => 'QA Team', 'deadline' => now()->addDays(4)->format('M d, Y'), 'status' => 'In Progress'],
|
||||
['task' => 'Provide compliance audit readiness checklist', 'owner' => 'Tech Lead', 'deadline' => now()->addDays(2)->format('M d, Y'), 'status' => 'Pending']
|
||||
],
|
||||
'todo_items' => [
|
||||
['id' => 1, 'task' => 'Increase unit & integration test coverage to >85%', 'owner' => 'QA Team', 'priority' => 'High', 'deadline' => now()->addDays(4)->format('M d, Y'), 'completed' => false],
|
||||
['id' => 2, 'task' => 'Provide compliance audit readiness checklist to David', 'owner' => 'Tech Lead', 'priority' => 'Medium', 'deadline' => now()->addDays(2)->format('M d, Y'), 'completed' => false],
|
||||
],
|
||||
'key_decisions' => [
|
||||
['decision' => 'Postponed non-critical UI enhancements to focus exclusively on test coverage.']
|
||||
],
|
||||
'detected_sentiment' => 'sad',
|
||||
'sentiment_breakdown' => ['happy_pct' => 10, 'neutral_pct' => 15, 'sad_pct' => 70, 'angry_pct' => 5],
|
||||
'angry_analysis' => ['is_angry' => false, 'trigger_words' => [], 'summary_why_angry' => '', 'grievances' => [], 'suggested_remedy' => ''],
|
||||
'emotion_details' => [
|
||||
'emotion' => 'sad',
|
||||
'headline' => 'Client Expressed Worry & Hesitation on Compliance Deadline',
|
||||
'bullet_points' => [
|
||||
'Worried that compliance audit deadline is only 3 weeks away while test coverage is at 65%.',
|
||||
'Uneasy about potential regulatory penalties if production release has unverified edge cases.',
|
||||
'Requested daily test metrics reports and prioritized automated regression tests.'
|
||||
],
|
||||
'trigger_quotes' => ['worried about deadline', 'feeling uneasy', 'concern about test coverage'],
|
||||
'suggested_action' => 'Tech Lead and PM should proactively provide a detailed test coverage roadmap and schedule a reassuring technical walkthrough.'
|
||||
],
|
||||
'emailed_at' => now()->setTime(10, 30),
|
||||
'emailed_to' => ['david.chen@fintechflow.com', 'user@company.com', 'admin@company.com']
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -61,6 +61,11 @@ public function run(): void
|
||||
['description' => 'Handles recruitment, payroll, and employee relations.']
|
||||
);
|
||||
|
||||
$directorRole = Role::firstOrCreate(
|
||||
['name' => 'Director'],
|
||||
['description' => 'Executive leadership overseeing client relations, projects, and strategy.']
|
||||
);
|
||||
|
||||
// 3. Seed default apps/services (Preserve if already exists)
|
||||
$outlookCalendar = App::firstOrCreate(
|
||||
['name' => 'Outlook Calendar'],
|
||||
|
||||
736
public/js/client-call.js
Normal file
736
public/js/client-call.js
Normal file
@ -0,0 +1,736 @@
|
||||
/**
|
||||
* SingleLogin Client Call - WebRTC Video & AI Meeting Intelligence Engine
|
||||
* Features: Multi-party video grid, Live AI transcription, Real-time emotion analysis,
|
||||
* In-call chat, and Full Call Recording with automated server upload.
|
||||
*/
|
||||
|
||||
let localStream = null;
|
||||
let screenStream = null;
|
||||
let peerConnections = {};
|
||||
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 = [];
|
||||
let callStartTime = Date.now();
|
||||
let peerId = 'peer_' + Math.random().toString(36).substring(2, 9);
|
||||
const config = window.CLIENT_CALL_CONFIG || {};
|
||||
|
||||
// Standard ICE servers
|
||||
const iceServers = [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
{ urls: 'stun:stun.cloudflare.com:3478' }
|
||||
];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
initTimer();
|
||||
await initMedia();
|
||||
initSpeechRecognition();
|
||||
startHeartbeatLoop();
|
||||
startEmotionMonitoring();
|
||||
|
||||
window.toggleAudio = toggleAudio;
|
||||
window.toggleVideo = toggleVideo;
|
||||
window.toggleScreenShare = toggleScreenShare;
|
||||
window.toggleCallRecording = toggleCallRecording;
|
||||
window.sendChatMessage = sendChatMessage;
|
||||
window.endMeetingAndGenerateMoM = endMeetingAndGenerateMoM;
|
||||
window.leaveCall = leaveCall;
|
||||
});
|
||||
|
||||
/**
|
||||
* Timer
|
||||
*/
|
||||
function initTimer() {
|
||||
const timerEl = document.getElementById('callTimer');
|
||||
setInterval(() => {
|
||||
const elapsedSec = Math.floor((Date.now() - callStartTime) / 1000);
|
||||
const mins = String(Math.floor(elapsedSec / 60)).padStart(2, '0');
|
||||
const secs = String(elapsedSec % 60).padStart(2, '0');
|
||||
if (timerEl) timerEl.innerText = `${mins}:${secs}`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Local Camera & Mic Stream
|
||||
*/
|
||||
async function initMedia() {
|
||||
try {
|
||||
localStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { width: { ideal: 1280 }, height: { ideal: 720 } },
|
||||
audio: true
|
||||
});
|
||||
const localVideo = document.getElementById('localVideo');
|
||||
if (localVideo) {
|
||||
localVideo.srcObject = localStream;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Camera/Mic access denied or unavailable:', err);
|
||||
// Fallback: audio only or dummy stream
|
||||
try {
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch (e) {
|
||||
console.warn('Audio also unavailable:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio / Video Toggles
|
||||
*/
|
||||
function toggleAudio() {
|
||||
if (!localStream) return;
|
||||
const audioTrack = localStream.getAudioTracks()[0];
|
||||
if (audioTrack) {
|
||||
isAudioMuted = !isAudioMuted;
|
||||
audioTrack.enabled = !isAudioMuted;
|
||||
const btn = document.getElementById('btnToggleMic');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active-off', isAudioMuted);
|
||||
btn.title = isAudioMuted ? 'Unmute Microphone' : 'Mute Microphone';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleVideo() {
|
||||
if (!localStream) return;
|
||||
const videoTrack = localStream.getVideoTracks()[0];
|
||||
if (videoTrack) {
|
||||
isVideoMuted = !isVideoMuted;
|
||||
videoTrack.enabled = !isVideoMuted;
|
||||
const btn = document.getElementById('btnToggleVideo');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active-off', isVideoMuted);
|
||||
btn.title = isVideoMuted ? 'Turn Camera On' : 'Turn Camera Off';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleScreenShare() {
|
||||
const btn = document.getElementById('btnScreenShare');
|
||||
if (!isScreenSharing) {
|
||||
try {
|
||||
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
|
||||
const screenTrack = screenStream.getVideoTracks()[0];
|
||||
|
||||
// Replace video track for peers
|
||||
for (let id in peerConnections) {
|
||||
const sender = peerConnections[id].getSenders().find(s => s.track && s.track.kind === 'video');
|
||||
if (sender) sender.replaceTrack(screenTrack);
|
||||
}
|
||||
|
||||
const localVideo = document.getElementById('localVideo');
|
||||
if (localVideo) localVideo.srcObject = screenStream;
|
||||
|
||||
screenTrack.onended = () => { stopScreenShare(); };
|
||||
isScreenSharing = true;
|
||||
if (btn) btn.classList.add('active-on');
|
||||
} catch (e) {
|
||||
console.warn('Screen share cancelled:', e);
|
||||
}
|
||||
} else {
|
||||
stopScreenShare();
|
||||
}
|
||||
}
|
||||
|
||||
function stopScreenShare() {
|
||||
if (screenStream) {
|
||||
screenStream.getTracks().forEach(t => t.stop());
|
||||
screenStream = null;
|
||||
}
|
||||
if (localStream) {
|
||||
const videoTrack = localStream.getVideoTracks()[0];
|
||||
for (let id in peerConnections) {
|
||||
const sender = peerConnections[id].getSenders().find(s => s.track && s.track.kind === 'video');
|
||||
if (sender && videoTrack) sender.replaceTrack(videoTrack);
|
||||
}
|
||||
const localVideo = document.getElementById('localVideo');
|
||||
if (localVideo) localVideo.srcObject = localStream;
|
||||
}
|
||||
isScreenSharing = false;
|
||||
const btn = document.getElementById('btnScreenShare');
|
||||
if (btn) btn.classList.remove('active-on');
|
||||
}
|
||||
|
||||
/**
|
||||
* =========================================================================
|
||||
* 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;
|
||||
const feed = document.getElementById('transcriptFeed');
|
||||
const statusEl = document.getElementById('sttStatus');
|
||||
|
||||
if (!SpeechRecognition) {
|
||||
if (statusEl) statusEl.innerText = '● Speech API (Manual)';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
speechRecognizer = new SpeechRecognition();
|
||||
speechRecognizer.continuous = true;
|
||||
speechRecognizer.interimResults = true;
|
||||
speechRecognizer.lang = 'en-US';
|
||||
|
||||
speechRecognizer.onresult = (event) => {
|
||||
for (let i = event.resultIndex; i < event.results.length; ++i) {
|
||||
if (event.results[i].isFinal) {
|
||||
const text = event.results[i][0].transcript.trim();
|
||||
if (text.length > 0) {
|
||||
const line = {
|
||||
speaker: config.participantName || 'Speaker',
|
||||
text: text,
|
||||
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
};
|
||||
accumulatedTranscript.push(line);
|
||||
appendTranscriptLine(line);
|
||||
|
||||
// Check emotion on spoken text
|
||||
checkEmotionOnText(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
speechRecognizer.onerror = (e) => {
|
||||
console.warn('Speech Recognition error:', e.error);
|
||||
};
|
||||
|
||||
speechRecognizer.onend = () => {
|
||||
// Auto restart recognition with debounce
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (speechRecognizer) {
|
||||
speechRecognizer.start();
|
||||
}
|
||||
} catch (err) {}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
speechRecognizer.start();
|
||||
} catch (e) {
|
||||
console.warn('Could not initialize Speech Recognition:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function appendTranscriptLine(line) {
|
||||
const feed = document.getElementById('transcriptFeed');
|
||||
if (!feed) return;
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'transcript-line';
|
||||
div.innerHTML = `<span class="transcript-speaker">${escapeHtml(line.speaker)} (${line.time}):</span> <span class="transcript-text">${escapeHtml(line.text)}</span>`;
|
||||
feed.appendChild(div);
|
||||
feed.scrollTop = feed.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Emotion & Expression Monitoring
|
||||
*/
|
||||
function startEmotionMonitoring() {
|
||||
// Check face emotion snapshot every 10 seconds
|
||||
setInterval(() => {
|
||||
captureAndAnalyzeFrame();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
function captureAndAnalyzeFrame() {
|
||||
const video = document.getElementById('localVideo');
|
||||
const canvas = document.getElementById('emotionCanvas');
|
||||
if (!video || !canvas || !video.videoWidth) return;
|
||||
|
||||
canvas.width = 320;
|
||||
canvas.height = 240;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const frameBase64 = canvas.toDataURL('image/jpeg', 0.6);
|
||||
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
fetch(config.detectEmotionUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
image_frame: frameBase64,
|
||||
speech_text: accumulatedTranscript.slice(-3).map(l => l.text).join(' ')
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data && data.emotion) {
|
||||
updateEmotionBadge(data.emotion, data.label);
|
||||
emotionTimeline.push({
|
||||
emotion: data.emotion,
|
||||
time: Date.now()
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function checkEmotionOnText(text) {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
fetch(config.detectEmotionUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ speech_text: text })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data && data.emotion) {
|
||||
updateEmotionBadge(data.emotion, data.label);
|
||||
emotionTimeline.push({
|
||||
emotion: data.emotion,
|
||||
time: Date.now()
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function updateEmotionBadge(emotion, label) {
|
||||
const badge = document.getElementById('liveEmotionBadge');
|
||||
const emojiEl = document.getElementById('emotionEmoji');
|
||||
const textEl = document.getElementById('emotionText');
|
||||
if (!badge || !emojiEl || !textEl) return;
|
||||
|
||||
badge.className = 'emotion-live-indicator';
|
||||
|
||||
if (emotion === 'happy') {
|
||||
badge.classList.add('emotion-badge-happy');
|
||||
emojiEl.innerText = '😊';
|
||||
textEl.innerText = label || 'Client Mood: Happy / Satisfied';
|
||||
} else if (emotion === 'angry') {
|
||||
badge.classList.add('emotion-badge-angry');
|
||||
emojiEl.innerText = '😡';
|
||||
textEl.innerText = label || 'Client Mood: Frustrated / Angry';
|
||||
} else if (emotion === 'sad') {
|
||||
badge.classList.add('emotion-badge-sad');
|
||||
emojiEl.innerText = '😟';
|
||||
textEl.innerText = label || 'Client Mood: Concerned / Sad';
|
||||
} else {
|
||||
badge.classList.add('emotion-badge-neutral');
|
||||
emojiEl.innerText = '😐';
|
||||
textEl.innerText = label || 'Client Mood: Neutral / Attentive';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC Signaling & Chat Loop
|
||||
*/
|
||||
let outgoingChatMessage = null;
|
||||
|
||||
function sendChatMessage() {
|
||||
const input = document.getElementById('chatInput');
|
||||
if (!input) return;
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
outgoingChatMessage = { text: text };
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function startHeartbeatLoop() {
|
||||
setInterval(async () => {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
try {
|
||||
const res = await fetch(config.heartbeatUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
peer_name: config.participantName,
|
||||
chat_message: outgoingChatMessage
|
||||
})
|
||||
});
|
||||
|
||||
outgoingChatMessage = null;
|
||||
const data = await res.json();
|
||||
|
||||
// Update chat feed
|
||||
if (data.chat_messages) {
|
||||
renderChat(data.chat_messages);
|
||||
}
|
||||
|
||||
// Manage remote peers
|
||||
if (data.active_peers) {
|
||||
handleActivePeers(data.active_peers);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('Heartbeat error:', e);
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
function renderChat(messages) {
|
||||
const feed = document.getElementById('chatFeed');
|
||||
if (!feed) return;
|
||||
feed.innerHTML = '';
|
||||
messages.forEach(m => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg';
|
||||
div.innerHTML = `<div class="chat-sender">${escapeHtml(m.sender)} • ${m.time || ''}</div><div>${escapeHtml(m.text)}</div>`;
|
||||
feed.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function handleActivePeers(peers) {
|
||||
const grid = document.getElementById('videoGrid');
|
||||
if (!grid) return;
|
||||
|
||||
peers.forEach(peer => {
|
||||
if (peer.peer_id === peerId) return;
|
||||
|
||||
let tile = document.getElementById('tile_' + peer.peer_id);
|
||||
if (!tile) {
|
||||
tile = document.createElement('div');
|
||||
tile.id = 'tile_' + peer.peer_id;
|
||||
tile.className = 'video-tile';
|
||||
tile.innerHTML = `
|
||||
<div class="video-avatar-placeholder">${(peer.peer_name || 'U').charAt(0).toUpperCase()}</div>
|
||||
<div class="video-overlay">
|
||||
<span>${escapeHtml(peer.peer_name)}</span>
|
||||
</div>
|
||||
`;
|
||||
grid.appendChild(tile);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove left peers
|
||||
const activeIds = peers.map(p => 'tile_' + p.peer_id);
|
||||
document.querySelectorAll('.video-tile').forEach(tile => {
|
||||
if (tile.id !== 'localVideoTile' && !activeIds.includes(tile.id)) {
|
||||
tile.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* End Call & MoM Generation
|
||||
*/
|
||||
async function endMeetingAndGenerateMoM() {
|
||||
if (!confirm('Are you sure you want to end this client call and generate AI Minutes of Meeting (MoM)?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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');
|
||||
|
||||
// Show processing indicator
|
||||
const btn = document.querySelector('.btn-end-call');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerText = 'Generating AI MoM & Saving Notes...';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(config.endCallUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
transcript: transcriptText,
|
||||
live_notes: liveNotes,
|
||||
emotion_timeline: emotionTimeline,
|
||||
recording_path: recordedVideoPath
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.redirect_url) {
|
||||
window.location.href = data.redirect_url;
|
||||
} else {
|
||||
window.location.href = config.redirectUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Meeting 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 String(str).replace(/[&<>"']/g, function(m) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m];
|
||||
});
|
||||
}
|
||||
722
resources/js/client-call.js
Normal file
722
resources/js/client-call.js
Normal file
@ -0,0 +1,722 @@
|
||||
/**
|
||||
* SingleLogin Client Call - WebRTC Video & AI Meeting Intelligence Engine
|
||||
* Features: Multi-party video grid, Live AI transcription, Real-time emotion analysis,
|
||||
* In-call chat, and Full Call Recording with automated server upload.
|
||||
*/
|
||||
|
||||
let localStream = null;
|
||||
let screenStream = null;
|
||||
let peerConnections = {};
|
||||
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 = [];
|
||||
let callStartTime = Date.now();
|
||||
let peerId = 'peer_' + Math.random().toString(36).substring(2, 9);
|
||||
const config = window.CLIENT_CALL_CONFIG || {};
|
||||
|
||||
// Standard ICE servers
|
||||
const iceServers = [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
{ urls: 'stun:stun.cloudflare.com:3478' }
|
||||
];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
initTimer();
|
||||
await initMedia();
|
||||
initSpeechRecognition();
|
||||
startHeartbeatLoop();
|
||||
startEmotionMonitoring();
|
||||
|
||||
window.toggleAudio = toggleAudio;
|
||||
window.toggleVideo = toggleVideo;
|
||||
window.toggleScreenShare = toggleScreenShare;
|
||||
window.toggleCallRecording = toggleCallRecording;
|
||||
window.sendChatMessage = sendChatMessage;
|
||||
window.endMeetingAndGenerateMoM = endMeetingAndGenerateMoM;
|
||||
window.leaveCall = leaveCall;
|
||||
});
|
||||
|
||||
/**
|
||||
* Timer
|
||||
*/
|
||||
function initTimer() {
|
||||
const timerEl = document.getElementById('callTimer');
|
||||
setInterval(() => {
|
||||
const elapsedSec = Math.floor((Date.now() - callStartTime) / 1000);
|
||||
const mins = String(Math.floor(elapsedSec / 60)).padStart(2, '0');
|
||||
const secs = String(elapsedSec % 60).padStart(2, '0');
|
||||
if (timerEl) timerEl.innerText = `${mins}:${secs}`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Local Camera & Mic Stream
|
||||
*/
|
||||
async function initMedia() {
|
||||
try {
|
||||
localStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { width: { ideal: 1280 }, height: { ideal: 720 } },
|
||||
audio: true
|
||||
});
|
||||
const localVideo = document.getElementById('localVideo');
|
||||
if (localVideo) {
|
||||
localVideo.srcObject = localStream;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Camera/Mic access denied or unavailable:', err);
|
||||
try {
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch (e) {
|
||||
console.warn('Audio also unavailable:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio / Video Toggles
|
||||
*/
|
||||
function toggleAudio() {
|
||||
if (!localStream) return;
|
||||
const audioTrack = localStream.getAudioTracks()[0];
|
||||
if (audioTrack) {
|
||||
isAudioMuted = !isAudioMuted;
|
||||
audioTrack.enabled = !isAudioMuted;
|
||||
const btn = document.getElementById('btnToggleMic');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active-off', isAudioMuted);
|
||||
btn.title = isAudioMuted ? 'Unmute Microphone' : 'Mute Microphone';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleVideo() {
|
||||
if (!localStream) return;
|
||||
const videoTrack = localStream.getVideoTracks()[0];
|
||||
if (videoTrack) {
|
||||
isVideoMuted = !isVideoMuted;
|
||||
videoTrack.enabled = !isVideoMuted;
|
||||
const btn = document.getElementById('btnToggleVideo');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active-off', isVideoMuted);
|
||||
btn.title = isVideoMuted ? 'Turn Camera On' : 'Turn Camera Off';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleScreenShare() {
|
||||
const btn = document.getElementById('btnScreenShare');
|
||||
if (!isScreenSharing) {
|
||||
try {
|
||||
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
|
||||
const screenTrack = screenStream.getVideoTracks()[0];
|
||||
|
||||
for (let id in peerConnections) {
|
||||
const sender = peerConnections[id].getSenders().find(s => s.track && s.track.kind === 'video');
|
||||
if (sender) sender.replaceTrack(screenTrack);
|
||||
}
|
||||
|
||||
const localVideo = document.getElementById('localVideo');
|
||||
if (localVideo) localVideo.srcObject = screenStream;
|
||||
|
||||
screenTrack.onended = () => { stopScreenShare(); };
|
||||
isScreenSharing = true;
|
||||
if (btn) btn.classList.add('active-on');
|
||||
} catch (e) {
|
||||
console.warn('Screen share cancelled:', e);
|
||||
}
|
||||
} else {
|
||||
stopScreenShare();
|
||||
}
|
||||
}
|
||||
|
||||
function stopScreenShare() {
|
||||
if (screenStream) {
|
||||
screenStream.getTracks().forEach(t => t.stop());
|
||||
screenStream = null;
|
||||
}
|
||||
if (localStream) {
|
||||
const videoTrack = localStream.getVideoTracks()[0];
|
||||
for (let id in peerConnections) {
|
||||
const sender = peerConnections[id].getSenders().find(s => s.track && s.track.kind === 'video');
|
||||
if (sender && videoTrack) sender.replaceTrack(videoTrack);
|
||||
}
|
||||
const localVideo = document.getElementById('localVideo');
|
||||
if (localVideo) localVideo.srcObject = localStream;
|
||||
}
|
||||
isScreenSharing = false;
|
||||
const btn = document.getElementById('btnScreenShare');
|
||||
if (btn) btn.classList.remove('active-on');
|
||||
}
|
||||
|
||||
/**
|
||||
* =========================================================================
|
||||
* 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;
|
||||
const feed = document.getElementById('transcriptFeed');
|
||||
const statusEl = document.getElementById('sttStatus');
|
||||
|
||||
if (!SpeechRecognition) {
|
||||
if (statusEl) statusEl.innerText = '● Speech API (Manual)';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
speechRecognizer = new SpeechRecognition();
|
||||
speechRecognizer.continuous = true;
|
||||
speechRecognizer.interimResults = true;
|
||||
speechRecognizer.lang = 'en-US';
|
||||
|
||||
speechRecognizer.onresult = (event) => {
|
||||
for (let i = event.resultIndex; i < event.results.length; ++i) {
|
||||
if (event.results[i].isFinal) {
|
||||
const text = event.results[i][0].transcript.trim();
|
||||
if (text.length > 0) {
|
||||
const line = {
|
||||
speaker: config.participantName || 'Speaker',
|
||||
text: text,
|
||||
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
};
|
||||
accumulatedTranscript.push(line);
|
||||
appendTranscriptLine(line);
|
||||
|
||||
checkEmotionOnText(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
speechRecognizer.onerror = (e) => {
|
||||
console.warn('Speech Recognition error:', e.error);
|
||||
};
|
||||
|
||||
speechRecognizer.onend = () => {
|
||||
try {
|
||||
speechRecognizer.start();
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
speechRecognizer.start();
|
||||
} catch (e) {
|
||||
console.warn('Could not initialize Speech Recognition:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function appendTranscriptLine(line) {
|
||||
const feed = document.getElementById('transcriptFeed');
|
||||
if (!feed) return;
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'transcript-line';
|
||||
div.innerHTML = `<span class="transcript-speaker">${escapeHtml(line.speaker)} (${line.time}):</span> <span class="transcript-text">${escapeHtml(line.text)}</span>`;
|
||||
feed.appendChild(div);
|
||||
feed.scrollTop = feed.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Emotion & Expression Monitoring
|
||||
*/
|
||||
function startEmotionMonitoring() {
|
||||
setInterval(() => {
|
||||
captureAndAnalyzeFrame();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
function captureAndAnalyzeFrame() {
|
||||
const video = document.getElementById('localVideo');
|
||||
const canvas = document.getElementById('emotionCanvas');
|
||||
if (!video || !canvas || !video.videoWidth) return;
|
||||
|
||||
canvas.width = 320;
|
||||
canvas.height = 240;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const frameBase64 = canvas.toDataURL('image/jpeg', 0.6);
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
fetch(config.detectEmotionUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
image_frame: frameBase64,
|
||||
speech_text: accumulatedTranscript.slice(-3).map(l => l.text).join(' ')
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data && data.emotion) {
|
||||
updateEmotionBadge(data.emotion, data.label);
|
||||
emotionTimeline.push({
|
||||
emotion: data.emotion,
|
||||
time: Date.now()
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function checkEmotionOnText(text) {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
fetch(config.detectEmotionUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ speech_text: text })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data && data.emotion) {
|
||||
updateEmotionBadge(data.emotion, data.label);
|
||||
emotionTimeline.push({
|
||||
emotion: data.emotion,
|
||||
time: Date.now()
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function updateEmotionBadge(emotion, label) {
|
||||
const badge = document.getElementById('liveEmotionBadge');
|
||||
const emojiEl = document.getElementById('emotionEmoji');
|
||||
const textEl = document.getElementById('emotionText');
|
||||
if (!badge || !emojiEl || !textEl) return;
|
||||
|
||||
badge.className = 'emotion-live-indicator';
|
||||
|
||||
if (emotion === 'happy') {
|
||||
badge.classList.add('emotion-badge-happy');
|
||||
emojiEl.innerText = '😊';
|
||||
textEl.innerText = label || 'Client Mood: Happy / Satisfied';
|
||||
} else if (emotion === 'angry') {
|
||||
badge.classList.add('emotion-badge-angry');
|
||||
emojiEl.innerText = '😡';
|
||||
textEl.innerText = label || 'Client Mood: Frustrated / Angry';
|
||||
} else if (emotion === 'sad') {
|
||||
badge.classList.add('emotion-badge-sad');
|
||||
emojiEl.innerText = '😟';
|
||||
textEl.innerText = label || 'Client Mood: Concerned / Sad';
|
||||
} else {
|
||||
badge.classList.add('emotion-badge-neutral');
|
||||
emojiEl.innerText = '😐';
|
||||
textEl.innerText = label || 'Client Mood: Neutral / Attentive';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC Signaling & Chat Loop
|
||||
*/
|
||||
let outgoingChatMessage = null;
|
||||
|
||||
function sendChatMessage() {
|
||||
const input = document.getElementById('chatInput');
|
||||
if (!input) return;
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
outgoingChatMessage = { text: text };
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function startHeartbeatLoop() {
|
||||
setInterval(async () => {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
try {
|
||||
const res = await fetch(config.heartbeatUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
peer_name: config.participantName,
|
||||
chat_message: outgoingChatMessage
|
||||
})
|
||||
});
|
||||
|
||||
outgoingChatMessage = null;
|
||||
const data = await res.json();
|
||||
|
||||
if (data.chat_messages) {
|
||||
renderChat(data.chat_messages);
|
||||
}
|
||||
|
||||
if (data.active_peers) {
|
||||
handleActivePeers(data.active_peers);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('Heartbeat error:', e);
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
function renderChat(messages) {
|
||||
const feed = document.getElementById('chatFeed');
|
||||
if (!feed) return;
|
||||
feed.innerHTML = '';
|
||||
messages.forEach(m => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg';
|
||||
div.innerHTML = `<div class="chat-sender">${escapeHtml(m.sender)} • ${m.time || ''}</div><div>${escapeHtml(m.text)}</div>`;
|
||||
feed.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function handleActivePeers(peers) {
|
||||
const grid = document.getElementById('videoGrid');
|
||||
if (!grid) return;
|
||||
|
||||
peers.forEach(peer => {
|
||||
if (peer.peer_id === peerId) return;
|
||||
|
||||
let tile = document.getElementById('tile_' + peer.peer_id);
|
||||
if (!tile) {
|
||||
tile = document.createElement('div');
|
||||
tile.id = 'tile_' + peer.peer_id;
|
||||
tile.className = 'video-tile';
|
||||
tile.innerHTML = `
|
||||
<div class="video-avatar-placeholder">${(peer.peer_name || 'U').charAt(0).toUpperCase()}</div>
|
||||
<div class="video-overlay">
|
||||
<span>${escapeHtml(peer.peer_name)}</span>
|
||||
</div>
|
||||
`;
|
||||
grid.appendChild(tile);
|
||||
}
|
||||
});
|
||||
|
||||
const activeIds = peers.map(p => 'tile_' + p.peer_id);
|
||||
document.querySelectorAll('.video-tile').forEach(tile => {
|
||||
if (tile.id !== 'localVideoTile' && !activeIds.includes(tile.id)) {
|
||||
tile.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* End Call & MoM Generation
|
||||
*/
|
||||
async function endMeetingAndGenerateMoM() {
|
||||
if (!confirm('Are you sure you want to end this client call and generate AI Minutes of Meeting (MoM)?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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');
|
||||
|
||||
const btn = document.querySelector('.btn-end-call');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerText = 'Generating AI MoM & Saving Notes...';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(config.endCallUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
transcript: transcriptText,
|
||||
live_notes: liveNotes,
|
||||
emotion_timeline: emotionTimeline,
|
||||
recording_path: recordedVideoPath
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.redirect_url) {
|
||||
window.location.href = data.redirect_url;
|
||||
} else {
|
||||
window.location.href = config.redirectUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Meeting 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 String(str).replace(/[&<>"']/g, function(m) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m];
|
||||
});
|
||||
}
|
||||
@ -816,7 +816,10 @@
|
||||
</div>
|
||||
<div class="nav-actions">
|
||||
<!-- Allow Admin to view the User portal dashboard -->
|
||||
<a href="{{ route('interview.index') }}" class="btn-nav" style="border-color: #22d3ee; color: #22d3ee; text-decoration: none;">
|
||||
<a href="{{ route('client-calls.index') }}" class="btn-nav" style="border-color: #22d3ee; color: #22d3ee; text-decoration: none;">
|
||||
📞 Client Calls Hub
|
||||
</a>
|
||||
<a href="{{ route('interview.index') }}" class="btn-nav" style="border-color: #34d399; color: #34d399; text-decoration: none;">
|
||||
⚡ Assessment Hub
|
||||
</a>
|
||||
<a href="{{ route('onboarding.index') }}" class="btn-nav" style="border-color: #818cf8; color: #818cf8; text-decoration: none;">
|
||||
@ -884,6 +887,22 @@
|
||||
</div>
|
||||
<div class="stat-icon" style="color: #a5b4fc;">🏷️</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div>
|
||||
<div class="stat-label">Client Projects</div>
|
||||
<div class="stat-value">{{ $stats['clients_count'] ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-icon" style="color: #22d3ee;">📁</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div>
|
||||
<div class="stat-label">Client Alerts</div>
|
||||
<div class="stat-value" style="{{ ($stats['angry_clients_count'] ?? 0) > 0 ? 'color: #f87171;' : '' }}">{{ $stats['angry_clients_count'] ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-icon" style="color: {{ ($stats['angry_clients_count'] ?? 0) > 0 ? '#f87171' : '#34d399' }};">{{ ($stats['angry_clients_count'] ?? 0) > 0 ? '⚠️' : '✨' }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Alert messages in dashboard -->
|
||||
@ -915,6 +934,96 @@
|
||||
|
||||
|
||||
|
||||
<!-- Client Projects & Calls Directory -->
|
||||
<section class="users-card" style="margin-bottom: 50px;">
|
||||
<div class="card-header" style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
|
||||
<div>
|
||||
<div class="card-title" style="font-family: 'Outfit', sans-serif; font-size: 1.25rem; font-weight: 700; color: #ffffff;">Client Projects & Meetings Hub</div>
|
||||
<div style="font-size: 0.8rem; color: var(--text-muted);">
|
||||
Real-time client collaboration, AI transcription, MoM records, and emotion detection
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<a href="{{ route('client-calls.index') }}" class="action-btn" style="color: #22d3ee; border-color: rgba(34, 211, 238, 0.4); text-decoration: none; font-size: 0.8rem; padding: 6px 14px; font-weight: 600;">
|
||||
Open Full Client Calls Hub ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project & Client</th>
|
||||
<th>Responsible Lead</th>
|
||||
<th>Total Calls</th>
|
||||
<th>Client Expression</th>
|
||||
<th style="text-align: right;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($clients as $cl)
|
||||
@php
|
||||
$sent = strtolower($cl->latest_sentiment ?? 'neutral');
|
||||
$badgeStyle = match($sent) {
|
||||
'happy' => 'background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.35);',
|
||||
'sad' => 'background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.35);',
|
||||
'angry' => 'background: rgba(239, 68, 68, 0.2); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.45); font-weight: 700;',
|
||||
default => 'background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.35);',
|
||||
};
|
||||
$emoji = match($sent) {
|
||||
'happy' => '😊 Happy',
|
||||
'sad' => '😟 Concerned',
|
||||
'angry' => '😡 Frustrated',
|
||||
default => '😐 Neutral',
|
||||
};
|
||||
$latestNote = $cl->callNotes->first();
|
||||
@endphp
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-weight: 700; color: #f3f4f6; font-size: 0.95rem;">{{ $cl->project_name }}</div>
|
||||
<div style="font-size: 0.8rem; color: var(--text-muted); display: flex; align-items: center; gap: 6px; margin-top: 2px;">
|
||||
<span>👤 {{ $cl->client_name }}</span>
|
||||
@if($cl->company_name)<span>• {{ $cl->company_name }}</span>@endif
|
||||
<span>• ✉️ {{ $cl->client_email }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" style="background: rgba(99, 102, 241, 0.15); color: #818cf8; border: 1px solid rgba(99, 102, 241, 0.3);">
|
||||
{{ $cl->responsibleUser->name ?? 'Unassigned' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style="font-weight: 600; font-size: 0.85rem; color: #cbd5e1;">{{ $cl->meetings->count() }} meetings</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" style="{{ $badgeStyle }} font-size: 0.75rem; padding: 4px 10px; border-radius: 999px;">
|
||||
{{ $emoji }}
|
||||
</span>
|
||||
@if($sent === 'angry' && $latestNote)
|
||||
<button onclick="openAdminWhyAngryModal({{ $latestNote->id }})" style="background: #ef4444; color: white; border: none; font-size: 0.7rem; padding: 3px 8px; border-radius: 4px; font-weight: 700; cursor: pointer; margin-left: 6px;">
|
||||
Why angry?
|
||||
</button>
|
||||
@endif
|
||||
</td>
|
||||
<td style="text-align: right;">
|
||||
<a href="{{ route('client-calls.show', $cl->id) }}" target="_blank" class="action-btn" style="color: #818cf8; border-color: rgba(99, 102, 241, 0.4); text-decoration: none; font-size: 0.75rem; padding: 5px 12px; font-weight: 600;">
|
||||
Open Client Hub ↗
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; color: var(--text-muted); padding: 24px;">
|
||||
No client projects registered yet. <a href="{{ route('client-calls.index') }}" style="color: #818cf8;">Create first client project</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Users Table Card -->
|
||||
<section class="users-card" style="margin-bottom: 50px;">
|
||||
<div class="card-header" style="margin-bottom: 20px;">
|
||||
@ -1779,8 +1888,95 @@ function confirmAdminToggle(userId, userName, actionType) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function openAdminWhyAngryModal(noteId) {
|
||||
const modal = document.getElementById('adminWhyAngryModal');
|
||||
const content = document.getElementById('adminWhyAngryContent');
|
||||
modal.classList.add('active');
|
||||
content.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-muted);">Analyzing transcript and emotion triggers...</div>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/client-calls/notes/${noteId}/angry-analysis`, {
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
content.innerHTML = `<div style="color:#f87171;padding:1rem;background:rgba(239,68,68,0.1);border-radius:8px;">${data.error || 'Access Denied.'}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const analysis = data.angry_analysis || {};
|
||||
const triggers = analysis.trigger_words || [];
|
||||
const grievances = analysis.grievances || [];
|
||||
|
||||
content.innerHTML = `
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<div style="font-size: 0.85rem; color: var(--text-muted); margin-bottom: 4px;">Client & Project</div>
|
||||
<div style="font-weight: 700; font-size: 1.1rem; color: #ffffff;">${data.client_name} • ${data.project_name}</div>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(239,68,68,0.15); border-left: 4px solid #ef4444; padding: 14px; border-radius: 0 8px 8px 0; margin-bottom: 1.25rem;">
|
||||
<div style="font-weight: 700; font-size: 0.85rem; color: #fca5a5; margin-bottom: 6px; text-transform: uppercase;">AI Executive Breakdown</div>
|
||||
<p style="font-size: 0.9rem; color: #fee2e2; line-height: 1.5;">${analysis.summary_why_angry || 'Client showed frustration during the meeting.'}</p>
|
||||
</div>
|
||||
|
||||
${triggers.length > 0 ? `
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<div style="font-weight: 600; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 6px;">Identified Frustrated / Angry Keywords:</div>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
|
||||
${triggers.map(t => `<span style="background: rgba(239,68,68,0.25); color: #fca5a5; font-size: 0.75rem; padding: 3px 8px; border-radius: 4px; border: 1px solid rgba(239,68,68,0.4); font-weight: 600;">"${t}"</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${grievances.length > 0 ? `
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<div style="font-weight: 600; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 6px;">Key Complaints / Pain Points:</div>
|
||||
<ul style="padding-left: 20px; font-size: 0.85rem; color: #cbd5e1; line-height: 1.6;">
|
||||
${grievances.map(g => `<li>${g}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${analysis.suggested_remedy ? `
|
||||
<div style="background: rgba(16,185,129,0.15); border: 1px solid rgba(16,185,129,0.35); border-radius: 8px; padding: 12px;">
|
||||
<div style="font-weight: 700; font-size: 0.8rem; color: #34d399; margin-bottom: 4px; text-transform: uppercase;">💡 Recommended Action for PM & Director:</div>
|
||||
<p style="font-size: 0.85rem; color: #d1fae5; line-height: 1.5;">${analysis.suggested_remedy}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
} catch (err) {
|
||||
content.innerHTML = `<div style="color:#f87171;padding:1rem;">Failed to load analysis.</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function closeAdminWhyAngryModal() {
|
||||
document.getElementById('adminWhyAngryModal').classList.remove('active');
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Admin Why Angry Modal -->
|
||||
<div id="adminWhyAngryModal" class="admin-modal">
|
||||
<div class="admin-modal-content" style="max-width: 600px; border-color: rgba(239, 68, 68, 0.5);">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h3 class="modal-title" style="color: #f87171; display: flex; align-items: center; gap: 8px;">
|
||||
<span>😡</span> AI Root-Cause: Why is the client angry?
|
||||
</h3>
|
||||
<div style="font-size: 0.75rem; color: #fca5a5; margin-top: 4px;">Confidential to Administrator & Assigned Lead</div>
|
||||
</div>
|
||||
<button class="modal-close" onclick="closeAdminWhyAngryModal()">×</button>
|
||||
</div>
|
||||
<div id="adminWhyAngryContent" style="padding: 10px 0;">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
<div class="modal-footer" style="margin-top: 15px; border-top: 1px solid var(--card-border); padding-top: 15px; text-align: right;">
|
||||
<button type="button" class="btn-secondary" onclick="closeAdminWhyAngryModal()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer style="text-align: center; padding: 40px 0; font-size: 0.75rem; color: var(--text-muted); z-index: 10;">
|
||||
© 2026 Systems. Authorized Access Only.
|
||||
</footer>
|
||||
|
||||
161
resources/views/client_calls/guest_join.blade.php
Normal file
161
resources/views/client_calls/guest_join.blade.php
Normal file
@ -0,0 +1,161 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Join Meeting | SingleLogin Connect</title>
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@500;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #0b0f19;
|
||||
--card-bg: rgba(17, 24, 39, 0.7);
|
||||
--card-border: rgba(255, 255, 255, 0.12);
|
||||
--accent-primary: #4f46e5;
|
||||
--text-main: #f3f4f6;
|
||||
--text-muted: #9ca3af;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background-image:
|
||||
radial-gradient(circle at 80% 20%, rgba(99, 102, 241, 0.15) 0%, transparent 50%),
|
||||
radial-gradient(circle at 20% 80%, rgba(6, 182, 212, 0.12) 0%, transparent 50%);
|
||||
}
|
||||
.join-card {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 20px;
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
text-align: center;
|
||||
}
|
||||
.app-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #818cf8;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.25rem;
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
.meeting-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.project-meta {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.form-group {
|
||||
text-align: left;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.8rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-main);
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus {
|
||||
border-color: #6366f1;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.btn-join {
|
||||
width: 100%;
|
||||
padding: 0.85rem;
|
||||
background: linear-gradient(135deg, #4f46e5, #6366f1);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.btn-join:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(79, 70, 229, 0.6);
|
||||
}
|
||||
.footer-note {
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.join-card { padding: 1.75rem 1.25rem; }
|
||||
.meeting-title { font-size: 1.25rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="join-card">
|
||||
<div class="app-badge">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
|
||||
SingleLogin Video Connect
|
||||
</div>
|
||||
|
||||
<h1 class="meeting-title">{{ $meeting->title }}</h1>
|
||||
<p class="project-meta">{{ $meeting->client->project_name }} • Hosted by {{ $meeting->host->name ?? 'SingleLogin Host' }}</p>
|
||||
|
||||
<form action="{{ route('client-calls.join-guest', $meeting->meeting_code) }}" method="POST">
|
||||
@csrf
|
||||
<div class="form-group">
|
||||
<label class="form-label">What's your name? *</label>
|
||||
<input type="text" name="guest_name" class="form-control" placeholder="e.g. Robert Miller (Acme Corp)" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Your Email (Optional)</label>
|
||||
<input type="email" name="guest_email" class="form-control" placeholder="e.g. robert@acme.com">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-join">
|
||||
👉 Join Meeting Room
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="footer-note">
|
||||
No account required. Protected with end-to-end WebRTC encryption.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
833
resources/views/client_calls/index.blade.php
Normal file
833
resources/views/client_calls/index.blade.php
Normal file
@ -0,0 +1,833 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>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;500;600;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
if (savedTheme === 'light') {
|
||||
document.documentElement.classList.add('light-mode');
|
||||
} else {
|
||||
document.documentElement.classList.remove('light-mode');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #0b0f19;
|
||||
--card-bg: rgba(17, 24, 39, 0.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.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: #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;
|
||||
--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: 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.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;
|
||||
}
|
||||
|
||||
/* Glass Navbar */
|
||||
.navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
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.82);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-decoration: none;
|
||||
color: var(--text-main);
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.brand-badge {
|
||||
background: linear-gradient(135deg, #4f46e5, #06b6d4);
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0.55rem 1.1rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
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(-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);
|
||||
}
|
||||
.nav-btn-primary:hover {
|
||||
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: 1320px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* Alert */
|
||||
.alert {
|
||||
padding: 1rem 1.25rem;
|
||||
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);
|
||||
border: 1px solid rgba(16, 185, 129, 0.35);
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 16px;
|
||||
padding: 1.35rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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: var(--card-hover-border);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 28px var(--accent-glow);
|
||||
}
|
||||
.stat-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.6rem;
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
color: #818cf8;
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
.stat-icon.cyan {
|
||||
background: rgba(6, 182, 212, 0.12);
|
||||
color: #22d3ee;
|
||||
border-color: rgba(6, 182, 212, 0.2);
|
||||
}
|
||||
.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.75rem;
|
||||
font-weight: 800;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
color: var(--text-main);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Section Header */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
.section-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
.section-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Search & Filter Bar */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.search-box {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
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: 12px;
|
||||
color: var(--text-main);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.search-input:focus {
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
/* Client Cards Grid */
|
||||
.clients-grid {
|
||||
display: grid;
|
||||
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(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 18px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
.client-card:hover {
|
||||
border-color: var(--card-hover-border);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 16px 36px var(--accent-glow);
|
||||
}
|
||||
|
||||
.client-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.project-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.client-company {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.client-info-list {
|
||||
margin: 1.25rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.info-row span:last-child {
|
||||
color: var(--text-main);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.responsible-badge {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #818cf8;
|
||||
padding: 3px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--card-border);
|
||||
}
|
||||
.btn-open-hub {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0.7rem 1.2rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
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.25s ease;
|
||||
}
|
||||
.btn-open-hub:hover {
|
||||
background: linear-gradient(135deg, #4f46e5, #06b6d4);
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Modal styling */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: var(--modal-overlay);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
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: 20px;
|
||||
width: 100%;
|
||||
max-width: 620px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
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.5rem;
|
||||
}
|
||||
.modal-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
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);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
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;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.team-members-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
.member-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px dashed var(--card-border);
|
||||
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>
|
||||
|
||||
<!-- Glass Navbar -->
|
||||
<nav class="navbar">
|
||||
<a href="{{ route('dashboard') }}" class="brand-logo">
|
||||
<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">
|
||||
🌙
|
||||
</button>
|
||||
<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>
|
||||
<button class="nav-btn-primary nav-btn" onclick="openAddClientModal()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
Add Client & Project
|
||||
</button>
|
||||
<a href="{{ route('dashboard') }}" class="nav-btn">
|
||||
Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-container">
|
||||
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success">
|
||||
<span>{{ session('success') }}</span>
|
||||
<button style="background:transparent;border:none;color:inherit;cursor:pointer;font-size:1.1rem;" onclick="this.parentElement.remove()">✕</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Stats Overview -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📁</div>
|
||||
<div>
|
||||
<div class="stat-val">{{ $totalClients }}</div>
|
||||
<div class="stat-label">Total Client Accounts</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon emerald">📞</div>
|
||||
<div>
|
||||
<div class="stat-val">{{ $totalMeetings }}</div>
|
||||
<div class="stat-label">Total Meetings Logged</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon cyan">🚀</div>
|
||||
<div>
|
||||
<div class="stat-val">{{ $activeProjectsCount ?? $totalClients }}</div>
|
||||
<div class="stat-label">Active Projects</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Header -->
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h1 class="section-title">Client Projects & Meeting Hub</h1>
|
||||
<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>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="filter-bar">
|
||||
<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: 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>
|
||||
</div>
|
||||
@else
|
||||
<div class="clients-grid" id="clientsGrid">
|
||||
@foreach($clients as $client)
|
||||
@php
|
||||
$meetingsCount = $client->meetings->count();
|
||||
$latestNote = $client->callNotes->first();
|
||||
@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 ?? '') }}">
|
||||
|
||||
<div>
|
||||
<div class="client-card-header">
|
||||
<div>
|
||||
<div class="project-title">{{ $client->project_name }}</div>
|
||||
<div class="client-company">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
|
||||
{{ $client->client_name }} {{ $client->company_name ? "• " . $client->company_name : "" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="client-info-list">
|
||||
<div class="info-row">
|
||||
<span>Client Email:</span>
|
||||
<span>{{ $client->client_email }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>Responsible Lead:</span>
|
||||
<span class="responsible-badge">{{ $client->responsibleUser->name ?? 'Unassigned' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>Total Calls:</span>
|
||||
<span>{{ $meetingsCount }} {{ $meetingsCount === 1 ? 'call' : 'calls' }}</span>
|
||||
</div>
|
||||
@if($latestNote)
|
||||
<div class="info-row">
|
||||
<span>Last Call Date:</span>
|
||||
<span>{{ $latestNote->created_at->format('M d, Y') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<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>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Add Client & Project Modal -->
|
||||
<div id="addClientModal" class="modal-overlay">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Add New Client & Project</h2>
|
||||
<button class="modal-close" onclick="closeAddClientModal()">×</button>
|
||||
</div>
|
||||
<form action="{{ route('client-calls.store') }}" method="POST">
|
||||
@csrf
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Client / Contact Name *</label>
|
||||
<input type="text" name="client_name" class="form-control" placeholder="e.g. Robert Miller" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Client Email Address *</label>
|
||||
<input type="email" name="client_email" class="form-control" placeholder="e.g. robert@acmecorp.com" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Company / Organization</label>
|
||||
<input type="text" name="company_name" class="form-control" placeholder="e.g. Acme Corp">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Phone Number</label>
|
||||
<input type="text" name="client_phone" class="form-control" placeholder="e.g. +1 555 019 283">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Project Name *</label>
|
||||
<input type="text" name="project_name" class="form-control" placeholder="e.g. Enterprise Cloud ERP Migration" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Project Details / Scope</label>
|
||||
<textarea name="project_details" class="form-control" rows="3" placeholder="Brief description of the deliverables, milestones, and client requirements..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Responsible Person (PM / TL / Director in charge) *</label>
|
||||
<select name="responsible_user_id" class="form-control" required>
|
||||
@foreach($managersAndLeads as $manager)
|
||||
<option value="{{ $manager->id }}" {{ Auth::id() === $manager->id ? 'selected' : '' }}>
|
||||
{{ $manager->name }} ({{ $manager->role }})
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Assign Team Members (Optional)</label>
|
||||
<div class="team-members-grid">
|
||||
@foreach($allUsers as $userItem)
|
||||
<label class="member-checkbox-label">
|
||||
<input type="checkbox" name="assigned_team_members[]" value="{{ $userItem->id }}">
|
||||
<span>{{ $userItem->name }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 1.5rem;">
|
||||
<button type="button" class="nav-btn" onclick="closeAddClientModal()">Cancel</button>
|
||||
<button type="submit" class="nav-btn-primary nav-btn">Create Client Profile</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openAddClientModal() {
|
||||
document.getElementById('addClientModal').style.display = 'flex';
|
||||
}
|
||||
function closeAddClientModal() {
|
||||
document.getElementById('addClientModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function filterCards() {
|
||||
const query = document.getElementById('searchInput').value.toLowerCase();
|
||||
const cards = document.querySelectorAll('.client-card');
|
||||
|
||||
cards.forEach(card => {
|
||||
const name = card.dataset.name || '';
|
||||
const project = card.dataset.project || '';
|
||||
const company = card.dataset.company || '';
|
||||
const lead = card.dataset.lead || '';
|
||||
|
||||
const matchesQuery = name.includes(query) || project.includes(query) || company.includes(query) || lead.includes(query);
|
||||
|
||||
if (matchesQuery) {
|
||||
card.style.display = 'flex';
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const isLight = document.documentElement.classList.toggle('light-mode');
|
||||
localStorage.setItem('theme', isLight ? 'light' : 'dark');
|
||||
const btn = document.getElementById('themeToggleBtn');
|
||||
if (btn) btn.innerText = isLight ? '☀️' : '🌙';
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const isLight = document.documentElement.classList.contains('light-mode');
|
||||
const btn = document.getElementById('themeToggleBtn');
|
||||
if (btn) btn.innerText = isLight ? '☀️' : '🌙';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1109
resources/views/client_calls/logs.blade.php
Normal file
1109
resources/views/client_calls/logs.blade.php
Normal file
File diff suppressed because it is too large
Load Diff
739
resources/views/client_calls/room.blade.php
Normal file
739
resources/views/client_calls/room.blade.php
Normal file
@ -0,0 +1,739 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ $meeting->title }} | Live Client Call Room</title>
|
||||
<!-- Google Fonts: Outfit & Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@500;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: #0b0f19;
|
||||
color: #f3f4f6;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Top Header */
|
||||
.call-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: rgba(17, 24, 39, 0.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;
|
||||
color: white;
|
||||
font-size: 0.65rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
animation: pulse-live 2s infinite;
|
||||
}
|
||||
@keyframes pulse-live {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
.meeting-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
.call-timer {
|
||||
font-family: monospace;
|
||||
font-size: 0.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 */
|
||||
.emotion-live-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.emotion-badge-happy { background: rgba(16, 185, 129, 0.2); border-color: rgba(16, 185, 129, 0.4); color: #34d399; }
|
||||
.emotion-badge-neutral { background: rgba(59, 130, 246, 0.2); border-color: rgba(59, 130, 246, 0.4); color: #60a5fa; }
|
||||
.emotion-badge-sad { background: rgba(245, 158, 11, 0.2); border-color: rgba(245, 158, 11, 0.4); color: #fbbf24; }
|
||||
.emotion-badge-angry { background: rgba(239, 68, 68, 0.25); border-color: rgba(239, 68, 68, 0.5); color: #f87171; animation: pulse-angry 1.5s infinite; }
|
||||
|
||||
@keyframes pulse-angry {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Main Video Stage */
|
||||
.call-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-grid-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.video-tile {
|
||||
position: relative;
|
||||
background: #1e293b;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 220px;
|
||||
max-height: 540px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.video-element {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.video-overlay {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(6px);
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: white;
|
||||
z-index: 10;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.video-avatar-placeholder {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #4f46e5, #06b6d4);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
|
||||
/* Collapsible Side Panel (AI Notes & Chat) */
|
||||
.side-panel {
|
||||
width: 360px;
|
||||
background: #111827;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.3s ease;
|
||||
z-index: 30;
|
||||
}
|
||||
.side-panel.collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.panel-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.panel-close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
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 {
|
||||
display: flex;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.panel-tab {
|
||||
flex: 1;
|
||||
padding: 0.6rem;
|
||||
text-align: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #9ca3af;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.panel-tab.active {
|
||||
color: #818cf8;
|
||||
border-bottom: 2px solid #6366f1;
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Transcript & Notes */
|
||||
.transcript-feed {
|
||||
flex: 1;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
max-height: 240px;
|
||||
}
|
||||
.transcript-line {
|
||||
line-height: 1.4;
|
||||
}
|
||||
.transcript-speaker {
|
||||
font-weight: 700;
|
||||
color: #818cf8;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.transcript-text {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.notes-textarea {
|
||||
width: 100%;
|
||||
height: 110px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
color: #f3f4f6;
|
||||
font-size: 0.8rem;
|
||||
outline: none;
|
||||
resize: none;
|
||||
}
|
||||
.notes-textarea:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
/* Chat messages */
|
||||
.chat-feed {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 380px;
|
||||
}
|
||||
.chat-msg {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.chat-sender {
|
||||
font-weight: 700;
|
||||
color: #38bdf8;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-input-box {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: auto;
|
||||
}
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
color: #f3f4f6;
|
||||
font-size: 0.8rem;
|
||||
outline: none;
|
||||
}
|
||||
.chat-input:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
/* Bottom Controls Bar */
|
||||
.call-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
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 {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.ctrl-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.ctrl-btn.active-off {
|
||||
background: #ef4444 !important;
|
||||
border-color: #ef4444 !important;
|
||||
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;
|
||||
height: 48px;
|
||||
border-radius: 24px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
.btn-end-call:hover {
|
||||
background: #dc2626;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.6);
|
||||
}
|
||||
|
||||
/* Toast & Modal */
|
||||
.toast-notify {
|
||||
position: fixed;
|
||||
bottom: 85px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(17, 24, 39, 0.95);
|
||||
border: 1px solid #4f46e5;
|
||||
padding: 10px 20px;
|
||||
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>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="call-header">
|
||||
<div class="header-left">
|
||||
<span class="live-tag">LIVE</span>
|
||||
<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: 10px; flex-wrap: wrap;">
|
||||
<!-- Live AI Emotion Indicator -->
|
||||
<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>
|
||||
|
||||
<button class="ctrl-btn" style="width: 36px; height: 36px; border-radius: 8px;" onclick="copyMeetingLink()" title="Copy Meeting Link">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Call Body -->
|
||||
<div class="call-body">
|
||||
<!-- Video Grid -->
|
||||
<div class="video-grid-container">
|
||||
<div class="video-grid" id="videoGrid">
|
||||
<!-- Local User Video Tile -->
|
||||
<div class="video-tile" id="localVideoTile">
|
||||
<video id="localVideo" class="video-element" autoplay playsinline muted></video>
|
||||
<div class="video-overlay">
|
||||
<span>{{ $participantName }} (You)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible Side Panel -->
|
||||
<div class="side-panel" id="sidePanel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">
|
||||
<span>✨</span> AI Meeting Assistant
|
||||
</div>
|
||||
<button class="panel-close-btn" onclick="toggleSidePanel()" 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)">💬 Chat</button>
|
||||
</div>
|
||||
|
||||
<!-- TAB 1: AI Live Notes -->
|
||||
<div id="notesTab" class="panel-body">
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<span style="font-size: 0.75rem; font-weight: 700; color: #818cf8; text-transform: uppercase;">Real-Time Speech Transcription</span>
|
||||
<span id="sttStatus" style="font-size: 0.65rem; color: #34d399;">● Listening</span>
|
||||
</div>
|
||||
<div class="transcript-feed" id="transcriptFeed">
|
||||
<div style="color: #6b7280; font-size: 0.75rem; font-style: italic;">Transcribing live spoken speech...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-size: 0.75rem; font-weight: 700; color: #818cf8; text-transform: uppercase; margin-bottom: 6px;">Live Meeting Notes</div>
|
||||
<textarea id="liveNotesInput" class="notes-textarea" placeholder="Type manual notes, key discussion points, or deliverables agreed during call..."></textarea>
|
||||
</div>
|
||||
|
||||
@if($isHost)
|
||||
<button onclick="endMeetingAndGenerateMoM()" style="background: linear-gradient(135deg, #4f46e5, #06b6d4); color: white; border: none; padding: 10px; border-radius: 8px; font-weight: 700; font-size: 0.85rem; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 6px; box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4);">
|
||||
✨ Finish & Generate AI MoM
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- TAB 2: Chat -->
|
||||
<div id="chatTab" class="panel-body" style="display: none;">
|
||||
<div class="chat-feed" id="chatFeed">
|
||||
<!-- Chat messages injected dynamically -->
|
||||
</div>
|
||||
<div class="chat-input-box">
|
||||
<input type="text" id="chatInput" class="chat-input" placeholder="Type a message..." onkeydown="if(event.key==='Enter') sendChatMessage()">
|
||||
<button onclick="sendChatMessage()" style="background: #4f46e5; color: white; border: none; border-radius: 8px; padding: 0 12px; cursor: pointer;">➤</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Controls Footer -->
|
||||
<footer class="call-footer">
|
||||
<!-- Mic Toggle -->
|
||||
<button id="btnToggleMic" class="ctrl-btn" onclick="toggleAudio()" title="Mute / Unmute Microphone">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>
|
||||
</button>
|
||||
|
||||
<!-- Video Toggle -->
|
||||
<button id="btnToggleVideo" class="ctrl-btn" onclick="toggleVideo()" title="Turn Camera On / Off">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"></polygon><rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect></svg>
|
||||
</button>
|
||||
|
||||
<!-- Screen Share -->
|
||||
<button id="btnScreenShare" class="ctrl-btn" onclick="toggleScreenShare()" title="Share Screen">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
|
||||
</button>
|
||||
|
||||
<!-- 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>
|
||||
</button>
|
||||
|
||||
<!-- End Call / Leave Button -->
|
||||
@if($isHost)
|
||||
<button class="btn-end-call" onclick="endMeetingAndGenerateMoM()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"></path><line x1="23" y1="1" x2="1" y2="23"></line></svg>
|
||||
End Call & Generate MoM
|
||||
</button>
|
||||
@else
|
||||
<button class="btn-end-call" onclick="leaveCall()">
|
||||
Leave Call
|
||||
</button>
|
||||
@endif
|
||||
</footer>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
<div id="toastNotification" class="toast-notify">Link copied to clipboard!</div>
|
||||
|
||||
<!-- Pass backend configurations to Javascript -->
|
||||
<script>
|
||||
window.CLIENT_CALL_CONFIG = {
|
||||
meetingCode: '{{ $meeting->meeting_code }}',
|
||||
clientId: {{ $client->id ?? $meeting->client_id }},
|
||||
participantName: '{{ $participantName }}',
|
||||
participantRole: '{{ $participantRole }}',
|
||||
isHost: {{ $isHost ? 'true' : 'false' }},
|
||||
isGuest: {{ $isGuest ? 'true' : 'false' }},
|
||||
detectEmotionUrl: '{{ route('client-calls.detect-emotion', $meeting->meeting_code) }}',
|
||||
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: '{{ $isGuest ? url('/') : route('client-calls.show', $client->id ?? $meeting->client_id) }}'
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Load Client Call Script -->
|
||||
<script type="module" src="{{ asset('js/client-call.js') }}"></script>
|
||||
|
||||
<script>
|
||||
function showToast(msg) {
|
||||
const toast = document.getElementById('toastNotification');
|
||||
if (toast) {
|
||||
toast.innerText = msg;
|
||||
toast.style.display = 'block';
|
||||
setTimeout(() => { toast.style.display = 'none'; }, 3500);
|
||||
}
|
||||
}
|
||||
|
||||
function copyMeetingLink() {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
showToast('Meeting link copied to clipboard!');
|
||||
}
|
||||
|
||||
function toggleSidePanel() {
|
||||
const panel = document.getElementById('sidePanel');
|
||||
if (panel) {
|
||||
panel.classList.toggle('collapsed');
|
||||
}
|
||||
}
|
||||
|
||||
function switchSideTab(tabId, el) {
|
||||
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'));
|
||||
const target = document.getElementById(tabId);
|
||||
if (target) target.style.display = 'flex';
|
||||
if (el) el.classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1691
resources/views/client_calls/show.blade.php
Normal file
1691
resources/views/client_calls/show.blade.php
Normal file
File diff suppressed because it is too large
Load Diff
@ -879,6 +879,12 @@
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if($user->canAccessClientCalls())
|
||||
<a href="{{ route('client-calls.index') }}" class="nav-action-btn" style="background: rgba(6, 182, 212, 0.15); color: #22d3ee; border-color: rgba(6, 182, 212, 0.35); text-decoration: none;" title="Client Calls & Projects Hub">
|
||||
📞 Client Calls
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if($user->canManageOnboarding())
|
||||
<a href="{{ route('onboarding.index') }}" class="nav-action-btn" style="background: rgba(99,102,241,0.15); color: #818cf8; border-color: rgba(99,102,241,0.3); text-decoration: none;" title="Onboarding & Offboarding Hub">
|
||||
🚀 Onboarding Hub
|
||||
|
||||
113
resources/views/emails/call_mom.blade.php
Normal file
113
resources/views/emails/call_mom.blade.php
Normal file
@ -0,0 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Minutes of Meeting (MoM)</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; background-color: #0b0f19; color: #f3f4f6; margin: 0; padding: 24px; }
|
||||
.email-card { max-width: 650px; margin: 0 auto; background: #111827; border-radius: 12px; border: 1px solid rgba(255,255,255,0.15); overflow: hidden; }
|
||||
.email-header { background: linear-gradient(135deg, #1e1b4b, #312e81); padding: 26px 24px; border-bottom: 1px solid rgba(255,255,255,0.1); }
|
||||
.email-header h1 { margin: 0; color: #ffffff; font-size: 20px; font-weight: 700; }
|
||||
.email-header p { margin: 6px 0 0 0; color: #a5b4fc; font-size: 13px; }
|
||||
.email-body { padding: 26px 24px; }
|
||||
.section-title { font-size: 15px; font-weight: 700; color: #e0e7ff; margin: 20px 0 10px 0; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 6px; }
|
||||
.summary-box { background: rgba(99, 102, 241, 0.08); border-left: 4px solid #6366f1; padding: 14px 18px; border-radius: 0 8px 8px 0; color: #cbd5e1; font-size: 14px; line-height: 1.6; margin-bottom: 20px; }
|
||||
.badge { display: inline-block; padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
|
||||
.badge-happy { background: rgba(16, 185, 129, 0.2); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.4); }
|
||||
.badge-neutral { background: rgba(59, 130, 246, 0.2); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.4); }
|
||||
.badge-sad { background: rgba(245, 158, 11, 0.2); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.4); }
|
||||
.badge-angry { background: rgba(239, 68, 68, 0.25); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.5); font-weight: 700; }
|
||||
|
||||
.table-custom { width: 100%; border-collapse: collapse; margin: 12px 0 20px 0; font-size: 13px; }
|
||||
.table-custom th { background: rgba(255,255,255,0.06); text-align: left; padding: 10px; color: #9ca3af; font-weight: 600; }
|
||||
.table-custom td { padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.05); color: #e5e7eb; }
|
||||
|
||||
.mom-content { background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.06); padding: 16px; border-radius: 8px; color: #d1d5db; font-size: 14px; line-height: 1.6; white-space: pre-wrap; }
|
||||
.btn-view { display: inline-block; background: #4f46e5; color: #ffffff !important; font-weight: 600; font-size: 14px; padding: 10px 20px; border-radius: 6px; text-decoration: none; margin-top: 20px; }
|
||||
.email-footer { border-top: 1px solid rgba(255,255,255,0.08); padding: 16px 24px; text-align: center; color: #6b7280; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-card">
|
||||
<div class="email-header">
|
||||
<h1>SingleLogin AI Meeting Intelligence</h1>
|
||||
<p>Minutes of Meeting (MoM) & AI Call Summary</p>
|
||||
</div>
|
||||
<div class="email-body">
|
||||
<p style="font-size: 15px; margin-top: 0;">Hello <strong>{{ $recipientName }}</strong>,</p>
|
||||
<p style="font-size: 14px; color: #d1d5db;">
|
||||
Here is the automated AI-generated Minutes of Meeting (MoM) for <strong>{{ $callNote->client->project_name ?? 'Client Call' }}</strong> with <strong>{{ $callNote->client->client_name ?? 'Client' }}</strong>.
|
||||
</p>
|
||||
|
||||
<div style="margin: 16px 0; display: flex; align-items: center; gap: 10px;">
|
||||
<span style="font-size: 13px; color: #9ca3af;">Detected Client Sentiment:</span>
|
||||
@php
|
||||
$sentiment = strtolower($callNote->detected_sentiment ?? 'neutral');
|
||||
$badgeClass = match($sentiment) {
|
||||
'happy' => 'badge-happy',
|
||||
'sad' => 'badge-sad',
|
||||
'angry' => 'badge-angry',
|
||||
default => 'badge-neutral',
|
||||
};
|
||||
$emoji = match($sentiment) {
|
||||
'happy' => '😊 Happy / Satisfied',
|
||||
'sad' => '😟 Concerned / Sad',
|
||||
'angry' => '😡 Frustrated / Angry',
|
||||
default => '😐 Neutral / Professional',
|
||||
};
|
||||
@endphp
|
||||
<span class="badge {{ $badgeClass }}">{{ $emoji }}</span>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Executive Summary</div>
|
||||
<div class="summary-box">
|
||||
{{ $callNote->summary ?? 'Meeting completed successfully.' }}
|
||||
</div>
|
||||
|
||||
@if(!empty($callNote->action_items) && count($callNote->action_items) > 0)
|
||||
<div class="section-title">Action Items & Deliverables</div>
|
||||
<table class="table-custom">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task / Action Item</th>
|
||||
<th>Assignee</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($callNote->action_items as $item)
|
||||
<tr>
|
||||
<td>{{ is_array($item) ? ($item['task'] ?? $item['action'] ?? json_encode($item)) : $item }}</td>
|
||||
<td>{{ is_array($item) ? ($item['owner'] ?? 'Team') : 'Team' }}</td>
|
||||
<td><span style="color: #34d399;">{{ is_array($item) ? ($item['status'] ?? 'Assigned') : 'Assigned' }}</span></td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@endif
|
||||
|
||||
@if(!empty($callNote->key_decisions) && count($callNote->key_decisions) > 0)
|
||||
<div class="section-title">Key Decisions Made</div>
|
||||
<ul style="color: #cbd5e1; font-size: 13px; line-height: 1.6; margin: 10px 0 20px 20px;">
|
||||
@foreach($callNote->key_decisions as $decision)
|
||||
<li>{{ is_array($decision) ? ($decision['decision'] ?? json_encode($decision)) : $decision }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
|
||||
<div class="section-title">Full Minutes of Meeting (MoM)</div>
|
||||
<div class="mom-content">{!! nl2br(e($callNote->mom_content)) !!}</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 24px;">
|
||||
<a href="{{ route('client-calls.show', $callNote->client_id) }}" class="btn-view" target="_blank">
|
||||
🔍 Open Client Hub & Complete Profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
<p>This automated summary was generated by SingleLogin AI Notes Engine. Saved to client profile archive.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
84
resources/views/emails/meeting_invite.blade.php
Normal file
84
resources/views/emails/meeting_invite.blade.php
Normal file
@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Meeting Invitation</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; background-color: #0b0f19; color: #f3f4f6; margin: 0; padding: 24px; }
|
||||
.email-card { max-width: 600px; margin: 0 auto; background: #111827; border-radius: 12px; border: 1px solid rgba(255,255,255,0.15); overflow: hidden; }
|
||||
.email-header { background: linear-gradient(135deg, #4f46e5, #06b6d4); padding: 28px 24px; text-align: center; }
|
||||
.email-header h1 { margin: 0; color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: -0.5px; }
|
||||
.email-header p { margin: 6px 0 0 0; color: rgba(255,255,255,0.9); font-size: 14px; }
|
||||
.email-body { padding: 28px 24px; }
|
||||
.badge { display: inline-block; padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
|
||||
.badge-instant { background: #f59e0b; color: #111827; }
|
||||
.badge-scheduled { background: #10b981; color: #ffffff; }
|
||||
.meeting-info { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 18px; margin: 20px 0; }
|
||||
.info-row { display: flex; margin-bottom: 10px; font-size: 14px; }
|
||||
.info-row:last-child { margin-bottom: 0; }
|
||||
.info-label { width: 120px; color: #9ca3af; font-weight: 600; }
|
||||
.info-value { color: #f3f4f6; font-weight: 500; }
|
||||
.btn-join { display: block; text-align: center; background: #4f46e5; color: #ffffff !important; font-weight: 700; font-size: 15px; padding: 14px 24px; border-radius: 8px; text-decoration: none; margin: 26px 0 16px 0; box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4); }
|
||||
.email-footer { border-top: 1px solid rgba(255,255,255,0.08); padding: 16px 24px; text-align: center; color: #6b7280; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-card">
|
||||
<div class="email-header">
|
||||
<h1>SingleLogin Client Connect</h1>
|
||||
<p>You have been invited to a client meeting session</p>
|
||||
</div>
|
||||
<div class="email-body">
|
||||
<p style="font-size: 15px; margin-top: 0;">Hello <strong>{{ $recipientName }}</strong>,</p>
|
||||
<p style="font-size: 14px; color: #d1d5db; line-height: 1.5;">
|
||||
You are invited to join the video conference for <strong>{{ $meeting->client->project_name }}</strong> ({{ $meeting->client->client_name }}).
|
||||
</p>
|
||||
|
||||
<div class="meeting-info">
|
||||
<div style="margin-bottom: 12px;">
|
||||
<span class="badge {{ $meeting->meeting_type === 'instant' ? 'badge-instant' : 'badge-scheduled' }}">
|
||||
{{ $meeting->meeting_type === 'instant' ? '⚡ Instant Meeting' : '📅 Scheduled Meeting' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Meeting Title:</span>
|
||||
<span class="info-value">{{ $meeting->title }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Project:</span>
|
||||
<span class="info-value">{{ $meeting->client->project_name }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Host:</span>
|
||||
<span class="info-value">{{ $meeting->host ? $meeting->host->name : 'SingleLogin Host' }}</span>
|
||||
</div>
|
||||
@if($meeting->scheduled_at)
|
||||
<div class="info-row">
|
||||
<span class="info-label">Date & Time:</span>
|
||||
<span class="info-value">{{ $meeting->scheduled_at->format('M d, Y h:i A') }} ({{ $meeting->duration_minutes }} mins)</span>
|
||||
</div>
|
||||
@endif
|
||||
@if($meeting->agenda)
|
||||
<div class="info-row" style="margin-top: 8px;">
|
||||
<span class="info-label">Agenda:</span>
|
||||
<span class="info-value" style="color: #cbd5e1;">{{ $meeting->agenda }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<a href="{{ $joinUrl }}" class="btn-join" target="_blank">
|
||||
👉 Click Here to Join Meeting
|
||||
</a>
|
||||
|
||||
<p style="font-size: 12px; color: #9ca3af; text-align: center; word-break: break-all;">
|
||||
Or copy and paste this link in your browser: <br>
|
||||
<a href="{{ $joinUrl }}" style="color: #38bdf8;">{{ $joinUrl }}</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
<p>SingleLogin Enterprise Workspace • AI-Powered Client Collaboration Platform</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AdminController;
|
||||
use App\Http\Controllers\ClientCallController;
|
||||
use App\Http\Controllers\DashboardController;
|
||||
use App\Http\Controllers\InterviewController;
|
||||
use App\Http\Controllers\LoginController;
|
||||
@ -85,8 +86,34 @@
|
||||
Route::get('/interviews/{id}/download-recording', [InterviewController::class, 'downloadRecording'])->name('interview.download-recording');
|
||||
Route::post('/interviews/{id}/call-status', [InterviewController::class, 'updateCallStatus'])->name('interview.call-status');
|
||||
Route::post('/interviews/{id}/toggle-tab-screenshot', [InterviewController::class, 'toggleTabScreenshot'])->name('interview.toggle-tab-screenshot');
|
||||
|
||||
// Client Call & Project Management Hub (PM, TL, Director, Admin)
|
||||
Route::get('/client-calls', [ClientCallController::class, 'index'])->name('client-calls.index');
|
||||
Route::get('/client-calls/logs', [ClientCallController::class, 'callLogs'])->name('client-calls.logs');
|
||||
Route::post('/client-calls', [ClientCallController::class, 'store'])->name('client-calls.store');
|
||||
Route::get('/client-calls/{id}', [ClientCallController::class, 'show'])->name('client-calls.show');
|
||||
Route::post('/client-calls/{id}/schedule-meeting', [ClientCallController::class, 'scheduleMeeting'])->name('client-calls.schedule-meeting');
|
||||
Route::post('/client-calls/{id}/instant-meeting', [ClientCallController::class, 'createInstantMeeting'])->name('client-calls.instant-meeting');
|
||||
Route::get('/client-calls/notes/{id}/download-mom', [ClientCallController::class, 'downloadMoM'])->name('client-calls.download-mom');
|
||||
Route::get('/client-calls/notes/{id}/download-todos', [ClientCallController::class, 'downloadTodos'])->name('client-calls.download-todos');
|
||||
Route::get('/client-calls/notes/{id}/emotion-details', [ClientCallController::class, 'getEmotionDetails'])->name('client-calls.emotion-details');
|
||||
Route::post('/client-calls/notes/{id}/toggle-todo/{todoId}', [ClientCallController::class, 'toggleTodo'])->name('client-calls.toggle-todo');
|
||||
Route::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');
|
||||
|
||||
|
||||
// Admin Control Panel (requires admin role)
|
||||
Route::middleware(['auth', 'role:admin'])->prefix('controlpannel')->group(function () {
|
||||
|
||||
1009
tests/Feature/ClientCallFeatureTest.php
Normal file
1009
tests/Feature/ClientCallFeatureTest.php
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user