feat(ucp): NexusPBX v2.5.0 Portail Utilisateur isolé (Refs #4)
This commit is contained in:
1 parent
7922e338e3
commit
72e43f1a9a
18 files changed
+3499
-62
No files matched your search
+342
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../src/Database.php';
|
||||
require_once __DIR__ . '/../src/FirewallManager.php';
|
||||
require_once __DIR__ . '/../src/AmiClient.php';
|
||||
require_once __DIR__ . '/../src/ConfigGenerator.php';
|
||||
require_once __DIR__ . '/../src/CdrManager.php';
|
||||
require_once __DIR__ . '/../src/PhonebookManager.php';
|
||||
require_once __DIR__ . '/../src/UcpAuth.php';
|
||||
require_once __DIR__ . '/../src/UcpManager.php';
|
||||
|
||||
use App\Database;
|
||||
use App\UcpAuth;
|
||||
use App\UcpManager;
|
||||
|
||||
$action = trim((string)($_GET['action'] ?? $_POST['action'] ?? ''));
|
||||
$isBinaryEndpoint = in_array($action, ['stream_voicemail', 'avatar'], true);
|
||||
|
||||
if (!$isBinaryEndpoint) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Database::getConnection();
|
||||
UcpAuth::startSession();
|
||||
|
||||
if (!UcpAuth::isUcpEnabled($db)) {
|
||||
http_response_code(503);
|
||||
echo json_encode(['success' => false, 'message' => 'Le portail utilisateur UCP est actuellement désactivé par l’administrateur.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Lecture du payload JSON si applicable
|
||||
$payload = $_POST;
|
||||
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
||||
if (str_contains($contentType, 'application/json')) {
|
||||
$raw = file_get_contents('php://input');
|
||||
$decoded = json_decode((string)$raw, true);
|
||||
if (is_array($decoded)) {
|
||||
$payload = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Authentification (routes publiques de session)
|
||||
// -------------------------------------------------------------------------
|
||||
if ($action === 'login') {
|
||||
$username = (string)($payload['username'] ?? '');
|
||||
$password = (string)($payload['password'] ?? '');
|
||||
|
||||
if (UcpAuth::login($db, $username, $password)) {
|
||||
$user = UcpAuth::getCurrentUser($db);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'username' => $user['username'] ?? $username,
|
||||
'csrf_token' => UcpAuth::getCsrfToken(),
|
||||
'message' => 'Connexion réussie.',
|
||||
]);
|
||||
} else {
|
||||
http_response_code(401);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => 'Identifiant ou mot de passe incorrect.',
|
||||
]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'logout') {
|
||||
UcpAuth::logout();
|
||||
echo json_encode(['success' => true, 'message' => 'Déconnexion effectuée.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Barrière d'authentification UCP obligatoire
|
||||
// -------------------------------------------------------------------------
|
||||
$currentUser = UcpAuth::getCurrentUser($db);
|
||||
if (!$currentUser) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'message' => 'Session expirée ou non authentifiée.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Protection CSRF sur toutes les actions modificatrices POST
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrfHeader = (string)($_SERVER['HTTP_X_CSRF_TOKEN'] ?? $payload['csrf_token'] ?? '');
|
||||
if (!UcpAuth::verifyCsrfToken($csrfHeader)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'message' => 'Jeton de sécurité CSRF invalide. Rechargez la page.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$ucpUserId = (int)$currentUser['id'];
|
||||
$allowedExts = UcpAuth::getAllowedExtensions($db, $ucpUserId);
|
||||
|
||||
switch ($action) {
|
||||
case 'get_state':
|
||||
$branding = $db->query('SELECT company_name, company_logo FROM settings WHERE id = 1 LIMIT 1')->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'user' => [
|
||||
'id' => $ucpUserId,
|
||||
'username' => (string)$currentUser['username'],
|
||||
'display_name' => (string)$currentUser['display_name'],
|
||||
'email' => (string)$currentUser['email'],
|
||||
'last_login_at' => $currentUser['last_login_at'],
|
||||
],
|
||||
'branding' => [
|
||||
'company_name' => (string)($branding['company_name'] ?? ''),
|
||||
'company_logo' => (string)($branding['company_logo'] ?? ''),
|
||||
],
|
||||
'csrf_token' => UcpAuth::getCsrfToken(),
|
||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
|
||||
'forward_targets' => UcpManager::getForwardTargets($db),
|
||||
],
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'save_call_features':
|
||||
$ext = (string)($payload['extension'] ?? '');
|
||||
UcpManager::setCallFeatures($db, $allowedExts, $ext, [
|
||||
'dnd' => !empty($payload['dnd']),
|
||||
'cf' => (string)($payload['cf'] ?? ''),
|
||||
'cfu' => (string)($payload['cfu'] ?? ''),
|
||||
]);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
|
||||
'message' => "Paramètres d'appel du poste {$ext} appliqués en direct.",
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'get_voicemails':
|
||||
$ext = (string)($_GET['extension'] ?? $payload['extension'] ?? '');
|
||||
if ($ext === '' && !empty($allowedExts)) {
|
||||
$ext = (string)$allowedExts[0]['extension'];
|
||||
}
|
||||
$messages = $ext !== '' ? UcpManager::getVoicemails($allowedExts, $ext) : [];
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'messages' => $messages,
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'stream_voicemail':
|
||||
$ext = (string)($_GET['extension'] ?? '');
|
||||
$folder = (string)($_GET['folder'] ?? '');
|
||||
$msgId = (string)($_GET['id'] ?? '');
|
||||
$download = !empty($_GET['download']);
|
||||
|
||||
$filePath = UcpManager::getVoicemailAudioPath($allowedExts, $ext, $folder, $msgId);
|
||||
$size = filesize($filePath);
|
||||
$fileExt = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
$mime = ($fileExt === 'gsm') ? 'audio/gsm' : 'audio/wav';
|
||||
|
||||
while (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
header("Content-Type: {$mime}");
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
if ($download) {
|
||||
$dlName = "voicemail_{$ext}_{$msgId}.{$fileExt}";
|
||||
header("Content-Disposition: attachment; filename=\"{$dlName}\"");
|
||||
}
|
||||
|
||||
if (isset($_SERVER['HTTP_RANGE'])) {
|
||||
[$param, $range] = explode('=', $_SERVER['HTTP_RANGE'], 2);
|
||||
if (strtolower(trim($param)) === 'bytes') {
|
||||
[$from, $to] = explode('-', $range, 2);
|
||||
$from = intval($from);
|
||||
$to = ($to === '') ? ($size - 1) : intval($to);
|
||||
if ($from > $to || $from >= $size) {
|
||||
http_response_code(416);
|
||||
exit;
|
||||
}
|
||||
$length = $to - $from + 1;
|
||||
http_response_code(206);
|
||||
header("Content-Range: bytes {$from}-{$to}/{$size}");
|
||||
header("Content-Length: {$length}");
|
||||
$fp = fopen($filePath, 'rb');
|
||||
fseek($fp, $from);
|
||||
$remaining = $length;
|
||||
while ($remaining > 0 && !feof($fp)) {
|
||||
$chunk = min(8192, $remaining);
|
||||
echo fread($fp, $chunk);
|
||||
$remaining -= $chunk;
|
||||
flush();
|
||||
}
|
||||
fclose($fp);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
header("Content-Length: {$size}");
|
||||
readfile($filePath);
|
||||
exit;
|
||||
|
||||
case 'toggle_voicemail_read':
|
||||
$ext = (string)($payload['extension'] ?? '');
|
||||
$folder = (string)($payload['folder'] ?? '');
|
||||
$msgId = (string)($payload['id'] ?? '');
|
||||
UcpManager::moveVoicemailFolder($db, $allowedExts, $ext, $folder, $msgId);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'messages' => UcpManager::getVoicemails($allowedExts, $ext),
|
||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
|
||||
'message' => ($folder === 'INBOX') ? 'Message marqué comme lu (archivé).' : 'Message marqué comme non lu.',
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'delete_voicemail':
|
||||
$ext = (string)($payload['extension'] ?? '');
|
||||
$folder = (string)($payload['folder'] ?? '');
|
||||
$msgId = (string)($payload['id'] ?? '');
|
||||
UcpManager::deleteVoicemail($db, $allowedExts, $ext, $folder, $msgId);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'messages' => UcpManager::getVoicemails($allowedExts, $ext),
|
||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
|
||||
'message' => 'Message vocal supprimé définitivement.',
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'save_voicemail_prefs':
|
||||
$ext = (string)($payload['extension'] ?? '');
|
||||
$pin = (string)($payload['pin'] ?? '');
|
||||
$email = (string)($payload['email'] ?? '');
|
||||
UcpManager::updateVoicemailPrefs($db, $allowedExts, $ext, $pin, $email);
|
||||
$freshAllowed = UcpAuth::getAllowedExtensions($db, $ucpUserId);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'extensions' => UcpManager::getExtensionsState($db, $freshAllowed),
|
||||
'message' => 'Préférences messagerie vocale enregistrées et appliquées.',
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'get_personal_cdr':
|
||||
$data = UcpManager::getPersonalCdr($db, $allowedExts, [
|
||||
'direction' => $_GET['direction'] ?? 'all',
|
||||
'disposition' => $_GET['disposition'] ?? 'all',
|
||||
'period' => $_GET['period'] ?? 'all',
|
||||
'search' => $_GET['search'] ?? '',
|
||||
]);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => $data,
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'get_phonebook':
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'contacts' => UcpManager::getPhonebookForUser($db, $allowedExts),
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'save_my_email':
|
||||
$ext = (string)($payload['extension'] ?? '');
|
||||
$email = (string)($payload['email'] ?? '');
|
||||
UcpManager::updateMyContactEmail($db, $allowedExts, $ext, $email);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'contacts' => UcpManager::getPhonebookForUser($db, $allowedExts),
|
||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
|
||||
'message' => "E-mail annuaire du poste {$ext} mis à jour.",
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'upload_my_avatar':
|
||||
$ext = (string)($_POST['extension'] ?? '');
|
||||
if (empty($_FILES['avatar'])) {
|
||||
throw new InvalidArgumentException("Aucun fichier image reçu.");
|
||||
}
|
||||
$avatarUrl = UcpManager::uploadMyAvatar($db, $allowedExts, $ext, $_FILES['avatar']);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'photo_url' => $avatarUrl,
|
||||
'contacts' => UcpManager::getPhonebookForUser($db, $allowedExts),
|
||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
|
||||
'message' => "Photo de profil du poste {$ext} mise à jour.",
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'avatar':
|
||||
$file = (string)($_GET['file'] ?? '');
|
||||
$path = UcpManager::resolveAvatarPath($file);
|
||||
if (!$path) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
$extName = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
$mime = match ($extName) {
|
||||
'png' => 'image/png',
|
||||
'webp' => 'image/webp',
|
||||
default => 'image/jpeg',
|
||||
};
|
||||
while (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
header("Content-Type: {$mime}");
|
||||
header('Cache-Control: public, max-age=3600');
|
||||
readfile($path);
|
||||
exit;
|
||||
|
||||
case 'change_password':
|
||||
$cur = (string)($payload['current_password'] ?? '');
|
||||
$new = (string)($payload['new_password'] ?? '');
|
||||
UcpAuth::updateOwnPassword($db, $ucpUserId, $cur, $new);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Mot de passe UCP modifié avec succès.',
|
||||
]);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'Action UCP inconnue.']);
|
||||
break;
|
||||
}
|
||||
} catch (InvalidArgumentException $e) {
|
||||
http_response_code(400);
|
||||
if ($isBinaryEndpoint) {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo $e->getMessage();
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
if ($isBinaryEndpoint) {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo 'Erreur serveur.';
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 795 KiB |
@@ -0,0 +1,831 @@
|
||||
'use strict';
|
||||
|
||||
let ucpState = {
|
||||
user: null,
|
||||
extensions: [],
|
||||
forward_targets: [],
|
||||
voicemailsByExt: {},
|
||||
selectedVmExt: '',
|
||||
vmFolderFilter: 'all',
|
||||
phonebook: [],
|
||||
activeTab: 'extensions',
|
||||
};
|
||||
|
||||
let cdrTimer = null;
|
||||
let pollTimer = null;
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function showUcpToast(message, type = 'info') {
|
||||
const container = document.getElementById('ucp-toast-container');
|
||||
if (!container) return;
|
||||
|
||||
const palette = {
|
||||
success: 'bg-emerald-50 border-emerald-200 text-emerald-800',
|
||||
error: 'bg-rose-50 border-rose-200 text-rose-800',
|
||||
info: 'bg-white border-slate-200 text-slate-800',
|
||||
}[type] || 'bg-white border-slate-200 text-slate-800';
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = `pointer-events-auto rounded-xl border px-4 py-3 text-xs font-semibold shadow-lg transition-all duration-200 ${palette}`;
|
||||
el.textContent = message;
|
||||
container.appendChild(el);
|
||||
|
||||
setTimeout(() => {
|
||||
el.style.opacity = '0';
|
||||
setTimeout(() => el.remove(), 220);
|
||||
}, 3500);
|
||||
}
|
||||
|
||||
function resolveUcpAvatarSrc(photoUrl) {
|
||||
const raw = String(photoUrl || '').trim();
|
||||
if (!raw) return '';
|
||||
if (raw.startsWith('/avatars/')) {
|
||||
const file = raw.slice('/avatars/'.length);
|
||||
return `/api.php?action=avatar&file=${encodeURIComponent(file)}`;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function ucpApiCall(action, method = 'GET', body = null) {
|
||||
const opts = {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {},
|
||||
};
|
||||
let url = `/api.php?action=${encodeURIComponent(action)}`;
|
||||
|
||||
if (method === 'GET' && body && typeof body === 'object') {
|
||||
const params = new URLSearchParams(body);
|
||||
url += `&${params.toString()}`;
|
||||
} else if (method === 'POST') {
|
||||
opts.headers['X-CSRF-Token'] = window.UCP_CSRF_TOKEN || '';
|
||||
if (body instanceof FormData) {
|
||||
opts.body = body;
|
||||
} else {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body || {});
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(url, opts);
|
||||
if (res.status === 401 && action !== 'login') {
|
||||
window.location.reload();
|
||||
return null;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success) {
|
||||
throw new Error(json.message || 'Erreur lors de la requête.');
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// AUTHENTIFICATION
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const loginForm = document.getElementById('ucp-login-form');
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const errBox = document.getElementById('ucp-login-error');
|
||||
const btn = document.getElementById('ucp-login-btn');
|
||||
if (errBox) errBox.classList.add('hidden');
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await ucpApiCall('login', 'POST', {
|
||||
username: loginForm.elements['username'].value.trim(),
|
||||
password: loginForm.elements['password'].value,
|
||||
});
|
||||
if (res && res.csrf_token) {
|
||||
window.UCP_CSRF_TOKEN = res.csrf_token;
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
if (errBox) {
|
||||
errBox.textContent = err.message;
|
||||
errBox.classList.remove('hidden');
|
||||
}
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function ucpLogout() {
|
||||
try {
|
||||
await ucpApiCall('logout', 'POST', {});
|
||||
} catch (_) {}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// NAVIGATION & ÉTAT GLOBAL
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function switchUcpTab(tabName) {
|
||||
ucpState.activeTab = tabName;
|
||||
|
||||
document.querySelectorAll('.ucp-panel').forEach(p => p.classList.add('hidden'));
|
||||
const activePanel = document.getElementById(`ucp-panel-${tabName}`);
|
||||
if (activePanel) activePanel.classList.remove('hidden');
|
||||
|
||||
document.querySelectorAll('.ucp-tab-btn').forEach(btn => {
|
||||
const isMe = btn.dataset.ucpTab === tabName;
|
||||
btn.className = isMe
|
||||
? 'ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 bg-indigo-50 text-indigo-700'
|
||||
: 'ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100';
|
||||
});
|
||||
|
||||
if (tabName === 'voicemail') {
|
||||
loadVoicemailsForSelected();
|
||||
} else if (tabName === 'cdr') {
|
||||
loadPersonalCdr();
|
||||
} else if (tabName === 'phonebook') {
|
||||
loadPhonebook();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUcpState(manual = false) {
|
||||
try {
|
||||
const res = await ucpApiCall('get_state', 'GET');
|
||||
if (!res || !res.data) return;
|
||||
|
||||
ucpState.user = res.data.user;
|
||||
ucpState.extensions = res.data.extensions || [];
|
||||
ucpState.forward_targets = res.data.forward_targets || [];
|
||||
if (res.data.csrf_token) {
|
||||
window.UCP_CSRF_TOKEN = res.data.csrf_token;
|
||||
}
|
||||
|
||||
// Sélection par défaut du poste pour la messagerie vocale
|
||||
const vmExts = ucpState.extensions.filter(e => Number(e.voicemail_enabled) === 1);
|
||||
if (!ucpState.selectedVmExt || !vmExts.some(e => e.extension === ucpState.selectedVmExt)) {
|
||||
ucpState.selectedVmExt = vmExts.length ? vmExts[0].extension : (ucpState.extensions[0]?.extension || '');
|
||||
}
|
||||
|
||||
updateBadges();
|
||||
renderExtensionsGrid();
|
||||
renderVmExtPills();
|
||||
syncVmPrefsForm();
|
||||
|
||||
const accExts = document.getElementById('ucp-account-exts');
|
||||
if (accExts) {
|
||||
accExts.textContent = ucpState.extensions.map(e => `${e.extension} (${e.name})`).join(', ') || 'Aucun poste lié';
|
||||
}
|
||||
|
||||
if (manual) {
|
||||
showUcpToast('État actualisé.', 'info');
|
||||
if (ucpState.activeTab === 'voicemail') await loadVoicemailsForSelected();
|
||||
if (ucpState.activeTab === 'cdr') await loadPersonalCdr();
|
||||
if (ucpState.activeTab === 'phonebook') await loadPhonebook();
|
||||
}
|
||||
} catch (err) {
|
||||
if (manual) showUcpToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateBadges() {
|
||||
const extBadge = document.getElementById('ucp-badge-exts');
|
||||
if (extBadge) extBadge.textContent = String(ucpState.extensions.length);
|
||||
|
||||
const totalNewVm = ucpState.extensions.reduce((acc, e) => acc + Number(e.vm_inbox_count || 0), 0);
|
||||
const vmBadge = document.getElementById('ucp-badge-vm');
|
||||
if (vmBadge) {
|
||||
vmBadge.textContent = String(totalNewVm);
|
||||
vmBadge.className = totalNewVm > 0
|
||||
? 'px-1.5 py-0.5 rounded-full bg-rose-100 text-rose-700 text-[10px] font-bold'
|
||||
: 'px-1.5 py-0.5 rounded-full bg-slate-200 text-slate-700 text-[10px] font-bold';
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 1. MES POSTES & REDIRECTIONS (DND / CF / CFU)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function buildForwardOptions(extObj, selectedValue) {
|
||||
const val = String(selectedValue || '');
|
||||
let html = `<option value="">— Désactivé —</option>`;
|
||||
|
||||
if (Number(extObj.voicemail_enabled) === 1) {
|
||||
const vmVal = `vm-${extObj.extension}`;
|
||||
html += `<option value="${escapeHtml(vmVal)}" ${val === vmVal ? 'selected' : ''}>📬 Ma messagerie vocale (${escapeHtml(extObj.extension)})</option>`;
|
||||
}
|
||||
|
||||
const others = (ucpState.forward_targets || []).filter(t => t.extension !== extObj.extension);
|
||||
if (others.length > 0) {
|
||||
html += `<optgroup label="Postes internes">`;
|
||||
others.forEach(t => {
|
||||
html += `<option value="${escapeHtml(t.extension)}" ${val === t.extension ? 'selected' : ''}>📱 ${escapeHtml(t.extension)} — ${escapeHtml(t.name)}</option>`;
|
||||
});
|
||||
html += `</optgroup>`;
|
||||
}
|
||||
|
||||
const otherVms = (ucpState.forward_targets || []).filter(t => t.extension !== extObj.extension && Number(t.voicemail_enabled) === 1);
|
||||
if (otherVms.length > 0) {
|
||||
html += `<optgroup label="Messageries vocales d'autres postes">`;
|
||||
otherVms.forEach(t => {
|
||||
const vVal = `vm-${t.extension}`;
|
||||
html += `<option value="${escapeHtml(vVal)}" ${val === vVal ? 'selected' : ''}>📬 Répondeur ${escapeHtml(t.extension)} — ${escapeHtml(t.name)}</option>`;
|
||||
});
|
||||
html += `</optgroup>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderExtensionsGrid() {
|
||||
const grid = document.getElementById('ucp-extensions-grid');
|
||||
if (!grid) return;
|
||||
|
||||
if (!ucpState.extensions.length) {
|
||||
grid.innerHTML = `
|
||||
<div class="ucp-card rounded-2xl p-8 text-center text-sm text-slate-500 col-span-full">
|
||||
Aucun poste SIP n'est encore lié à votre compte. Contactez votre administrateur.
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = ucpState.extensions.map(ext => {
|
||||
const avatarSrc = resolveUcpAvatarSrc(ext.photo_url);
|
||||
const initials = (ext.name || ext.extension).slice(0, 2).toUpperCase();
|
||||
const statusPill = ext.online
|
||||
? `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-700 text-[11px] font-semibold">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
||||
En ligne ${ext.rtt_ms !== null ? `• ${ext.rtt_ms} ms` : ''}
|
||||
</span>`
|
||||
: `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-slate-100 border border-slate-200 text-slate-500 text-[11px] font-semibold">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-slate-400"></span>
|
||||
Hors ligne
|
||||
</span>`;
|
||||
|
||||
const vmPill = Number(ext.voicemail_enabled) === 1
|
||||
? `<button type="button" onclick="openExtensionVoicemail('${escapeHtml(ext.extension)}')"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-semibold border transition ${ext.vm_inbox_count > 0 ? 'bg-rose-50 border-rose-200 text-rose-700 hover:bg-rose-100' : 'bg-slate-50 border-slate-200 text-slate-600 hover:bg-slate-100'}">
|
||||
📬 ${ext.vm_inbox_count} nouveau(x)
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="ucp-card rounded-2xl p-5 space-y-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
${avatarSrc
|
||||
? `<img src="${escapeHtml(avatarSrc)}" alt="" class="w-12 h-12 rounded-full object-cover border border-slate-200 shrink-0">`
|
||||
: `<div class="w-12 h-12 rounded-full bg-indigo-50 border border-indigo-100 text-indigo-700 font-bold flex items-center justify-center text-sm shrink-0">${escapeHtml(initials)}</div>`}
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-mono font-bold text-indigo-700 text-base">${escapeHtml(ext.extension)}</span>
|
||||
<span class="font-bold text-slate-900 text-sm truncate">${escapeHtml(ext.name)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1 flex-wrap">
|
||||
${statusPill}
|
||||
${vmPill}
|
||||
</div>
|
||||
${ext.ip ? `<div class="text-[11px] text-slate-400 font-mono mt-1">${escapeHtml(ext.ip)} ${ext.user_agent ? '• ' + escapeHtml(ext.user_agent) : ''}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Switch Ne Pas Déranger (DND) -->
|
||||
<div class="flex items-center justify-between p-3.5 rounded-xl border ${ext.dnd ? 'bg-rose-50/70 border-rose-200' : 'bg-slate-50 border-slate-200'} transition">
|
||||
<div>
|
||||
<div class="text-xs font-bold ${ext.dnd ? 'text-rose-800' : 'text-slate-800'}">Ne Pas Déranger (DND)</div>
|
||||
<div class="text-[11px] ${ext.dnd ? 'text-rose-600' : 'text-slate-500'}">
|
||||
${ext.dnd ? 'Activé — les appels entrants sont refusés / envoyés en messagerie' : 'Désactivé — le poste sonne normalement'}
|
||||
</div>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer shrink-0">
|
||||
<input type="checkbox" id="ucp-dnd-${escapeHtml(ext.extension)}" ${ext.dnd ? 'checked' : ''}
|
||||
onchange="saveExtensionFeatures('${escapeHtml(ext.extension)}')" class="sr-only peer">
|
||||
<div class="w-11 h-6 bg-slate-300 rounded-full peer peer-checked:bg-rose-600 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-full"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Redirections (CF & CFU) -->
|
||||
<form onsubmit="event.preventDefault(); saveExtensionFeatures('${escapeHtml(ext.extension)}');" class="space-y-3 pt-1">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-[11px] font-semibold text-slate-600 mb-1">Renvoi inconditionnel (CF)</label>
|
||||
<select id="ucp-cf-${escapeHtml(ext.extension)}"
|
||||
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
|
||||
${buildForwardOptions(ext, ext.cf)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-semibold text-slate-600 mb-1">Renvoi si non-réponse (CFU)</label>
|
||||
<select id="ucp-cfu-${escapeHtml(ext.extension)}"
|
||||
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
|
||||
${buildForwardOptions(ext, ext.cfu)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-1">
|
||||
<span class="text-[11px] text-slate-400">Renvoi limité aux postes internes & répondeurs</span>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold transition">
|
||||
Appliquer les renvois
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function saveExtensionFeatures(extNum) {
|
||||
const dndEl = document.getElementById(`ucp-dnd-${extNum}`);
|
||||
const cfEl = document.getElementById(`ucp-cf-${extNum}`);
|
||||
const cfuEl = document.getElementById(`ucp-cfu-${extNum}`);
|
||||
|
||||
try {
|
||||
const res = await ucpApiCall('save_call_features', 'POST', {
|
||||
extension: extNum,
|
||||
dnd: dndEl ? dndEl.checked : false,
|
||||
cf: cfEl ? cfEl.value : '',
|
||||
cfu: cfuEl ? cfuEl.value : '',
|
||||
});
|
||||
if (res && res.extensions) {
|
||||
ucpState.extensions = res.extensions;
|
||||
renderExtensionsGrid();
|
||||
}
|
||||
showUcpToast(res.message || 'Enregistré.', 'success');
|
||||
} catch (err) {
|
||||
showUcpToast(err.message, 'error');
|
||||
refreshUcpState(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openExtensionVoicemail(extNum) {
|
||||
ucpState.selectedVmExt = extNum;
|
||||
switchUcpTab('voicemail');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 2. MESSAGERIE VOCALE
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function renderVmExtPills() {
|
||||
const container = document.getElementById('ucp-vm-ext-pills');
|
||||
if (!container) return;
|
||||
|
||||
const vmExts = ucpState.extensions.filter(e => Number(e.voicemail_enabled) === 1);
|
||||
if (!vmExts.length) {
|
||||
container.innerHTML = `<span class="text-xs text-slate-400 italic">Aucune boîte vocale activée sur vos postes</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = vmExts.map(e => {
|
||||
const active = e.extension === ucpState.selectedVmExt;
|
||||
return `
|
||||
<button type="button" onclick="selectVmExtension('${escapeHtml(e.extension)}')"
|
||||
class="px-3 py-1.5 rounded-xl text-xs font-semibold border transition flex items-center gap-1.5 ${active ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-slate-700 border-slate-200 hover:bg-slate-50'}">
|
||||
<span class="font-mono">${escapeHtml(e.extension)}</span>
|
||||
<span>${escapeHtml(e.name)}</span>
|
||||
${e.vm_inbox_count > 0 ? `<span class="px-1.5 py-0.2 rounded-full text-[10px] font-bold ${active ? 'bg-white/20 text-white' : 'bg-rose-100 text-rose-700'}">${e.vm_inbox_count}</span>` : ''}
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function selectVmExtension(extNum) {
|
||||
ucpState.selectedVmExt = extNum;
|
||||
renderVmExtPills();
|
||||
syncVmPrefsForm();
|
||||
loadVoicemailsForSelected();
|
||||
}
|
||||
|
||||
function syncVmPrefsForm() {
|
||||
const form = document.getElementById('ucp-vm-prefs-form');
|
||||
const sub = document.getElementById('ucp-vm-prefs-subtitle');
|
||||
if (!form) return;
|
||||
|
||||
const extObj = ucpState.extensions.find(e => e.extension === ucpState.selectedVmExt);
|
||||
if (!extObj || Number(extObj.voicemail_enabled) !== 1) {
|
||||
if (sub) sub.textContent = 'Aucune boîte vocale sélectionnée';
|
||||
form.classList.add('opacity-50', 'pointer-events-none');
|
||||
return;
|
||||
}
|
||||
|
||||
form.classList.remove('opacity-50', 'pointer-events-none');
|
||||
if (sub) sub.textContent = `Poste ${extObj.extension} — ${extObj.name}`;
|
||||
form.elements['pin'].value = '';
|
||||
form.elements['email'].value = extObj.voicemail_email || '';
|
||||
}
|
||||
|
||||
function setVmFolderFilter(folder) {
|
||||
ucpState.vmFolderFilter = folder;
|
||||
['all', 'INBOX', 'Old'].forEach(f => {
|
||||
const btn = document.getElementById(`vm-filter-${f}`);
|
||||
if (!btn) return;
|
||||
btn.className = f === folder
|
||||
? 'px-3 py-1.5 rounded-lg bg-indigo-50 text-indigo-700'
|
||||
: 'px-3 py-1.5 rounded-lg text-slate-600 hover:bg-slate-50';
|
||||
});
|
||||
renderVoicemailsList();
|
||||
}
|
||||
|
||||
async function loadVoicemailsForSelected() {
|
||||
const listEl = document.getElementById('ucp-vm-list');
|
||||
const extObj = ucpState.extensions.find(e => e.extension === ucpState.selectedVmExt);
|
||||
|
||||
if (!extObj || Number(extObj.voicemail_enabled) !== 1) {
|
||||
if (listEl) {
|
||||
listEl.innerHTML = `<div class="ucp-card rounded-2xl p-8 text-center text-sm text-slate-400">La messagerie vocale n'est activée sur aucun de vos postes liés.</div>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await ucpApiCall('get_voicemails', 'GET', { extension: extObj.extension });
|
||||
ucpState.voicemailsByExt[extObj.extension] = res.messages || [];
|
||||
renderVoicemailsList();
|
||||
} catch (err) {
|
||||
if (listEl) {
|
||||
listEl.innerHTML = `<div class="ucp-card rounded-2xl p-6 text-center text-xs text-rose-600">${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(sec) {
|
||||
const s = Math.max(0, Number(sec || 0));
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${m}:${String(r).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function renderVoicemailsList() {
|
||||
const listEl = document.getElementById('ucp-vm-list');
|
||||
if (!listEl) return;
|
||||
|
||||
const all = ucpState.voicemailsByExt[ucpState.selectedVmExt] || [];
|
||||
const filtered = ucpState.vmFolderFilter === 'all'
|
||||
? all
|
||||
: all.filter(m => m.folder === ucpState.vmFolderFilter);
|
||||
|
||||
if (!filtered.length) {
|
||||
listEl.innerHTML = `
|
||||
<div class="ucp-card rounded-2xl p-10 text-center text-sm text-slate-400">
|
||||
Aucun message vocal dans ce dossier.
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = filtered.map(msg => {
|
||||
const streamUrl = `/api.php?action=stream_voicemail&extension=${encodeURIComponent(msg.extension)}&folder=${encodeURIComponent(msg.folder)}&id=${encodeURIComponent(msg.id)}`;
|
||||
const dlUrl = `${streamUrl}&download=1`;
|
||||
const badge = msg.is_new
|
||||
? `<span class="px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-700 text-[10px] font-bold uppercase">Nouveau</span>`
|
||||
: `<span class="px-2 py-0.5 rounded-full bg-slate-100 text-slate-600 text-[10px] font-semibold uppercase">Lu / Archivé</span>`;
|
||||
|
||||
return `
|
||||
<div class="ucp-card rounded-2xl p-4 space-y-3 ${msg.is_new ? 'border-l-4 border-l-indigo-500' : ''}">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-bold text-slate-900 text-sm">${escapeHtml(msg.callerid)}</span>
|
||||
${badge}
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 mt-0.5">
|
||||
📅 ${escapeHtml(msg.date_human)} • ⏱️ ${formatDuration(msg.duration)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<a href="${dlUrl}" class="px-2.5 py-1.5 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 text-[11px] font-semibold text-slate-700 transition">
|
||||
⬇️ Télécharger
|
||||
</a>
|
||||
<button type="button" onclick="toggleVmRead('${escapeHtml(msg.extension)}', '${escapeHtml(msg.folder)}', '${escapeHtml(msg.id)}')"
|
||||
class="px-2.5 py-1.5 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 text-[11px] font-semibold text-slate-700 transition">
|
||||
${msg.is_new ? '✓ Marquer lu' : '↺ Marquer non lu'}
|
||||
</button>
|
||||
<button type="button" onclick="deleteVm('${escapeHtml(msg.extension)}', '${escapeHtml(msg.folder)}', '${escapeHtml(msg.id)}')"
|
||||
class="px-2.5 py-1.5 rounded-lg border border-rose-200 bg-rose-50 hover:bg-rose-100 text-[11px] font-semibold text-rose-700 transition">
|
||||
🗑️ Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<audio controls preload="none" src="${streamUrl}" class="w-full h-9 rounded-lg"></audio>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function toggleVmRead(ext, folder, id) {
|
||||
try {
|
||||
const res = await ucpApiCall('toggle_voicemail_read', 'POST', { extension: ext, folder, id });
|
||||
ucpState.voicemailsByExt[ext] = res.messages || [];
|
||||
if (res.extensions) {
|
||||
ucpState.extensions = res.extensions;
|
||||
updateBadges();
|
||||
renderVmExtPills();
|
||||
renderExtensionsGrid();
|
||||
}
|
||||
renderVoicemailsList();
|
||||
showUcpToast(res.message, 'success');
|
||||
} catch (err) {
|
||||
showUcpToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVm(ext, folder, id) {
|
||||
if (!confirm('Supprimer définitivement ce message vocal ?')) return;
|
||||
try {
|
||||
const res = await ucpApiCall('delete_voicemail', 'POST', { extension: ext, folder, id });
|
||||
ucpState.voicemailsByExt[ext] = res.messages || [];
|
||||
if (res.extensions) {
|
||||
ucpState.extensions = res.extensions;
|
||||
updateBadges();
|
||||
renderVmExtPills();
|
||||
renderExtensionsGrid();
|
||||
}
|
||||
renderVoicemailsList();
|
||||
showUcpToast(res.message, 'success');
|
||||
} catch (err) {
|
||||
showUcpToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const vmPrefsForm = document.getElementById('ucp-vm-prefs-form');
|
||||
if (vmPrefsForm) {
|
||||
vmPrefsForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!ucpState.selectedVmExt) return;
|
||||
const btn = document.getElementById('ucp-vm-prefs-btn');
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await ucpApiCall('save_voicemail_prefs', 'POST', {
|
||||
extension: ucpState.selectedVmExt,
|
||||
pin: vmPrefsForm.elements['pin'].value.trim(),
|
||||
email: vmPrefsForm.elements['email'].value.trim(),
|
||||
});
|
||||
if (res.extensions) {
|
||||
ucpState.extensions = res.extensions;
|
||||
syncVmPrefsForm();
|
||||
}
|
||||
showUcpToast(res.message, 'success');
|
||||
} catch (err) {
|
||||
showUcpToast(err.message, 'error');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 3. JOURNAL D'APPELS PERSONNEL
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function debounceLoadCdr() {
|
||||
clearTimeout(cdrTimer);
|
||||
cdrTimer = setTimeout(loadPersonalCdr, 300);
|
||||
}
|
||||
|
||||
async function loadPersonalCdr() {
|
||||
const tbody = document.getElementById('ucp-cdr-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
try {
|
||||
const res = await ucpApiCall('get_personal_cdr', 'GET', {
|
||||
direction: document.getElementById('ucp-cdr-direction')?.value || 'all',
|
||||
disposition: document.getElementById('ucp-cdr-disposition')?.value || 'all',
|
||||
period: document.getElementById('ucp-cdr-period')?.value || 'all',
|
||||
search: document.getElementById('ucp-cdr-search')?.value || '',
|
||||
});
|
||||
const data = res.data || {};
|
||||
const calls = data.calls || [];
|
||||
const stats = data.stats || { total: 0, answered: 0, missed: 0 };
|
||||
|
||||
document.getElementById('ucp-cdr-total').textContent = String(stats.total);
|
||||
document.getElementById('ucp-cdr-answered').textContent = String(stats.answered);
|
||||
document.getElementById('ucp-cdr-missed').textContent = String(stats.missed);
|
||||
|
||||
if (!calls.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="6" class="py-8 text-center text-slate-400">Aucun appel correspondant.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = calls.map(c => {
|
||||
const dirMap = {
|
||||
inbound: '<span class="px-2 py-0.5 rounded-full bg-sky-50 text-sky-700 border border-sky-200 text-[10px] font-bold">↘ Entrant</span>',
|
||||
outbound: '<span class="px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-700 border border-indigo-200 text-[10px] font-bold">↗ Sortant</span>',
|
||||
internal: '<span class="px-2 py-0.5 rounded-full bg-slate-100 text-slate-700 border border-slate-200 text-[10px] font-bold">⇄ Interne</span>',
|
||||
};
|
||||
const ok = c.disposition === 'ANSWERED';
|
||||
const stBadge = ok
|
||||
? '<span class="px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200 text-[10px] font-bold">Répondu</span>'
|
||||
: '<span class="px-2 py-0.5 rounded-full bg-rose-50 text-rose-700 border border-rose-200 text-[10px] font-bold">Manqué</span>';
|
||||
|
||||
return `
|
||||
<tr class="hover:bg-slate-50/80 transition">
|
||||
<td class="py-3 px-4 text-slate-600 font-mono whitespace-nowrap">${escapeHtml(c.date_human || c.calldate || '')}</td>
|
||||
<td class="py-3 px-4">${dirMap[c.direction] || escapeHtml(c.direction)}</td>
|
||||
<td class="py-3 px-4 font-semibold text-slate-900">${escapeHtml(c.src_label || c.src || '')}</td>
|
||||
<td class="py-3 px-4 font-semibold text-slate-900">${escapeHtml(c.dst_label || c.dst || '')}</td>
|
||||
<td class="py-3 px-4 font-mono text-slate-600">${formatDuration(c.billsec || c.duration || 0)}</td>
|
||||
<td class="py-3 px-4">${stBadge}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr><td colspan="6" class="py-6 text-center text-rose-600">${escapeHtml(err.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 4. ANNUAIRE & PROFIL UTILISATEUR
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
async function loadPhonebook() {
|
||||
try {
|
||||
const res = await ucpApiCall('get_phonebook', 'GET');
|
||||
ucpState.phonebook = res.contacts || [];
|
||||
renderPhonebookGrid();
|
||||
} catch (err) {
|
||||
showUcpToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderPhonebookGrid() {
|
||||
const grid = document.getElementById('ucp-pb-grid');
|
||||
if (!grid) return;
|
||||
|
||||
const q = (document.getElementById('ucp-pb-search')?.value || '').trim().toLowerCase();
|
||||
const list = (ucpState.phonebook || []).filter(c => {
|
||||
if (!q) return true;
|
||||
return [c.display_name, c.internal_number, c.mobile_number, c.other_number, c.email, c.group_name]
|
||||
.some(v => String(v || '').toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
if (!list.length) {
|
||||
grid.innerHTML = `<div class="ucp-card rounded-2xl p-8 text-center text-sm text-slate-400 col-span-full">Aucun contact trouvé.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = list.map(c => {
|
||||
const avatarSrc = resolveUcpAvatarSrc(c.photo_url);
|
||||
const initials = (c.display_name || '?').slice(0, 2).toUpperCase();
|
||||
const mineBadge = c.is_mine
|
||||
? `<span class="px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-700 border border-indigo-200 text-[10px] font-bold">Mon poste</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="ucp-card rounded-2xl p-4 flex flex-col justify-between gap-3 ${c.is_mine ? 'ring-1 ring-indigo-200' : ''}">
|
||||
<div class="flex items-start gap-3">
|
||||
${avatarSrc
|
||||
? `<img src="${escapeHtml(avatarSrc)}" alt="" class="w-12 h-12 rounded-full object-cover border border-slate-200 shrink-0">`
|
||||
: `<div class="w-12 h-12 rounded-full bg-slate-100 text-slate-600 font-bold flex items-center justify-center text-sm shrink-0">${escapeHtml(initials)}</div>`}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="font-bold text-slate-900 text-sm truncate">${escapeHtml(c.display_name)}</span>
|
||||
${mineBadge}
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-400">${escapeHtml(c.group_name || 'Général')}</div>
|
||||
<div class="mt-2 space-y-1 text-xs text-slate-600">
|
||||
${c.internal_number ? `<div class="font-mono">📱 Interne : <strong class="text-slate-900">${escapeHtml(c.internal_number)}</strong></div>` : ''}
|
||||
${c.mobile_number ? `<div class="font-mono">📲 Mobile : ${escapeHtml(c.mobile_number)}</div>` : ''}
|
||||
${c.other_number ? `<div class="font-mono">☎️ Autre : ${escapeHtml(c.other_number)}</div>` : ''}
|
||||
${c.email ? `<div class="truncate">✉️ ${escapeHtml(c.email)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${c.is_mine ? `
|
||||
<button type="button" onclick="openProfileModal('${escapeHtml(c.mine_extension)}')"
|
||||
class="w-full py-1.5 px-3 rounded-xl bg-indigo-50 hover:bg-indigo-100 text-indigo-700 text-xs font-semibold border border-indigo-200 transition">
|
||||
✏️ Modifier ma photo / mon e-mail
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function openProfileModal(extNum) {
|
||||
const contact = (ucpState.phonebook || []).find(c => c.is_mine && String(c.mine_extension) === String(extNum));
|
||||
if (!contact) return;
|
||||
|
||||
document.getElementById('ucp-profile-ext').value = extNum;
|
||||
document.getElementById('ucp-profile-modal-sub').textContent = `Poste ${extNum} — ${contact.display_name}`;
|
||||
document.getElementById('ucp-profile-email-input').value = contact.email || '';
|
||||
|
||||
updateProfileModalPreview(contact.photo_url, contact.display_name);
|
||||
document.getElementById('ucp-profile-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function updateProfileModalPreview(photoUrl, name) {
|
||||
const img = document.getElementById('ucp-profile-preview');
|
||||
const init = document.getElementById('ucp-profile-initials');
|
||||
const src = resolveUcpAvatarSrc(photoUrl);
|
||||
|
||||
if (src) {
|
||||
img.src = src;
|
||||
img.classList.remove('hidden');
|
||||
init.classList.add('hidden');
|
||||
} else {
|
||||
img.classList.add('hidden');
|
||||
init.textContent = (name || '?').slice(0, 2).toUpperCase();
|
||||
init.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function closeProfileModal() {
|
||||
document.getElementById('ucp-profile-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function uploadSelectedAvatar() {
|
||||
const input = document.getElementById('ucp-avatar-input');
|
||||
const ext = document.getElementById('ucp-profile-ext').value;
|
||||
if (!input || !input.files || !input.files[0] || !ext) return;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('extension', ext);
|
||||
fd.append('avatar', input.files[0]);
|
||||
|
||||
try {
|
||||
const res = await ucpApiCall('upload_my_avatar', 'POST', fd);
|
||||
if (res.contacts) ucpState.phonebook = res.contacts;
|
||||
if (res.extensions) ucpState.extensions = res.extensions;
|
||||
updateProfileModalPreview(res.photo_url, ext);
|
||||
renderPhonebookGrid();
|
||||
renderExtensionsGrid();
|
||||
showUcpToast(res.message, 'success');
|
||||
} catch (err) {
|
||||
showUcpToast(err.message, 'error');
|
||||
} finally {
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
const profileEmailForm = document.getElementById('ucp-profile-email-form');
|
||||
if (profileEmailForm) {
|
||||
profileEmailForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const ext = document.getElementById('ucp-profile-ext').value;
|
||||
const email = document.getElementById('ucp-profile-email-input').value.trim();
|
||||
|
||||
try {
|
||||
const res = await ucpApiCall('save_my_email', 'POST', { extension: ext, email });
|
||||
if (res.contacts) ucpState.phonebook = res.contacts;
|
||||
if (res.extensions) ucpState.extensions = res.extensions;
|
||||
renderPhonebookGrid();
|
||||
closeProfileModal();
|
||||
showUcpToast(res.message, 'success');
|
||||
} catch (err) {
|
||||
showUcpToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 5. CHANGEMENT DE MOT DE PASSE UCP
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const passwordForm = document.getElementById('ucp-password-form');
|
||||
if (passwordForm) {
|
||||
passwordForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const cur = passwordForm.elements['current_password'].value;
|
||||
const nw = passwordForm.elements['new_password'].value;
|
||||
const conf = passwordForm.elements['confirm_password'].value;
|
||||
|
||||
if (nw !== conf) {
|
||||
showUcpToast('Les deux nouveaux mots de passe ne correspondent pas.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await ucpApiCall('change_password', 'POST', {
|
||||
current_password: cur,
|
||||
new_password: nw,
|
||||
});
|
||||
passwordForm.reset();
|
||||
showUcpToast(res.message, 'success');
|
||||
} catch (err) {
|
||||
showUcpToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// INITIALISATION
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
if (window.UCP_IS_AUTH) {
|
||||
refreshUcpState(false);
|
||||
pollTimer = setInterval(() => {
|
||||
if (!document.hidden) {
|
||||
refreshUcpState(false);
|
||||
}
|
||||
}, 15000);
|
||||
}
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../src/Database.php';
|
||||
require_once __DIR__ . '/../src/FirewallManager.php';
|
||||
require_once __DIR__ . '/../src/UcpAuth.php';
|
||||
|
||||
use App\Database;
|
||||
use App\UcpAuth;
|
||||
|
||||
$db = Database::getConnection();
|
||||
$ucpEnabled = UcpAuth::isUcpEnabled($db);
|
||||
$currentUser = $ucpEnabled ? UcpAuth::getCurrentUser($db) : null;
|
||||
$isAuth = ($currentUser !== null);
|
||||
|
||||
$branding = $db->query('SELECT company_name, company_logo FROM settings WHERE id = 1 LIMIT 1')->fetch(\PDO::FETCH_ASSOC) ?: [];
|
||||
$companyName = trim((string)($branding['company_name'] ?? ''));
|
||||
$companyLogo = trim((string)($branding['company_logo'] ?? ''));
|
||||
$appVerData = file_exists(__DIR__ . '/../version.json') ? json_decode((string)file_get_contents(__DIR__ . '/../version.json'), true) : [];
|
||||
$appVersion = $appVerData['version'] ?? '2.5.0';
|
||||
$csrfToken = UcpAuth::getCsrfToken();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" class="h-full bg-slate-50">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($companyName ?: 'NexusPBX') ?> — Portail Utilisateur (UCP)</title>
|
||||
<link rel="icon" type="image/png" href="/assets/favicon.png?v=<?= file_exists(__DIR__ . '/assets/favicon.png') ? filemtime(__DIR__ . '/assets/favicon.png') : 1 ?>">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
body { background-color: #f8fafc; color: #0f172a; }
|
||||
.ucp-card { background: #ffffff; border: 1px solid #e2e8f0; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04); }
|
||||
::-webkit-scrollbar { width: 7px; height: 7px; }
|
||||
::-webkit-scrollbar-track { background: #f1f5f9; }
|
||||
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-full font-sans antialiased text-slate-800">
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div id="ucp-toast-container" class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none px-4 sm:px-0"></div>
|
||||
|
||||
<?php if (!$ucpEnabled): ?>
|
||||
<div class="min-h-screen flex items-center justify-center p-6">
|
||||
<div class="max-w-md w-full ucp-card rounded-2xl p-8 text-center space-y-3">
|
||||
<div class="w-12 h-12 mx-auto rounded-xl bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600 text-2xl">🔒</div>
|
||||
<h1 class="text-lg font-bold text-slate-900">Portail Utilisateur désactivé</h1>
|
||||
<p class="text-sm text-slate-500">Ce service a été temporairement désactivé par votre administrateur système.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif (!$isAuth): ?>
|
||||
<!-- ÉCRAN DE CONNEXION UCP (THÈME CLAIR ÉPURÉ) -->
|
||||
<div class="min-h-screen flex flex-col items-center justify-center p-4 bg-gradient-to-br from-slate-50 via-blue-50/40 to-indigo-50/50">
|
||||
<div class="w-full max-w-md ucp-card rounded-3xl p-8 shadow-xl shadow-slate-200/60 border border-slate-200/80">
|
||||
<div class="flex flex-col items-center text-center mb-7">
|
||||
<?php if ($companyLogo !== ''): ?>
|
||||
<img src="<?= htmlspecialchars($companyLogo) ?>" alt="Logo" class="h-14 w-auto object-contain mb-3 rounded-xl">
|
||||
<?php else: ?>
|
||||
<img src="/assets/logo.png?v=1" alt="NexusPBX" class="h-14 w-auto object-contain mb-3">
|
||||
<?php endif; ?>
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-indigo-50 border border-indigo-100 text-indigo-700 text-[11px] font-semibold uppercase tracking-wider mb-2">
|
||||
Portail Utilisateur • UCP
|
||||
</span>
|
||||
<h1 class="text-xl font-bold text-slate-900"><?= htmlspecialchars($companyName ?: 'NexusPBX') ?></h1>
|
||||
<p class="text-xs text-slate-500 mt-1">Connectez-vous pour gérer vos postes, renvois, messagerie et annuaire</p>
|
||||
</div>
|
||||
|
||||
<form id="ucp-login-form" class="space-y-4">
|
||||
<div>
|
||||
<label for="ucp-login-user" class="block text-xs font-semibold text-slate-700 mb-1.5">Identifiant utilisateur</label>
|
||||
<input type="text" id="ucp-login-user" name="username" required autocomplete="username" autofocus
|
||||
placeholder="ex: fred ou fred@entreprise.fr"
|
||||
class="w-full px-3.5 py-2.5 rounded-xl bg-slate-50 border border-slate-300 text-sm text-slate-900 focus:bg-white focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none transition">
|
||||
</div>
|
||||
<div>
|
||||
<label for="ucp-login-pass" class="block text-xs font-semibold text-slate-700 mb-1.5">Mot de passe</label>
|
||||
<input type="password" id="ucp-login-pass" name="password" required autocomplete="current-password"
|
||||
placeholder="••••••••"
|
||||
class="w-full px-3.5 py-2.5 rounded-xl bg-slate-50 border border-slate-300 text-sm text-slate-900 focus:bg-white focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none transition">
|
||||
</div>
|
||||
<div id="ucp-login-error" class="hidden rounded-xl bg-rose-50 border border-rose-200 px-3.5 py-2.5 text-xs font-medium text-rose-700"></div>
|
||||
<button type="submit" id="ucp-login-btn"
|
||||
class="w-full py-2.5 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold shadow-sm shadow-indigo-500/20 transition">
|
||||
Se connecter au portail
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 pt-5 border-t border-slate-100 text-center text-[11px] text-slate-400">
|
||||
Espace utilisateur isolé • Protégé par Fail2ban • NexusPBX v<?= htmlspecialchars($appVersion) ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<!-- INTERFACE PRINCIPALE UCP -->
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<!-- Top Navbar -->
|
||||
<header class="sticky top-0 z-30 bg-white/95 backdrop-blur border-b border-slate-200">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<?php if ($companyLogo !== ''): ?>
|
||||
<img src="<?= htmlspecialchars($companyLogo) ?>" alt="Logo" class="h-9 w-auto rounded-lg object-contain shrink-0">
|
||||
<?php else: ?>
|
||||
<img src="/assets/logo.png?v=1" alt="NexusPBX" class="h-9 w-auto object-contain shrink-0">
|
||||
<?php endif; ?>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold text-slate-900 text-sm sm:text-base truncate"><?= htmlspecialchars($companyName ?: 'NexusPBX') ?></span>
|
||||
<span class="hidden sm:inline-flex px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-700 border border-indigo-100 text-[10px] font-bold uppercase tracking-wide">UCP</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-slate-500 truncate">Espace personnel de <strong class="text-slate-700"><?= htmlspecialchars((string)($currentUser['display_name'] ?: $currentUser['username'])) ?></strong></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button type="button" onclick="refreshUcpState(true)" title="Actualiser"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-slate-200 bg-white hover:bg-slate-50 text-xs font-semibold text-slate-600 transition">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
<span class="hidden sm:inline">Actualiser</span>
|
||||
</button>
|
||||
<button type="button" onclick="ucpLogout()"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-rose-200 bg-rose-50 hover:bg-rose-100 text-xs font-semibold text-rose-700 transition">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||
<span>Déconnexion</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Onglets -->
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 border-t border-slate-100 overflow-x-auto">
|
||||
<nav class="flex items-center gap-1 py-2 whitespace-nowrap" id="ucp-nav">
|
||||
<button type="button" data-ucp-tab="extensions" onclick="switchUcpTab('extensions')"
|
||||
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 bg-indigo-50 text-indigo-700">
|
||||
<span>📱 Mes Postes & Renvois</span>
|
||||
<span id="ucp-badge-exts" class="px-1.5 py-0.5 rounded-full bg-indigo-100 text-indigo-700 text-[10px] font-bold">0</span>
|
||||
</button>
|
||||
<button type="button" data-ucp-tab="voicemail" onclick="switchUcpTab('voicemail')"
|
||||
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
|
||||
<span>📬 Messagerie Vocale</span>
|
||||
<span id="ucp-badge-vm" class="px-1.5 py-0.5 rounded-full bg-slate-200 text-slate-700 text-[10px] font-bold">0</span>
|
||||
</button>
|
||||
<button type="button" data-ucp-tab="cdr" onclick="switchUcpTab('cdr')"
|
||||
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
|
||||
<span>📞 Mes Appels</span>
|
||||
</button>
|
||||
<button type="button" data-ucp-tab="phonebook" onclick="switchUcpTab('phonebook')"
|
||||
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
|
||||
<span>📖 Annuaire & Profil</span>
|
||||
</button>
|
||||
<button type="button" data-ucp-tab="account" onclick="switchUcpTab('account')"
|
||||
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
|
||||
<span>🔐 Mon Compte</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Contenu principal -->
|
||||
<main class="flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 py-6 space-y-6">
|
||||
|
||||
<!-- ONGLET 1 : MES POSTES & RENVOIS -->
|
||||
<section id="ucp-panel-extensions" class="ucp-panel space-y-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Mes Postes & Redirections</h2>
|
||||
<p class="text-xs text-slate-500">Activez le mode Ne Pas Déranger (DND) ou configurez vos renvois vers un autre poste ou votre messagerie vocale.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ucp-extensions-grid" class="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div class="ucp-card rounded-2xl p-8 text-center text-sm text-slate-400 col-span-full">Chargement de vos postes…</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ONGLET 2 : MESSAGERIE VOCALE -->
|
||||
<section id="ucp-panel-voicemail" class="ucp-panel hidden space-y-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Messagerie Vocale</h2>
|
||||
<p class="text-xs text-slate-500">Écoutez, téléchargez, archivez ou supprimez vos messages vocaux et gérez votre code PIN.</p>
|
||||
</div>
|
||||
<div id="ucp-vm-ext-pills" class="flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Liste des messages -->
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="inline-flex rounded-xl bg-white border border-slate-200 p-1 text-xs font-semibold">
|
||||
<button type="button" onclick="setVmFolderFilter('all')" id="vm-filter-all" class="px-3 py-1.5 rounded-lg bg-indigo-50 text-indigo-700">Tous</button>
|
||||
<button type="button" onclick="setVmFolderFilter('INBOX')" id="vm-filter-INBOX" class="px-3 py-1.5 rounded-lg text-slate-600 hover:bg-slate-50">Nouveaux</button>
|
||||
<button type="button" onclick="setVmFolderFilter('Old')" id="vm-filter-Old" class="px-3 py-1.5 rounded-lg text-slate-600 hover:bg-slate-50">Archivés</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ucp-vm-list" class="space-y-3">
|
||||
<div class="ucp-card rounded-2xl p-8 text-center text-sm text-slate-400">Chargement des messages…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Préférences Boîte Vocale -->
|
||||
<div class="ucp-card rounded-2xl p-5 h-fit space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-slate-900">Préférences Boîte Vocale</h3>
|
||||
<p id="ucp-vm-prefs-subtitle" class="text-xs text-slate-500 mt-0.5">Poste sélectionné</p>
|
||||
</div>
|
||||
<form id="ucp-vm-prefs-form" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 mb-1">Nouveau code PIN (4 à 12 chiffres)</label>
|
||||
<input type="password" name="pin" inputmode="numeric" pattern="\d{4,12}" placeholder="Laisser vide pour conserver l'actuel"
|
||||
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 font-mono focus:bg-white focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 mb-1">E-mail de notification</label>
|
||||
<input type="email" name="email" placeholder="prenom@entreprise.fr"
|
||||
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none">
|
||||
</div>
|
||||
<button type="submit" id="ucp-vm-prefs-btn"
|
||||
class="w-full py-2 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold transition">
|
||||
Enregistrer les préférences
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ONGLET 3 : MES APPELS (MINI-CDR PERSONNEL) -->
|
||||
<section id="ucp-panel-cdr" class="ucp-panel hidden space-y-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Mon Journal d'Appels</h2>
|
||||
<p class="text-xs text-slate-500">Historique filtré sur vos postes liés uniquement.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class="px-3 py-1 rounded-full bg-white border border-slate-200 font-semibold text-slate-700">Total : <strong id="ucp-cdr-total">0</strong></span>
|
||||
<span class="px-3 py-1 rounded-full bg-emerald-50 border border-emerald-200 font-semibold text-emerald-700">Répondus : <strong id="ucp-cdr-answered">0</strong></span>
|
||||
<span class="px-3 py-1 rounded-full bg-rose-50 border border-rose-200 font-semibold text-rose-700">Manqués : <strong id="ucp-cdr-missed">0</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ucp-card rounded-2xl p-4 grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||
<select id="ucp-cdr-direction" onchange="loadPersonalCdr()" class="px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs font-medium text-slate-800">
|
||||
<option value="all">Toutes directions</option>
|
||||
<option value="inbound">Appels entrants</option>
|
||||
<option value="outbound">Appels sortants</option>
|
||||
<option value="internal">Appels internes</option>
|
||||
</select>
|
||||
<select id="ucp-cdr-disposition" onchange="loadPersonalCdr()" class="px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs font-medium text-slate-800">
|
||||
<option value="all">Tous statuts</option>
|
||||
<option value="ANSWERED">Répondus uniquement</option>
|
||||
<option value="MISSED">Manqués / Sans réponse</option>
|
||||
</select>
|
||||
<select id="ucp-cdr-period" onchange="loadPersonalCdr()" class="px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs font-medium text-slate-800">
|
||||
<option value="all">Tout l'historique</option>
|
||||
<option value="today">Aujourd'hui</option>
|
||||
<option value="week">7 derniers jours</option>
|
||||
<option value="month">30 derniers jours</option>
|
||||
</select>
|
||||
<input type="text" id="ucp-cdr-search" oninput="debounceLoadCdr()" placeholder="Rechercher un numéro ou nom…"
|
||||
class="px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
|
||||
<div class="ucp-card rounded-2xl overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-xs">
|
||||
<thead class="bg-slate-50 border-b border-slate-200 text-slate-500 uppercase text-[11px]">
|
||||
<tr>
|
||||
<th class="py-3 px-4">Date & Heure</th>
|
||||
<th class="py-3 px-4">Direction</th>
|
||||
<th class="py-3 px-4">De</th>
|
||||
<th class="py-3 px-4">Vers</th>
|
||||
<th class="py-3 px-4">Durée</th>
|
||||
<th class="py-3 px-4">Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ucp-cdr-tbody" class="divide-y divide-slate-100">
|
||||
<tr><td colspan="6" class="py-8 text-center text-slate-400">Chargement…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ONGLET 4 : ANNUAIRE & PROFIL -->
|
||||
<section id="ucp-panel-phonebook" class="ucp-panel hidden space-y-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Annuaire de l'Entreprise</h2>
|
||||
<p class="text-xs text-slate-500">Consultez tous les contacts et mettez à jour la photo ou l'e-mail de vos propres postes.</p>
|
||||
</div>
|
||||
<input type="text" id="ucp-pb-search" oninput="renderPhonebookGrid()" placeholder="Rechercher un nom, poste, email…"
|
||||
class="w-full sm:w-72 px-3.5 py-2 rounded-xl bg-white border border-slate-300 text-xs text-slate-900 focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none">
|
||||
</div>
|
||||
|
||||
<div id="ucp-pb-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="ucp-card rounded-2xl p-8 text-center text-sm text-slate-400 col-span-full">Chargement de l'annuaire…</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ONGLET 5 : MON COMPTE -->
|
||||
<section id="ucp-panel-account" class="ucp-panel hidden space-y-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Mon Compte UCP</h2>
|
||||
<p class="text-xs text-slate-500">Sécurisez votre accès personnel au portail utilisateur.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="ucp-card rounded-2xl p-6 space-y-4">
|
||||
<h3 class="text-sm font-bold text-slate-900">Informations de session</h3>
|
||||
<dl class="space-y-2.5 text-xs">
|
||||
<div class="flex justify-between py-2 border-b border-slate-100">
|
||||
<dt class="text-slate-500">Identifiant</dt>
|
||||
<dd class="font-mono font-semibold text-slate-900"><?= htmlspecialchars((string)$currentUser['username']) ?></dd>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-slate-100">
|
||||
<dt class="text-slate-500">Nom affiché</dt>
|
||||
<dd class="font-semibold text-slate-900"><?= htmlspecialchars((string)($currentUser['display_name'] ?: $currentUser['username'])) ?></dd>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-slate-100">
|
||||
<dt class="text-slate-500">Postes autorisés</dt>
|
||||
<dd id="ucp-account-exts" class="font-mono font-semibold text-indigo-600">—</dd>
|
||||
</div>
|
||||
<div class="flex justify-between py-2">
|
||||
<dt class="text-slate-500">Dernière connexion</dt>
|
||||
<dd class="text-slate-700"><?= htmlspecialchars((string)($currentUser['last_login_at'] ?? 'Première connexion')) ?></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="ucp-card rounded-2xl p-6 space-y-4">
|
||||
<h3 class="text-sm font-bold text-slate-900">Changer mon mot de passe UCP</h3>
|
||||
<form id="ucp-password-form" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 mb-1">Mot de passe actuel</label>
|
||||
<input type="password" name="current_password" required autocomplete="current-password"
|
||||
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 mb-1">Nouveau mot de passe (min. 6 caractères)</label>
|
||||
<input type="password" name="new_password" required minlength="6" autocomplete="new-password"
|
||||
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 mb-1">Confirmer le nouveau mot de passe</label>
|
||||
<input type="password" name="confirm_password" required minlength="6" autocomplete="new-password"
|
||||
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<button type="submit" class="w-full py-2.5 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold transition">
|
||||
Mettre à jour mon mot de passe
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- MODALE ÉDITION PROFIL ANNUAIRE (PHOTO + EMAIL UNIQUEMENT) -->
|
||||
<div id="ucp-profile-modal" class="hidden fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md bg-white rounded-2xl border border-slate-200 shadow-2xl p-6 space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-slate-900">Modifier ma fiche annuaire</h3>
|
||||
<p id="ucp-profile-modal-sub" class="text-xs text-slate-500">Poste —</p>
|
||||
</div>
|
||||
<button type="button" onclick="closeProfileModal()" class="text-slate-400 hover:text-slate-700 text-lg font-bold">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 p-3.5 rounded-xl bg-slate-50 border border-slate-200">
|
||||
<img id="ucp-profile-preview" src="" alt="Avatar" class="w-14 h-14 rounded-full object-cover border border-slate-200 bg-white hidden">
|
||||
<div id="ucp-profile-initials" class="w-14 h-14 rounded-full bg-indigo-100 text-indigo-700 font-bold flex items-center justify-center text-lg">?</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<label class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white border border-slate-300 hover:bg-slate-100 text-xs font-semibold text-slate-700 cursor-pointer transition">
|
||||
📷 Choisir une photo
|
||||
<input type="file" id="ucp-avatar-input" accept="image/jpeg,image/png,image/webp" class="hidden" onchange="uploadSelectedAvatar()">
|
||||
</label>
|
||||
<p class="text-[11px] text-slate-400 mt-1">JPG, PNG ou WebP • 5 Mo max.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="ucp-profile-email-form" class="space-y-3">
|
||||
<input type="hidden" id="ucp-profile-ext" value="">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 mb-1">Adresse e-mail affichée dans l'annuaire</label>
|
||||
<input type="email" id="ucp-profile-email-input" placeholder="prenom.nom@entreprise.fr"
|
||||
class="w-full px-3.5 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div class="flex gap-2 pt-2">
|
||||
<button type="button" onclick="closeProfileModal()" class="flex-1 py-2 px-4 rounded-xl border border-slate-200 text-xs font-semibold text-slate-600 hover:bg-slate-50">Fermer</button>
|
||||
<button type="submit" class="flex-1 py-2 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold">Enregistrer l'e-mail</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<script>
|
||||
window.UCP_CSRF_TOKEN = <?= json_encode($csrfToken) ?>;
|
||||
window.UCP_IS_AUTH = <?= json_encode($isAuth) ?>;
|
||||
</script>
|
||||
<script src="/assets/ucp.js?v=<?= file_exists(__DIR__ . '/assets/ucp.js') ? filemtime(__DIR__ . '/assets/ucp.js') : time() ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user