Version initiale

This commit is contained in:
Pierre Marx
2025-09-04 11:43:50 -04:00
commit 7e34c471de
10 changed files with 2342 additions and 0 deletions

43
.gitignore vendored Normal file
View File

@@ -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
*~

153
README.md Normal file
View File

@@ -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.

109
config.json Normal file
View File

@@ -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
}
}

309
cti-simulator.js Normal file
View File

@@ -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 = `
<h3>Panneau de test CTI</h3>
<div class="test-controls">
<button onclick="ctiSimulator.generateRandomCall()">Appel aléatoire</button>
<button onclick="ctiSimulator.startAutoSimulation(30)">Démarrer auto (30s)</button>
<button onclick="ctiSimulator.stopAutoSimulation()">Arrêter auto</button>
</div>
<div class="test-scenarios">
<h4>Scénarios</h4>
${this.simulateScenarios().map(s =>
`<button onclick="ctiSimulator.runScenario('${s.name}')">${s.name}</button>`
).join('')}
</div>
<div class="test-stats">
<h4>Statistiques</h4>
<div id="cti-stats"></div>
</div>
`;
// 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 = `
<div>Total: ${stats.total} appels</div>
<div>Aujourd'hui: ${stats.today}</div>
<div>Répondus: ${stats.answered}</div>
<div>Manqués: ${stats.missed}</div>
<div>Durée moy: ${stats.average_duration}s</div>
`;
}
}
}
// 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();
});

406
docs/INTEGRATION_SIGNALR.md Normal file
View File

@@ -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

112
index.html Normal file
View File

@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SimpleConnect - Gestion Centralisée des Plannings</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- Page de connexion -->
<div id="loginPage" class="page active">
<div class="login-container">
<h1>SimpleConnect</h1>
<h2>Connexion Agent</h2>
<form id="loginForm">
<input type="email" id="email" placeholder="Email" required>
<input type="password" id="password" placeholder="Mot de passe" required>
<button type="submit">Se connecter</button>
<div id="loginError" class="error-message"></div>
</form>
</div>
</div>
<!-- Page principale -->
<div id="mainPage" class="page">
<!-- Header -->
<header>
<div class="header-left">
<h1>SimpleConnect</h1>
<span id="agentName" class="agent-name"></span>
</div>
<div class="header-center">
<div id="callStatus" class="call-status">
<span class="status-indicator" id="statusIndicator"></span>
<span id="statusText">En attente</span>
</div>
</div>
<div class="header-right">
<button id="simulateCallBtn" class="btn-secondary">Simuler un appel</button>
<button id="logoutBtn" class="btn-secondary">Déconnexion</button>
</div>
</header>
<!-- Zone d'alerte appel entrant -->
<div id="incomingCallAlert" class="incoming-call-alert">
<div class="call-icon">📞</div>
<div class="call-info">
<div class="call-title">APPEL ENTRANT</div>
<div class="call-center" id="callCenterName"></div>
<div class="call-patient" id="callPatientInfo"></div>
</div>
<button id="acceptCallBtn" class="btn-accept">Prendre l'appel</button>
</div>
<!-- Conteneur principal -->
<div class="main-container">
<!-- Sidebar avec la liste des centres -->
<aside class="sidebar">
<h3>Mes Centres</h3>
<div id="centersList" class="centers-list"></div>
<div class="sidebar-footer">
<h4>Statistiques du jour</h4>
<div id="dailyStats" class="daily-stats">
<div class="stat-item">
<span class="stat-label">Appels traités:</span>
<span class="stat-value" id="callCount">0</span>
</div>
<div class="stat-item">
<span class="stat-label">RDV pris:</span>
<span class="stat-value" id="appointmentCount">0</span>
</div>
</div>
</div>
</aside>
<!-- Zone principale avec les webviews -->
<main class="content">
<!-- Onglets des centres -->
<div class="tabs" id="centerTabs"></div>
<!-- Container des webviews -->
<div id="webviewContainer" class="webview-container">
<div class="no-center-selected">
<p>Sélectionnez un centre ou attendez un appel entrant</p>
</div>
</div>
<!-- Zone de notes rapides -->
<div class="notes-section">
<h4>Notes rapides</h4>
<textarea id="quickNotes" placeholder="Prenez des notes ici..."></textarea>
<button id="saveNotesBtn" class="btn-small">Sauvegarder</button>
</div>
</main>
</div>
</div>
<!-- Modal de simulation d'appel -->
<div id="callSimulationModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Simuler un appel entrant</h2>
<div id="simulatedCallsList" class="simulated-calls-list"></div>
<button id="customCallBtn" class="btn-secondary">Appel personnalisé</button>
</div>
</div>
<!-- Scripts -->
<script src="renderer.js"></script>
<script src="cti-simulator.js"></script>
</body>
</html>

198
main.js Normal file
View File

@@ -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 };
});

69
package.json Normal file
View File

@@ -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"
}
}

395
renderer.js Normal file
View File

@@ -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 = `
<div class="center-indicator" style="background-color: ${centre.couleur}"></div>
<div class="center-info">
<div class="center-name">${centre.nom}</div>
<div class="center-phone">${centre.telephone}</div>
</div>
<div class="center-status" id="status-${centre.id}">●</div>
`;
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 = `
<button onclick="navigateWebview('${centre.id}', 'back')">◀</button>
<button onclick="navigateWebview('${centre.id}', 'forward')">▶</button>
<button onclick="navigateWebview('${centre.id}', 'reload')">🔄</button>
<span class="webview-url" id="url-${centre.id}">${centre.url}</span>
`;
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 => `
<div class="simulated-call-item" onclick="simulateThisCall('${JSON.stringify(call).replace(/'/g, "\\'")}')">
<div class="call-name">${call.nom}</div>
<div class="call-details">${call.numero} - ${call.centreNom}</div>
</div>
`).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 = '';
}
}

548
styles.css Normal file
View File

@@ -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;
}