From 7e34c471de3f3b23486bb070fb4bf10aa6105e7a Mon Sep 17 00:00:00 2001 From: Pierre Marx Date: Thu, 4 Sep 2025 11:43:50 -0400 Subject: [PATCH] Version initiale --- .gitignore | 43 +++ README.md | 153 ++++++++++ config.json | 109 +++++++ cti-simulator.js | 309 ++++++++++++++++++++ docs/INTEGRATION_SIGNALR.md | 406 ++++++++++++++++++++++++++ index.html | 112 ++++++++ main.js | 198 +++++++++++++ package.json | 69 +++++ renderer.js | 395 ++++++++++++++++++++++++++ styles.css | 548 ++++++++++++++++++++++++++++++++++++ 10 files changed, 2342 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.json create mode 100644 cti-simulator.js create mode 100644 docs/INTEGRATION_SIGNALR.md create mode 100644 index.html create mode 100644 main.js create mode 100644 package.json create mode 100644 renderer.js create mode 100644 styles.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa8e467 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Dependencies +node_modules/ +package-lock.json +yarn.lock + +# Build output +dist/ +build/ +out/ + +# Electron +.electron/ + +# Notes and call history +notes/ +call_history.json + +# OS files +.DS_Store +Thumbs.db +desktop.ini + +# IDE +.vscode/ +.idea/ +*.sublime-project +*.sublime-workspace + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment variables +.env +.env.local +.env.*.local + +# Temporary files +*.tmp +*.temp +*~ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..598c5d9 --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# SimpleConnect - Gestion Centralisée des Plannings Médicaux + +Application Electron pour centraliser la gestion de multiples plannings médicaux dans un centre d'appel. + +## Fonctionnalités principales + +- **Connexion unique** : Les agents se connectent une seule fois pour accéder à tous leurs plannings +- **Multi-plannings** : Gestion de 1 à 5 plannings différents par agent +- **Intégration CTI** : Détection automatique du centre concerné lors d'un appel entrant +- **Interface unifiée** : Toutes les webviews des plannings dans une seule application +- **Simulation d'appels** : Mode test pour simuler des appels entrants +- **Notes rapides** : Prise de notes pendant les appels +- **Statistiques** : Suivi du nombre d'appels et de RDV pris + +## Installation + +### Prérequis + +- Node.js version 16 ou supérieure +- npm ou yarn + +### Étapes d'installation + +1. Cloner le repository : +```bash +cd ~/Dev/test/simpleconnect-electron +``` + +2. Installer les dépendances : +```bash +npm install +``` + +3. Lancer l'application : +```bash +npm start +``` + +Pour le mode développement avec outils de debug : +```bash +npm run dev +``` + +## Configuration + +La configuration se trouve dans le fichier `config.json`. Vous pouvez y modifier : + +- **Agents** : Liste des agents avec leurs identifiants et centres assignés +- **Centres** : Informations sur chaque centre médical (URL, téléphone, couleur, etc.) +- **CTI** : Configuration de la simulation d'appels +- **Préférences** : Paramètres généraux de l'application + +### Agents de test disponibles + +| Email | Mot de passe | Centres assignés | +|-------|-------------|------------------| +| marie.dupont@callcenter.fr | demo123 | Centre Cardio Lyon, Clinique Saint-Jean, Cabinet Dr Martin | +| jean.martin@callcenter.fr | demo456 | Clinique Saint-Jean, Centre Radiologie, Laboratoire BioLab | +| sophie.bernard@callcenter.fr | demo789 | Tous les centres sauf Clinique Saint-Jean | + +## Utilisation + +### Connexion + +1. Lancez l'application +2. Connectez-vous avec un des comptes agents configurés +3. L'interface principale s'affiche avec vos centres assignés + +### Gestion des appels + +1. **Appel entrant** : Une notification apparaît en haut de l'écran +2. **Acceptation** : Cliquez sur "Prendre l'appel" +3. **Bascule automatique** : Le planning du bon centre s'affiche automatiquement +4. **Prise de RDV** : Utilisez l'interface web du planning +5. **Notes** : Prenez des notes dans la zone dédiée + +### Simulation d'appels (Mode test) + +1. Cliquez sur "Simuler un appel" +2. Choisissez un appel prédéfini ou créez un appel personnalisé +3. L'appel se déclenche comme un vrai appel entrant + +## Architecture technique + +- **Frontend** : HTML/CSS/JavaScript avec Electron +- **Webviews** : Intégration native des sites de planning existants +- **IPC** : Communication entre le processus principal et le renderer +- **Stockage** : Fichiers JSON pour la configuration et l'historique + +## Structure des fichiers + +``` +simpleconnect-electron/ +├── main.js # Processus principal Electron +├── renderer.js # Logique frontend +├── index.html # Interface utilisateur +├── styles.css # Styles de l'application +├── cti-simulator.js # Module de simulation CTI +├── config.json # Configuration de l'application +├── package.json # Dépendances et scripts +└── README.md # Documentation +``` + +## Sécurité + +- Les mots de passe des agents sont stockés en clair dans `config.json` (à améliorer en production) +- Les sessions des plannings sont isolées par centre (partition Electron) +- CORS désactivé pour permettre le chargement des plannings externes + +## Développement + +### Build pour production + +Windows : +```bash +npm run build:win +``` + +macOS : +```bash +npm run build:mac +``` + +Linux : +```bash +npm run build:linux +``` + +### Personnalisation + +Pour ajouter un nouveau centre : +1. Modifiez `config.json` pour ajouter le centre +2. Assignez-le aux agents concernés +3. Redémarrez l'application + +## Limitations actuelles + +- La connexion automatique aux plannings n'est pas implémentée (dépend de chaque site) +- Les webviews peuvent ne pas fonctionner avec certains sites très sécurisés +- La simulation CTI est basique et ne reflète pas un vrai système téléphonique + +## Améliorations futures + +- [ ] Connexion automatique aux plannings via injection de scripts +- [ ] Intégration avec de vrais systèmes CTI (Asterisk, etc.) +- [ ] Base de données pour l'historique des appels +- [ ] Authentification sécurisée des agents +- [ ] Mode hors-ligne avec synchronisation +- [ ] Rapports et statistiques avancées + +## Support + +Pour toute question ou problème, créez une issue sur le repository GitHub. \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..aa66435 --- /dev/null +++ b/config.json @@ -0,0 +1,109 @@ +{ + "agents": [ + { + "id": "agent1", + "name": "Marie DUPONT", + "email": "marie.dupont@callcenter.fr", + "password": "demo123", + "centresAssignes": ["centre1", "centre2", "centre3"] + }, + { + "id": "agent2", + "name": "Jean MARTIN", + "email": "jean.martin@callcenter.fr", + "password": "demo456", + "centresAssignes": ["centre2", "centre4", "centre5"] + }, + { + "id": "agent3", + "name": "Sophie BERNARD", + "email": "sophie.bernard@callcenter.fr", + "password": "demo789", + "centresAssignes": ["centre1", "centre3", "centre4", "centre5"] + } + ], + "centres": [ + { + "id": "centre1", + "nom": "Centre Cardio Lyon", + "url": "https://www.doctolib.fr/centre-de-sante/lyon/centre-de-cardiologie-lyon", + "telephone": "+33478901234", + "couleur": "#FF6B6B", + "credentials": { + "username": "agent_cardio", + "password": "pass123" + } + }, + { + "id": "centre2", + "nom": "Clinique Saint-Jean", + "url": "https://www.doctolib.fr/clinique-privee/lyon/clinique-saint-jean", + "telephone": "+33478905678", + "couleur": "#4ECDC4", + "credentials": { + "username": "agent_stjean", + "password": "pass456" + } + }, + { + "id": "centre3", + "nom": "Cabinet Dr Martin", + "url": "https://www.doctolib.fr/medecin-generaliste/villeurbanne/martin", + "telephone": "+33472334455", + "couleur": "#45B7D1", + "credentials": { + "username": "cabinet_martin", + "password": "pass789" + } + }, + { + "id": "centre4", + "nom": "Centre Radiologie Villeurbanne", + "url": "https://www.doctolib.fr/centre-de-radiologie/villeurbanne/centre-imagerie-medicale", + "telephone": "+33472667788", + "couleur": "#96CEB4", + "credentials": { + "username": "radio_vlb", + "password": "passabc" + } + }, + { + "id": "centre5", + "nom": "Laboratoire BioLab", + "url": "https://www.doctolib.fr/laboratoire/lyon/laboratoire-biolab", + "telephone": "+33478112233", + "couleur": "#DDA0DD", + "credentials": { + "username": "lab_bio", + "password": "passdef" + } + } + ], + "cti": { + "simulationMode": true, + "webhookUrl": "http://localhost:3000/cti-webhook", + "appelSimules": [ + { + "numero": "+33612345678", + "nom": "Patient Test 1", + "centreId": "centre1" + }, + { + "numero": "+33687654321", + "nom": "Patient Test 2", + "centreId": "centre2" + }, + { + "numero": "+33611223344", + "nom": "Patient Test 3", + "centreId": "centre3" + } + ] + }, + "preferences": { + "tempsSessionMinutes": 480, + "autoConnexion": true, + "notificationSonore": true, + "affichageCompact": false + } +} \ No newline at end of file diff --git a/cti-simulator.js b/cti-simulator.js new file mode 100644 index 0000000..31262f8 --- /dev/null +++ b/cti-simulator.js @@ -0,0 +1,309 @@ +// Module de simulation CTI (Computer Telephony Integration) +// Ce module simule l'intégration téléphonique pour les tests + +class CTISimulator { + constructor() { + this.isRunning = false; + this.autoCallInterval = null; + this.callHistory = []; + } + + // Démarrer la simulation automatique d'appels + startAutoSimulation(intervalSeconds = 60) { + if (this.isRunning) return; + + this.isRunning = true; + console.log(`Simulation CTI démarrée - Appels toutes les ${intervalSeconds} secondes`); + + // Premier appel après 10 secondes + setTimeout(() => { + this.generateRandomCall(); + }, 10000); + + // Puis appels réguliers + this.autoCallInterval = setInterval(() => { + if (Math.random() > 0.3) { // 70% de chance d'avoir un appel + this.generateRandomCall(); + } + }, intervalSeconds * 1000); + } + + // Arrêter la simulation + stopAutoSimulation() { + if (this.autoCallInterval) { + clearInterval(this.autoCallInterval); + this.autoCallInterval = null; + } + this.isRunning = false; + console.log('Simulation CTI arrêtée'); + } + + // Générer un appel aléatoire + async generateRandomCall() { + const config = await ipcRenderer.invoke('get-config'); + const simulatedCalls = config.cti.appelSimules; + + if (!simulatedCalls || simulatedCalls.length === 0) return; + + // Sélectionner un appel au hasard + const randomCall = simulatedCalls[Math.floor(Math.random() * simulatedCalls.length)]; + + // Vérifier que l'agent a accès à ce centre + const currentAgent = await ipcRenderer.invoke('get-current-agent'); + if (!currentAgent || !currentAgent.agent.centresAssignes.includes(randomCall.centreId)) { + // Essayer avec un autre appel + const validCalls = simulatedCalls.filter(call => + currentAgent.agent.centresAssignes.includes(call.centreId) + ); + + if (validCalls.length > 0) { + const validCall = validCalls[Math.floor(Math.random() * validCalls.length)]; + this.triggerIncomingCall(validCall); + } + } else { + this.triggerIncomingCall(randomCall); + } + } + + // Déclencher un appel entrant + triggerIncomingCall(callData) { + console.log('Appel entrant simulé:', callData); + + // Ajouter des métadonnées + const enrichedCall = { + ...callData, + id: this.generateCallId(), + timestamp: new Date().toISOString(), + duration: 0, + status: 'ringing' + }; + + // Ajouter à l'historique + this.callHistory.push(enrichedCall); + + // Déclencher l'événement + ipcRenderer.invoke('simulate-call', enrichedCall); + } + + // Générer un ID unique pour l'appel + generateCallId() { + return `CALL-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } + + // Obtenir les statistiques des appels + getCallStats() { + const now = new Date(); + const todayStart = new Date(now.setHours(0, 0, 0, 0)); + + const todayCalls = this.callHistory.filter(call => + new Date(call.timestamp) >= todayStart + ); + + return { + total: this.callHistory.length, + today: todayCalls.length, + answered: todayCalls.filter(c => c.status === 'answered').length, + missed: todayCalls.filter(c => c.status === 'missed').length, + average_duration: this.calculateAverageDuration(todayCalls) + }; + } + + // Calculer la durée moyenne des appels + calculateAverageDuration(calls) { + const answered = calls.filter(c => c.duration > 0); + if (answered.length === 0) return 0; + + const total = answered.reduce((sum, call) => sum + call.duration, 0); + return Math.round(total / answered.length); + } + + // Simuler différents scénarios d'appels + simulateScenarios() { + const scenarios = [ + { + name: 'Appel urgent', + setup: () => { + this.triggerIncomingCall({ + numero: '+33699887766', + nom: 'URGENT - Marie LAMBERT', + centreId: 'centre1', + priority: 'high', + motif: 'Consultation urgente cardiologie' + }); + } + }, + { + name: 'Patient régulier', + setup: () => { + this.triggerIncomingCall({ + numero: '+33612345678', + nom: 'Jean DUPUIS (Patient régulier)', + centreId: 'centre2', + priority: 'normal', + motif: 'Suivi mensuel', + lastVisit: '2024-11-15' + }); + } + }, + { + name: 'Nouveau patient', + setup: () => { + this.triggerIncomingCall({ + numero: '+33755443322', + nom: 'Sophie MARTIN', + centreId: 'centre3', + priority: 'normal', + motif: 'Première consultation', + isNew: true + }); + } + }, + { + name: 'Appel pour résultats', + setup: () => { + this.triggerIncomingCall({ + numero: '+33688776655', + nom: 'Paul BERNARD', + centreId: 'centre4', + priority: 'low', + motif: 'Demande résultats radiologie', + examDate: '2024-12-20' + }); + } + } + ]; + + return scenarios; + } + + // Interface de test pour le développement + showTestPanel() { + const testPanel = document.createElement('div'); + testPanel.id = 'cti-test-panel'; + testPanel.className = 'cti-test-panel'; + testPanel.innerHTML = ` +

