Version initiale
This commit is contained in:
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal 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
153
README.md
Normal 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
109
config.json
Normal 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
309
cti-simulator.js
Normal 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
406
docs/INTEGRATION_SIGNALR.md
Normal 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
112
index.html
Normal 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">×</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
198
main.js
Normal 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
69
package.json
Normal 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
395
renderer.js
Normal 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
548
styles.css
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user