đ§ SCRIPT COMPLET â Agent IA dâentretien gĂ©riatrique
1- contexte
đ Bot IA gĂ©riatrique â n8n â Odoo
Elle est structurée, lisible comme une fiche projet, compréhensible par un métier (pharmacien / chef de projet / développeur), et directement exploitable.
đ§ Synergia Senior
Documentation fonctionnelle â Flow âEntretien gĂ©riatrique IA â Odooâ
đŻ Objectif mĂ©tier
Automatiser lâenregistrement structurĂ© dâun entretien gĂ©riatrique rĂ©alisĂ© par un agent IA vers Odoo, afin de :
- centraliser les évaluations des patients seniors,
- tracer les scores de fragilité,
- déclencher des alertes,
- alimenter un dossier patient unifié.
Lâagent IA ne pose pas de diagnostic mĂ©dical mais agit comme un outil de prĂ©-alerte et dâorientation.
đ§± Vue dâensemble du systĂšme
Acteurs
- Patient / Aidant : répond aux questions via voix ou texte.
- Bot IA gĂ©riatrique : conduit lâentretien, calcule les scores.
- n8n : orchestre les échanges techniques.
- Odoo Online : devient la base de données patient.
đ SchĂ©ma global (logique mĂ©tier)
Patient / Aidant | | (voix / texte) v đ€ Agent IA | | JSON structurĂ© v âïž Webhook n8n | +--> đ Recherche patient (Odoo) | +--> đ CrĂ©ation entretien gĂ©riatrique (Odoo) | +--> đ Mise Ă jour fiche patient (score & niveau) | +--> đš Alerte si risque Ă©levĂ© (email, future extension : SMS / Slack)
đ§ SCRIPT COMPLET â Agent IA dâentretien gĂ©riatrique
Objectif : évaluer autonomie, risques et besoins
Durée cible : 12 à 18 minutes
Mode : voix ou texte
Output : score global + alertes + orientation
đ§ BLOC 0 â Introduction & consentement
Agent :
Bonjour, je suis votre assistant de suivi bien-ĂȘtre. Je vais vous poser quelques questions pour mieux comprendre comment vous allez au quotidien.
Vous pouvez rĂ©pondre librement, il nây a pas de bonnes ou de mauvaises rĂ©ponses.
Ătes-vous dâaccord pour commencer ?
â Si OUI â BLOC 1
â Si NON â Fin avec message de respect
đ€ BLOC 1 â Profil gĂ©nĂ©ral
- Quel est votre Ăąge ?
- Vivez-vous seul(e) ou avec quelquâun ?
- Avez-vous une personne qui vous aide réguliÚrement ?
đ§ DĂ©tection : isolement / aidance
đ BLOC 2 â MobilitĂ© & chutes
- Marchez-vous sans aide, avec une canne, un dĂ©ambulateur, ou ĂȘtes-vous souvent aidĂ©(e) ?
-
Avez-vous chuté ces derniers mois ?
- Si oui : combien de fois ?
- Avez-vous eu des blessures ?
- Avez-vous peur de tomber ?
â ïž DĂ©tection : risque de chute
đ BLOC 3 â Autonomie quotidienne
-
Avez-vous des difficultés pour :
- vous laver ?
- vous habiller ?
- préparer vos repas ?
- faire vos courses ?
- prendre vos médicaments ?
đŻ Ăchelle ADL / IADL simplifiĂ©e
đ§ BLOC 4 â MĂ©moire & cognition
- Vous arrive-t-il dâoublier des rendez-vous ?
- Avez-vous du mal Ă retrouver vos mots ?
- Vous trompez-vous parfois de jour ou de moment de la journée ?
đ§ Suspicion trouble cognitif
â€ïž BLOC 5 â SantĂ© gĂ©nĂ©rale
- Avez-vous des douleurs fréquentes ?
- Ătes-vous suivi(e) pour une maladie chronique ?
- Ătes-vous souvent fatiguĂ©(e) ?
đŻ FragilitĂ© mĂ©dicale
đ BLOC 6 â MĂ©dicaments
- Prenez-vous des médicaments tous les jours ?
- Vous arrive-t-il dâen oublier ?
- Les prenez-vous seul(e) ou avec aide ?
â ïž Risque de non-observance
đ„ BLOC 7 â Nutrition & hydratation
- Avez-vous perdu du poids récemment ?
- Mangez-vous moins quâavant ?
- Buvez-vous suffisamment ?
đœïž Risque nutritionnel
đ BLOC 8 â Moral & solitude
- Vous sentez-vous souvent seul(e) ?
- Avez-vous le moral en ce moment ?
- Vous arrive-t-il de perdre lâenvie de faire des choses que vous aimiez ?
đ§ DĂ©pistage dĂ©pression
đĄ BLOC 9 â Environnement de vie
- Votre logement vous semble-t-il sûr ?
- tapis ?
- escaliers ?
- éclairage ?
- Avez-vous des barres dâappui dans la salle de bain ?
đïž Risque environnemental
đȘ BLOC 10 â Aidants
- La personne qui vous aide semble-t-elle fatiguée ?
- Y a-t-il quelquâun en cas dâurgence ?
đ„ Charge aidant
â BLOC 11 â ClĂŽture bienveillante
Agent :
Merci pour vos réponses.
Elles vont permettre de mieux comprendre vos besoins et de voir comment améliorer votre confort et votre sécurité.
â ïž Cadre lĂ©gal intĂ©grĂ©
Lâagent affiche automatiquement :
Ce service ne remplace pas un professionnel de santé.
En cas dâurgence, contactez les services mĂ©dicaux.
đ OUTPUT STRUCTURĂ (format JSON recommandĂ©)
đ OUTPUT STRUCTURĂ (format JSON recommandĂ©)
{ "profil": { "age": 82, "habitat": "seul", "aidant": true }, "scores": { "autonomie": 55, "cognitif": 40, "chute": 80, "nutrition": 60, "moral": 65 }, "fragilite_globale": 68, "niveau_risque": "élevé", "alertes": ["chute", "isolement", "oubli traitement"], "recommandations": [ "aménagement salle de bain", "visite infirmiÚre", "bilan gériatrique", "appel de suivi hebdomadaire" ] }
1ïžâŁ OĂč inscrire ça dans Odoo ?
Recycler l'eau pour la réutiliser au lieu d'utiliser des réserves d'eau douce permet d'économiser de l'eau.
.
.
.
.
B) le moteur de scoring
Je te fais un moteur de scoring complet, directement exploitable dans un backend (Nest, Node, etc.) mais expliqué en français.
1ïžâŁ Principe gĂ©nĂ©ral
-
Chaque domaine â un score de risque de 0 Ă 100
- 0 = aucun risque détecté
- 100 = risque maximal sur ce domaine
- Puis un score global de fragilité = moyenne pondérée des domaines.
- Des seuils transforment les scores en niveaux : faible / modéré / élevé.
2ïžâŁ Codage standard des rĂ©ponses
Pour simplifier, tu peux normaliser les réponses en codes :
A. Questions de type « difficulté »
Options proposĂ©es Ă lâutilisateur :
- Aucune difficulté
- Petite difficulté
- Grosse difficulté
- Impossible sans aide
Codage interne (risque) :
- Aucune difficultĂ© â 0
- Petite difficultĂ© â 1
- Grosse difficultĂ© â 2
- Impossible sans aide â 3
đ Score de lâitem = code / 3 * 100.
B. Questions Oui / Non
- Oui = prĂ©sence de symptĂŽme / problĂšme â 1
- Non = absence â 0
đ Score de lâitem = code * 100 (ou pondĂ©rĂ©).
3ïžâŁ ModĂšle de donnĂ©es (input)
Exemple de structure JSON aprĂšs entretien :
{ "profil": { "age": 82, "habitat": "seul", "hasAidant": true }, "mobilite": { "aideMarche": "canne", // "aucune" | "canne" | "deambulateur" | "fauteuil" | "aide_humaine" "chutes12m": 2, // nombre de chutes "blessureChute": true, // oui / non "peurDeTomber": true // oui / non }, "autonomie": { "toilette": "grosse_difficulte", // codage texte â 0..3 "habillage": "petite_difficulte", "repas": "aucune_difficulte", "courses": "impossible", "medicaments": "grosse_difficulte" }, "cognitif": { "oublisRendezVous": true, "trousDeMemoire": true, "desorientation": false }, "sante": { "douleursFrequentes": true, "maladieChronique": true, "fatigueFrequente": true }, "medicaments": { "nbQuotidien": 5, "oublis": "souvent", // "jamais" | "rarement" | "souvent" "aidePourPrise": true }, "nutrition": { "pertePoids": "oui", // "non" | "oui_leger" | "oui_marque" "appetit": "diminue", // "normal" | "diminue" | "tres_diminue" "hydratationFaible": true }, "moral": { "seSentSeul": true, "moralBas": true, "perteInterets": true }, "environnement": { "tapisRisque": true, "escaliersDangereux": true, "eclairageMauvais": false, "barresAppuiSalleDeBain": false }, "aidant": { "aidantPresent": true, "aidantFatigue": true, "aidantDeborde": true } }
4ïžâŁ Calcul des scores par domaine
4.1. đ Risque de chute (score_chute)
base = 0 // Nombre de chutes si chutes12m == 0 â base += 0 si chutes12m == 1 â base += 60 si chutes12m >= 2 â base += 80 // Blessures si blessureChute == true â base += 20 // Peur de tomber si peurDeTomber == true â base += 20 score_chute = min(base, 100)
4.2. đ Autonomie (score_autonomie)
Items : toilette, habillage, repas, courses, médicaments (5 items).
-
Convertir chaque réponse en code numérique :
- aucune_difficulte â 0
- petite_difficulte â 1
- grosse_difficulte â 2
- impossible â 3
- Score autonomie :
codes = [toilette, habillage, repas, courses, medicaments] // 0..3 moyenne_code = moyenne(codes) // entre 0 et 3 score_autonomie = (moyenne_code / 3) * 100
4.3. đ§ Cognitif (score_cognitif)
Items : oublis, trous de mémoire, désorientation (3 questions oui/non).
nb_positifs = (oublisRendezVous ? 1 : 0) + (trousDeMemoire ? 1 : 0) + (desorientation ? 1 : 0) score_cognitif = (nb_positifs / 3) * 100
4.4. â€ïž SantĂ© gĂ©nĂ©rale (score_sante)
Items : douleurs fréquentes, maladie chronique, fatigue fréquente.
nb_positifs = (douleursFrequentes ? 1 : 0) + (maladieChronique ? 1 : 0) + (fatigueFrequente ? 1 : 0) score_sante = (nb_positifs / 3) * 100
4.5. đ MĂ©dicaments (score_medicaments)
- Polymédication : plus il y a de médicaments, plus le risque augmente.
- Oublis : jamais / rarement / souvent.
- Besoin dâaide.
// polymĂ©dication si nbQuotidien <= 3 â poly = 20 si 4 <= nbQuotidien <= 5 â poly = 40 si nbQuotidien >= 6 â poly = 60 // oublis si oublis == "jamais" â oublisScore = 0 si oublis == "rarement" â oublisScore = 30 si oublis == "souvent" â oublisScore = 70 // aide aideScore = aidePourPrise ? 40 : 0 score_medicaments = min(poly + oublisScore + aideScore, 100)
4.6. đ„ Nutrition (score_nutrition)
// perte de poids si pertePoids == "non" â perteScore = 0 si pertePoids == "oui_leger" â perteScore = 40 si pertePoids == "oui_marque" â perteScore = 70 // appĂ©tit si appetit == "normal" â appetitScore = 0 si appetit == "diminue" â appetitScore = 30 si appetit == "tres_diminue" â appetitScore = 60 // hydratation hydratationScore = hydratationFaible ? 40 : 0 score_nutrition = min(perteScore + appetitScore + hydratationScore, 100)
4.7. đ Moral / psycho-social (score_moral)
Items : se sent seul, moral bas, perte dâintĂ©rĂȘt.
nb_positifs = (seSentSeul ? 1 : 0) + (moralBas ? 1 : 0) + (perteInterets ? 1 : 0) score_moral = (nb_positifs / 3) * 100
4.8. đĄ Environnement (score_environnement)
Items : tapis, escaliers, Ă©clairage, barres dâappui.
nb_risques = (tapisRisque ? 1 : 0) + (escaliersDangereux ? 1 : 0) + (eclairageMauvais ? 1 : 0) abs_barres = (barresAppuiSalleDeBain ? 0 : 1) nb_total = nb_risques + abs_barres // 0..4 score_environnement = (nb_total / 4) * 100
4.9. đȘ Aidant (score_aidant)
Si pas dâaidant â score_aidant = 0 (ou null).
si !aidantPresent: score_aidant = 0 sinon: nb_positifs = (aidantFatigue ? 1 : 0) + (aidantDeborde ? 1 : 0) score_aidant = (nb_positifs / 2) * 100
5ïžâŁ FragilitĂ© globale
On peut utiliser une moyenne pondérée en donnant davantage de poids aux domaines critiques (chute, autonomie, cognition, médicaments) :
poids = { chute: 2, autonomie: 2, cognitif: 2, sante: 1, medicaments: 2, nutrition: 1, moral: 1, environnement: 1, aidant: 1 } somme_poids = 13 fragilite_globale = (score_chute * 2 + score_autonomie * 2 + score_cognitif * 2 + score_sante * 1 + score_medicaments * 2 + score_nutrition * 1 + score_moral * 1 + score_environnement* 1 + score_aidant * 1) / somme_poids
6ïžâŁ Niveaux de risque & rĂšgles dâalerte
A. Niveaux (pour chaque domaine et global)
- 0â33 â Risque faible
- 34â66 â Risque modĂ©rĂ©
- 67â100 â Risque Ă©levĂ©
fonction niveau(score): si score < 34 â "faible" sinon si score < 67 â "modĂ©rĂ©" sinon â "Ă©levĂ©"
B. DĂ©clenchement dâalertes
Exemples de rĂšgles simples :
- Si score_chute >= 67 â Ajouter alerte "risque_chute"
- Si score_autonomie >= 67 â "perte_autonomie"
- Si score_cognitif >= 67 â "trouble_cognitif_suspect"
- Si score_moral >= 67 â "risque_depression"
- Si score_nutrition >= 67 â "risque_denutrition"
- Si fragilite_globale >= 67 â "fragilite_globale_elevee"
7ïžâŁ Exemple de rĂ©sultat final (output)
{ "scores": { "chute": 85, "autonomie": 70, "cognitif": 66, "sante": 67, "medicaments": 75, "nutrition": 60, "moral": 80, "environnement": 50, "aidant": 50 }, "niveaux": { "chute": "élevé", "autonomie": "élevé", "cognitif": "modéré", "sante": "élevé", "medicaments": "élevé", "nutrition": "modéré", "moral": "élevé", "environnement": "modéré", "aidant": "modéré" }, "fragilite_globale": 72, "niveau_fragilite": "élevé", "alertes": [ "risque_chute", "perte_autonomie", "trouble_cognitif_suspect", "risque_depression", "fragilite_globale_elevee" ] }
Une seule page âdashboard patientâ Ă©ditable via Odoo Studio, qui agrĂšge :
1ïžâŁ Concept : une âFiche Synergia Seniorâ sur res.partner
câest tout Ă fait possible de faire ça dans Odoo â et proprement â avec une seule page âdashboard patientâ Ă©ditable via Odoo Studio, qui agrĂšge :
- đŠ Les infos aidant / famille venant de la WebApp n°1
- đš Les infos senior venant du bot dâentretien gĂ©riatrique
- đ„ Une synthĂšse visuelle (scores, risques, alertes) tout en haut
On garde le patient comme res.partner, et on ajoute un onglet dédié :
Contact â Synergia Senior
Cette page se découpe en 3 blocs :
-
Bandeau synthĂšse (en haut)
- identité + badges + scores clés
-
Colonne gauche : âAidant & Famille (WebApp #1)â
- qui sont les enfants / aidants ?
- coordonnées, disponibilité, niveau de charge
- remarques de la WebApp aidant
-
Colonne droite : âSenior & GĂ©riatrie (Bot)â
- dernier score de fragilité + niveau de risque
- mini détail des scores par domaine
- liste des derniers entretiens (smart button / sous-liste)
2ïžâŁ Layout proposĂ© (vue formulaire Odoo)
đ§© Onglet âSynergia Seniorâ (sur la fiche Contact)
BANDEROLE HAUT (pleine largeur, 1 ligne)
- name (nom du patient)
- x_external_ref (ID patient Synergia)
- x_last_risk_level (badge couleur : vert / orange / rouge)
- x_last_fragility_score (KPI numérique)
- x_dependency_level (sélection : autonome / fragile / dépendant)
LIGNE SUIVANTE : 2 colonnes
đŠ Colonne gauche â âAidant & Famille (WebApp #1)â
Bloc A â Aidant principal
- x_has_caregiver (booléen : Aidant identifié ?)
- x_main_caregiver_id (Many2one â res.partner)
- x_caregiver_relation (sélection : enfant, conjoint, voisin, autre)
- x_caregiver_phone (Char, téléphone direct si différent)
- x_caregiver_email (Char, email direct si différent)
Bloc B â Enfants & proches
- x_children_json (Text) â JSON brut des enfants provenant de la WebApp#1
-
Exemple :
[ {"nom":"Paul","lien":"fils","tel":"+33..."}, {"nom":"Marie","lien":"fille","tel":"+33..."} ]
-
Exemple :
- x_children_notes (Text multi-ligne) â rĂ©sumĂ© lisible (gĂ©nĂ©rĂ© cĂŽtĂ© WebApp ou IA)
Bloc C â Ressenti de lâaidant (WebApp #1)
- x_caregiver_burnout (bool : âAidant en difficultĂ© ?â)
- x_caregiver_burnout_level (sélection : faible / modéré / élevé)
- x_caregiver_comments (Text) â ce que lâaidant a exprimĂ© dans la WebApp
- x_support_needs (Text) â besoins signalĂ©s (soutien psychologique, aide Ă domicileâŠ)
đš Colonne droite â âSenior & GĂ©riatrie (Bot IA)â
Bloc D â SynthĂšse derniĂšre Ă©valuation
x_last_ai_interview (Datetime) â date du dernier entretien
x_last_fragility_score (Integer) â 0â100
x_last_risk_level (Selection : faible / modéré / élevé)
x_mobility_level (Selection : marche autonome, canne, dĂ©ambulateur, fauteuilâŠ)
x_known_cognitive_issue (Boolean) â âTroubles cognitifs connusâ
Bloc E â Scores dĂ©taillĂ©s (lecture seule, dernier entretien)
(champs remontés depuis le dernier x_synergia_geriatrie_entretien lié)
- x_score_autonomie
- x_score_cognitif
- x_score_chute
- x_score_nutrition
- x_score_moral
Option UI : afficher ces 5 champs dans une seule âboxâ horizontale, façon petits KPIs.
Bloc F â Alertes & recommandations
- x_last_alertes_label (Text) â ex : âRisque de chute ; Isolement ; Oubli traitementâ
- x_last_reco_text (Text) â ex : les 3â4 reco principales
Bloc G â Historique des entretiens
- Smart button âEntretiens gĂ©riatriques (N)â qui ouvre la liste x_synergia_geriatrie_entretien filtrĂ©e sur ce patient.
- En dessous Ă©ventuellement un widget âOne2manyâ montrant les 3 derniers (date / risque / score global).
3ïžâŁ Champs principaux Ă prĂ©voir (rĂ©sumĂ©)
Tu as déjà la plupart, mais pour ce dashboard unifié aidant + senior, les plus importants :
Sur res.partner (patient)
- x_external_ref (Char) â ID patient Synergia
- x_contact_type (Selection) â on met patient
- x_has_caregiver (Bool)
- x_main_caregiver_id (Many2one â res.partner)
- x_caregiver_relation (Selection)
- x_children_json (Text)
- x_children_notes (Text)
- x_caregiver_burnout (Bool)
- x_caregiver_burnout_level (Selection)
- x_caregiver_comments (Text)
- x_support_needs (Text)
- x_last_ai_interview (Datetime)
- x_last_fragility_score (Integer)
- x_last_risk_level (Selection)
- x_mobility_level (Selection)
- x_known_cognitive_issue (Bool)
- x_score_autonomie (Integer)
- x_score_cognitif (Integer)
- x_score_chute (Integer)
- x_score_nutrition (Integer)
- x_score_moral (Integer)
- x_last_alertes_label (Text)
- x_last_reco_text (Text)
Ces champs sont alimentés cÎté n8n à chaque nouvel entretien ou chaque mise à jour de WebApp#1. via le bot
4ïžâŁ Construction dans Odoo Studio (step-by-step rapide)
- Ouvrir Contacts â Studio.
-
Sur la vue formulaire :
- Ajouter un onglet âSynergia Seniorâ.
-
Dans cet onglet :
- Ajouter un groupe horizontal pour le bandeau haut (name + id + risque + score).
- Ajouter un groupement en 2 colonnes.
- Colonne gauche : blocs Aidant / Famille / Ressenti (champs WebApp#1).
- Colonne droite : blocs Scores + Alertes + Historique.
- CrĂ©er un smart button âEntretiens gĂ©riatriquesâ liĂ© au modĂšle x_synergia_geriatrie_entretien.
Tu obtiens ainsi une vue unique, lisible par un pharmacien ou un infirmier, qui voit en un clin dâĆil :
- ce que lâaidant dit (WebApp1)
- ce que le senior dit (bot IA gériatrique)
- un score global + la couleur du risque.
5ïžâŁ Image HD du dashboard â comment tâen servir

mapping exact n8n â champs de ce dashboard (table de correspondance),
Les grandes histoires ont une personnalitĂ©. Envisagez de raconter une belle histoire qui donne de la personnalitĂ©. Ăcrire une histoire avec de la personnalitĂ© pour des clients potentiels aidera Ă Ă©tablir un lien relationnel. Cela se traduit par de petites spĂ©cificitĂ©s comme le choix des mots ou des phrases. Ăcrivez de votre point de vue, pas de l'expĂ©rience de quelqu'un d'autre.
Les grandes histoires sont pour tout le monde, mĂȘme lorsqu'elles ne sont Ă©crites que pour une seule personne. Si vous essayez d'Ă©crire en pensant Ă un public large et gĂ©nĂ©ral, votre histoire sonnera fausse et manquera d'Ă©motion. Personne ne sera intĂ©ressĂ©. Ecrire pour une personne en particulier signifie que si c'est authentique pour l'un, c'est authentique pour le reste.
JSON âspecâ de ce dashboard Ă donner Ă un designer / dev front pour une version WebApp indĂ©pendante dâOdoo.
1ïžâŁ ModĂšle de donnĂ©es â patient.schema.json
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "SynergiaSeniorPatient", "type": "object", "properties": { "patient": { "type": "object", "properties": { "id": { "type": "string" }, "externalRef": { "type": "string" }, "fullName": { "type": "string" }, "birthDate": { "type": "string", "format": "date" }, "age": { "type": "integer" }, "gender": { "type": "string", "enum": ["homme", "femme", "autre", "inconnu"] }, "dependencyLevel": { "type": "string", "enum": ["autonome", "fragile", "dependant", "inconnu"] }, "preferredChannel": { "type": "string", "enum": ["voix", "sms", "email", "whatsapp", "app", "inconnu"] }, "language": { "type": "string" } }, "required": ["id", "fullName"] }, "caregiverBlock": { "type": "object", "properties": { "hasCaregiver": { "type": "boolean" }, "mainCaregiver": { "type": "object", "properties": { "id": { "type": "string" }, "fullName": { "type": "string" }, "relation": { "type": "string", "enum": ["enfant", "conjoint", "frere_soeur", "proche", "voisin", "autre", "inconnu"] }, "phone": { "type": "string" }, "email": { "type": "string" } } }, "relatives": { "type": "array", "items": { "type": "object", "properties": { "fullName": { "type": "string" }, "relation": { "type": "string" }, "phone": { "type": "string" }, "email": { "type": "string" } } } }, "caregiverDistress": { "type": "object", "properties": { "isInDifficulty": { "type": "boolean" }, "level": { "type": "string", "enum": ["faible", "moderee", "elevee", "inconnu"] }, "freeText": { "type": "string" }, "supportNeeds": { "type": "string" } } } } }, "geriatricsBlock": { "type": "object", "properties": { "lastInterviewAt": { "type": "string", "format": "date-time" }, "globalFragilityScore": { "type": "integer", "minimum": 0, "maximum": 100 }, "globalRiskLevel": { "type": "string", "enum": ["faible", "modere", "eleve", "inconnu"] }, "mobilityLevel": { "type": "string", "enum": ["autonome", "canne", "deambulateur", "fauteuil", "alitE", "inconnu"] }, "cognitiveIssuesKnown": { "type": "boolean" }, "detailedScores": { "type": "object", "properties": { "autonomy": { "type": "integer", "minimum": 0, "maximum": 100 }, "cognitive": { "type": "integer", "minimum": 0, "maximum": 100 }, "falls": { "type": "integer", "minimum": 0, "maximum": 100 }, "nutrition": { "type": "integer", "minimum": 0, "maximum": 100 }, "mood": { "type": "integer", "minimum": 0, "maximum": 100 } } }, "alerts": { "type": "array", "items": { "type": "string" } }, "alertsLabel": { "type": "string" }, "recommendations": { "type": "array", "items": { "type": "string" } }, "interviewsSummary": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "date": { "type": "string", "format": "date-time" }, "globalScore": { "type": "integer" }, "riskLevel": { "type": "string", "enum": ["faible", "modere", "eleve", "inconnu"] } } } } } } }, "required": ["patient", "geriatricsBlock"] }
2ïžâŁ SpĂ©cification UI â patientDashboard.ui.json
Ici on décrit la page : sections, composants, bindings avec le modÚle de données.
{ "id": "patient-dashboard", "title": "Synergia Senior â Fiche patient", "layout": { "type": "page", "sections": [ { "id": "header", "type": "row", "style": { "padding": 24, "borderBottom": true }, "columns": [ { "width": "2fr", "components": [ { "type": "text", "variant": "title", "bind": "patient.fullName" }, { "type": "text", "variant": "subtitle", "text": "ID Synergia :", "inline": true }, { "type": "badge", "variant": "neutral", "bind": "patient.externalRef", "inline": true } ] }, { "width": "1fr", "align": "right", "components": [ { "type": "pill", "label": "Risque", "bind": "geriatricsBlock.globalRiskLevel", "colorMap": { "faible": "green", "modere": "orange", "eleve": "red", "inconnu": "gray" } }, { "type": "kpi", "label": "Score fragilitĂ©", "bind": "geriatricsBlock.globalFragilityScore" }, { "type": "tag", "label": "DĂ©pendance", "bind": "patient.dependencyLevel" } ] } ] }, { "id": "main-two-columns", "type": "row", "style": { "padding": 24, "gap": 24 }, "columns": [ { "id": "col-caregiver", "width": "1fr", "title": "Aidant & Famille (WebApp #1)", "components": [ { "type": "card", "title": "Aidant principal", "components": [ { "type": "checkbox", "label": "Aidant identifiĂ©", "bind": "caregiverBlock.hasCaregiver", "readOnly": true }, { "type": "keyValue", "items": [ { "label": "Nom", "bind": "caregiverBlock.mainCaregiver.fullName" }, { "label": "Lien", "bind": "caregiverBlock.mainCaregiver.relation" }, { "label": "TĂ©lĂ©phone", "bind": "caregiverBlock.mainCaregiver.phone" }, { "label": "E-mail", "bind": "caregiverBlock.mainCaregiver.email" } ] } ] }, { "type": "card", "title": "Enfants & proches", "components": [ { "type": "table", "bind": "caregiverBlock.relatives", "columns": [ { "header": "Nom", "field": "fullName" }, { "header": "Lien", "field": "relation" }, { "header": "TĂ©lĂ©phone", "field": "phone" }, { "header": "E-mail", "field": "email" } ] } ] }, { "type": "card", "title": "Ressenti de lâaidant", "components": [ { "type": "checkbox", "label": "Aidant en difficultĂ© ?", "bind": "caregiverBlock.caregiverDistress.isInDifficulty", "readOnly": true }, { "type": "keyValue", "items": [ { "label": "Niveau de charge", "bind": "caregiverBlock.caregiverDistress.level" } ] }, { "type": "textarea", "label": "Paroles de lâaidant", "bind": "caregiverBlock.caregiverDistress.freeText", "readOnly": true, "rows": 3 }, { "type": "textarea", "label": "Besoins exprimĂ©s", "bind": "caregiverBlock.caregiverDistress.supportNeeds", "readOnly": true, "rows": 2 } ] } ] }, { "id": "col-geriatrics", "width": "1fr", "title": "Senior & GĂ©riatrie (Bot IA)", "components": [ { "type": "card", "title": "SynthĂšse derniĂšre Ă©valuation", "components": [ { "type": "keyValue", "items": [ { "label": "Date", "bind": "geriatricsBlock.lastInterviewAt", "format": "date" }, { "label": "Score fragilitĂ©", "bind": "geriatricsBlock.globalFragilityScore" }, { "label": "Niveau de risque", "bind": "geriatricsBlock.globalRiskLevel" }, { "label": "MobilitĂ©", "bind": "geriatricsBlock.mobilityLevel" }, { "label": "Troubles cognitifs connus", "type": "booleanText", "bind": "geriatricsBlock.cognitiveIssuesKnown" } ] } ] }, { "type": "card", "title": "Scores dĂ©taillĂ©s", "components": [ { "type": "scoreStrip", "scores": [ { "label": "Autonomie", "bind": "geriatricsBlock.detailedScores.autonomy" }, { "label": "Cognitif", "bind": "geriatricsBlock.detailedScores.cognitive" }, { "label": "Chute", "bind": "geriatricsBlock.detailedScores.falls" }, { "label": "Nutrition", "bind": "geriatricsBlock.detailedScores.nutrition" }, { "label": "Moral", "bind": "geriatricsBlock.detailedScores.mood" } ] } ] }, { "type": "card", "title": "Alertes & recommandations", "components": [ { "type": "tagList", "bind": "geriatricsBlock.alerts" }, { "type": "list", "label": "Recommandations", "bind": "geriatricsBlock.recommendations" } ] }, { "type": "card", "title": "Entretiens gĂ©riatriques", "components": [ { "type": "table", "bind": "geriatricsBlock.interviewsSummary", "columns": [ { "header": "Date", "field": "date", "format": "date" }, { "header": "Score", "field": "globalScore" }, { "header": "Risque", "field": "riskLevel" } ], "actions": [ { "type": "link", "label": "Voir le dĂ©tail", "route": "/geriatrie/entretien/:id", "paramField": "id" } ] } ] } ] } ] } ] } }
Comment lâutiliser
-
Designer : sâappuie sur patientDashboard.ui.json pour savoir
- quelles sections,
- quels types de composants,
- quelles infos affichĂ©es oĂč.
-
Dev front :
- mappe les type vers ses composants React/Vue (ex. card, scoreStrip, tagListâŠ),
- connecte les bind au JSON renvoyĂ© par lâAPI (patient.schema.json).
Si tu veux, je peux maintenant te faire la version TypeScript des types (Patient, CaregiverBlock, GeriatricsBlock) ou un exemple de composant React <PatientDashboard /> qui consomme exactement ce JSON.
> exemple de composant React <PatientDashboard /> qui consomme exactement ce JSON.

Voici un exemple complet de composant React + TypeScript <PatientDashboard /> qui consomme exactement le JSON quâon a dĂ©fini (patient, caregiverBlock, geriatricsBlock).
Je pars sur :
- â Props typĂ©es (interfaces TS cohĂ©rentes avec ton schema)
- â Mise en page 2 colonnes avec un bandeau haut
- â Classes type Tailwind (faciles Ă adapter / remplacer)
Tu peux coller ça dans un projet React/Next et adapter le style.
1ïžâŁ Types TypeScript pour les donnĂ©es
// types.ts export type RiskLevel = "faible" | "modere" | "eleve" | "inconnu"; export interface PatientInfo { id: string; externalRef?: string; fullName: string; birthDate?: string; age?: number; gender?: "homme" | "femme" | "autre" | "inconnu"; dependencyLevel?: "autonome" | "fragile" | "dependant" | "inconnu"; preferredChannel?: "voix" | "sms" | "email" | "whatsapp" | "app" | "inconnu"; language?: string; } export interface CaregiverPerson { id?: string; fullName?: string; relation?: string; phone?: string; email?: string; } export interface CaregiverDistress { isInDifficulty?: boolean; level?: "faible" | "moderee" | "elevee" | "inconnu"; freeText?: string; supportNeeds?: string; } export interface CaregiverBlock { hasCaregiver?: boolean; mainCaregiver?: CaregiverPerson; relatives?: CaregiverPerson[]; caregiverDistress?: CaregiverDistress; } export interface GeriatricScores { autonomy?: number; cognitive?: number; falls?: number; nutrition?: number; mood?: number; } export interface InterviewSummary { id: string; date: string; globalScore?: number; riskLevel?: RiskLevel; } export interface GeriatricsBlock { lastInterviewAt?: string; globalFragilityScore?: number; globalRiskLevel?: RiskLevel; mobilityLevel?: | "autonome" | "canne" | "deambulateur" | "fauteuil" | "alitE" | "inconnu"; cognitiveIssuesKnown?: boolean; detailedScores?: GeriatricScores; alerts?: string[]; alertsLabel?: string; recommendations?: string[]; interviewsSummary?: InterviewSummary[]; } export interface PatientDashboardData { patient: PatientInfo; caregiverBlock?: CaregiverBlock; geriatricsBlock: GeriatricsBlock; }
2ïžâŁ Composant <PatientDashboard />
// PatientDashboard.tsx import React from "react"; import { PatientDashboardData, RiskLevel, CaregiverPerson, GeriatricScores, } from "./types"; interface PatientDashboardProps { data: PatientDashboardData; } const riskLabelMap: Record<RiskLevel, string> = { faible: "Risque faible", modere: "Risque modĂ©rĂ©", eleve: "Risque Ă©levĂ©", inconnu: "Risque inconnu", }; const riskColorMap: Record<RiskLevel, string> = { faible: "bg-green-100 text-green-800", modere: "bg-orange-100 text-orange-800", eleve: "bg-red-100 text-red-800", inconnu: "bg-gray-100 text-gray-800", }; const dependencyLabelMap: Record<string, string> = { autonome: "Autonome", fragile: "Fragile", dependant: "DĂ©pendant", inconnu: "Inconnu", }; function formatDate(dateStr?: string) { if (!dateStr) return "â"; const d = new Date(dateStr); if (isNaN(d.getTime())) return dateStr; return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric", }); } function booleanToText(v?: boolean) { if (v === true) return "Oui"; if (v === false) return "Non"; return "â"; } // Mini composant KPI const KPI: React.FC<{ label: string; value?: number | string }> = ({ label, value, }) => ( <div className="flex flex-col items-end ml-4"> <span className="text-xs text-gray-500">{label}</span> <span className="text-xl font-semibold text-gray-900"> {value !== undefined && value !== null && value !== "" ? value : "â"} </span> </div> ); // Badge risque const RiskBadge: React.FC<{ level?: RiskLevel }> = ({ level = "inconnu" }) => ( <span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${riskColorMap[level]}`} > {riskLabelMap[level]} </span> ); // Card gĂ©nĂ©rique const Card: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children, }) => ( <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 mb-4"> <h3 className="text-sm font-semibold text-gray-700 mb-3">{title}</h3> {children} </div> ); // Liste clĂ©/valeur const KeyValueList: React.FC<{ items: { label: string; value?: string | number | null }[]; }> = ({ items }) => ( <dl className="space-y-1 text-sm"> {items.map((item, idx) => ( <div key={idx} className="flex justify-between"> <dt className="text-gray-500">{item.label}</dt> <dd className="text-gray-900 font-medium ml-4 text-right"> {item.value !== undefined && item.value !== null && item.value !== "" ? item.value : "â"} </dd> </div> ))} </dl> ); // Table enfants / proches const RelativesTable: React.FC<{ relatives?: CaregiverPerson[] }> = ({ relatives, }) => { if (!relatives || relatives.length === 0) { return <p className="text-sm text-gray-400">Aucun proche dĂ©clarĂ©.</p>; } return ( <div className="overflow-hidden rounded-lg border border-gray-100"> <table className="min-w-full divide-y divide-gray-100 text-sm"> <thead className="bg-gray-50"> <tr> <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500"> Nom </th> <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500"> Lien </th> <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500"> TĂ©lĂ©phone </th> <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500"> E-mail </th> </tr> </thead> <tbody className="bg-white divide-y divide-gray-50"> {relatives.map((r, idx) => ( <tr key={idx}> <td className="px-3 py-2 text-gray-900">{r.fullName || "â"}</td> <td className="px-3 py-2 text-gray-700">{r.relation || "â"}</td> <td className="px-3 py-2 text-gray-700">{r.phone || "â"}</td> <td className="px-3 py-2 text-gray-700">{r.email || "â"}</td> </tr> ))} </tbody> </table> </div> ); }; // Bande de scores const ScoresStrip: React.FC<{ scores?: GeriatricScores }> = ({ scores }) => { const items: { key: keyof GeriatricScores; label: string }[] = [ { key: "autonomy", label: "Autonomie" }, { key: "cognitive", label: "Cognitif" }, { key: "falls", label: "Chute" }, { key: "nutrition", label: "Nutrition" }, { key: "mood", label: "Moral" }, ]; return ( <div className="grid grid-cols-2 md:grid-cols-5 gap-3"> {items.map(({ key, label }) => { const value = scores?.[key]; return ( <div key={key} className="flex flex-col items-center rounded-lg border border-gray-100 bg-gray-50 px-3 py-2" > <span className="text-xs text-gray-500 mb-1">{label}</span> <span className="text-lg font-semibold text-gray-900"> {typeof value === "number" ? `${value}` : "â"} </span> </div> ); })} </div> ); }; export const PatientDashboard: React.FC<PatientDashboardProps> = ({ data }) => { const { patient, caregiverBlock, geriatricsBlock } = data; return ( <div className="w-full max-w-6xl mx-auto p-6 bg-gray-50 min-h-screen"> {/* HEADER */} <header className="flex flex-col md:flex-row md:items-center md:justify-between border-b border-gray-200 pb-4 mb-6"> <div className="mb-3 md:mb-0"> <h1 className="text-2xl font-semibold text-gray-900"> {patient.fullName} </h1> <div className="flex items-center flex-wrap gap-2 mt-1"> {patient.externalRef && ( <span className="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700"> ID Synergia : {patient.externalRef} </span> )} {patient.age !== undefined && ( <span className="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700"> {patient.age} ans </span> )} {patient.dependencyLevel && ( <span className="inline-flex items-center rounded-full bg-purple-50 px-2.5 py-1 text-xs font-medium text-purple-700"> {dependencyLabelMap[patient.dependencyLevel] ?? patient.dependencyLevel} </span> )} </div> </div> <div className="flex flex-col items-end"> <RiskBadge level={geriatricsBlock.globalRiskLevel || "inconnu"} /> <div className="flex mt-2"> <KPI label="Score fragilitĂ©" value={ geriatricsBlock.globalFragilityScore !== undefined ? geriatricsBlock.globalFragilityScore : "â" } /> </div> </div> </header> {/* MAIN 2 COLS */} <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> {/* COL GAUCHE : Aidant & famille */} <section> <h2 className="text-sm font-semibold text-gray-600 mb-3"> Aidant & Famille (WebApp #1) </h2> <Card title="Aidant principal"> <div className="flex items-center justify-between mb-2"> <span className="text-sm text-gray-700"> Aidant identifiĂ© :{" "} <span className="font-medium"> {booleanToText(caregiverBlock?.hasCaregiver)} </span> </span> </div> <KeyValueList items={[ { label: "Nom", value: caregiverBlock?.mainCaregiver?.fullName || "â", }, { label: "Lien", value: caregiverBlock?.mainCaregiver?.relation || "â", }, { label: "TĂ©lĂ©phone", value: caregiverBlock?.mainCaregiver?.phone || "â", }, { label: "E-mail", value: caregiverBlock?.mainCaregiver?.email || "â", }, ]} /> </Card> <Card title="Enfants & proches"> <RelativesTable relatives={caregiverBlock?.relatives} /> </Card> <Card title="Ressenti de lâaidant"> <div className="mb-2"> <span className="text-sm text-gray-700"> Aidant en difficultĂ© :{" "} <span className="font-medium"> {booleanToText( caregiverBlock?.caregiverDistress?.isInDifficulty )} </span> </span> </div> <KeyValueList items={[ { label: "Niveau de charge", value: caregiverBlock?.caregiverDistress?.level?.toString() ?? "â", }, ]} /> <div className="mt-3"> <label className="block text-xs font-medium text-gray-500 mb-1"> Paroles de lâaidant </label> <p className="text-sm text-gray-800 whitespace-pre-line bg-gray-50 rounded-md p-2"> {caregiverBlock?.caregiverDistress?.freeText || "â"} </p> </div> <div className="mt-3"> <label className="block text-xs font-medium text-gray-500 mb-1"> Besoins exprimĂ©s </label> <p className="text-sm text-gray-800 whitespace-pre-line bg-gray-50 rounded-md p-2"> {caregiverBlock?.caregiverDistress?.supportNeeds || "â"} </p> </div> </Card> </section> {/* COL DROITE : Senior & gĂ©riatrie */} <section> <h2 className="text-sm font-semibold text-gray-600 mb-3"> Senior & GĂ©riatrie (Bot IA) </h2> <Card title="SynthĂšse derniĂšre Ă©valuation"> <KeyValueList items={[ { label: "Date", value: formatDate(geriatricsBlock.lastInterviewAt), }, { label: "Score fragilitĂ©", value: geriatricsBlock.globalFragilityScore !== undefined ? `${geriatricsBlock.globalFragilityScore}` : "â", }, { label: "Niveau de risque", value: riskLabelMap[ geriatricsBlock.globalRiskLevel || "inconnu" ], }, { label: "MobilitĂ©", value: geriatricsBlock.mobilityLevel || "â", }, { label: "Troubles cognitifs connus", value: booleanToText( geriatricsBlock.cognitiveIssuesKnown ), }, ]} /> </Card> <Card title="Scores dĂ©taillĂ©s"> <ScoresStrip scores={geriatricsBlock.detailedScores} /> </Card> <Card title="Alertes & recommandations"> <div className="mb-3"> <label className="block text-xs font-medium text-gray-500 mb-1"> Alertes </label> <div className="flex flex-wrap gap-2"> {(geriatricsBlock.alerts || []).length > 0 ? ( geriatricsBlock.alerts!.map((a, idx) => ( <span key={idx} className="inline-flex items-center rounded-full bg-red-50 px-2.5 py-1 text-xs font-medium text-red-700" > {a} </span> )) ) : ( <span className="text-sm text-gray-400"> Aucune alerte active. </span> )} </div> </div> <div> <label className="block text-xs font-medium text-gray-500 mb-1"> Recommandations </label> {(geriatricsBlock.recommendations || []).length > 0 ? ( <ul className="list-disc list-inside text-sm text-gray-800 space-y-1"> {geriatricsBlock.recommendations!.map((r, idx) => ( <li key={idx}>{r}</li> ))} </ul> ) : ( <p className="text-sm text-gray-400">Aucune recommandation.</p> )} </div> </Card> <Card title="Entretiens gĂ©riatriques"> {geriatricsBlock.interviewsSummary && geriatricsBlock.interviewsSummary.length > 0 ? ( <div className="overflow-hidden rounded-lg border border-gray-100"> <table className="min-w-full divide-y divide-gray-100 text-sm"> <thead className="bg-gray-50"> <tr> <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500"> Date </th> <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500"> Score </th> <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500"> Risque </th> </tr> </thead> <tbody className="bg-white divide-y divide-gray-50"> {geriatricsBlock.interviewsSummary.map((itw) => ( <tr key={itw.id}> <td className="px-3 py-2 text-gray-900"> {formatDate(itw.date)} </td> <td className="px-3 py-2 text-gray-700"> {itw.globalScore ?? "â"} </td> <td className="px-3 py-2 text-gray-700"> {riskLabelMap[itw.riskLevel || "inconnu"]} </td> </tr> ))} </tbody> </table> </div> ) : ( <p className="text-sm text-gray-400"> Aucun entretien gĂ©riatrique enregistrĂ©. </p> )} </Card> </section> </div> </div> ); };
3ïžâŁ Exemple dâappel du composant
// App.tsx import React from "react"; import { PatientDashboard } from "./PatientDashboard"; import { PatientDashboardData } from "./types"; const mockData: PatientDashboardData = { patient: { id: "123", externalRef: "PAT-000123", fullName: "Jeanne Dupont", age: 82, dependencyLevel: "fragile", }, caregiverBlock: { hasCaregiver: true, mainCaregiver: { fullName: "Paul Dupont", relation: "fils", phone: "+33 6 12 34 56 78", email: "paul.dupont@example.com", }, relatives: [ { fullName: "Marie Dupont", relation: "fille", phone: "+33 6 98 76 54 32", email: "marie.dupont@example.com", }, ], caregiverDistress: { isInDifficulty: true, level: "elevee", freeText: "Je me sens dĂ©passĂ©, jâai du mal Ă concilier travail et accompagnement.", supportNeeds: "Besoin dâaide Ă domicile et de rĂ©pit.", }, }, geriatricsBlock: { lastInterviewAt: "2025-11-27T14:25:00Z", globalFragilityScore: 68, globalRiskLevel: "eleve", mobilityLevel: "canne", cognitiveIssuesKnown: true, detailedScores: { autonomy: 55, cognitive: 40, falls: 80, nutrition: 60, mood: 65, }, alerts: ["chute", "isolement", "oubli traitement"], recommendations: [ "AmĂ©nagement salle de bain", "Visite infirmiĂšre hebdomadaire", "Bilan gĂ©riatrique spĂ©cialisĂ©", ], interviewsSummary: [ { id: "itw-1", date: "2025-10-10T10:00:00Z", globalScore: 62, riskLevel: "modere", }, { id: "itw-2", date: "2025-08-01T09:30:00Z", globalScore: 55, riskLevel: "modere", }, ], }, }; export const App: React.FC = () => { return <PatientDashboard data={mockData} />; };
Si tu veux ensuite, je peux :
- te faire la version strictement alignée avec Odoo (noms de champs x_⊠en interne + adaptateur),
- ou un schĂ©ma âAPI backendâ pour que ton orchestrateur (n8n / Nest) alimente exactement ce JSON pour le front.
version strictement alignée avec Odoo (noms de champs x_⊠en interne + adaptateur),
Parfait, on va coller Ă Odoo Ă 100 % :
- noms de champs x_⊠tels quâon les a dĂ©finis,
- un type TypeScript pour la réponse search_read Odoo,
- un adaptateur qui transforme ça en PatientDashboardData déjà utilisé par <PatientDashboard />.
1ïžâŁ Interfaces cĂŽtĂ© Odoo (JSON-RPC)
// odoo-types.ts /** Représentation d'un Many2one Odoo : [id, display_name] */ export type OdooMany2One = [number, string] | false | null; /** Enregistrement res.partner tel que renvoyé par search_read */ export interface OdooPartnerRecord { id: number; name: string; // Identité / profil x_external_ref?: string; birthdate_date?: string; // "YYYY-MM-DD" x_gender?: "homme" | "femme" | "autre" | "inconnu"; x_dependency_level?: "autonome" | "fragile" | "dependant" | "inconnu"; x_preferred_channel?: | "voix" | "sms" | "email" | "whatsapp" | "app" | "inconnu"; x_language?: string; // Aidant / famille (WebApp #1) x_has_caregiver?: boolean; x_main_caregiver_id?: OdooMany2One; // -> res.partner x_caregiver_relation?: string; x_children_json?: string; // JSON texte x_caregiver_burnout?: boolean; x_caregiver_burnout_level?: "faible" | "moderee" | "elevee" | "inconnu"; x_caregiver_comments?: string; x_support_needs?: string; // Dernier état gériatrique (Bot IA) x_last_ai_interview?: string; // datetime ISO x_last_fragility_score?: number; x_last_risk_level?: "faible" | "modere" | "eleve" | "inconnu"; x_mobility_level?: | "autonome" | "canne" | "deambulateur" | "fauteuil" | "alitE" | "inconnu"; x_known_cognitive_issue?: boolean; x_score_autonomie?: number; x_score_cognitif?: number; x_score_chute?: number; x_score_nutrition?: number; x_score_moral?: number; x_last_alertes_label?: string; x_last_reco_text?: string; } /** Enregistrement x_synergia_geriatrie_entretien pour l'historique */ export interface OdooGeriatrieEntretien { id: number; x_date_entretien?: string; // datetime x_fragilite_globale?: number; x_niveau_risque?: "faible" | "modere" | "eleve" | "inconnu"; }
2ïžâŁ Types front (dĂ©jĂ utilisĂ©s par ton dashboard)
On reprend les types du <PatientDashboard /> (résumé) :
// front-types.ts export type RiskLevel = "faible" | "modere" | "eleve" | "inconnu"; export interface PatientInfo { id: string; externalRef?: string; fullName: string; birthDate?: string; age?: number; gender?: "homme" | "femme" | "autre" | "inconnu"; dependencyLevel?: "autonome" | "fragile" | "dependant" | "inconnu"; preferredChannel?: "voix" | "sms" | "email" | "whatsapp" | "app" | "inconnu"; language?: string; } export interface CaregiverPerson { id?: string; fullName?: string; relation?: string; phone?: string; email?: string; } export interface CaregiverDistress { isInDifficulty?: boolean; level?: "faible" | "moderee" | "elevee" | "inconnu"; freeText?: string; supportNeeds?: string; } export interface CaregiverBlock { hasCaregiver?: boolean; mainCaregiver?: CaregiverPerson; relatives?: CaregiverPerson[]; caregiverDistress?: CaregiverDistress; } export interface GeriatricScores { autonomy?: number; cognitive?: number; falls?: number; nutrition?: number; mood?: number; } export interface InterviewSummary { id: string; date: string; globalScore?: number; riskLevel?: RiskLevel; } export interface GeriatricsBlock { lastInterviewAt?: string; globalFragilityScore?: number; globalRiskLevel?: RiskLevel; mobilityLevel?: | "autonome" | "canne" | "deambulateur" | "fauteuil" | "alitE" | "inconnu"; cognitiveIssuesKnown?: boolean; detailedScores?: GeriatricScores; alerts?: string[]; alertsLabel?: string; recommendations?: string[]; interviewsSummary?: InterviewSummary[]; } export interface PatientDashboardData { patient: PatientInfo; caregiverBlock?: CaregiverBlock; geriatricsBlock: GeriatricsBlock; }
3ïžâŁ Adaptateur Odoo â Dashboard
Ce module fait le âpontâ entre Odoo JSON-RPC et le front.
Tu peux lâutiliser dans ton backend (Nest) ou directement cĂŽtĂ© front si tu appelles lâAPI Odoo depuis un proxy.
// odoo-adapter.ts import { OdooPartnerRecord, OdooGeriatrieEntretien, OdooMany2One, } from "./odoo-types"; import { PatientDashboardData, CaregiverPerson, RiskLevel, } from "./front-types"; function unwrapMany2One(m2o?: OdooMany2One): { id?: string; name?: string } { if (!m2o || m2o === false) return {}; const [id, name] = m2o; return { id: String(id), name }; } function computeAge(birthdate?: string): number | undefined { if (!birthdate) return undefined; const d = new Date(birthdate); if (isNaN(d.getTime())) return undefined; const now = new Date(); let age = now.getFullYear() - d.getFullYear(); const m = now.getMonth() - d.getMonth(); if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--; return age; } export function mapOdooToDashboard( partner: OdooPartnerRecord, entretiens: OdooGeriatrieEntretien[] ): PatientDashboardData { // 1) Patient const patient = { id: String(partner.id), externalRef: partner.x_external_ref, fullName: partner.name, birthDate: partner.birthdate_date, age: computeAge(partner.birthdate_date), gender: partner.x_gender, dependencyLevel: partner.x_dependency_level, preferredChannel: partner.x_preferred_channel, language: partner.x_language, }; // 2) Aidant & famille const mainCaregiverRaw = unwrapMany2One(partner.x_main_caregiver_id); const mainCaregiver: CaregiverPerson | undefined = mainCaregiverRaw.id || mainCaregiverRaw.name ? { id: mainCaregiverRaw.id, fullName: mainCaregiverRaw.name, relation: partner.x_caregiver_relation, // téléphone / email : à rajouter si tu les exposes dans search_read } : undefined; // Enfants / proches : stockés en JSON texte dans x_children_json let relatives: CaregiverPerson[] | undefined; if (partner.x_children_json) { try { const parsed = JSON.parse(partner.x_children_json) as any[]; relatives = parsed.map((child) => ({ fullName: child.nom ?? child.fullName, relation: child.lien ?? child.relation, phone: child.tel ?? child.phone, email: child.email, })); } catch (e) { console.warn("x_children_json invalide pour partner", partner.id, e); } } const caregiverBlock = { hasCaregiver: partner.x_has_caregiver, mainCaregiver, relatives, caregiverDistress: { isInDifficulty: partner.x_caregiver_burnout, level: partner.x_caregiver_burnout_level, freeText: partner.x_caregiver_comments, supportNeeds: partner.x_support_needs, }, }; // 3) Bloc gériatrie const geriatricsBlock = { lastInterviewAt: partner.x_last_ai_interview, globalFragilityScore: partner.x_last_fragility_score, globalRiskLevel: partner.x_last_risk_level as RiskLevel | undefined, mobilityLevel: partner.x_mobility_level, cognitiveIssuesKnown: partner.x_known_cognitive_issue, detailedScores: { autonomy: partner.x_score_autonomie, cognitive: partner.x_score_cognitif, falls: partner.x_score_chute, nutrition: partner.x_score_nutrition, mood: partner.x_score_moral, }, alerts: partner.x_last_alertes_label ? partner.x_last_alertes_label .split(";") .map((s) => s.trim()) .filter(Boolean) : undefined, alertsLabel: partner.x_last_alertes_label, recommendations: partner.x_last_reco_text ? partner.x_last_reco_text .split("\n") .map((s) => s.trim()) .filter(Boolean) : undefined, interviewsSummary: entretiens.map((e) => ({ id: String(e.id), date: e.x_date_entretien || "", globalScore: e.x_fragilite_globale, riskLevel: e.x_niveau_risque as RiskLevel | undefined, })), }; return { patient, caregiverBlock, geriatricsBlock, }; }
4ïžâŁ Exemple dâusage end-to-end
Dans ton backend (NestJS ou autre) :
// Exemple pseudo-service async function getPatientDashboardFromOdoo(partnerId: number) { // 1) search_read sur res.partner avec tous les champs x_⊠nĂ©cessaires const [partner] = (await odooCall<OdooPartnerRecord[]>("res.partner", "search_read", [ [["id", "=", partnerId]], [ "name", "birthdate_date", "x_external_ref", "x_gender", "x_dependency_level", "x_preferred_channel", "x_language", "x_has_caregiver", "x_main_caregiver_id", "x_caregiver_relation", "x_children_json", "x_caregiver_burnout", "x_caregiver_burnout_level", "x_caregiver_comments", "x_support_needs", "x_last_ai_interview", "x_last_fragility_score", "x_last_risk_level", "x_mobility_level", "x_known_cognitive_issue", "x_score_autonomie", "x_score_cognitif", "x_score_chute", "x_score_nutrition", "x_score_moral", "x_last_alertes_label", "x_last_reco_text" ] ])) || []; // 2) search_read sur x_synergia_geriatrie_entretien pour lâhistorique const entretiens = await odooCall<OdooGeriatrieEntretien[]>( "x_synergia_geriatrie_entretien", "search_read", [ [["x_patient_id", "=", partnerId]], ["id", "x_date_entretien", "x_fragilite_globale", "x_niveau_risque"], ] ); // 3) Adapter vers PatientDashboardData return mapOdooToDashboard(partner, entretiens); }
Ensuite, cÎté front :
// front const data: PatientDashboardData = await fetch("/api/patient/123").then(r => r.json()); return <PatientDashboard data={data} />;
Avec ça, tu as vraiment :
- đ un contrat strictement alignĂ© Odoo (tous les x_âŠ)
- đ un adaptateur unique Ă maintenir si on ajoute des champs
- đš un front React totalement indĂ©pendant dâOdoo, qui consomme un JSON propre.
Si tu veux, je peux te faire la version NestJS complÚte du odooCall() (service générique JSON-RPC /web/dataset/call_kw), ou une spec OpenAPI pour ton endpoint /api/patient/:id.
schĂ©ma âAPI backendâ pour que ton orchestrateur (n8n / Nest) alimente exactement ce JSON pour le front.
Parfait, on va faire une vraie spec dâAPI backend pour servir ton PatientDashboardData au front, en restant agnostique : que ce soit Nest qui parle Ă Odoo, ou n8n qui orchestre derriĂšre, le front ne voit quâun joli JSON propre.
Je te donne :
- đïž Architecture logique
- đ Endpoints proposĂ©s
- đŠ SchĂ©ma JSON de la rĂ©ponse (strict)
- đ§Ș Exemple de payload complet
- đ Extrait OpenAPI 3.0
1ïžâŁ Architecture logique
-
Frontend (React / Next)
â appelle une API REST simple, ex. GET /api/patient-dashboard/:id -
Backend API (NestJS ou autre)
â implĂ©mente /api/patient-dashboard
â appelle Odoo (et Ă©ventuellement n8n) via JSON-RPC / HTTP
â utilise lâadaptateur mapOdooToDashboard(...) quâon a dĂ©fini
â renvoie le JSON propre au front -
Odoo / n8n
- Odoo : source de vérité (res.partner + x_synergia_geriatrie_entretien)
-
n8n : pour les flows âĂ©critureâ (entretien IA, alertes, etc.).
LâAPI de dashboard, elle, peut taper directement Odoo.
2ïžâŁ Endpoints proposĂ©s (backend)
Je te propose un mini-API trĂšs simple :
| Méthode | URL | Description |
|---|---|---|
| GET | /api/patient-dashboard/:id | Charger la fiche dâun patient par id Odoo |
| GET | /api/patient-dashboard/by-external/:ref | Charger la fiche par x_external_ref |
| GET | /api/patient-dashboard/:id/interviews | (optionnel) Liste brute des entretiens |
Pour le front, un seul endpoint suffit vraiment : GET /api/patient-dashboard/:id.
3ïžâŁ SchĂ©ma JSON de la rĂ©ponse
Câest exactement notre PatientDashboardData :
{ "patient": { "id": "123", "externalRef": "PAT-000123", "fullName": "Jeanne Dupont", "birthDate": "1943-05-12", "age": 82, "gender": "femme", "dependencyLevel": "fragile", "preferredChannel": "voix", "language": "fr" }, "caregiverBlock": { "hasCaregiver": true, "mainCaregiver": { "id": "456", "fullName": "Paul Dupont", "relation": "fils", "phone": "+33 6 12 34 56 78", "email": "paul.dupont@example.com" }, "relatives": [ { "fullName": "Marie Dupont", "relation": "fille", "phone": "+33 6 98 76 54 32", "email": "marie.dupont@example.com" } ], "caregiverDistress": { "isInDifficulty": true, "level": "elevee", "freeText": "Je me sens dĂ©passĂ©âŠ", "supportNeeds": "Besoin dâaide Ă domicile et de rĂ©pit." } }, "geriatricsBlock": { "lastInterviewAt": "2025-11-27T14:25:00Z", "globalFragilityScore": 68, "globalRiskLevel": "eleve", "mobilityLevel": "canne", "cognitiveIssuesKnown": true, "detailedScores": { "autonomy": 55, "cognitive": 40, "falls": 80, "nutrition": 60, "mood": 65 }, "alerts": ["chute", "isolement", "oubli traitement"], "alertsLabel": "chute ; isolement ; oubli traitement", "recommendations": [ "AmĂ©nagement salle de bain", "Visite infirmiĂšre hebdomadaire", "Bilan gĂ©riatrique spĂ©cialisĂ©" ], "interviewsSummary": [ { "id": "itw-1", "date": "2025-10-10T10:00:00Z", "globalScore": 62, "riskLevel": "modere" }, { "id": "itw-2", "date": "2025-08-01T09:30:00Z", "globalScore": 55, "riskLevel": "modere" } ] } }
Erreur standard (simple)
En cas dâerreur (patient introuvable, problĂšme Odoo, etc.) :
{ "error": { "code": "PATIENT_NOT_FOUND", "message": "Aucun patient trouvé avec cet identifiant." } }
4ïžâŁ SpĂ©cification âAPI backendâ (logique)
4.1. Endpoint principal
GET /api/patient-dashboard/:id
-
Path params :
- id : string â identifiant interne Odoo (res.partner.id)
-
Réponse 200 :
Objet PatientDashboardData (voir JSON ci-dessus). - Réponse 404 :
{ "error": { "code": "PATIENT_NOT_FOUND", "message": "Aucun patient trouvé." } }
4.2. Endpoint alternatif par externalRef
GET /api/patient-dashboard/by-external/:ref
-
Path params :
- ref : string â x_external_ref du patient
-
Comportement :
- Le backend fait un search_read Odoo sur res.partner avec x_external_ref = :ref
- Sâil trouve un partner, il appelle la mĂȘme logique que pour /api/patient-dashboard/:id
- Sinon, 404.
4.3. (Optionnel) Endpoint âinterviews onlyâ
GET /api/patient-dashboard/:id/interviews
- Retourne juste :
{ "patientId": "123", "interviews": [ { "id": "itw-1", "date": "2025-10-10T10:00:00Z", "globalScore": 62, "riskLevel": "modere" } ] }
5ïžâŁ Extrait OpenAPI 3.0 (Ă coller dans Notion / YAML)
openapi: 3.0.3 info: title: Synergia Senior â Patient Dashboard API version: "1.0.0" paths: /api/patient-dashboard/{id}: get: summary: RĂ©cupĂ©rer le dashboard patient par ID Odoo parameters: - in: path name: id required: true schema: type: string description: Identifiant interne Odoo (res.partner.id) responses: "200": description: Dashboard patient content: application/json: schema: $ref: "#/components/schemas/PatientDashboardData" "404": description: Patient introuvable content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /api/patient-dashboard/by-external/{ref}: get: summary: RĂ©cupĂ©rer le dashboard patient par identifiant externe Synergia parameters: - in: path name: ref required: true schema: type: string description: Identifiant externe (x_external_ref) responses: "200": description: Dashboard patient content: application/json: schema: $ref: "#/components/schemas/PatientDashboardData" "404": description: Patient introuvable content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" components: schemas: PatientDashboardData: type: object properties: patient: type: object properties: id: { type: string } externalRef: { type: string } fullName: { type: string } birthDate: { type: string, format: date } age: { type: integer } gender: type: string enum: [homme, femme, autre, inconnu] dependencyLevel: type: string enum: [autonome, fragile, dependant, inconnu] preferredChannel: type: string enum: [voix, sms, email, whatsapp, app, inconnu] language: { type: string } required: [id, fullName] caregiverBlock: type: object nullable: true properties: hasCaregiver: { type: boolean } mainCaregiver: type: object nullable: true properties: id: { type: string } fullName: { type: string } relation: { type: string } phone: { type: string } email: { type: string } relatives: type: array items: type: object properties: fullName: { type: string } relation: { type: string } phone: { type: string } email: { type: string } caregiverDistress: type: object nullable: true properties: isInDifficulty: { type: boolean } level: type: string enum: [faible, moderee, elevee, inconnu] freeText: { type: string } supportNeeds: { type: string } geriatricsBlock: type: object properties: lastInterviewAt: { type: string, format: date-time } globalFragilityScore: { type: integer, minimum: 0, maximum: 100 } globalRiskLevel: type: string enum: [faible, modere, eleve, inconnu] mobilityLevel: type: string enum: [autonome, canne, deambulateur, fauteuil, alitE, inconnu] cognitiveIssuesKnown: { type: boolean } detailedScores: type: object properties: autonomy: { type: integer, minimum: 0, maximum: 100 } cognitive: { type: integer, minimum: 0, maximum: 100 } falls: { type: integer, minimum: 0, maximum: 100 } nutrition: { type: integer, minimum: 0, maximum: 100 } mood: { type: integer, minimum: 0, maximum: 100 } alerts: type: array items: { type: string } alertsLabel: { type: string } recommendations: type: array items: { type: string } interviewsSummary: type: array items: type: object properties: id: { type: string } date: { type: string, format: date-time } globalScore: { type: integer } riskLevel: type: string enum: [faible, modere, eleve, inconnu] ErrorResponse: type: object properties: error: type: object properties: code: { type: string } message: { type: string } required: [error]
Comment ton orchestrateur sây branche
-
Nest :
- Controller /api/patient-dashboard
- Service qui appelle Odoo JSON-RPC, puis mapOdooToDashboard(...)
- Renvoie PatientDashboardData
-
n8n (si tu veux lâutiliser comme façade) :
- Webhook /api/patient-dashboard/:id
- Nodes HTTP â Odoo + Function â mapOdooToDashboard (version JS)
- Réponse JSON au front
controller NestJS complet (avec DTO) qui implĂ©mente exactement ce schĂ©ma, ou le flow n8n âAPI façadeâ qui sert ce JSON au front
On va partir sur NestJS complet đ
Je te donne :
- les DTO (réponse + erreur)
- le controller avec 2 endpoints
- un service PatientDashboardService
- un client Odoo générique pour call_kw
- lâintĂ©gration de mapOdooToDashboard(...) quâon a dĂ©jĂ dĂ©fini
Tu peux quasi copié-coller dans un projet Nest.
1ïžâŁ DTO & types (src/patient-dashboard/dto/*.ts)
a) DTO de rĂ©ponse â patient-dashboard-response.dto.ts
// src/patient-dashboard/dto/patient-dashboard-response.dto.ts import { ApiProperty } from '@nestjs/swagger'; export type RiskLevel = 'faible' | 'modere' | 'eleve' | 'inconnu'; export class PatientInfoDto { @ApiProperty() id: string; @ApiProperty({ required: false }) externalRef?: string; @ApiProperty() fullName: string; @ApiProperty({ required: false, format: 'date' }) birthDate?: string; @ApiProperty({ required: false }) age?: number; @ApiProperty({ required: false, enum: ['homme', 'femme', 'autre', 'inconnu'], }) gender?: 'homme' | 'femme' | 'autre' | 'inconnu'; @ApiProperty({ required: false, enum: ['autonome', 'fragile', 'dependant', 'inconnu'], }) dependencyLevel?: 'autonome' | 'fragile' | 'dependant' | 'inconnu'; @ApiProperty({ required: false, enum: ['voix', 'sms', 'email', 'whatsapp', 'app', 'inconnu'], }) preferredChannel?: 'voix' | 'sms' | 'email' | 'whatsapp' | 'app' | 'inconnu'; @ApiProperty({ required: false }) language?: string; } export class CaregiverPersonDto { @ApiProperty({ required: false }) id?: string; @ApiProperty({ required: false }) fullName?: string; @ApiProperty({ required: false }) relation?: string; @ApiProperty({ required: false }) phone?: string; @ApiProperty({ required: false }) email?: string; } export class CaregiverDistressDto { @ApiProperty({ required: false }) isInDifficulty?: boolean; @ApiProperty({ required: false, enum: ['faible', 'moderee', 'elevee', 'inconnu'], }) level?: 'faible' | 'moderee' | 'elevee' | 'inconnu'; @ApiProperty({ required: false }) freeText?: string; @ApiProperty({ required: false }) supportNeeds?: string; } export class CaregiverBlockDto { @ApiProperty({ required: false }) hasCaregiver?: boolean; @ApiProperty({ type: () => CaregiverPersonDto, required: false }) mainCaregiver?: CaregiverPersonDto; @ApiProperty({ type: () => [CaregiverPersonDto], required: false }) relatives?: CaregiverPersonDto[]; @ApiProperty({ type: () => CaregiverDistressDto, required: false }) caregiverDistress?: CaregiverDistressDto; } export class GeriatricScoresDto { @ApiProperty({ required: false }) autonomy?: number; @ApiProperty({ required: false }) cognitive?: number; @ApiProperty({ required: false }) falls?: number; @ApiProperty({ required: false }) nutrition?: number; @ApiProperty({ required: false }) mood?: number; } export class InterviewSummaryDto { @ApiProperty() id: string; @ApiProperty({ format: 'date-time' }) date: string; @ApiProperty({ required: false }) globalScore?: number; @ApiProperty({ required: false, enum: ['faible', 'modere', 'eleve', 'inconnu'], }) riskLevel?: RiskLevel; } export class GeriatricsBlockDto { @ApiProperty({ required: false, format: 'date-time' }) lastInterviewAt?: string; @ApiProperty({ required: false, minimum: 0, maximum: 100 }) globalFragilityScore?: number; @ApiProperty({ required: false, enum: ['faible', 'modere', 'eleve', 'inconnu'], }) globalRiskLevel?: RiskLevel; @ApiProperty({ required: false, enum: ['autonome', 'canne', 'deambulateur', 'fauteuil', 'alitE', 'inconnu'], }) mobilityLevel?: | 'autonome' | 'canne' | 'deambulateur' | 'fauteuil' | 'alitE' | 'inconnu'; @ApiProperty({ required: false }) cognitiveIssuesKnown?: boolean; @ApiProperty({ type: () => GeriatricScoresDto, required: false }) detailedScores?: GeriatricScoresDto; @ApiProperty({ type: () => [String], required: false }) alerts?: string[]; @ApiProperty({ required: false }) alertsLabel?: string; @ApiProperty({ type: () => [String], required: false }) recommendations?: string[]; @ApiProperty({ type: () => [InterviewSummaryDto], required: false }) interviewsSummary?: InterviewSummaryDto[]; } export class PatientDashboardDataDto { @ApiProperty({ type: () => PatientInfoDto }) patient: PatientInfoDto; @ApiProperty({ type: () => CaregiverBlockDto, required: false }) caregiverBlock?: CaregiverBlockDto; @ApiProperty({ type: () => GeriatricsBlockDto }) geriatricsBlock: GeriatricsBlockDto; } export class ErrorResponseDto { @ApiProperty({ example: { code: 'PATIENT_NOT_FOUND', message: 'Aucun patient trouvé.' }, }) error: { code: string; message: string; }; }
2ïžâŁ Client Odoo gĂ©nĂ©rique â odoo.client.ts
// src/odoo/odoo.client.ts import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; import axios, { AxiosInstance } from 'axios'; interface OdooJsonRpcParams { model: string; method: string; args: any[]; kwargs?: Record<string, any>; } interface OdooJsonRpcRequest { jsonrpc: '2.0'; method: 'call'; params: OdooJsonRpcParams; } interface OdooJsonRpcResponse<T> { jsonrpc: '2.0'; id?: any; result?: T; error?: { code: number; message: string; data?: any; }; } @Injectable() export class OdooClient { private readonly http: AxiosInstance; constructor() { this.http = axios.create({ baseURL: process.env.ODOO_BASE_URL, // ex: https://xxx.odoo.com headers: { 'Content-Type': 'application/json', }, }); // si tu utilises une API key / auth basique : const apiKey = process.env.ODOO_API_KEY; const db = process.env.ODOO_DB; const login = process.env.ODOO_LOGIN; if (apiKey && db && login) { // Auth Odoo Online typique via header custom ou Basic + context this.http.defaults.auth = { username: `${login}`, password: apiKey, }; } } async call<T>(params: OdooJsonRpcParams): Promise<T> { const payload: OdooJsonRpcRequest = { jsonrpc: '2.0', method: 'call', params, }; try { const res = await this.http.post<OdooJsonRpcResponse<T>>( '/web/dataset/call_kw', payload, ); if (res.data.error) { throw new HttpException( { error: { code: 'ODOO_ERROR', message: res.data.error.message, data: res.data.error.data, }, }, HttpStatus.BAD_GATEWAY, ); } return res.data.result as T; } catch (e: any) { throw new HttpException( { error: { code: 'ODOO_CALL_FAILED', message: e.message ?? 'Erreur lors de lâappel Odoo', }, }, HttpStatus.BAD_GATEWAY, ); } } }
3ïžâŁ Types Odoo & adaptateur â (reprend ce quâon a dĂ©jĂ )
Fichier types Odoo + mapper (je condense, tu peux reprendre la version longue quâon a Ă©crite) :
// src/patient-dashboard/odoo-types.ts export type OdooMany2One = [number, string] | false | null; export interface OdooPartnerRecord { id: number; name: string; x_external_ref?: string; birthdate_date?: string; x_gender?: 'homme' | 'femme' | 'autre' | 'inconnu'; x_dependency_level?: 'autonome' | 'fragile' | 'dependant' | 'inconnu'; x_preferred_channel?: | 'voix' | 'sms' | 'email' | 'whatsapp' | 'app' | 'inconnu'; x_language?: string; x_has_caregiver?: boolean; x_main_caregiver_id?: OdooMany2One; x_caregiver_relation?: string; x_children_json?: string; x_caregiver_burnout?: boolean; x_caregiver_burnout_level?: 'faible' | 'moderee' | 'elevee' | 'inconnu'; x_caregiver_comments?: string; x_support_needs?: string; x_last_ai_interview?: string; x_last_fragility_score?: number; x_last_risk_level?: 'faible' | 'modere' | 'eleve' | 'inconnu'; x_mobility_level?: | 'autonome' | 'canne' | 'deambulateur' | 'fauteuil' | 'alitE' | 'inconnu'; x_known_cognitive_issue?: boolean; x_score_autonomie?: number; x_score_cognitif?: number; x_score_chute?: number; x_score_nutrition?: number; x_score_moral?: number; x_last_alertes_label?: string; x_last_reco_text?: string; } export interface OdooGeriatrieEntretien { id: number; x_date_entretien?: string; x_fragilite_globale?: number; x_niveau_risque?: 'faible' | 'modere' | 'eleve' | 'inconnu'; }
// src/patient-dashboard/odoo-to-dashboard.mapper.ts import { OdooPartnerRecord, OdooGeriatrieEntretien, OdooMany2One, } from './odoo-types'; import { PatientDashboardDataDto, RiskLevel, } from './dto/patient-dashboard-response.dto'; function unwrapMany2One(m2o?: OdooMany2One): { id?: string; name?: string } { if (!m2o || m2o === false) return {}; const [id, name] = m2o; return { id: String(id), name }; } function computeAge(birthdate?: string): number | undefined { if (!birthdate) return undefined; const d = new Date(birthdate); if (isNaN(d.getTime())) return undefined; const now = new Date(); let age = now.getFullYear() - d.getFullYear(); const m = now.getMonth() - d.getMonth(); if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--; return age; } export function mapOdooToDashboard( partner: OdooPartnerRecord, entretiens: OdooGeriatrieEntretien[], ): PatientDashboardDataDto { const patient: PatientDashboardDataDto['patient'] = { id: String(partner.id), externalRef: partner.x_external_ref, fullName: partner.name, birthDate: partner.birthdate_date, age: computeAge(partner.birthdate_date), gender: partner.x_gender, dependencyLevel: partner.x_dependency_level, preferredChannel: partner.x_preferred_channel, language: partner.x_language, }; const mainCaregiverRaw = unwrapMany2One(partner.x_main_caregiver_id); const mainCaregiver = mainCaregiverRaw.id || mainCaregiverRaw.name ? { id: mainCaregiverRaw.id, fullName: mainCaregiverRaw.name, relation: partner.x_caregiver_relation, } : undefined; let relatives; if (partner.x_children_json) { try { const parsed = JSON.parse(partner.x_children_json) as any[]; relatives = parsed.map((child) => ({ fullName: child.nom ?? child.fullName, relation: child.lien ?? child.relation, phone: child.tel ?? child.phone, email: child.email, })); } catch (e) { console.warn('x_children_json invalide pour partner', partner.id, e); } } const caregiverBlock: PatientDashboardDataDto['caregiverBlock'] = { hasCaregiver: partner.x_has_caregiver, mainCaregiver, relatives, caregiverDistress: { isInDifficulty: partner.x_caregiver_burnout, level: partner.x_caregiver_burnout_level, freeText: partner.x_caregiver_comments, supportNeeds: partner.x_support_needs, }, }; const geriatricsBlock: PatientDashboardDataDto['geriatricsBlock'] = { lastInterviewAt: partner.x_last_ai_interview, globalFragilityScore: partner.x_last_fragility_score, globalRiskLevel: partner.x_last_risk_level as RiskLevel | undefined, mobilityLevel: partner.x_mobility_level, cognitiveIssuesKnown: partner.x_known_cognitive_issue, detailedScores: { autonomy: partner.x_score_autonomie, cognitive: partner.x_score_cognitif, falls: partner.x_score_chute, nutrition: partner.x_score_nutrition, mood: partner.x_score_moral, }, alerts: partner.x_last_alertes_label ? partner.x_last_alertes_label .split(';') .map((s) => s.trim()) .filter(Boolean) : undefined, alertsLabel: partner.x_last_alertes_label, recommendations: partner.x_last_reco_text ? partner.x_last_reco_text .split('\n') .map((s) => s.trim()) .filter(Boolean) : undefined, interviewsSummary: entretiens.map((e) => ({ id: String(e.id), date: e.x_date_entretien || '', globalScore: e.x_fragilite_globale, riskLevel: e.x_niveau_risque as RiskLevel | undefined, })), }; return { patient, caregiverBlock, geriatricsBlock, }; }
4ïžâŁ Service â patient-dashboard.service.ts
// src/patient-dashboard/patient-dashboard.service.ts import { Injectable, NotFoundException, } from '@nestjs/common'; import { OdooClient } from '../odoo/odoo.client'; import { OdooPartnerRecord, OdooGeriatrieEntretien, } from './odoo-types'; import { mapOdooToDashboard } from './odoo-to-dashboard.mapper'; import { PatientDashboardDataDto } from './dto/patient-dashboard-response.dto'; @Injectable() export class PatientDashboardService { constructor(private readonly odoo: OdooClient) {} async getById(partnerId: number): Promise<PatientDashboardDataDto> { const partners = await this.odoo.call<OdooPartnerRecord[]>({ model: 'res.partner', method: 'search_read', args: [ [['id', '=', partnerId]], [ 'name', 'birthdate_date', 'x_external_ref', 'x_gender', 'x_dependency_level', 'x_preferred_channel', 'x_language', 'x_has_caregiver', 'x_main_caregiver_id', 'x_caregiver_relation', 'x_children_json', 'x_caregiver_burnout', 'x_caregiver_burnout_level', 'x_caregiver_comments', 'x_support_needs', 'x_last_ai_interview', 'x_last_fragility_score', 'x_last_risk_level', 'x_mobility_level', 'x_known_cognitive_issue', 'x_score_autonomie', 'x_score_cognitif', 'x_score_chute', 'x_score_nutrition', 'x_score_moral', 'x_last_alertes_label', 'x_last_reco_text', ], ], }); if (!partners || partners.length === 0) { throw new NotFoundException({ error: { code: 'PATIENT_NOT_FOUND', message: 'Aucun patient trouvé.', }, }); } const partner = partners[0]; const entretiens = await this.odoo.call<OdooGeriatrieEntretien[]>({ model: 'x_synergia_geriatrie_entretien', method: 'search_read', args: [ [['x_patient_id', '=', partnerId]], ['id', 'x_date_entretien', 'x_fragilite_globale', 'x_niveau_risque'], ], kwargs: { limit: 20, order: 'x_date_entretien desc', }, }); return mapOdooToDashboard(partner, entretiens || []); } async getByExternalRef(externalRef: string): Promise<PatientDashboardDataDto> { const partners = await this.odoo.call<OdooPartnerRecord[]>({ model: 'res.partner', method: 'search_read', args: [ [['x_external_ref', '=', externalRef]], ['id'], // on refait un getById derriÚre pour ne pas dupliquer la logique ], kwargs: { limit: 1 }, }); if (!partners || partners.length === 0) { throw new NotFoundException({ error: { code: 'PATIENT_NOT_FOUND', message: 'Aucun patient trouvé pour cet identifiant externe.', }, }); } return this.getById(partners[0].id); } }
5ïžâŁ Controller NestJS â patient-dashboard.controller.ts
// src/patient-dashboard/patient-dashboard.controller.ts import { Controller, Get, Param, ParseIntPipe, } from '@nestjs/common'; import { ApiOkResponse, ApiNotFoundResponse, ApiTags } from '@nestjs/swagger'; import { PatientDashboardService } from './patient-dashboard.service'; import { PatientDashboardDataDto, ErrorResponseDto, } from './dto/patient-dashboard-response.dto'; @ApiTags('Patient Dashboard') @Controller('api/patient-dashboard') export class PatientDashboardController { constructor( private readonly patientDashboardService: PatientDashboardService, ) {} @Get(':id') @ApiOkResponse({ type: PatientDashboardDataDto }) @ApiNotFoundResponse({ type: ErrorResponseDto }) async getById( @Param('id', ParseIntPipe) id: number, ): Promise<PatientDashboardDataDto> { return this.patientDashboardService.getById(id); } @Get('by-external/:ref') @ApiOkResponse({ type: PatientDashboardDataDto }) @ApiNotFoundResponse({ type: ErrorResponseDto }) async getByExternalRef( @Param('ref') ref: string, ): Promise<PatientDashboardDataDto> { return this.patientDashboardService.getByExternalRef(ref); } }
6ïžâŁ Module â patient-dashboard.module.ts
// src/patient-dashboard/patient-dashboard.module.ts import { Module } from '@nestjs/common'; import { PatientDashboardController } from './patient-dashboard.controller'; import { PatientDashboardService } from './patient-dashboard.service'; import { OdooClient } from '../odoo/odoo.client'; @Module({ controllers: [PatientDashboardController], providers: [PatientDashboardService, OdooClient], }) export class PatientDashboardModule {}
Avec ça tu as :
- đ une façade backend propre,
- đ totalement alignĂ©e sur Odoo (x_âŠ),
- đš qui sert exactement le JSON attendu par ton <PatientDashboard />.
Les grandes histoires ont une personnalitĂ©. Envisagez de raconter une belle histoire qui donne de la personnalitĂ©. Ăcrire une histoire avec de la personnalitĂ© pour des clients potentiels aidera Ă Ă©tablir un lien relationnel. Cela se traduit par de petites spĂ©cificitĂ©s comme le choix des mots ou des phrases. Ăcrivez de votre point de vue, pas de l'expĂ©rience de quelqu'un d'autre.
Les grandes histoires sont pour tout le monde, mĂȘme lorsqu'elles ne sont Ă©crites que pour une seule personne. Si vous essayez d'Ă©crire en pensant Ă un public large et gĂ©nĂ©ral, votre histoire sonnera fausse et manquera d'Ă©motion. Personne ne sera intĂ©ressĂ©. Ecrire pour une personne en particulier signifie que si c'est authentique pour l'un, c'est authentique pour le reste.