Panneau de test CTI

+
+ + + +
+
+

Scénarios

+ ${this.simulateScenarios().map(s => + `` + ).join('')} +
+
+

Statistiques

+
+
+ `; + + // Ajouter les styles + if (!document.getElementById('cti-test-styles')) { + const styles = document.createElement('style'); + styles.id = 'cti-test-styles'; + styles.innerHTML = ` + .cti-test-panel { + position: fixed; + right: 20px; + bottom: 20px; + width: 300px; + background: white; + border: 1px solid #ddd; + border-radius: 8px; + padding: 15px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + z-index: 9999; + } + .cti-test-panel h3 { + margin-bottom: 10px; + color: #333; + font-size: 16px; + } + .cti-test-panel h4 { + margin: 10px 0 5px 0; + color: #666; + font-size: 14px; + } + .test-controls button, + .test-scenarios button { + margin: 5px; + padding: 5px 10px; + background: #667eea; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + } + .test-controls button:hover, + .test-scenarios button:hover { + background: #5a6fd8; + } + #cti-stats { + font-size: 12px; + color: #666; + } + `; + document.head.appendChild(styles); + } + + document.body.appendChild(testPanel); + this.updateStats(); + } + + // Exécuter un scénario + runScenario(name) { + const scenario = this.simulateScenarios().find(s => s.name === name); + if (scenario) { + scenario.setup(); + } + } + + // Mettre à jour les statistiques + updateStats() { + const stats = this.getCallStats(); + const statsDiv = document.getElementById('cti-stats'); + if (statsDiv) { + statsDiv.innerHTML = ` +
Total: ${stats.total} appels
+
Aujourd'hui: ${stats.today}
+
Répondus: ${stats.answered}
+
Manqués: ${stats.missed}
+
Durée moy: ${stats.average_duration}s
+ `; + } + } +} + +// Créer une instance globale +const ctiSimulator = new CTISimulator(); + +// Exposer pour les tests en développement +if (process.env.NODE_ENV === 'development') { + window.ctiSimulator = ctiSimulator; + // Afficher le panneau de test après connexion + document.addEventListener('DOMContentLoaded', () => { + setTimeout(() => { + const mainPage = document.getElementById('mainPage'); + if (mainPage && mainPage.classList.contains('active')) { + ctiSimulator.showTestPanel(); + } + }, 2000); + }); +} + +// Démarrer la simulation automatique après connexion (optionnel) +ipcRenderer.on('agent-logged-in', () => { + // Démarrer la simulation après 5 secondes + setTimeout(() => { + ctiSimulator.startAutoSimulation(90); // Un appel toutes les 90 secondes + }, 5000); +}); + +// Arrêter la simulation à la déconnexion +ipcRenderer.on('agent-logged-out', () => { + ctiSimulator.stopAutoSimulation(); +}); \ No newline at end of file diff --git a/docs/INTEGRATION_SIGNALR.md b/docs/INTEGRATION_SIGNALR.md new file mode 100644 index 0000000..f70f2d2 --- /dev/null +++ b/docs/INTEGRATION_SIGNALR.md @@ -0,0 +1,406 @@ +# Guide d'intégration SignalR pour SimpleConnect + +## Vue d'ensemble + +Ce document décrit l'architecture SignalR utilisée dans SimpleConnect v2 pour implémenter une intégration CTI (Computer Telephony Integration) complète avec gestion en temps réel des appels et basculement automatique entre les différentes plateformes de prise de rendez-vous. + +## Architecture générale + +### Composants principaux + +1. **Serveur SignalR** : Hub centralisé gérant les événements téléphoniques +2. **Client Electron** : Application agent avec connexion persistante au hub +3. **Système IPBX** : Infrastructure téléphonique envoyant les événements + +### Configuration serveur + +```json +{ + "ServerIp": "10.90.20.201:8002", + "ServiceProvider": "RDVPREM" +} +``` + +## Installation des dépendances + +```bash +npm install @microsoft/signalr +``` + +## Implémentation du client SignalR + +### 1. Connexion au Hub + +```javascript +const signalR = require("@microsoft/signalr"); + +// Créer la connexion au hub SignalR +function createSignalRConnection(serverUrl) { + const connection = new signalR.HubConnectionBuilder() + .withUrl(`http://${serverUrl}/signalR`) + .withAutomaticReconnect([0, 2000, 5000, 10000]) // Reconnexion automatique + .configureLogging(signalR.LogLevel.Information) + .build(); + + return connection; +} + +// Démarrer la connexion +async function startConnection(connection) { + try { + await connection.start(); + console.log('Connexion SignalR établie'); + return true; + } catch (err) { + console.error('Erreur de connexion SignalR:', err); + setTimeout(() => startConnection(connection), 5000); + return false; + } +} +``` + +### 2. Authentification des agents + +```javascript +// Structure de connexion agent +async function agentLogin(connection, credentials) { + try { + const result = await connection.invoke('AgentLogin', + credentials.email, + credentials.password, + credentials.terminal + ); + + if (result) { + // Stockage des informations agent + global.AgentConnectionInfo = { + accessCode: result.accessCode, + firstName: result.firstName, + lastName: result.lastName, + connList: result.connList // Liste des files/applications + }; + + // Traitement des URLs des applications + processApplicationUrls(result.connList); + + return { success: true, data: result }; + } + + return { success: false, message: 'Échec de connexion' }; + } catch (error) { + console.error('Erreur login:', error); + return { success: false, message: error.message }; + } +} + +// Traitement des URLs avec placeholders +function processApplicationUrls(connList) { + return connList.map(conn => { + let url = conn.applicationName; + + // Remplacement des placeholders + url = url.replace("#CA#", conn.accessCode); + url = url.replace("#MP#", conn.password); + + // Gestion des cas spécifiques + if (url === "pro.mondocteur.fr") { + url = "https://pro.mondocteur.fr/backoffice.do"; + } + if (url === "pro.doctolib.fr") { + url = "https://pro.doctolib.fr/signin"; + } + + return { + id: conn.code, + url: url, + credentials: { + accessCode: conn.accessCode, + password: conn.password + } + }; + }); +} +``` + +### 3. Gestion des événements IPBX en temps réel + +```javascript +// Écouter les événements téléphoniques +function setupIpbxEventListener(connection, mainWindow) { + connection.on('IpbxEvent', (name, args) => { + if (!args || !global.AgentConnectionInfo) return; + + const event = args[0]; + console.log(`Événement IPBX reçu: + Code: ${event.eventCode} + Terminal: ${event.terminal} + File: ${event.queueName} + `); + + handleIpbxEvent(event, mainWindow); + }); +} + +// Traitement des événements +function handleIpbxEvent(event, mainWindow) { + switch (event.eventCode) { + case 1: // Appel décroché + handleCallPickedUp(event, mainWindow); + break; + + case 2: // Appel raccroché + handleCallHungUp(event, mainWindow); + break; + + default: + console.log('Code événement non géré:', event.eventCode); + } +} + +// Basculement automatique vers la bonne file +function handleCallPickedUp(event, mainWindow) { + // Identifier le centre correspondant à la file + const centre = identifyCentreByQueue(event.queueName); + + if (centre) { + // Envoyer l'instruction de basculement à la fenêtre principale + mainWindow.webContents.send('switchToCenter', { + centreId: centre.id, + queueName: event.queueName, + terminal: event.terminal + }); + + // Logger l'événement + logCallEvent('CALL_PICKUP', event); + } +} + +// Libération de la file après raccrochage +function handleCallHungUp(event, mainWindow) { + mainWindow.webContents.send('releaseCenter', { + queueName: event.queueName + }); + + // Logger l'événement + logCallEvent('CALL_HANGUP', event); +} +``` + +### 4. Récupération de la liste des terminaux + +```javascript +// Obtenir les terminaux disponibles +async function getTerminalList(connection, serviceProvider) { + try { + const terminals = await connection.invoke( + 'GetTerminalListByServiceProvider', + serviceProvider + ); + + console.log('Terminaux disponibles:', terminals); + return terminals; + } catch (error) { + console.error('Erreur récupération terminaux:', error); + return []; + } +} +``` + +### 5. Déconnexion propre + +```javascript +// Déconnexion agent +async function agentLogoff(connection, userId) { + try { + await connection.invoke('AgentLogoff', userId); + console.log('Agent déconnecté'); + + // Nettoyer les données locales + global.AgentConnectionInfo = null; + } catch (error) { + console.error('Erreur déconnexion:', error); + } +} + +// Arrêt de la connexion SignalR +async function stopConnection(connection) { + if (connection.state === signalR.HubConnectionState.Connected) { + await connection.stop(); + console.log('Connexion SignalR fermée'); + } +} +``` + +## Intégration dans l'application Electron + +### Dans le processus principal (main.js) + +```javascript +const { app, BrowserWindow, ipcMain } = require('electron'); +const signalR = require("@microsoft/signalr"); + +let signalRConnection = null; +let mainWindow = null; + +app.on('ready', async () => { + // Créer la fenêtre principale + mainWindow = createMainWindow(); + + // Charger la configuration + const config = loadConfig(); + + // Initialiser SignalR + signalRConnection = createSignalRConnection(config.ServerIp); + + // Configurer les listeners + setupIpbxEventListener(signalRConnection, mainWindow); + + // Démarrer la connexion + const connected = await startConnection(signalRConnection); + + if (connected) { + mainWindow.webContents.send('signalr-connected'); + } +}); + +// Gestion des IPC depuis le renderer +ipcMain.handle('agent-login', async (event, credentials) => { + return await agentLogin(signalRConnection, credentials); +}); + +ipcMain.handle('get-terminals', async (event) => { + return await getTerminalList(signalRConnection, config.ServiceProvider); +}); + +// Nettoyage à la fermeture +app.on('before-quit', async () => { + if (global.AgentConnectionInfo) { + await agentLogoff(signalRConnection, global.AgentConnectionInfo.accessCode); + } + await stopConnection(signalRConnection); +}); +``` + +### Dans le processus renderer (renderer.js) + +```javascript +const { ipcRenderer } = require('electron'); + +// Écouter la connexion SignalR +ipcRenderer.on('signalr-connected', () => { + console.log('SignalR connecté'); + updateConnectionStatus('connected'); +}); + +// Écouter les changements de file +ipcRenderer.on('switchToCenter', (event, data) => { + // Basculer automatiquement vers le bon centre + selectCenter(data.centreId); + + // Afficher une notification + showNotification(`Appel entrant sur ${data.queueName}`); + + // Mettre à jour l'interface + updateCallStatus('in-call', data); +}); + +// Libération de file +ipcRenderer.on('releaseCenter', (event, data) => { + updateCallStatus('available'); + + // Optionnel : revenir à l'écran d'accueil + showHomeScreen(); +}); +``` + +## Flux de données complet + +### Séquence d'un appel type + +1. **Appel entrant** → Système IPBX +2. **Événement généré** → Serveur SignalR +3. **IpbxEvent transmis** → Client Electron (eventCode: 1) +4. **Identification de la file** → Mapping file/centre +5. **Basculement automatique** → Webview du centre concerné +6. **Agent traite l'appel** → Prise de RDV +7. **Fin d'appel** → Système IPBX +8. **IpbxEvent transmis** → Client Electron (eventCode: 2) +9. **Libération de la file** → Retour état disponible + +## Monitoring et logs + +```javascript +// Structure de log pour dashboard +function createDashboardLog(agentInfo, terminals, connection) { + return { + "PrestaConnect": { + "Ouverture": { + "Ouvert": "Oui", + "Date": new Date().toISOString(), + "IP_Client": getLocalIP(), + "IP_Serveur": config.ServerIp, + "Liste_Telephones": terminals + }, + "Connexion": { + "Connecte": agentInfo ? "Oui" : "Non", + "Agent": agentInfo?.accessCode, + "Telephone": agentInfo?.terminal, + "Nom_Agent": `${agentInfo?.firstName} ${agentInfo?.lastName}`, + "Nombre_Files": agentInfo?.connList.length, + "Files": agentInfo?.connList + }, + "SignalR": { + "État": connection.state, + "Reconnexions": connection.reconnectAttempts || 0 + } + } + }; +} + +// Écriture du log +function writeDashboardLog(logData) { + const fs = require('fs'); + fs.writeFileSync( + './log/log-Dashboard.json', + JSON.stringify(logData, null, 2) + ); +} +``` + +## Points d'attention pour l'implémentation + +### 1. Gestion de la reconnexion +- Implémenter une stratégie de reconnexion automatique +- Gérer les états de connexion dans l'interface +- Sauvegarder l'état local pour restauration après reconnexion + +### 2. Sécurité +- Ne jamais exposer les credentials en clair dans les logs +- Utiliser HTTPS pour la connexion SignalR en production +- Implémenter un timeout de session + +### 3. Performance +- Limiter le nombre de listeners SignalR actifs +- Nettoyer les listeners lors des changements de contexte +- Implémenter un système de cache pour les données statiques + +### 4. Gestion d'erreurs +- Capturer toutes les erreurs de connexion +- Afficher des messages utilisateur clairs +- Logger tous les événements pour debug + +## Tests recommandés + +1. **Test de connexion/déconnexion** +2. **Test de basculement automatique entre files** +3. **Test de perte de connexion réseau** +4. **Test de charge avec appels multiples** +5. **Test de synchronisation multi-agents** + +## Prochaines étapes + +1. Adapter le code SignalR au contexte de notre application +2. Configurer le serveur SignalR backend +3. Mapper les centres médicaux avec les files téléphoniques +4. Implémenter l'interface de monitoring temps réel +5. Tester l'intégration complète avec le système IPBX \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..7c74d83 --- /dev/null +++ b/index.html @@ -0,0 +1,112 @@ + + + + + + SimpleConnect - Gestion Centralisée des Plannings + + + + +
+ +
+ + +
+ +
+
+

SimpleConnect

+ +
+
+
+ + En attente +
+
+
+ + +
+
+ + +
+
📞
+
+
APPEL ENTRANT
+
+
+
+ +
+ + +
+ + + + +
+ +
+ + +
+
+

Sélectionnez un centre ou attendez un appel entrant

+
+
+ + +
+

Notes rapides

+ + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/main.js b/main.js new file mode 100644 index 0000000..6f32513 --- /dev/null +++ b/main.js @@ -0,0 +1,198 @@ +const { app, BrowserWindow, ipcMain, session } = require('electron'); +const path = require('path'); +const fs = require('fs'); + +let mainWindow; +let config; +let currentAgent = null; + +// Charger la configuration +function loadConfig() { + const configPath = path.join(__dirname, 'config.json'); + const configData = fs.readFileSync(configPath, 'utf8'); + config = JSON.parse(configData); +} + +// Créer la fenêtre principale +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1400, + height: 900, + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + webviewTag: true, + webSecurity: false + }, + icon: path.join(__dirname, 'icon.png'), + title: 'SimpleConnect - Gestion Centralisée des Plannings' + }); + + // Charger l'interface HTML + mainWindow.loadFile('index.html'); + + // Ouvrir les DevTools en mode développement + if (process.env.NODE_ENV === 'development') { + mainWindow.webContents.openDevTools(); + } + + // Gérer la fermeture de la fenêtre + mainWindow.on('closed', () => { + mainWindow = null; + }); +} + +// Initialisation de l'application +app.whenReady().then(() => { + // Configuration de la session pour éviter les problèmes CORS + session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { + details.requestHeaders['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + callback({ requestHeaders: details.requestHeaders }); + }); + + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + callback({ + responseHeaders: { + ...details.responseHeaders, + 'Content-Security-Policy': ['default-src * \'unsafe-inline\' \'unsafe-eval\' data: blob:;'] + } + }); + }); + + loadConfig(); + createWindow(); +}); + +// Quitter quand toutes les fenêtres sont fermées +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +// Réactiver l'app sur macOS +app.on('activate', () => { + if (mainWindow === null) { + createWindow(); + } +}); + +// === IPC HANDLERS === + +// Obtenir la configuration +ipcMain.handle('get-config', () => { + return config; +}); + +// Connexion agent +ipcMain.handle('login-agent', (event, credentials) => { + const agent = config.agents.find(a => + a.email === credentials.email && + a.password === credentials.password + ); + + if (agent) { + currentAgent = agent; + // Retourner l'agent avec ses centres assignés + const centresAssignes = config.centres.filter(c => + agent.centresAssignes.includes(c.id) + ); + return { + success: true, + agent: agent, + centres: centresAssignes + }; + } + + return { success: false, message: 'Email ou mot de passe incorrect' }; +}); + +// Déconnexion +ipcMain.handle('logout', () => { + currentAgent = null; + return { success: true }; +}); + +// Obtenir l'agent actuel +ipcMain.handle('get-current-agent', () => { + if (!currentAgent) return null; + + const centresAssignes = config.centres.filter(c => + currentAgent.centresAssignes.includes(c.id) + ); + + return { + agent: currentAgent, + centres: centresAssignes + }; +}); + +// Simuler un appel entrant +ipcMain.handle('simulate-call', (event, callData) => { + // Envoyer l'événement d'appel entrant à la fenêtre + mainWindow.webContents.send('incoming-call', callData); + return { success: true }; +}); + +// Obtenir les données pour simuler des appels +ipcMain.handle('get-simulated-calls', () => { + return config.cti.appelSimules.map(appel => { + const centre = config.centres.find(c => c.id === appel.centreId); + return { + ...appel, + centreNom: centre ? centre.nom : 'Centre inconnu' + }; + }); +}); + +// Sauvegarder les notes de l'agent +ipcMain.handle('save-notes', (event, noteData) => { + const notesDir = path.join(__dirname, 'notes'); + if (!fs.existsSync(notesDir)) { + fs.mkdirSync(notesDir); + } + + const fileName = `notes_${currentAgent.id}_${Date.now()}.json`; + const filePath = path.join(notesDir, fileName); + + fs.writeFileSync(filePath, JSON.stringify({ + agent: currentAgent.id, + timestamp: new Date().toISOString(), + ...noteData + }, null, 2)); + + return { success: true, file: fileName }; +}); + +// Obtenir l'historique des appels +ipcMain.handle('get-call-history', () => { + const historyFile = path.join(__dirname, 'call_history.json'); + if (fs.existsSync(historyFile)) { + const data = fs.readFileSync(historyFile, 'utf8'); + return JSON.parse(data); + } + return []; +}); + +// Sauvegarder un appel dans l'historique +ipcMain.handle('save-call-history', (event, callData) => { + const historyFile = path.join(__dirname, 'call_history.json'); + let history = []; + + if (fs.existsSync(historyFile)) { + const data = fs.readFileSync(historyFile, 'utf8'); + history = JSON.parse(data); + } + + history.unshift({ + ...callData, + agentId: currentAgent?.id, + timestamp: new Date().toISOString() + }); + + // Garder seulement les 100 derniers appels + history = history.slice(0, 100); + + fs.writeFileSync(historyFile, JSON.stringify(history, null, 2)); + return { success: true }; +}); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..ee4b4a4 --- /dev/null +++ b/package.json @@ -0,0 +1,69 @@ +{ + "name": "simpleconnect-electron", + "version": "1.0.0", + "description": "Application de gestion centralisée des plannings médicaux pour centres d'appels", + "main": "main.js", + "scripts": { + "start": "electron .", + "dev": "NODE_ENV=development electron .", + "build": "electron-builder", + "build:win": "electron-builder --win", + "build:mac": "electron-builder --mac", + "build:linux": "electron-builder --linux", + "dist": "electron-builder", + "postinstall": "electron-builder install-app-deps" + }, + "keywords": [ + "electron", + "medical", + "planning", + "call-center", + "cti" + ], + "author": "SimpleConnect", + "license": "MIT", + "devDependencies": { + "electron": "^28.0.0", + "electron-builder": "^24.9.1" + }, + "build": { + "appId": "com.simpleconnect.planning", + "productName": "SimpleConnect", + "directories": { + "output": "dist" + }, + "files": [ + "**/*", + "!dist${/*}", + "!node_modules${/*}/.bin", + "!.git${/*}", + "!README.md" + ], + "mac": { + "category": "public.app-category.business", + "icon": "icon.icns" + }, + "win": { + "target": "nsis", + "icon": "icon.ico" + }, + "linux": { + "target": "AppImage", + "icon": "icon.png", + "category": "Office" + }, + "nsis": { + "oneClick": false, + "perMachine": true, + "allowElevation": true, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true, + "shortcutName": "SimpleConnect" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/simpleconnect/electron-app.git" + } +} \ No newline at end of file diff --git a/renderer.js b/renderer.js new file mode 100644 index 0000000..bffb3be --- /dev/null +++ b/renderer.js @@ -0,0 +1,395 @@ +const { ipcRenderer } = require('electron'); + +// Variables globales +let currentAgent = null; +let currentCentres = []; +let activeCenter = null; +let webviews = {}; +let callStats = { + calls: 0, + appointments: 0 +}; + +// === GESTION DE LA CONNEXION === +document.addEventListener('DOMContentLoaded', async () => { + // Vérifier si un agent est déjà connecté + const agentData = await ipcRenderer.invoke('get-current-agent'); + if (agentData) { + currentAgent = agentData.agent; + currentCentres = agentData.centres; + showMainPage(); + } + + // Gestionnaire du formulaire de connexion + const loginForm = document.getElementById('loginForm'); + if (loginForm) { + loginForm.addEventListener('submit', handleLogin); + } + + // Bouton de déconnexion + const logoutBtn = document.getElementById('logoutBtn'); + if (logoutBtn) { + logoutBtn.addEventListener('click', handleLogout); + } + + // Bouton simulation d'appel + const simulateBtn = document.getElementById('simulateCallBtn'); + if (simulateBtn) { + simulateBtn.addEventListener('click', showCallSimulation); + } + + // Bouton sauvegarder notes + const saveNotesBtn = document.getElementById('saveNotesBtn'); + if (saveNotesBtn) { + saveNotesBtn.addEventListener('click', saveNotes); + } + + // Écouter les appels entrants + ipcRenderer.on('incoming-call', (event, callData) => { + handleIncomingCall(callData); + }); +}); + +// Connexion +async function handleLogin(e) { + e.preventDefault(); + + const email = document.getElementById('email').value; + const password = document.getElementById('password').value; + const errorDiv = document.getElementById('loginError'); + + const result = await ipcRenderer.invoke('login-agent', { email, password }); + + if (result.success) { + currentAgent = result.agent; + currentCentres = result.centres; + errorDiv.textContent = ''; + showMainPage(); + } else { + errorDiv.textContent = result.message; + } +} + +// Déconnexion +async function handleLogout() { + if (confirm('Voulez-vous vraiment vous déconnecter ?')) { + await ipcRenderer.invoke('logout'); + currentAgent = null; + currentCentres = []; + activeCenter = null; + webviews = {}; + showLoginPage(); + } +} + +// === GESTION DES PAGES === +function showLoginPage() { + document.getElementById('loginPage').classList.add('active'); + document.getElementById('mainPage').classList.remove('active'); +} + +function showMainPage() { + document.getElementById('loginPage').classList.remove('active'); + document.getElementById('mainPage').classList.add('active'); + + // Afficher le nom de l'agent + document.getElementById('agentName').textContent = currentAgent.name; + + // Initialiser l'interface + initializeCenters(); + updateStatus('available'); +} + +// === GESTION DES CENTRES === +function initializeCenters() { + const centersList = document.getElementById('centersList'); + const centerTabs = document.getElementById('centerTabs'); + const webviewContainer = document.getElementById('webviewContainer'); + + // Vider les contenus existants + centersList.innerHTML = ''; + centerTabs.innerHTML = ''; + webviewContainer.innerHTML = ''; + + // Créer la liste des centres et les onglets + currentCentres.forEach(centre => { + // Élément dans la sidebar + const centerItem = document.createElement('div'); + centerItem.className = 'center-item'; + centerItem.dataset.centerId = centre.id; + centerItem.innerHTML = ` +
+
+
${centre.nom}
+
${centre.telephone}
+
+
+ `; + centerItem.addEventListener('click', () => selectCenter(centre.id)); + centersList.appendChild(centerItem); + + // Onglet + const tab = document.createElement('div'); + tab.className = 'tab'; + tab.dataset.centerId = centre.id; + tab.style.borderBottomColor = centre.couleur; + tab.textContent = centre.nom; + tab.addEventListener('click', () => selectCenter(centre.id)); + centerTabs.appendChild(tab); + + // Créer la webview + const webviewWrapper = document.createElement('div'); + webviewWrapper.className = 'webview-wrapper'; + webviewWrapper.dataset.centerId = centre.id; + webviewWrapper.style.display = 'none'; + + const webview = document.createElement('webview'); + webview.id = `webview-${centre.id}`; + webview.src = centre.url; + webview.className = 'planning-webview'; + webview.setAttribute('partition', `persist:${centre.id}`); + webview.setAttribute('useragent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'); + + // Barre d'outils pour la webview + const toolbar = document.createElement('div'); + toolbar.className = 'webview-toolbar'; + toolbar.innerHTML = ` + + + + ${centre.url} + `; + + webviewWrapper.appendChild(toolbar); + webviewWrapper.appendChild(webview); + webviewContainer.appendChild(webviewWrapper); + + // Stocker la référence de la webview + webviews[centre.id] = webview; + + // Gérer les événements de la webview + webview.addEventListener('dom-ready', () => { + console.log(`Webview ${centre.nom} prête`); + document.getElementById(`status-${centre.id}`).style.color = '#4CAF50'; + + // Auto-connexion si credentials disponibles + if (centre.credentials && centre.credentials.username) { + // Injecter le script de connexion automatique (à adapter selon le site) + const loginScript = ` + // Script générique de connexion - à adapter selon le planning + console.log('Tentative de connexion automatique pour ${centre.nom}'); + `; + webview.executeJavaScript(loginScript); + } + }); + + webview.addEventListener('did-navigate', (e) => { + document.getElementById(`url-${centre.id}`).textContent = e.url; + }); + + webview.addEventListener('did-fail-load', (e) => { + console.error(`Erreur chargement ${centre.nom}:`, e); + document.getElementById(`status-${centre.id}`).style.color = '#FF5252'; + }); + }); +} + +// Sélectionner un centre +function selectCenter(centerId) { + // Mettre à jour le centre actif + activeCenter = centerId; + + // Mettre à jour l'UI + document.querySelectorAll('.center-item').forEach(item => { + item.classList.toggle('active', item.dataset.centerId === centerId); + }); + + document.querySelectorAll('.tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.centerId === centerId); + }); + + // Afficher la bonne webview + document.querySelectorAll('.webview-wrapper').forEach(wrapper => { + wrapper.style.display = wrapper.dataset.centerId === centerId ? 'flex' : 'none'; + }); +} + +// Navigation dans les webviews +window.navigateWebview = function(centerId, action) { + const webview = webviews[centerId]; + if (webview) { + switch(action) { + case 'back': + webview.goBack(); + break; + case 'forward': + webview.goForward(); + break; + case 'reload': + webview.reload(); + break; + } + } +}; + +// === GESTION DES APPELS === +function handleIncomingCall(callData) { + const centre = currentCentres.find(c => c.id === callData.centreId); + if (!centre) return; + + // Afficher l'alerte + const alert = document.getElementById('incomingCallAlert'); + alert.classList.add('active'); + + document.getElementById('callCenterName').textContent = centre.nom; + document.getElementById('callPatientInfo').textContent = + `${callData.nom || 'Patient'} - ${callData.numero}`; + + // Jouer un son (à implémenter) + playNotificationSound(); + + // Gérer l'acceptation de l'appel + const acceptBtn = document.getElementById('acceptCallBtn'); + acceptBtn.onclick = () => { + acceptCall(callData, centre); + }; + + // Auto-accepter après 3 secondes en mode démo + setTimeout(() => { + if (alert.classList.contains('active')) { + acceptCall(callData, centre); + } + }, 3000); +} + +function acceptCall(callData, centre) { + // Masquer l'alerte + document.getElementById('incomingCallAlert').classList.remove('active'); + + // Sélectionner automatiquement le bon centre + selectCenter(centre.id); + + // Mettre à jour le statut + updateStatus('incall', centre.nom); + + // Incrémenter les stats + callStats.calls++; + document.getElementById('callCount').textContent = callStats.calls; + + // Sauvegarder l'appel dans l'historique + ipcRenderer.invoke('save-call-history', { + ...callData, + centreId: centre.id, + centreName: centre.nom, + status: 'answered' + }); + + // Simuler la fin de l'appel après 30 secondes + setTimeout(() => { + endCall(); + }, 30000); +} + +function endCall() { + updateStatus('available'); + callStats.appointments++; + document.getElementById('appointmentCount').textContent = callStats.appointments; +} + +// === SIMULATION D'APPELS === +function showCallSimulation() { + const modal = document.getElementById('callSimulationModal'); + modal.style.display = 'block'; + + // Charger les appels simulés + loadSimulatedCalls(); + + // Fermer la modal + modal.querySelector('.close').onclick = () => { + modal.style.display = 'none'; + }; + + // Appel personnalisé + document.getElementById('customCallBtn').onclick = () => { + const customCall = { + numero: prompt('Numéro de téléphone:'), + nom: prompt('Nom du patient:'), + centreId: currentCentres[0]?.id + }; + if (customCall.numero) { + ipcRenderer.invoke('simulate-call', customCall); + modal.style.display = 'none'; + } + }; +} + +async function loadSimulatedCalls() { + const calls = await ipcRenderer.invoke('get-simulated-calls'); + const listDiv = document.getElementById('simulatedCallsList'); + + listDiv.innerHTML = calls.map(call => ` +
+
${call.nom}
+
${call.numero} - ${call.centreNom}
+
+ `).join(''); +} + +window.simulateThisCall = function(callDataStr) { + const callData = JSON.parse(callDataStr); + ipcRenderer.invoke('simulate-call', callData); + document.getElementById('callSimulationModal').style.display = 'none'; +}; + +// === UTILITAIRES === +function updateStatus(status, details = '') { + const indicator = document.getElementById('statusIndicator'); + const text = document.getElementById('statusText'); + + switch(status) { + case 'available': + indicator.className = 'status-indicator available'; + text.textContent = 'Disponible'; + break; + case 'incall': + indicator.className = 'status-indicator busy'; + text.textContent = details ? `En appel - ${details}` : 'En appel'; + break; + case 'offline': + indicator.className = 'status-indicator offline'; + text.textContent = 'Hors ligne'; + break; + } +} + +function playNotificationSound() { + // Créer un bip simple avec l'API Web Audio + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.frequency.value = 800; + oscillator.type = 'sine'; + gainNode.gain.value = 0.3; + + oscillator.start(); + oscillator.stop(audioContext.currentTime + 0.2); +} + +async function saveNotes() { + const notes = document.getElementById('quickNotes').value; + if (!notes.trim()) return; + + const result = await ipcRenderer.invoke('save-notes', { + content: notes, + centre: activeCenter + }); + + if (result.success) { + alert('Notes sauvegardées !'); + document.getElementById('quickNotes').value = ''; + } +} \ No newline at end of file diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..328e958 --- /dev/null +++ b/styles.css @@ -0,0 +1,548 @@ +/* === RESET ET BASE === */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background: #f5f5f5; + color: #333; + height: 100vh; + overflow: hidden; +} + +/* === PAGES === */ +.page { + display: none; + width: 100%; + height: 100vh; +} + +.page.active { + display: flex; +} + +/* === PAGE DE CONNEXION === */ +#loginPage { + justify-content: center; + align-items: center; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.login-container { + background: white; + padding: 40px; + border-radius: 10px; + box-shadow: 0 20px 40px rgba(0,0,0,0.1); + width: 90%; + max-width: 400px; +} + +.login-container h1 { + text-align: center; + color: #667eea; + margin-bottom: 10px; + font-size: 32px; +} + +.login-container h2 { + text-align: center; + color: #666; + margin-bottom: 30px; + font-size: 18px; + font-weight: normal; +} + +#loginForm input { + width: 100%; + padding: 12px; + margin-bottom: 15px; + border: 1px solid #ddd; + border-radius: 5px; + font-size: 14px; +} + +#loginForm button { + width: 100%; + padding: 12px; + background: #667eea; + color: white; + border: none; + border-radius: 5px; + font-size: 16px; + cursor: pointer; + transition: background 0.3s; +} + +#loginForm button:hover { + background: #5a6fd8; +} + +.error-message { + color: #e74c3c; + text-align: center; + margin-top: 10px; + font-size: 14px; +} + +/* === PAGE PRINCIPALE === */ +#mainPage { + flex-direction: column; +} + +/* === HEADER === */ +header { + background: white; + border-bottom: 1px solid #e0e0e0; + padding: 15px 20px; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); +} + +.header-left h1 { + display: inline-block; + font-size: 20px; + color: #667eea; + margin-right: 20px; +} + +.agent-name { + color: #666; + font-size: 14px; +} + +.call-status { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 16px; + background: #f8f8f8; + border-radius: 20px; +} + +.status-indicator { + width: 10px; + height: 10px; + border-radius: 50%; + animation: pulse 2s infinite; +} + +.status-indicator.available { + background: #4CAF50; +} + +.status-indicator.busy { + background: #FF9800; + animation: pulse 1s infinite; +} + +.status-indicator.offline { + background: #9E9E9E; + animation: none; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.btn-secondary { + padding: 8px 16px; + background: white; + border: 1px solid #ddd; + border-radius: 5px; + cursor: pointer; + transition: all 0.3s; + margin-left: 10px; +} + +.btn-secondary:hover { + background: #f5f5f5; + border-color: #667eea; + color: #667eea; +} + +/* === ALERTE APPEL ENTRANT === */ +.incoming-call-alert { + display: none; + background: linear-gradient(135deg, #FF6B6B, #FF8E53); + color: white; + padding: 20px; + align-items: center; + gap: 20px; + animation: slideDown 0.5s; +} + +.incoming-call-alert.active { + display: flex; +} + +@keyframes slideDown { + from { transform: translateY(-100%); } + to { transform: translateY(0); } +} + +.call-icon { + font-size: 40px; + animation: ring 1s infinite; +} + +@keyframes ring { + 0%, 100% { transform: rotate(0deg); } + 25% { transform: rotate(20deg); } + 75% { transform: rotate(-20deg); } +} + +.call-info { + flex: 1; +} + +.call-title { + font-size: 14px; + opacity: 0.9; + margin-bottom: 5px; +} + +.call-center { + font-size: 20px; + font-weight: bold; + margin-bottom: 5px; +} + +.call-patient { + font-size: 14px; + opacity: 0.9; +} + +.btn-accept { + padding: 12px 30px; + background: white; + color: #FF6B6B; + border: none; + border-radius: 5px; + font-size: 16px; + font-weight: bold; + cursor: pointer; + transition: transform 0.2s; +} + +.btn-accept:hover { + transform: scale(1.05); +} + +/* === CONTENEUR PRINCIPAL === */ +.main-container { + display: flex; + flex: 1; + overflow: hidden; +} + +/* === SIDEBAR === */ +.sidebar { + width: 280px; + background: white; + border-right: 1px solid #e0e0e0; + display: flex; + flex-direction: column; +} + +.sidebar h3 { + padding: 20px; + background: #f8f8f8; + border-bottom: 1px solid #e0e0e0; + font-size: 16px; + color: #666; +} + +.centers-list { + flex: 1; + overflow-y: auto; +} + +.center-item { + display: flex; + align-items: center; + padding: 15px 20px; + border-bottom: 1px solid #f0f0f0; + cursor: pointer; + transition: background 0.2s; +} + +.center-item:hover { + background: #f8f8f8; +} + +.center-item.active { + background: #f0f7ff; + border-left: 3px solid #667eea; +} + +.center-indicator { + width: 12px; + height: 12px; + border-radius: 50%; + margin-right: 15px; +} + +.center-info { + flex: 1; +} + +.center-name { + font-weight: 500; + margin-bottom: 3px; +} + +.center-phone { + font-size: 12px; + color: #999; +} + +.center-status { + color: #ddd; + font-size: 10px; +} + +.sidebar-footer { + padding: 20px; + background: #f8f8f8; + border-top: 1px solid #e0e0e0; +} + +.sidebar-footer h4 { + font-size: 14px; + color: #666; + margin-bottom: 10px; +} + +.daily-stats { + display: flex; + flex-direction: column; + gap: 8px; +} + +.stat-item { + display: flex; + justify-content: space-between; + font-size: 13px; +} + +.stat-value { + font-weight: bold; + color: #667eea; +} + +/* === ZONE PRINCIPALE === */ +.content { + flex: 1; + display: flex; + flex-direction: column; + background: #f5f5f5; +} + +/* === ONGLETS === */ +.tabs { + display: flex; + background: white; + border-bottom: 1px solid #e0e0e0; + overflow-x: auto; +} + +.tab { + padding: 12px 20px; + cursor: pointer; + border-bottom: 3px solid transparent; + white-space: nowrap; + transition: all 0.3s; + font-size: 14px; +} + +.tab:hover { + background: #f8f8f8; +} + +.tab.active { + font-weight: 500; + border-bottom-width: 3px; +} + +/* === WEBVIEW CONTAINER === */ +.webview-container { + flex: 1; + position: relative; + background: white; + margin: 20px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + overflow: hidden; +} + +.no-center-selected { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + color: #999; + font-size: 16px; +} + +.webview-wrapper { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; +} + +.webview-toolbar { + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + background: #f8f8f8; + border-bottom: 1px solid #e0e0e0; +} + +.webview-toolbar button { + padding: 5px 10px; + background: white; + border: 1px solid #ddd; + border-radius: 3px; + cursor: pointer; +} + +.webview-toolbar button:hover { + background: #f0f0f0; +} + +.webview-url { + flex: 1; + font-size: 12px; + color: #666; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.planning-webview { + flex: 1; + width: 100%; +} + +/* === NOTES === */ +.notes-section { + padding: 20px; + background: white; + margin: 0 20px 20px 20px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); +} + +.notes-section h4 { + margin-bottom: 10px; + color: #666; + font-size: 14px; +} + +#quickNotes { + width: 100%; + height: 80px; + padding: 10px; + border: 1px solid #ddd; + border-radius: 5px; + resize: vertical; + font-family: inherit; + font-size: 13px; +} + +.btn-small { + margin-top: 10px; + padding: 6px 12px; + background: #667eea; + color: white; + border: none; + border-radius: 4px; + font-size: 13px; + cursor: pointer; +} + +.btn-small:hover { + background: #5a6fd8; +} + +/* === MODAL === */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.5); +} + +.modal-content { + background: white; + margin: 10% auto; + padding: 30px; + width: 80%; + max-width: 500px; + border-radius: 8px; + animation: fadeIn 0.3s; +} + +@keyframes fadeIn { + from { opacity: 0; transform: scale(0.9); } + to { opacity: 1; transform: scale(1); } +} + +.close { + float: right; + font-size: 28px; + font-weight: bold; + cursor: pointer; + color: #999; +} + +.close:hover { + color: #333; +} + +.modal-content h2 { + margin-bottom: 20px; + color: #333; +} + +.simulated-calls-list { + margin: 20px 0; + max-height: 300px; + overflow-y: auto; +} + +.simulated-call-item { + padding: 15px; + border: 1px solid #e0e0e0; + border-radius: 5px; + margin-bottom: 10px; + cursor: pointer; + transition: all 0.2s; +} + +.simulated-call-item:hover { + background: #f8f8f8; + border-color: #667eea; +} + +.call-name { + font-weight: 500; + margin-bottom: 5px; +} + +.call-details { + font-size: 13px; + color: #666; +} \ No newline at end of file