572 lines
30 KiB
HTML
572 lines
30 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>CHU-IPTV — Administration</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
|
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script>
|
|
<style>
|
|
:root {
|
|
--accent: #00d4aa; --accent-dim: rgba(0,212,170,0.12);
|
|
--bg: #0a0e1a; --bg-card: #111827; --bg-elevated: #1a2240;
|
|
--text: #f0f4ff; --text-dim: #8892b0;
|
|
--danger: #ff4466; --warning: #ffaa33; --success: #00d4aa; --info: #4488ff;
|
|
--radius: 14px;
|
|
}
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: 'Inter', system-ui, sans-serif; background: var(--bg); color: var(--text); height: 100vh; overflow: hidden; }
|
|
|
|
/* LOGIN */
|
|
#loginScreen { position: fixed; inset: 0; z-index: 1000; background: var(--bg); display: flex; align-items: center; justify-content: center; }
|
|
#loginScreen.hidden { display: none; }
|
|
.login-card { background: var(--bg-card); border: 1px solid rgba(255,255,255,0.06); border-radius: 20px; padding: 40px; width: 380px; text-align: center; }
|
|
.login-card .logo { font-size: 28px; font-weight: 800; color: var(--accent); margin-bottom: 4px; }
|
|
.login-card .subtitle { color: var(--text-dim); font-size: 13px; margin-bottom: 32px; }
|
|
.login-card input { width: 100%; background: var(--bg-elevated); border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 14px 16px; font-size: 14px; color: var(--text); outline: none; margin-bottom: 12px; }
|
|
.login-card input:focus { border-color: var(--accent); }
|
|
.login-card button { width: 100%; background: var(--accent); color: var(--bg); border: none; border-radius: 10px; padding: 14px; font-size: 15px; font-weight: 700; cursor: pointer; }
|
|
.login-error { color: var(--danger); font-size: 12px; margin-top: 8px; min-height: 16px; }
|
|
|
|
/* LAYOUT */
|
|
.app-layout { display: flex; height: 100vh; }
|
|
.sidebar { width: 280px; background: var(--bg-card); border-right: 1px solid rgba(255,255,255,0.05); display: flex; flex-direction: column; overflow: hidden; }
|
|
.sidebar-header { padding: 20px; border-bottom: 1px solid rgba(255,255,255,0.05); }
|
|
.sidebar-header .brand { font-size: 18px; font-weight: 800; color: var(--accent); }
|
|
.sidebar-header .role { font-size: 11px; color: var(--text-dim); margin-top: 2px; }
|
|
.sidebar-stats { padding: 14px 16px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; border-bottom: 1px solid rgba(255,255,255,0.05); }
|
|
.stat-box { background: var(--bg-elevated); border-radius: 10px; padding: 10px 12px; }
|
|
.stat-box .stat-val { font-size: 20px; font-weight: 700; }
|
|
.stat-box .stat-label { font-size: 9px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; }
|
|
.stat-box.alert .stat-val { color: var(--danger); }
|
|
.sidebar-rooms { flex: 1; overflow-y: auto; padding: 12px; }
|
|
.sidebar-rooms::-webkit-scrollbar { width: 4px; }
|
|
.sidebar-rooms::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
|
|
.service-title { font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 1px; font-weight: 600; padding: 8px 8px 4px; }
|
|
.room-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 10px; cursor: pointer; transition: all 0.15s; margin-bottom: 2px; }
|
|
.room-item:hover { background: rgba(255,255,255,0.04); }
|
|
.room-item.active { background: var(--accent-dim); border: 1px solid rgba(0,212,170,0.3); }
|
|
.ri-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
.ri-dot.connected { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
|
.ri-dot.waiting { background: var(--warning); }
|
|
.ri-dot.empty { background: #444; }
|
|
.ri-info { flex: 1; }
|
|
.ri-number { font-size: 13px; font-weight: 600; }
|
|
.ri-status { font-size: 10px; color: var(--text-dim); }
|
|
.ri-alert { width: 18px; height: 18px; background: var(--danger); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 700; animation: pulse 1.5s infinite; }
|
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
|
|
|
/* MAIN */
|
|
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
.main-header { padding: 16px 24px; border-bottom: 1px solid rgba(255,255,255,0.05); display: flex; align-items: center; justify-content: space-between; background: rgba(17,24,39,0.6); backdrop-filter: blur(8px); }
|
|
.main-header h2 { font-size: 18px; font-weight: 700; }
|
|
.hdr-actions { display: flex; gap: 8px; }
|
|
.btn { padding: 8px 16px; border-radius: 8px; border: none; font-size: 12px; font-weight: 600; cursor: pointer; transition: all 0.2s; display: inline-flex; align-items: center; gap: 6px; }
|
|
.btn-primary { background: var(--accent); color: var(--bg); }
|
|
.btn-danger { background: rgba(255,68,102,0.12); color: var(--danger); border: 1px solid rgba(255,68,102,0.3); }
|
|
.btn-secondary { background: rgba(255,255,255,0.06); color: var(--text); border: 1px solid rgba(255,255,255,0.1); }
|
|
.btn:hover { opacity: 0.85; }
|
|
.main-content { flex: 1; overflow-y: auto; padding: 24px; }
|
|
|
|
/* ROOM DETAIL */
|
|
.room-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
.detail-card { background: var(--bg-card); border: 1px solid rgba(255,255,255,0.05); border-radius: var(--radius); padding: 20px; }
|
|
.detail-card h3 { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 14px; font-weight: 600; }
|
|
.detail-card.full { grid-column: 1 / -1; }
|
|
|
|
/* QR */
|
|
.qr-center { text-align: center; }
|
|
.qr-center canvas, .qr-center img { width: 180px; height: 180px; background: #fff; border-radius: 12px; padding: 10px; }
|
|
.qr-code { font-size: 22px; font-weight: 800; letter-spacing: 3px; color: var(--accent); margin-top: 10px; }
|
|
.qr-hint { font-size: 11px; color: var(--text-dim); margin-top: 4px; }
|
|
|
|
/* Requests */
|
|
.req-list { max-height: 260px; overflow-y: auto; }
|
|
.req-item { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.04); }
|
|
.req-item:last-child { border: none; }
|
|
.req-item .ri-icon { font-size: 20px; }
|
|
.req-item .ri-content { flex: 1; }
|
|
.req-item .ri-type { font-size: 13px; font-weight: 500; }
|
|
.req-item .ri-time { font-size: 10px; color: var(--text-dim); }
|
|
.req-item .ri-actions .btn { padding: 5px 10px; font-size: 10px; }
|
|
.priority-high { border-left: 3px solid var(--danger); padding-left: 10px; }
|
|
|
|
/* Chat */
|
|
.chat-panel { display: flex; flex-direction: column; height: 260px; }
|
|
.chat-msgs { flex: 1; overflow-y: auto; margin-bottom: 10px; }
|
|
.chat-msg { max-width: 80%; margin-bottom: 8px; padding: 8px 12px; border-radius: 12px; font-size: 13px; }
|
|
.chat-msg.staff { background: var(--accent); color: var(--bg); margin-left: auto; border-bottom-right-radius: 3px; }
|
|
.chat-msg.patient { background: var(--bg-elevated); border: 1px solid rgba(255,255,255,0.06); border-bottom-left-radius: 3px; }
|
|
.chat-msg .cm-meta { font-size: 9px; opacity: 0.6; margin-top: 3px; }
|
|
.chat-input-row { display: flex; gap: 8px; }
|
|
.chat-input-row input { flex: 1; background: var(--bg-elevated); border: 1px solid rgba(255,255,255,0.08); border-radius: 20px; padding: 10px 16px; font-size: 13px; color: var(--text); outline: none; }
|
|
.chat-input-row input:focus { border-color: var(--accent); }
|
|
.chat-input-row button { width: 36px; height: 36px; background: var(--accent); border: none; border-radius: 50%; cursor: pointer; color: var(--bg); font-size: 16px; }
|
|
|
|
/* Domotique */
|
|
.domo-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.04); }
|
|
.domo-row:last-child { border: none; }
|
|
.domo-row .dr-left { display: flex; align-items: center; gap: 10px; }
|
|
.domo-row .dr-icon { font-size: 18px; }
|
|
.domo-row .dr-label { font-size: 13px; }
|
|
.toggle { width: 40px; height: 22px; background: rgba(255,255,255,0.1); border-radius: 11px; position: relative; cursor: pointer; transition: background 0.3s; }
|
|
.toggle.on { background: var(--accent); }
|
|
.toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; background: #fff; border-radius: 50%; transition: transform 0.3s; }
|
|
.toggle.on::after { transform: translateX(18px); }
|
|
|
|
/* Empty */
|
|
.empty-state { text-align: center; padding: 80px 20px; color: var(--text-dim); }
|
|
.empty-state .es-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
|
|
.empty-state .es-text { font-size: 14px; }
|
|
|
|
/* Alert */
|
|
.alert-bar { position: fixed; top: 16px; left: 50%; transform: translateX(-50%); z-index: 200; background: var(--bg-card); border: 1px solid var(--danger); border-radius: 12px; padding: 10px 20px; font-size: 13px; font-weight: 500; color: var(--danger); display: none; animation: slideDown 0.3s ease; box-shadow: 0 8px 24px rgba(0,0,0,0.4); }
|
|
.alert-bar.visible { display: flex; align-items: center; gap: 8px; }
|
|
@keyframes slideDown { from { opacity: 0; transform: translate(-50%, -10px); } to { opacity: 1; transform: translate(-50%, 0); } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- LOGIN -->
|
|
<div id="loginScreen">
|
|
<div class="login-card">
|
|
<div class="logo">CHU-IPTV</div>
|
|
<div class="subtitle">Espace soignant — Administration</div>
|
|
<input type="email" id="loginEmail" placeholder="Email" value="admin@chu-iptv.cosmolan.fr">
|
|
<input type="password" id="loginPassword" placeholder="Mot de passe" value="admin2026">
|
|
<button onclick="doLogin()">Se connecter</button>
|
|
<div class="login-error" id="loginError"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- APP -->
|
|
<div class="app-layout" id="appLayout" style="display:none;">
|
|
<aside class="sidebar">
|
|
<div class="sidebar-header">
|
|
<div class="brand">CHU-IPTV</div>
|
|
<a href="/monitoring.html" style="font-size:11px;color:var(--accent);text-decoration:none;padding:4px 10px;border:1px solid var(--accent);border-radius:6px;margin-left:8px;">📊 Monitoring</a>
|
|
<div class="role" id="staffName">Soignant</div>
|
|
</div>
|
|
<div class="sidebar-stats">
|
|
<div class="stat-box"><div class="stat-val" id="statTotal">0</div><div class="stat-label">Chambres</div></div>
|
|
<div class="stat-box"><div class="stat-val" id="statConnected">0</div><div class="stat-label">Connectées</div></div>
|
|
<div class="stat-box alert"><div class="stat-val" id="statPending">0</div><div class="stat-label">Demandes</div></div>
|
|
<div class="stat-box"><div class="stat-val" id="statMessages">0</div><div class="stat-label">Messages</div></div>
|
|
</div>
|
|
<div class="sidebar-rooms" id="roomList"></div>
|
|
</aside>
|
|
<main class="main">
|
|
<div class="main-header">
|
|
<h2 id="mainTitle">Sélectionnez une chambre</h2>
|
|
<div class="hdr-actions" id="mainActions"></div>
|
|
</div>
|
|
<div class="main-content" id="mainContent">
|
|
<div class="empty-state">
|
|
<div class="es-icon">🏥</div>
|
|
<div class="es-text">Sélectionnez une chambre dans la liste pour la gérer</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<div class="alert-bar" id="alertBar">🔔 Nouvelle demande</div>
|
|
|
|
<script>
|
|
const API_URL = 'https://chu-iptv-api.cosmolan.fr';
|
|
let staffToken = localStorage.getItem('chu-iptv-staff-token');
|
|
let socket = null;
|
|
let rooms = [];
|
|
let selectedRoom = null;
|
|
let roomRequests = {};
|
|
let roomChats = {};
|
|
let pendingTotal = 0;
|
|
let msgTotal = 0;
|
|
|
|
// LOGIN
|
|
async function doLogin() {
|
|
const email = document.getElementById('loginEmail').value;
|
|
const password = document.getElementById('loginPassword').value;
|
|
try {
|
|
const res = await fetch(`${API_URL}/api/auth/staff/login`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
staffToken = data.token;
|
|
localStorage.setItem('chu-iptv-staff-token', staffToken);
|
|
document.getElementById('staffName').textContent = data.name || data.staff?.name || 'Soignant';
|
|
enterApp();
|
|
} else {
|
|
document.getElementById('loginError').textContent = 'Identifiants incorrects';
|
|
}
|
|
} catch { document.getElementById('loginError').textContent = 'Erreur de connexion'; }
|
|
}
|
|
document.getElementById('loginPassword').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
|
|
|
// Auto-login
|
|
async function tryAutoLogin() {
|
|
if (!staffToken) return;
|
|
try {
|
|
const res = await fetch(`${API_URL}/api/auth/verify`, { headers: { Authorization: `Bearer ${staffToken}` } });
|
|
if (res.ok) { const d = await res.json(); if (d.valid) enterApp(); }
|
|
} catch {}
|
|
}
|
|
tryAutoLogin();
|
|
|
|
function enterApp() {
|
|
document.getElementById('loginScreen').classList.add('hidden');
|
|
document.getElementById('appLayout').style.display = 'flex';
|
|
loadRooms();
|
|
connectSocket();
|
|
initPushNotifications();
|
|
}
|
|
|
|
// PUSH NOTIFICATIONS
|
|
async function initPushNotifications() {
|
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
|
try {
|
|
const reg = await navigator.serviceWorker.register('/push-sw.js');
|
|
const permission = await Notification.requestPermission();
|
|
if (permission !== 'granted') return;
|
|
|
|
// Récupérer la clé VAPID
|
|
const vapidRes = await fetch(`${API_URL}/api/push/vapid`);
|
|
const { publicKey } = await vapidRes.json();
|
|
if (!publicKey) return;
|
|
|
|
// S'abonner
|
|
const subscription = await reg.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: urlBase64ToUint8Array(publicKey)
|
|
});
|
|
|
|
// Envoyer la subscription au serveur
|
|
await fetch(`${API_URL}/api/push/subscribe`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${staffToken}` },
|
|
body: JSON.stringify({
|
|
endpoint: subscription.endpoint,
|
|
keys: {
|
|
p256dh: btoa(String.fromCharCode(...new Uint8Array(subscription.getKey('p256dh')))),
|
|
auth: btoa(String.fromCharCode(...new Uint8Array(subscription.getKey('auth'))))
|
|
}
|
|
})
|
|
});
|
|
console.log('[Push] Notifications activées');
|
|
} catch (e) {
|
|
console.warn('[Push] Erreur:', e);
|
|
}
|
|
}
|
|
|
|
function urlBase64ToUint8Array(base64String) {
|
|
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
const rawData = window.atob(base64);
|
|
const outputArray = new Uint8Array(rawData.length);
|
|
for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i);
|
|
return outputArray;
|
|
}
|
|
|
|
// ROOMS
|
|
async function loadRooms() {
|
|
try {
|
|
const res = await fetch(`${API_URL}/api/rooms`, { headers: { Authorization: `Bearer ${staffToken}` } });
|
|
if (res.ok) rooms = await res.json();
|
|
} catch {}
|
|
renderRoomList();
|
|
updateStats();
|
|
}
|
|
|
|
function renderRoomList() {
|
|
const container = document.getElementById('roomList');
|
|
const services = {};
|
|
rooms.forEach(r => { if (!services[r.service]) services[r.service] = []; services[r.service].push(r); });
|
|
let html = '';
|
|
for (const [svc, list] of Object.entries(services)) {
|
|
html += `<div class="service-title">${svc}</div>`;
|
|
for (const r of list) {
|
|
const pending = (roomRequests[r.id] || []).filter(x => x.status === 'PENDING').length;
|
|
const status = r.activeSession ? 'connected' : (r.tvOnline ? 'waiting' : 'empty');
|
|
html += `
|
|
<div class="room-item${selectedRoom?.id === r.id ? ' active' : ''}" onclick="selectRoom('${r.id}')">
|
|
<div class="ri-dot ${status}"></div>
|
|
<div class="ri-info">
|
|
<div class="ri-number">${r.number}</div>
|
|
<div class="ri-status">${r.activeSession ? 'Patient connecté' : r.tvOnline ? 'TV en ligne' : 'En attente'}</div>
|
|
</div>
|
|
${pending > 0 ? `<div class="ri-alert">${pending}</div>` : ''}
|
|
</div>`;
|
|
}
|
|
}
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function updateStats() {
|
|
document.getElementById('statTotal').textContent = rooms.length;
|
|
document.getElementById('statConnected').textContent = rooms.filter(r => r.activeSession || r.tvOnline).length;
|
|
pendingTotal = Object.values(roomRequests).flat().filter(r => r.status === 'PENDING').length;
|
|
document.getElementById('statPending').textContent = pendingTotal;
|
|
document.getElementById('statMessages').textContent = msgTotal;
|
|
}
|
|
|
|
// ROOM DETAIL
|
|
async function selectRoom(roomId) {
|
|
selectedRoom = rooms.find(r => r.id === roomId);
|
|
if (!selectedRoom) return;
|
|
renderRoomList();
|
|
document.getElementById('mainTitle').textContent = `Chambre ${selectedRoom.number} — ${selectedRoom.service}`;
|
|
document.getElementById('mainActions').innerHTML = `
|
|
<button class="btn btn-primary" onclick="generateQR()">📱 QR Code</button>
|
|
<button class="btn btn-secondary" onclick="forceChannel()">📺 Forcer chaîne</button>
|
|
<button class="btn btn-danger" onclick="wipeSession()">🗑️ Wipe</button>
|
|
`;
|
|
// Load data
|
|
try {
|
|
const [reqRes, chatRes, domoRes] = await Promise.all([
|
|
fetch(`${API_URL}/api/requests?roomId=${roomId}`, { headers: { Authorization: `Bearer ${staffToken}` } }),
|
|
fetch(`${API_URL}/api/chat/${roomId}`, { headers: { Authorization: `Bearer ${staffToken}` } }),
|
|
fetch(`${API_URL}/api/domotic/${roomId}`, { headers: { Authorization: `Bearer ${staffToken}` } }),
|
|
]);
|
|
if (reqRes.ok) roomRequests[roomId] = await reqRes.json();
|
|
if (chatRes.ok) roomChats[roomId] = await chatRes.json();
|
|
} catch {}
|
|
renderDetail();
|
|
}
|
|
|
|
function renderDetail() {
|
|
if (!selectedRoom) return;
|
|
const reqs = (roomRequests[selectedRoom.id] || []).filter(r => r.status !== 'COMPLETED').slice(0, 8);
|
|
const chat = roomChats[selectedRoom.id] || [];
|
|
const icons = { WATER: '💧', PAIN: '🩹', TOILET: '🚽', MEAL: '🍽️', BLANKET: '🛏️', HELP: '🆘' };
|
|
const labels = { WATER: 'Eau', PAIN: 'Douleur', TOILET: 'Toilettes', MEAL: 'Repas', BLANKET: 'Couverture', HELP: 'Aide' };
|
|
|
|
document.getElementById('mainContent').innerHTML = `
|
|
<div class="room-grid">
|
|
<div class="detail-card">
|
|
<h3>Session & QR Code</h3>
|
|
<div class="qr-center" id="qrArea">
|
|
${selectedRoom.activeSession ? `
|
|
<div style="display:flex;align-items:center;gap:8px;justify-content:center;margin-bottom:12px;">
|
|
<div style="width:10px;height:10px;background:var(--success);border-radius:50%;"></div>
|
|
<span style="font-size:14px;font-weight:500;">Patient connecté</span>
|
|
</div>
|
|
<div style="font-size:11px;color:var(--text-dim);">Depuis ${new Date(selectedRoom.activeSession.createdAt).toLocaleString('fr-FR')}</div>
|
|
` : `<p style="color:var(--text-dim);font-size:12px;">Cliquez "QR Code" pour activer la session</p>`}
|
|
</div>
|
|
</div>
|
|
<div class="detail-card">
|
|
<h3>Domotique</h3>
|
|
<div class="domo-row"><div class="dr-left"><span class="dr-icon">💡</span><span class="dr-label">Lumière principale</span></div><div class="toggle" onclick="toggleDomo(this,'mainLight')"></div></div>
|
|
<div class="domo-row"><div class="dr-left"><span class="dr-icon">🌙</span><span class="dr-label">Veilleuse</span></div><div class="toggle" onclick="toggleDomo(this,'nightLight')"></div></div>
|
|
<div class="domo-row"><div class="dr-left"><span class="dr-icon">🪟</span><span class="dr-label">Stores</span></div><div class="toggle" onclick="toggleDomo(this,'blinds')"></div></div>
|
|
</div>
|
|
<div class="detail-card">
|
|
<h3>Demandes en cours (${reqs.length})</h3>
|
|
<div class="req-list">
|
|
${reqs.length === 0 ? '<p style="font-size:12px;color:var(--text-dim);">Aucune demande en attente</p>' :
|
|
reqs.map(r => `
|
|
<div class="req-item${r.priority === 'HIGH' ? ' priority-high' : ''}">
|
|
<div class="ri-icon">${icons[r.type] || '❓'}</div>
|
|
<div class="ri-content">
|
|
<div class="ri-type">${labels[r.type] || r.type}</div>
|
|
<div class="ri-time">${new Date(r.createdAt).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</div>
|
|
</div>
|
|
<div class="ri-actions">
|
|
${r.status === 'PENDING' ? `<button class="btn btn-primary" onclick="ackReq('${r.id}')">Prendre en charge</button>` :
|
|
`<button class="btn btn-secondary" onclick="doneReq('${r.id}')">Terminé</button>`}
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
<div class="detail-card">
|
|
<h3>Chat patient</h3>
|
|
<div class="chat-panel">
|
|
<div class="chat-msgs" id="chatMsgs">
|
|
${chat.slice(-15).map(m => `
|
|
<div class="chat-msg ${m.sender === 'STAFF' ? 'staff' : 'patient'}">
|
|
${m.content}
|
|
<div class="cm-meta">${m.sender === 'STAFF' ? 'Vous' : 'Patient'} • ${new Date(m.createdAt).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
<div class="chat-input-row">
|
|
<input type="text" id="staffChatInput" placeholder="Répondre..." onkeydown="if(event.key==='Enter')sendChat()">
|
|
<button onclick="sendChat()">➤</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
const chatEl = document.getElementById('chatMsgs');
|
|
if (chatEl) chatEl.scrollTop = chatEl.scrollHeight;
|
|
}
|
|
|
|
// ACTIONS
|
|
async function generateQR() {
|
|
if (!selectedRoom) return;
|
|
try {
|
|
const res = await fetch(`${API_URL}/api/auth/qr/generate`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${staffToken}` },
|
|
body: JSON.stringify({ roomId: selectedRoom.id }),
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const mobileUrl = `https://chu-iptv-mobile.cosmolan.fr?pair=${data.pairingCode}`;
|
|
const canvas = document.createElement('canvas');
|
|
new QRious({ element: canvas, value: mobileUrl, size: 180, level: 'M', padding: 8 });
|
|
const area = document.getElementById('qrArea');
|
|
if (area) {
|
|
area.innerHTML = `
|
|
<img src="${canvas.toDataURL()}" style="width:180px;height:180px;background:#fff;border-radius:12px;padding:10px;">
|
|
<div class="qr-code">${data.pairingCode}</div>
|
|
<div class="qr-hint">Ce code est affiché sur la TV de la chambre</div>
|
|
`;
|
|
}
|
|
socket?.emit('qr:push', { roomId: selectedRoom.id, pairingCode: data.pairingCode, mobileUrl });
|
|
}
|
|
} catch (e) { console.error(e); }
|
|
}
|
|
|
|
async function wipeSession() {
|
|
if (!selectedRoom || !confirm(`Wipe chambre ${selectedRoom.number} ? Toutes les données patient seront supprimées.`)) return;
|
|
try {
|
|
await fetch(`${API_URL}/api/rooms/${selectedRoom.id}/wipe`, { method: 'POST', headers: { Authorization: `Bearer ${staffToken}` } });
|
|
socket?.emit('session:wipe', { roomId: selectedRoom.id });
|
|
loadRooms();
|
|
setTimeout(() => selectRoom(selectedRoom.id), 500);
|
|
} catch {}
|
|
}
|
|
|
|
function forceChannel() {
|
|
const num = prompt('Numéro de chaîne à forcer :');
|
|
if (!num || !selectedRoom) return;
|
|
socket?.emit('tv:tune', { roomId: selectedRoom.id, channelNumber: parseInt(num) });
|
|
}
|
|
|
|
async function ackReq(id) {
|
|
await fetch(`${API_URL}/api/requests/${id}/acknowledge`, { method: 'PATCH', headers: { Authorization: `Bearer ${staffToken}` } }).catch(() => {});
|
|
selectRoom(selectedRoom.id);
|
|
}
|
|
async function doneReq(id) {
|
|
await fetch(`${API_URL}/api/requests/${id}/complete`, { method: 'PATCH', headers: { Authorization: `Bearer ${staffToken}` } }).catch(() => {});
|
|
selectRoom(selectedRoom.id);
|
|
}
|
|
|
|
function sendChat() {
|
|
const input = document.getElementById('staffChatInput');
|
|
const content = input.value.trim();
|
|
if (!content || !selectedRoom) return;
|
|
socket?.emit('chat:send', { roomId: selectedRoom.id, content, sender: 'STAFF', staffName: 'Soignant' });
|
|
fetch(`${API_URL}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${staffToken}` }, body: JSON.stringify({ roomId: selectedRoom.id, content, sender: 'STAFF' }) }).catch(() => {});
|
|
if (!roomChats[selectedRoom.id]) roomChats[selectedRoom.id] = [];
|
|
roomChats[selectedRoom.id].push({ content, sender: 'STAFF', createdAt: new Date().toISOString() });
|
|
renderDetail();
|
|
input.value = '';
|
|
}
|
|
|
|
function toggleDomo(el, key) {
|
|
if (!selectedRoom) return;
|
|
el.classList.toggle('on');
|
|
const val = el.classList.contains('on');
|
|
socket?.emit('domotic:set', { roomId: selectedRoom.id, [key]: key === 'blinds' ? (val ? 100 : 0) : val });
|
|
fetch(`${API_URL}/api/domotic/${selectedRoom.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${staffToken}` }, body: JSON.stringify({ [key]: key === 'blinds' ? (val ? 100 : 0) : val }) }).catch(() => {});
|
|
}
|
|
|
|
// WEBSOCKET
|
|
function connectSocket() {
|
|
socket = io(API_URL, { auth: { token: staffToken }, reconnection: true, reconnectionDelay: 2000 });
|
|
socket.on('connect', () => { socket.emit('join:admin'); rooms.forEach(r => socket.emit('join:room', { roomId: r.id })); });
|
|
socket.on('request:new', (req) => {
|
|
if (!roomRequests[req.roomId]) roomRequests[req.roomId] = [];
|
|
roomRequests[req.roomId].unshift(req);
|
|
renderRoomList(); updateStats();
|
|
if (selectedRoom?.id === req.roomId) renderDetail();
|
|
showAlert(req);
|
|
playAlert();
|
|
});
|
|
socket.on('chat:message', (data) => {
|
|
if (data.sender === 'PATIENT') {
|
|
if (!roomChats[data.roomId]) roomChats[data.roomId] = [];
|
|
roomChats[data.roomId].push(data);
|
|
msgTotal++;
|
|
updateStats();
|
|
if (selectedRoom?.id === data.roomId) renderDetail();
|
|
}
|
|
});
|
|
socket.on('paired', (data) => {
|
|
const room = rooms.find(r => r.id === data.roomId);
|
|
if (room) room.activeSession = { createdAt: new Date().toISOString() };
|
|
renderRoomList(); updateStats();
|
|
if (selectedRoom?.id === data.roomId) setTimeout(() => selectRoom(data.roomId), 300);
|
|
});
|
|
socket.on('room:update', (data) => { loadRooms(); });
|
|
socket.on('request:sos', (data) => {
|
|
const room = rooms.find(r => r.id === data.roomId);
|
|
showSOSAlert(room?.number || data.room, data.service || '');
|
|
});
|
|
}
|
|
|
|
function showAlert(req) {
|
|
const labels = { WATER: 'Eau', PAIN: 'Douleur', TOILET: 'Toilettes', MEAL: 'Repas', BLANKET: 'Couverture', HELP: 'Aide' };
|
|
const room = rooms.find(r => r.id === req.roomId);
|
|
const bar = document.getElementById('alertBar');
|
|
bar.textContent = `🔔 Chambre ${room?.number || '?'} — ${labels[req.type] || req.type}${req.priority === 'HIGH' ? ' (URGENT)' : ''}`;
|
|
bar.classList.add('visible');
|
|
setTimeout(() => bar.classList.remove('visible'), 5000);
|
|
}
|
|
|
|
function showSOSAlert(roomNumber, service) {
|
|
// Alerte visuelle plein écran
|
|
let overlay = document.getElementById('sosOverlay');
|
|
if (!overlay) {
|
|
overlay = document.createElement('div');
|
|
overlay.id = 'sosOverlay';
|
|
overlay.style.cssText = 'position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:16px;background:rgba(255,0,0,0.15);backdrop-filter:blur(4px);animation:sosPulse 0.5s infinite alternate;';
|
|
const style = document.createElement('style');
|
|
style.textContent = '@keyframes sosPulse{0%{background:rgba(255,0,0,0.1)}100%{background:rgba(255,0,0,0.25)}}';
|
|
document.head.appendChild(style);
|
|
document.body.appendChild(overlay);
|
|
}
|
|
overlay.innerHTML = `
|
|
<div style="font-size:64px;">🚨</div>
|
|
<div style="font-size:28px;font-weight:800;color:#fff;">APPEL D'URGENCE</div>
|
|
<div style="font-size:20px;color:#fff;">Chambre ${roomNumber} — ${service}</div>
|
|
<button onclick="this.parentElement.remove()" style="margin-top:20px;padding:12px 32px;background:#ff4466;color:#fff;border:none;border-radius:8px;font-size:16px;font-weight:700;cursor:pointer;">Prendre en charge</button>
|
|
`;
|
|
// Sirène sonore (3 bips urgents)
|
|
playSOSSound();
|
|
}
|
|
|
|
function playSOSSound() {
|
|
try {
|
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
const playBeep = (freq, start, dur) => {
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.connect(gain); gain.connect(ctx.destination);
|
|
osc.frequency.value = freq; gain.gain.value = 0.5;
|
|
osc.start(ctx.currentTime + start);
|
|
osc.stop(ctx.currentTime + start + dur);
|
|
};
|
|
playBeep(880, 0, 0.2); playBeep(660, 0.3, 0.2); playBeep(880, 0.6, 0.2);
|
|
playBeep(660, 0.9, 0.2); playBeep(880, 1.2, 0.3);
|
|
} catch {}
|
|
}
|
|
|
|
function playAlert() {
|
|
try {
|
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.connect(gain); gain.connect(ctx.destination);
|
|
osc.frequency.value = 800; gain.gain.value = 0.3;
|
|
osc.start();
|
|
setTimeout(() => { osc.frequency.value = 1000; }, 150);
|
|
setTimeout(() => { osc.stop(); ctx.close(); }, 300);
|
|
} catch {}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|