Sprint performance temps reco > RAG > ODOO
Image from code REACT JS en dessous
Je viens de générer une image HD qui montre l’interface telle que l’utilisateur la verrait :
- Écran mobile “Enregistrement libre”
- Zone “Notes de visite” en haut
- Trois gros boutons micro CRM (bleu) / Prescription (orange) / Observance (rouge)
- Sélecteur “Type de visite”
- Slider “Niveau de douleur 0–10”
- Bloc “Photos” avec un bouton +
- Bloc “Actions IA” avec les 4 boutons
- Deux gros CTA en bas : Valider la visite et Enregistrer en brouillon
- Barre de navigation fixe en bas (Tournée / Enregistrements / Paramètres).
- Structure visuelle : L'application utilise un design "cartes" (Card-based design) sur un fond gris clair.
- Modification demandée : Remplacement du bouton micro unique par 3 boutons distincts (CRM, Prescription, Observance) avec codes couleurs spécifiques.
- Logique métier (Backend) : Le code React ci-dessous inclut la structure pour capturer l'audio et l'envoyer à votre middleware (n8n), qui se chargera ensuite de l'orchestration vers Whisper et Odoo via JSON-RPC.
Voici le code complet en React JS avec Tailwind CSS.
Composant React (VisitRecorder.jsx)
J'ai utilisé lucide-react pour les icônes. Assurez-vous de l'installer (npm install lucide-react).
J'ai utilisé lucide-react pour les icônes. Assurez-vous de l'installer (npm install lucide-react).
JavaScript
import React, { useState, useRef } from 'react';
import {
Mic,
ChevronLeft,
Plus,
Sparkles,
Send,
Zap,
Search,
Calendar,
Volume2,
Settings,
StopCircle
} from 'lucide-react';
const VisitRecorder = () => {
// --- États ---
const [note, setNote] = useState('');
const [painLevel, setPainLevel] = useState(0);
const [visitType, setVisitType] = useState('Soin');
// État pour l'enregistrement audio
const [isRecording, setIsRecording] = useState(false);
const [activeCategory, setActiveCategory] = useState(null); // 'crm', 'prescription', 'observance'
// Références pour l'API MediaRecorder (Simulation)
const mediaRecorderRef = useRef(null);
const audioChunksRef = useRef([]);
// --- Logique Métier & Orchestration (Mock) ---
/**
* Démarre l'enregistrement pour une catégorie spécifique
*/
const startRecording = async (category) => {
try {
// Demande d'accès au micro
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Initialisation du MediaRecorder
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => handleStopRecording(category);
mediaRecorder.start();
setIsRecording(true);
setActiveCategory(category);
console.log(`Enregistrement démarré pour : ${category}`);
} catch (err) {
console.error("Erreur d'accès au micro:", err);
alert("Impossible d'accéder au micro.");
}
};
/**
* Arrête l'enregistrement
*/
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
// On garde la catégorie active pour le traitement, puis on reset après upload
}
};
/**
* Traitement du fichier audio et envoi vers n8n
* C'est ici que se fait le lien avec votre architecture
*/
const handleStopRecording = async (category) => {
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
// Simulation de l'envoi vers votre Webhook n8n
// Le workflow n8n devra :
// 1. Recevoir le fichier
// 2. Envoyer à OpenAI Whisper -> Récupérer le texte
// 3. Formater via JSON-RPC
// 4. Envoyer dans le bon modèle Odoo selon la "category" (CRM, Prescription, etc.)
console.log("Envoi du Blob audio vers n8n...", audioBlob);
console.log(`Contexte métier : ${category}`);
// Exemple de fetch (pseudo-code)
/*
const formData = new FormData();
formData.append('audio', audioBlob);
formData.append('category', category); // 'crm', 'prescription', 'observance'
await fetch('https://votre-n8n-instance.com/webhook/audio-process', {
method: 'POST',
body: formData
});
*/
setActiveCategory(null);
alert(`Audio pour "${category}" envoyé au traitement IA.`);
};
// --- Rendu UI ---
return (
<div className="min-h-screen bg-gray-50 pb-24 font-sans text-slate-800">
{/* Header */}
<div className="bg-white p-4 shadow-sm sticky top-0 z-10">
<div className="flex items-center text-blue-600 mb-2 cursor-pointer">
<ChevronLeft size={20} />
<span className="font-medium">Retour</span>
</div>
<h1 className="text-2xl font-bold text-gray-900">Enregistrement libre</h1>
<p className="text-gray-400 text-sm">N/A ans</p>
</div>
<div className="p-4 space-y-4 max-w-md mx-auto">
{/* Section Notes & Microphones */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<h2 className="font-semibold mb-3 text-gray-700">Notes de visite</h2>
<textarea
className="w-full p-3 border border-gray-200 rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 text-gray-600 h-32"
placeholder="Dicter ou saisir les observations de la visite..."
value={note}
onChange={(e) => setNote(e.target.value)}
/>
{/* ZONES D'ENREGISTREMENT - MODIFIÉES SELON DEMANDE */}
<div className="mt-6">
{isRecording ? (
// Vue pendant l'enregistrement
<div className="flex flex-col items-center animate-pulse">
<button
onClick={stopRecording}
className="bg-red-100 p-4 rounded-full text-red-600 mb-2 border-4 border-red-50"
>
<StopCircle size={32} />
</button>
<p className="text-sm font-medium text-gray-500">
Enregistrement {activeCategory}...
</p>
</div>
) : (
// Les 3 Boutons demandés
<div className="flex justify-around items-start pt-2">
{/* Bouton 1: CRM (Bleu) */}
<div className="flex flex-col items-center gap-1 group cursor-pointer" onClick={() => startRecording('crm')}>
<div className="w-14 h-14 bg-blue-600 rounded-full flex items-center justify-center shadow-lg shadow-blue-200 active:scale-95 transition-transform">
<Mic className="text-white" size={24} />
</div>
<span className="text-xs font-bold text-blue-700 uppercase tracking-wide">CRM</span>
</div>
{/* Bouton 2: Prescription (Orange) */}
<div className="flex flex-col items-center gap-1 group cursor-pointer" onClick={() => startRecording('prescription')}>
<div className="w-14 h-14 bg-orange-500 rounded-full flex items-center justify-center shadow-lg shadow-orange-200 active:scale-95 transition-transform">
<Mic className="text-white" size={24} />
</div>
<span className="text-xs font-bold text-orange-600 uppercase tracking-wide">Prescription</span>
</div>
{/* Bouton 3: Observance (Rouge) */}
<div className="flex flex-col items-center gap-1 group cursor-pointer" onClick={() => startRecording('observance')}>
<div className="w-14 h-14 bg-red-500 rounded-full flex items-center justify-center shadow-lg shadow-red-200 active:scale-95 transition-transform">
<Mic className="text-white" size={24} />
</div>
<span className="text-xs font-bold text-red-600 uppercase tracking-wide">Observance</span>
</div>
</div>
)}
</div>
</div>
{/* Type de visite */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="block font-semibold mb-2 text-gray-700">Type de visite</label>
<select
value={visitType}
onChange={(e) => setVisitType(e.target.value)}
className="w-full p-3 bg-white border border-gray-200 rounded-xl focus:outline-none focus:border-blue-400 appearance-none"
>
<option>Soin</option>
<option>Suivi</option>
<option>Urgence</option>
</select>
</div>
{/* Niveau de douleur */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex justify-between items-center mb-4">
<label className="font-semibold text-gray-700">Niveau de douleur</label>
<span className="text-blue-600 font-bold text-xl">{painLevel}/10</span>
</div>
<input
type="range"
min="0"
max="10"
value={painLevel}
onChange={(e) => setPainLevel(parseInt(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
<div className="flex justify-between text-xs text-gray-400 mt-2">
<span>Aucune</span>
<span>Modérée</span>
<span>Extrême</span>
</div>
</div>
{/* Photos */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="block font-semibold mb-3 text-gray-700">Photos</label>
<button className="w-24 h-24 border-2 border-dashed border-gray-300 rounded-xl flex items-center justify-center text-gray-400 hover:border-blue-400 hover:text-blue-400 transition-colors">
<Plus size={32} />
</button>
</div>
{/* Actions IA */}
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<h2 className="font-semibold text-gray-700 mb-2">Actions IA</h2>
<button className="w-full bg-blue-300/50 text-slate-700 py-3 rounded-full flex items-center justify-center gap-2 font-medium hover:bg-blue-300/70 transition">
<Sparkles size={18} /> Générer un résumé
</button>
<button className="w-full border border-gray-200 py-3 rounded-full flex items-center justify-center gap-2 text-gray-600 hover:bg-gray-50 transition">
<Send size={18} /> Transmission médecin
</button>
<button className="w-full border border-gray-200 py-3 rounded-full flex items-center justify-center gap-2 text-gray-600 hover:bg-gray-50 transition">
<Zap size={18} /> Actions rapides...
</button>
<button className="w-full border border-gray-200 py-3 rounded-full flex items-center justify-center gap-2 text-gray-600 hover:bg-gray-50 transition">
<Search size={18} /> Autofill
</button>
</div>
{/* Footer Buttons */}
<div className="space-y-3 pt-2">
<button className="w-full bg-green-400/80 text-white font-semibold py-4 rounded-full shadow-sm hover:bg-green-500 transition">
Valider la visite
</button>
<button className="w-full bg-white border border-gray-200 text-gray-600 font-medium py-4 rounded-full shadow-sm hover:bg-gray-50 transition">
Enregistrer en brouillon
</button>
</div>
</div>
{/* Bottom Navigation */}
<div className="fixed bottom-0 w-full bg-white border-t border-gray-100 flex justify-around py-3 pb-6 text-gray-400 text-xs font-medium z-20">
<div className="flex flex-col items-center gap-1 hover:text-blue-600 cursor-pointer">
<Calendar size={24} />
<span>Tournée</span>
</div>
<div className="flex flex-col items-center gap-1 text-gray-800 cursor-pointer">
<Volume2 size={24} />
<span>Enregistrements</span>
</div>
<div className="flex flex-col items-center gap-1 hover:text-blue-600 cursor-pointer">
<Settings size={24} />
<span>Paramètres</span>
</div>
</div>
</div>
);
};
export default VisitRecorder;
Explication de l'architecture technique suggérée
Pour réussir l'intégration avec Odoo Online via n8n et Whisper, voici comment le code ci-dessus s'insère dans le flux :
- Capture Frontend (React) :
- J'ai créé une fonction startRecording qui prend en paramètre le "type" (crm, prescription, observance).
- Ceci est crucial car vous enverrez ce paramètre comme métadonnée à n8n.
- Le Pivot (n8n) :
- Le code React doit faire un POST (Multipart/form-data) vers un Webhook n8n.
- Payload : { file: audioBlob, type: 'prescription' }.
- L'Orchestration (Workflow n8n) :
- Node 1 (Webhook) : Reçoit le fichier.
- Node 2 (OpenAI Whisper) : Transcrit l'audio en texte.
- Node 3 (Switch/If) : Regarde le champ type.
- Si CRM : Node HTTP Request (JSON-RPC) vers Odoo pour créer une note dans crm.lead ou res.partner.
- Si Prescription : Node HTTP Request vers Odoo pour créer un enregistrement dans votre module médical personnalisé.
- Si Observance : Node HTTP Request pour mettre à jour le suivi patient.
- JSON-RPC vers Odoo :
- Rappelez-vous que Odoo Online nécessite une authentification. N8n gère très bien cela. Vous n'avez pas besoin d'exposer vos identifiants Odoo dans le code React (ce qui serait dangereux). Le React parle à n8n, et n8n (sécurisé côté serveur) parle à Odoo.