Files
NexusPBX/ucp/assets/ucp.js
T

1208 lines
61 KiB
JavaScript

'use strict';
let ucpState = {
user: null,
extensions: [],
forward_targets: [],
feature_codes: [],
voicemailsByExt: {},
selectedVmExt: '',
vmFolderFilter: 'all',
phonebook: [],
selectedPbGroup: '__all__',
activeTab: 'extensions',
};
let cdrTimer = null;
let pollTimer = null;
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function ucpIcon(name, cls = 'w-4 h-4') {
const paths = {
voicemail: '<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z"/>',
bolt: '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z"/>',
calendar: '<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"/>',
clock: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/>',
download: '<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/>',
trash: '<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/>',
phone: '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"/>',
mobile: '<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"/>',
mail: '<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"/>',
pencil: '<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z"/>',
forward: '<path stroke-linecap="round" stroke-linejoin="round" d="M15 15l6-6m0 0l-6-6m6 6H9a6 6 0 000 12h3"/>',
pickup: '<path stroke-linecap="round" stroke-linejoin="round" d="M14.25 9.75v-4.5m0 4.5h4.5m-4.5 0l6-6m-3 18c-8.284 0-15-6.716-15-15V4.5A2.25 2.25 0 014.5 2.25h1.372c.516 0 .966.351 1.091.852l1.106 4.423c.11.44-.054.902-.417 1.173l-1.293.97a1.062 1.062 0 00-.38 1.21 12.035 12.035 0 007.143 7.143c.441.162.928-.004 1.21-.38l.97-1.293a1.125 1.125 0 011.173-.417l4.423 1.106c.5.125.852.575.852 1.091V19.5a2.25 2.25 0 01-2.25 2.25h-2.25z"/>',
dnd: '<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>',
shield: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/>',
wrench: '<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085"/>',
tag: '<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"/>',
};
return `<svg class="${cls} shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24">${paths[name] || paths.tag}</svg>`;
}
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 (isMe && typeof btn.scrollIntoView === 'function') {
btn.scrollIntoView({ behavior: 'smooth', inline: 'nearest', block: 'nearest' });
}
});
setTimeout(updateUcpNavScrollHints, 120);
if (tabName === 'voicemail') {
loadVoicemailsForSelected();
} else if (tabName === 'cdr') {
loadPersonalCdr();
} else if (tabName === 'phonebook') {
loadPhonebook();
} else if (tabName === 'guide') {
renderFeatureGuide();
}
}
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 || [];
ucpState.feature_codes = res.data.feature_codes || [];
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();
renderFeatureGuide();
const accExts = document.getElementById('ucp-account-exts');
if (accExts) {
accExts.textContent = ucpState.extensions.map(e => `${e.extension} (${e.name})`).join(', ') || 'Aucun poste lié';
}
const guideExts = document.getElementById('ucp-guide-user-exts');
if (guideExts) {
guideExts.textContent = ucpState.extensions.map(e => e.extension).join(', ') || 'Aucun';
}
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'}">
${ucpIcon('voicemail', 'w-3.5 h-3.5')}
<span>${ext.vm_inbox_count} nouveau(x)</span>
</button>`
: '';
return `
<div class="ucp-card rounded-2xl p-4 sm:p-5 space-y-4 min-w-0 overflow-hidden">
<div class="flex items-start justify-between gap-3 min-w-0">
<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 break-all">${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 &amp; 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>
${(Number(ext.groundwire_enabled) === 1 && ext.groundwire_deeplink_url && ext.groundwire_xml_url) ? `
<div class="pt-3 border-t border-slate-100">
<div class="rounded-xl bg-slate-50/80 border border-slate-200/80 p-3 space-y-2.5">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-xs font-bold text-slate-700">GroundWire Mobile</div>
<div class="flex items-center gap-2 text-[11px] font-medium text-slate-500">
<a href="https://apps.apple.com/fr/app/groundwire-voip-sip-softphone/id378503081"
target="_blank" rel="noopener noreferrer"
class="hover:text-indigo-600 transition underline decoration-slate-300 underline-offset-2">
iOS
</a>
<span class="text-slate-300">•</span>
<a href="https://play.google.com/store/apps/details?id=cz.acrobits.softphone.aliengroundwire"
target="_blank" rel="noopener noreferrer"
class="hover:text-indigo-600 transition underline decoration-slate-300 underline-offset-2">
Android
</a>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<a href="${escapeHtml(ext.groundwire_deeplink_url)}"
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-xl bg-indigo-50 hover:bg-indigo-100 text-indigo-700 border border-indigo-200 text-xs font-semibold transition flex-1 sm:flex-initial">
${ucpIcon('bolt', 'w-3.5 h-3.5')}
<span>Configurer dans Groundwire</span>
</a>
<a href="${escapeHtml(ext.groundwire_xml_url)}"
download="groundwire-${escapeHtml(ext.extension)}.xml"
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-xl bg-white hover:bg-slate-100 text-slate-700 border border-slate-200 text-xs font-medium transition">
${ucpIcon('download', 'w-3.5 h-3.5')}
<span>Télécharger le XML</span>
</a>
</div>
</div>
</div>
` : ''}
</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 flex items-center gap-2 flex-wrap">
<span class="inline-flex items-center gap-1">${ucpIcon('calendar', 'w-3.5 h-3.5 text-slate-400')} ${escapeHtml(msg.date_human)}</span>
<span>•</span>
<span class="inline-flex items-center gap-1">${ucpIcon('clock', 'w-3.5 h-3.5 text-slate-400')} ${formatDuration(msg.duration)}</span>
</div>
</div>
<div class="flex items-center gap-1.5 flex-wrap">
<a href="${dlUrl}" class="inline-flex items-center gap-1 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">
${ucpIcon('download', 'w-3.5 h-3.5 text-slate-500')}
<span>Télécharger</span>
</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="inline-flex items-center gap-1 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">
${ucpIcon('trash', 'w-3.5 h-3.5')}
<span>Supprimer</span>
</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');
const mobileBox = document.getElementById('ucp-cdr-mobile-list');
if (!tbody && !mobileBox) 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) {
if (tbody) tbody.innerHTML = `<tr><td colspan="6" class="py-8 text-center text-slate-400">Aucun appel correspondant.</td></tr>`;
if (mobileBox) mobileBox.innerHTML = `<div class="ucp-card rounded-2xl p-6 text-center text-xs text-slate-400">Aucun appel correspondant.</div>`;
return;
}
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 formatParty = (name, num) => {
if (name && num && name !== num) {
return `<div class="font-semibold text-slate-900">${escapeHtml(name)}</div><div class="text-[11px] font-mono text-slate-500">${escapeHtml(num)}</div>`;
}
return `<span class="font-semibold font-mono text-slate-900">${escapeHtml(num || name || '—')}</span>`;
};
const formatInlineParty = (name, num) => {
if (name && num && name !== num) {
return `<strong class="text-slate-900">${escapeHtml(name)}</strong> <span class="font-mono text-[11px] text-slate-500">(${escapeHtml(num)})</span>`;
}
return `<strong class="font-mono text-slate-900">${escapeHtml(num || name || '—')}</strong>`;
};
const rowsHtml = [];
const cardsHtml = [];
calls.forEach(c => {
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">${escapeHtml(c.status_label || '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">${escapeHtml(c.status_label || 'Manqué')}</span>`;
const dirBadge = dirMap[c.direction] || escapeHtml(c.direction_label || c.direction);
const srcNum = String(c.caller_number ?? c.src ?? '');
const srcName = String(c.caller_name ?? '');
const dstNum = String(c.callee_number ?? c.dst ?? '');
const dstName = String(c.callee_name ?? '');
const durText = escapeHtml(c.billsec_formatted || formatDuration(c.billsec || c.duration || 0));
const dtText = escapeHtml(c.datetime_formatted || c.datetime || '');
rowsHtml.push(`
<tr class="hover:bg-slate-50/80 transition">
<td class="py-3 px-4 text-slate-600 font-mono whitespace-nowrap">${dtText}</td>
<td class="py-3 px-4">${dirBadge}</td>
<td class="py-3 px-4">${formatParty(srcName, srcNum)}</td>
<td class="py-3 px-4">${formatParty(dstName, dstNum)}</td>
<td class="py-3 px-4 font-mono text-slate-600">${durText}</td>
<td class="py-3 px-4">${stBadge}</td>
</tr>
`);
cardsHtml.push(`
<div class="ucp-card rounded-2xl p-3.5 space-y-2 min-w-0">
<div class="flex items-center justify-between gap-2 flex-wrap">
<div class="flex items-center gap-1.5">
${dirBadge}
${stBadge}
</div>
<span class="inline-flex items-center gap-1 font-mono text-xs font-semibold text-slate-700">${ucpIcon('clock', 'w-3.5 h-3.5 text-slate-400')} ${durText}</span>
</div>
<div class="text-xs leading-snug break-words flex flex-wrap items-center gap-1.5">
<span>${formatInlineParty(srcName, srcNum)}</span>
<span class="text-slate-400 font-bold">→</span>
<span>${formatInlineParty(dstName, dstNum)}</span>
</div>
<div class="text-[11px] font-mono text-slate-400">${dtText}</div>
</div>
`);
});
if (tbody) tbody.innerHTML = rowsHtml.join('');
if (mobileBox) mobileBox.innerHTML = cardsHtml.join('');
} catch (err) {
if (tbody) tbody.innerHTML = `<tr><td colspan="6" class="py-6 text-center text-rose-600">${escapeHtml(err.message)}</td></tr>`;
if (mobileBox) mobileBox.innerHTML = `<div class="ucp-card rounded-2xl p-5 text-center text-xs text-rose-600">${escapeHtml(err.message)}</div>`;
}
}
// -----------------------------------------------------------------------------
// 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 getContactGroups(contact) {
if (Array.isArray(contact?.groups) && contact.groups.length > 0) {
return contact.groups.map(g => String(g || '').trim()).filter(Boolean);
}
const split = String(contact?.group_name || '')
.split(',')
.map(g => g.trim())
.filter(Boolean);
return split.length > 0 ? Array.from(new Set(split)) : ['Général'];
}
function renderPhonebookGroupTabs(allContacts) {
const tabsEl = document.getElementById('ucp-pb-group-tabs');
if (!tabsEl) return;
const counts = {};
for (const c of allContacts) {
for (const grp of getContactGroups(c)) {
counts[grp] = (counts[grp] || 0) + 1;
}
}
const groups = Object.keys(counts).sort((a, b) => {
if (a === 'Général') return -1;
if (b === 'Général') return 1;
return a.localeCompare(b, 'fr', { sensitivity: 'base' });
});
if (ucpState.selectedPbGroup !== '__all__' && !counts[ucpState.selectedPbGroup]) {
ucpState.selectedPbGroup = '__all__';
}
if (groups.length <= 1 && allContacts.length === 0) {
tabsEl.innerHTML = '';
return;
}
const renderPill = (key, label, count) => {
const isActive = ucpState.selectedPbGroup === key;
const btnCls = isActive
? 'bg-indigo-600 text-white border-indigo-600 shadow-sm'
: 'bg-white hover:bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300';
const badgeCls = isActive
? 'bg-white/20 text-white'
: 'bg-slate-100 text-slate-600';
return `
<button type="button" data-pb-group="${escapeHtml(key)}"
class="ucp-pb-group-btn inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl border text-xs font-semibold transition ${btnCls}">
<span>${escapeHtml(label)}</span>
<span class="px-1.5 py-0.2 rounded-full text-[10px] font-bold ${badgeCls}">${count}</span>
</button>
`;
};
tabsEl.innerHTML = [
renderPill('__all__', 'Tous', allContacts.length),
...groups.map(g => renderPill(g, g, counts[g])),
].join('');
tabsEl.querySelectorAll('.ucp-pb-group-btn').forEach(btn => {
btn.addEventListener('click', () => {
ucpState.selectedPbGroup = btn.getAttribute('data-pb-group') || '__all__';
renderPhonebookGrid();
});
});
}
function renderPhonebookGrid() {
const grid = document.getElementById('ucp-pb-grid');
if (!grid) return;
const allContacts = ucpState.phonebook || [];
renderPhonebookGroupTabs(allContacts);
const activeGroup = ucpState.selectedPbGroup || '__all__';
const q = (document.getElementById('ucp-pb-search')?.value || '').trim().toLowerCase();
const list = allContacts.filter(c => {
if (activeGroup !== '__all__' && !getContactGroups(c).includes(activeGroup)) return false;
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 flex items-center gap-1.5">${ucpIcon('phone', 'w-3.5 h-3.5 text-indigo-500')} Interne : <strong class="text-slate-900">${escapeHtml(c.internal_number)}</strong></div>` : ''}
${c.mobile_number ? `<div class="font-mono flex items-center gap-1.5">${ucpIcon('mobile', 'w-3.5 h-3.5 text-emerald-500')} Mobile : ${escapeHtml(c.mobile_number)}</div>` : ''}
${c.other_number ? `<div class="font-mono flex items-center gap-1.5">${ucpIcon('phone', 'w-3.5 h-3.5 text-slate-400')} Autre : ${escapeHtml(c.other_number)}</div>` : ''}
${c.email ? `<div class="truncate flex items-center gap-1.5">${ucpIcon('mail', 'w-3.5 h-3.5 text-slate-400')} <span class="truncate">${escapeHtml(c.email)}</span></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 inline-flex items-center justify-center gap-1.5">
${ucpIcon('pencil', 'w-3.5 h-3.5')}
<span>Modifier ma photo / mon e-mail</span>
</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. NOTICE D'UTILISATION, CODES DE FONCTION & EXPORT PDF
// -----------------------------------------------------------------------------
function printFeatureGuide() {
if (ucpState.activeTab !== 'guide') {
switchUcpTab('guide');
}
const dateEl = document.getElementById('ucp-guide-print-date');
if (dateEl) {
dateEl.textContent = new Date().toLocaleDateString('fr-FR');
}
setTimeout(() => window.print(), 80);
}
function renderFeatureGuide() {
const quickBox = document.getElementById('ucp-guide-quickcards');
const catBox = document.getElementById('ucp-guide-categories');
if (!quickBox || !catBox) return;
const allCodes = ucpState.feature_codes || [];
const byKey = new Map(allCodes.map(c => [String(c.feature_key), c]));
const codeOf = (key, fallback = '') => escapeHtml(byKey.get(key)?.code || fallback);
// 1. Encarts "Notice pratique pas à pas"
const vmOwn = byKey.get('voicemail_own');
const vmOther = byKey.get('voicemail_general');
const cfOn = byKey.get('cf_activate');
const cfOff = byKey.get('cf_deactivate');
const cfuOn = byKey.get('cfu_activate');
const dndTog = byKey.get('dnd_toggle');
const dndOn = byKey.get('dnd_activate');
const pkBlf = byKey.get('pickup_blf');
const pkGrp = byKey.get('pickup_group');
quickBox.innerHTML = `
<div class="ucp-print-avoid-break rounded-2xl border border-indigo-200 bg-indigo-50/70 p-4 space-y-2">
<div class="flex items-center gap-2 text-xs font-bold text-indigo-900 uppercase tracking-wide">
${ucpIcon('voicemail', 'w-4 h-4 text-indigo-600')}
<span>Utiliser la Messagerie Vocale</span>
</div>
<ul class="text-[11px] text-slate-700 space-y-1.5 leading-relaxed">
${vmOwn ? `<li>• Depuis votre poste : composez <strong class="font-mono px-1.5 py-0.5 rounded bg-white border border-indigo-200 text-indigo-700">${codeOf('voicemail_own')}</strong> (accès direct sans PIN).</li>` : ''}
${vmOther ? `<li>• Depuis un autre poste : composez <strong class="font-mono px-1.5 py-0.5 rounded bg-white border border-indigo-200 text-indigo-700">${codeOf('voicemail_general')}</strong> + n° de poste + code PIN.</li>` : ''}
<li>• Menu vocal : <strong>1</strong> Écouter • <strong>2</strong> Changer de dossier • <strong>3</strong> Options / Supprimer.</li>
<li>• Depuis l'UCP : écoutez, téléchargez (.wav) et changez votre PIN sans appeler.</li>
</ul>
</div>
<div class="ucp-print-avoid-break rounded-2xl border border-amber-200 bg-amber-50/70 p-4 space-y-2">
<div class="flex items-center gap-2 text-xs font-bold text-amber-900 uppercase tracking-wide">
${ucpIcon('forward', 'w-4 h-4 text-amber-600')}
<span>Gérer Renvois &amp; Ne Pas Déranger</span>
</div>
<ul class="text-[11px] text-slate-700 space-y-1.5 leading-relaxed">
${cfOn ? `<li>• Renvoi immédiat : composez <strong class="font-mono px-1.5 py-0.5 rounded bg-white border border-amber-200 text-amber-800">${codeOf('cf_activate')} + N°</strong>${cfOff ? ` (annuler : <strong class="font-mono text-amber-800">${codeOf('cf_deactivate')}</strong>)` : ''}.</li>` : ''}
${cfuOn ? `<li>• Renvoi si absent : composez <strong class="font-mono px-1.5 py-0.5 rounded bg-white border border-amber-200 text-amber-800">${codeOf('cfu_activate')} + N°</strong>.</li>` : ''}
${dndTog || dndOn ? `<li>• Mode DND (Ne Pas Déranger) : composez <strong class="font-mono px-1.5 py-0.5 rounded bg-white border border-amber-200 text-amber-800">${dndTog ? codeOf('dnd_toggle') : codeOf('dnd_activate')}</strong>.</li>` : ''}
<li>• Toutes ces options sont synchronisées en direct avec l'onglet <em>Mes Postes</em>.</li>
</ul>
</div>
<div class="ucp-print-avoid-break rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4 space-y-2">
<div class="flex items-center gap-2 text-xs font-bold text-emerald-900 uppercase tracking-wide">
${ucpIcon('pickup', 'w-4 h-4 text-emerald-600')}
<span>Interception &amp; Prise d'Appel</span>
</div>
<ul class="text-[11px] text-slate-700 space-y-1.5 leading-relaxed">
${pkBlf ? `<li>• Poste qui clignote (BLF) : appuyez sur sa touche ou composez <strong class="font-mono px-1.5 py-0.5 rounded bg-white border border-emerald-200 text-emerald-800">${codeOf('pickup_blf')} + Poste</strong>.</li>` : ''}
${pkGrp ? `<li>• Intercepter un poste du groupe : composez <strong class="font-mono px-1.5 py-0.5 rounded bg-white border border-emerald-200 text-emerald-800">${codeOf('pickup_group')}</strong>.</li>` : ''}
<li>• En appel : utilisez la touche <strong>Transfert</strong> de votre téléphone + n° du collègue.</li>
</ul>
</div>
`;
// 2. Regroupement des codes actifs par catégorie
const q = (document.getElementById('ucp-guide-search')?.value || '').trim().toLowerCase();
const filtered = allCodes.filter(c => {
if (!q) return true;
return String(c.code || '').toLowerCase().includes(q)
|| String(c.name || '').toLowerCase().includes(q)
|| String(c.description || '').toLowerCase().includes(q)
|| String(c.category || '').toLowerCase().includes(q);
});
if (!filtered.length) {
catBox.innerHTML = `<div class="rounded-2xl border border-slate-200 bg-slate-50 p-6 text-center text-xs text-slate-500">Aucun code de fonction correspondant à « ${escapeHtml(q)} ».</div>`;
return;
}
const catMeta = {
voicemail: { label: 'Messagerie Vocale', icon: ucpIcon('voicemail', 'w-4 h-4 text-indigo-600'), badge: 'bg-indigo-50 text-indigo-700 border-indigo-200', header: 'bg-indigo-50/60 border-indigo-100 text-indigo-950' },
forward: { label: "Renvois d'Appel", icon: ucpIcon('forward', 'w-4 h-4 text-amber-600'), badge: 'bg-amber-50 text-amber-800 border-amber-200', header: 'bg-amber-50/60 border-amber-100 text-amber-950' },
dnd: { label: 'Ne Pas Déranger (DND)', icon: ucpIcon('dnd', 'w-4 h-4 text-rose-600'), badge: 'bg-rose-50 text-rose-700 border-rose-200', header: 'bg-rose-50/60 border-rose-100 text-rose-950' },
pickup: { label: "Interception d'Appels", icon: ucpIcon('pickup', 'w-4 h-4 text-emerald-600'), badge: 'bg-emerald-50 text-emerald-700 border-emerald-200', header: 'bg-emerald-50/60 border-emerald-100 text-emerald-950' },
security: { label: 'Liste Noire & Blocage Appels', icon: ucpIcon('shield', 'w-4 h-4 text-slate-600'), badge: 'bg-slate-100 text-slate-700 border-slate-300', header: 'bg-slate-100/80 border-slate-200 text-slate-900' },
tools: { label: 'Outils, Tests Audio & Services', icon: ucpIcon('wrench', 'w-4 h-4 text-sky-600'), badge: 'bg-sky-50 text-sky-700 border-sky-200', header: 'bg-sky-50/60 border-sky-100 text-sky-950' },
};
const orderedCats = ['voicemail', 'forward', 'dnd', 'pickup', 'security', 'tools'];
const grouped = new Map();
filtered.forEach(c => {
const key = c.category || 'tools';
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(c);
});
const catKeys = [
...orderedCats.filter(k => grouped.has(k)),
...[...grouped.keys()].filter(k => !orderedCats.includes(k)),
];
catBox.innerHTML = catKeys.map(catKey => {
const meta = catMeta[catKey] || { label: catKey, icon: ucpIcon('tag', 'w-4 h-4 text-slate-500'), badge: 'bg-slate-100 text-slate-700 border-slate-300', header: 'bg-slate-50 border-slate-200 text-slate-900' };
const items = grouped.get(catKey) || [];
const rows = items.map(item => `
<div class="ucp-print-row flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 px-4 py-3 border-t border-slate-100">
<div class="w-24 shrink-0">
<span class="inline-flex items-center justify-center min-w-[3.25rem] px-2.5 py-1 rounded-lg border font-mono text-xs font-extrabold tracking-wide ${meta.badge}">
${escapeHtml(item.code)}
</span>
</div>
<div class="min-w-0 flex-1">
<div class="text-xs font-bold text-slate-900">${escapeHtml(item.name)}</div>
<p class="text-[11px] text-slate-600 mt-0.5 leading-snug">${escapeHtml(item.description || '')}</p>
</div>
</div>
`).join('');
return `
<div class="ucp-print-avoid-break rounded-2xl border border-slate-200 overflow-hidden bg-white">
<div class="px-4 py-2.5 border-b border-slate-200 flex items-center justify-between ${meta.header}">
<div class="flex items-center gap-2 text-xs font-extrabold uppercase tracking-wide">
<span>${meta.icon}</span>
<span>${escapeHtml(meta.label)}</span>
</div>
<span class="text-[10px] font-semibold opacity-75">${items.length} code(s)</span>
</div>
<div>${rows}</div>
</div>
`;
}).join('');
}
// -----------------------------------------------------------------------------
// 6. 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);
initUcpNavScrollHints();
pollTimer = setInterval(() => {
if (!document.hidden) {
refreshUcpState(false);
}
}, 15000);
}
function updateUcpNavScrollHints() {
const scroller = document.getElementById('ucp-nav-scroller');
const leftHint = document.getElementById('ucp-nav-hint-left');
const rightHint = document.getElementById('ucp-nav-hint-right');
if (!scroller || !leftHint || !rightHint) return;
const hasLeft = scroller.scrollLeft > 4;
const hasRight = scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 4;
leftHint.classList.toggle('hidden', !hasLeft);
leftHint.classList.toggle('flex', hasLeft);
rightHint.classList.toggle('hidden', !hasRight);
rightHint.classList.toggle('flex', hasRight);
}
function scrollUcpNav(direction) {
const scroller = document.getElementById('ucp-nav-scroller');
if (!scroller) return;
scroller.scrollBy({ left: direction * 170, behavior: 'smooth' });
}
function initUcpNavScrollHints() {
const scroller = document.getElementById('ucp-nav-scroller');
if (!scroller) return;
scroller.addEventListener('scroll', updateUcpNavScrollHints, { passive: true });
window.addEventListener('resize', updateUcpNavScrollHints);
requestAnimationFrame(updateUcpNavScrollHints);
setTimeout(updateUcpNavScrollHints, 250);
}