New cockpit

This commit is contained in:
2026-05-07 20:47:49 +02:00
parent 474f18404f
commit 0bca327b80
4 changed files with 656 additions and 60 deletions

View File

@@ -5,85 +5,483 @@
<div class="admin-layout">
{% include 'admin/_sidebar.html' %}
<div class="admin-main">
<div class="admin-page-header">
<div class="admin-main admin-main--cockpit">
{# ── Header ─────────────────────────────────────────────────────── #}
<div class="lib-header px-4 py-3 border-bottom bg-white d-flex justify-content-between align-items-center flex-shrink-0">
<div>
<h1 class="admin-page-title">
<h1 class="admin-page-title mb-0">
<i class="fas fa-book-medical me-2 text-accent"></i>Bibliothèque
</h1>
<p class="text-muted mb-0">{{ recs|length }} recommandation(s) approuvée(s) et active(s)</p>
<p class="text-muted mb-0 small">{{ recs|length }} recommandation(s) approuvée(s) et active(s)</p>
</div>
<div class="d-flex align-items-center gap-3">
<div class="d-flex gap-1 align-items-center">
<span class="evidence-badge ev-A">A <span class="ms-1 opacity-75">{{ ev_total.A }}</span></span>
<span class="evidence-badge ev-B">B <span class="ms-1 opacity-75">{{ ev_total.B }}</span></span>
<span class="evidence-badge ev-C">C <span class="ms-1 opacity-75">{{ ev_total.C }}</span></span>
</div>
<a href="{{ url_for('admin_review', status='pending') }}" class="btn btn-sm btn-primary">
<i class="fas fa-inbox me-1"></i>File d'attente
</a>
</div>
</div>
{% if recs %}
{% set current_domain = namespace(val='') %}
{% for rec in recs %}
{% if rec.domain != current_domain.val %}
{% set current_domain.val = rec.domain %}
<div class="admin-domain-divider">
{{ domain_labels.get(rec.domain, (rec.domain, ''))[1] }}
{{ domain_labels.get(rec.domain, (rec.domain, ''))[0] }}
<span class="text-muted ms-1 fw-normal small">
({{ recs|selectattr('domain', 'equalto', rec.domain)|list|length }})
</span>
</div>
{% endif %}
{# ── Cockpit body ────────────────────────────────────────────────── #}
<div class="lib-cockpit">
<div class="card border-0 shadow-sm rounded-3 mb-2">
<div class="card-body p-3">
<div class="d-flex align-items-start gap-3">
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 mb-1 flex-wrap">
<span class="evidence-badge ev-{{ rec.evidence_level }}">Niveau {{ rec.evidence_level }}</span>
<span class="badge bg-light text-muted border small">Priorité {{ rec.priority }}</span>
<span class="text-muted small">{{ rec.reviewed_at.strftime('%d/%m/%Y') if rec.reviewed_at else '—' }}</span>
</div>
<strong>{{ rec.title }}</strong>
<p class="text-muted small mb-1 mt-1">{{ rec.summary }}</p>
{% if rec.source_title %}
<div class="small text-muted">
<i class="fas fa-book me-1"></i>{{ rec.source_title }}
{% if rec.source_doi %} — {{ rec.source_doi }}{% endif %}
</div>
{% endif %}
<!-- Targeting pills -->
<div class="d-flex flex-wrap gap-1 mt-2">
{% if rec.target_sex and rec.target_sex != 'all' %}
<span class="tag-pill">{{ 'Hommes' if rec.target_sex == 'male' else 'Femmes' }}</span>
{% endif %}
{% if rec.target_age_min or rec.target_age_max %}
<span class="tag-pill">{{ rec.target_age_min or '?' }}{{ rec.target_age_max or '∞' }} ans</span>
{% endif %}
{% for c in rec.required_conditions %}
<span class="tag-pill tag-pill-blue">Si: {{ c.replace('_',' ') }}</span>
{% endfor %}
{% for c in rec.excluded_conditions %}
<span class="tag-pill tag-pill-red">Exclu: {{ c.replace('_',' ') }}</span>
{% endfor %}
{% if not rec.required_conditions and not rec.required_health_goals and not rec.required_diet_types and not rec.required_activity_levels %}
<span class="tag-pill tag-pill-green">Universel</span>
{% endif %}
</div>
{# ── Left tree nav ──────────────────────────────────────────────── #}
<div class="lib-tree">
<div class="p-2">
<button class="lib-tree-node active" data-filter-domain="" data-filter-tag="">
<span><i class="fas fa-layer-group me-2 opacity-50" style="font-size:.8rem;"></i>Toutes</span>
<div class="d-flex align-items-center gap-1 flex-shrink-0">
{% if ev_total.A %}<span class="lib-tree-ev ev-A">{{ ev_total.A }}</span>{% endif %}
{% if ev_total.B %}<span class="lib-tree-ev ev-B">{{ ev_total.B }}</span>{% endif %}
{% if ev_total.C %}<span class="lib-tree-ev ev-C">{{ ev_total.C }}</span>{% endif %}
<span class="badge bg-secondary rounded-pill ms-1" style="font-size:.65rem;">{{ recs|length }}</span>
</div>
<form action="{{ url_for('admin_library_delete', rid=rec.id) }}" method="post"
onsubmit="return confirm('Retirer « {{ rec.title }} » de la bibliothèque ?')">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Retirer">
<i class="fas fa-archive"></i>
</button>
<div class="my-2" style="border-top:1px solid var(--border);"></div>
{% for domain_key, label in domain_labels.items() %}
{% if domain_key in tree %}
{% set node = tree[domain_key] %}
<div class="mb-1">
<button class="lib-tree-node lib-tree-domain-btn"
data-filter-domain="{{ domain_key }}" data-filter-tag=""
{% if node.tags %}data-bs-toggle="collapse" data-bs-target="#tt-{{ domain_key }}"{% endif %}>
<span class="text-truncate">{{ label[1] }}&nbsp;{{ label[0] }}</span>
<div class="d-flex align-items-center gap-1 flex-shrink-0">
{% if node.ev.A %}<span class="lib-tree-ev ev-A">{{ node.ev.A }}</span>{% endif %}
{% if node.ev.B %}<span class="lib-tree-ev ev-B">{{ node.ev.B }}</span>{% endif %}
{% if node.ev.C %}<span class="lib-tree-ev ev-C">{{ node.ev.C }}</span>{% endif %}
<span class="badge bg-secondary rounded-pill ms-1" style="font-size:.65rem;">{{ node.recs|length }}</span>
{% if node.tags %}<i class="fas fa-chevron-right lib-tree-chevron ms-1 text-muted" style="font-size:.6rem;transition:transform .2s;"></i>{% endif %}
</div>
</button>
{% if node.tags %}
<div class="collapse" id="tt-{{ domain_key }}">
{% for tag, tnode in node.tags.items()|sort %}
<button class="lib-tree-node lib-tree-tag"
data-filter-domain="{{ domain_key }}" data-filter-tag="{{ tag }}">
<span class="text-truncate">
<i class="fas fa-hashtag me-1 opacity-40" style="font-size:.6rem;"></i>{{ tag }}
</span>
<div class="d-flex align-items-center gap-1 flex-shrink-0">
{% if tnode.ev.A %}<span class="lib-tree-ev ev-A">{{ tnode.ev.A }}</span>{% endif %}
{% if tnode.ev.B %}<span class="lib-tree-ev ev-B">{{ tnode.ev.B }}</span>{% endif %}
{% if tnode.ev.C %}<span class="lib-tree-ev ev-C">{{ tnode.ev.C }}</span>{% endif %}
<span class="badge bg-secondary rounded-pill ms-1" style="font-size:.65rem;">{{ tnode.recs|length }}</span>
</div>
</button>
</form>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
{# ── Right cards panel ──────────────────────────────────────────── #}
<div class="lib-cards">
<div id="lib-empty" class="text-center py-5 text-muted d-none">
<i class="fas fa-filter fa-2x mb-3 opacity-25"></i>
<p class="mb-0">Aucune recommandation dans cette catégorie.</p>
</div>
{% for rec in recs %}
{% set is_universal = not rec.required_conditions and not rec.required_health_goals and not rec.required_diet_types and not rec.required_activity_levels %}
{% set has_gates = rec.not_for_under_18 or rec.not_for_pregnant or (rec.target_sex and rec.target_sex != 'all') or rec.target_age_min or rec.target_age_max or rec.excluded_conditions %}
<div class="lib-card mb-2"
data-domain="{{ rec.domain }}"
data-tags="{{ rec.tags | join(' ') | lower }}"
id="rec-{{ rec.id }}">
{# Card header — click to expand #}
<div class="lib-card-head d-flex align-items-start gap-2 p-3"
data-bs-toggle="collapse" data-bs-target="#cb-{{ rec.id }}"
aria-expanded="false" aria-controls="cb-{{ rec.id }}">
<span class="evidence-badge ev-{{ rec.evidence_level }} flex-shrink-0" style="margin-top:2px;">{{ rec.evidence_level }}</span>
<div class="flex-grow-1 min-w-0">
<div class="fw-semibold" style="line-height:1.3;">{{ rec.title }}</div>
<div class="d-flex flex-wrap gap-1 mt-1">
{% if is_universal %}
<span class="tag-pill tag-pill-green">🌍 Universel</span>
{% else %}
<span class="tag-pill tag-pill-blue">🎯 Ciblé</span>
{% endif %}
{% if rec.target_sex == 'male' %}<span class="tag-pill">♂ Hommes</span>
{% elif rec.target_sex == 'female' %}<span class="tag-pill">♀ Femmes</span>{% endif %}
{% if rec.target_age_min or rec.target_age_max %}
<span class="tag-pill">{{ rec.target_age_min or '?' }}{{ rec.target_age_max or '∞' }} ans</span>
{% endif %}
{% for t in rec.tags[:4] %}<span class="tag-pill">{{ t }}</span>{% endfor %}
</div>
</div>
<i class="fas fa-chevron-down lib-chevron text-muted flex-shrink-0" style="margin-top:4px;"></i>
</div>
{# Card body — collapsible #}
<div class="collapse" id="cb-{{ rec.id }}">
<div class="lib-card-body border-top px-3 pb-3 pt-2">
{% if rec.summary %}
<p class="text-muted small mb-2">{{ rec.summary }}</p>
{% endif %}
{% if rec.details %}
<p class="small mb-3 text-secondary">{{ rec.details }}</p>
{% endif %}
{# Intervention + Effect #}
{% if rec.intervention or rec.effect %}
<div class="row g-2 mb-3">
{% if rec.intervention %}
<div class="col-md-6">
<div class="p-2 rounded-2" style="background:#f0fdf4;border:1px solid #bbf7d0;">
<div class="small fw-semibold text-success mb-1"><i class="fas fa-play-circle me-1"></i>Intervention</div>
<div class="small">{{ rec.intervention }}</div>
</div>
</div>
{% endif %}
{% if rec.effect %}
<div class="col-md-6">
<div class="p-2 rounded-2" style="background:#eff6ff;border:1px solid #bfdbfe;">
<div class="small fw-semibold text-primary mb-1"><i class="fas fa-chart-line me-1"></i>Effet attendu</div>
<div class="small">{{ rec.effect }}</div>
</div>
</div>
{% endif %}
</div>
{% endif %}
{# Source citation #}
{% if rec.source_title %}
<div class="small text-muted mb-3 p-2 rounded-2" style="background:#f8fafc;border:1px solid var(--border);">
<i class="fas fa-book me-1"></i>
<em>{{ rec.source_title }}</em>
{% if rec.source_doi %} — <code class="small">{{ rec.source_doi }}</code>{% endif %}
{% if rec.study_type %}<span class="tag-pill ms-1">{{ rec.study_type.replace('_',' ') }}</span>{% endif %}
</div>
{% endif %}
{# ── Targeting breakdown ─────────────────────────────────── #}
<div class="targeting-panel p-3 mb-3">
<div class="d-flex align-items-center gap-2 mb-2 flex-wrap">
<span class="text-uppercase fw-bold text-muted" style="font-size:.65rem;letter-spacing:.06em;">Ciblage profil</span>
{% if is_universal %}
<span class="tag-pill tag-pill-green">🌍 Universel — affiché à tous</span>
{% else %}
<span class="tag-pill tag-pill-blue">🎯 Conditionnel</span>
{% endif %}
</div>
{% if has_gates %}
<div class="mb-2">
<div class="small fw-semibold text-danger mb-1" style="font-size:.75rem;">
<i class="fas fa-ban me-1"></i>Exclusions (portes dures — toutes doivent passer)
</div>
<div class="d-flex flex-wrap gap-1">
{% if rec.not_for_under_18 %}<span class="tag-pill tag-pill-red">✗ Pas &lt; 18 ans</span>{% endif %}
{% if rec.not_for_pregnant %}<span class="tag-pill tag-pill-red">✗ Pas grossesse</span>{% endif %}
{% if rec.target_sex == 'male' %}<span class="tag-pill tag-pill-red">♂ Hommes uniquement</span>{% endif %}
{% if rec.target_sex == 'female' %}<span class="tag-pill tag-pill-red">♀ Femmes uniquement</span>{% endif %}
{% if rec.target_age_min %}<span class="tag-pill tag-pill-red">≥ {{ rec.target_age_min }} ans</span>{% endif %}
{% if rec.target_age_max %}<span class="tag-pill tag-pill-red">≤ {{ rec.target_age_max }} ans</span>{% endif %}
{% for c in rec.excluded_conditions %}
<span class="tag-pill tag-pill-red">Exclu si: {{ condition_labels.get(c, c) }}</span>
{% endfor %}
</div>
</div>
{% endif %}
{% if not is_universal %}
<div>
<div class="small fw-semibold text-primary mb-1" style="font-size:.75rem;">
<i class="fas fa-bolt me-1"></i>Déclencheurs — logique OU (un groupe suffit)
</div>
<div class="d-flex flex-wrap gap-1">
{% for c in rec.required_conditions %}
<span class="tag-pill tag-pill-blue">Si: {{ condition_labels.get(c, c) }}</span>
{% endfor %}
{% for g in rec.required_health_goals %}
<span class="tag-pill tag-pill-blue">Objectif: {{ goal_labels.get(g, g) }}</span>
{% endfor %}
{% for d in rec.required_diet_types %}
<span class="tag-pill tag-pill-blue">Régime: {{ diet_labels.get(d, d) }}</span>
{% endfor %}
{% for a in rec.required_activity_levels %}
<span class="tag-pill tag-pill-blue">Activité: {{ activity_labels.get(a, a) }}</span>
{% endfor %}
</div>
</div>
{% else %}
<p class="text-muted small mb-0">
<i class="fas fa-info-circle me-1"></i>Aucun critère de déclenchement — affichée à tous les utilisateurs (sous réserve des exclusions ci-dessus si définies).
</p>
{% endif %}
</div>
{# Actions #}
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#modal-{{ rec.id }}">
<i class="fas fa-sliders-h me-1"></i>Modifier le ciblage
</button>
<form action="{{ url_for('admin_library_delete', rid=rec.id) }}" method="post"
onsubmit="return confirm('Retirer « {{ rec.title|replace(&quot;'&quot;, '') }} » de la bibliothèque ?')">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="fas fa-archive me-1"></i>Archiver
</button>
</form>
</div>
</div>
</div>
</div>
{# ── Edit targeting modal ───────────────────────────────────── #}
<div class="modal fade" id="modal-{{ rec.id }}" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form action="{{ url_for('admin_library_update', rid=rec.id) }}" method="POST">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-sliders-h me-2"></i>Ciblage
<span class="text-muted fw-normal small ms-1">— {{ rec.title[:60] }}{% if rec.title|length > 60 %}…{% endif %}</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
{# Demographics #}
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label small fw-semibold">Sexe cible</label>
<select name="target_sex" class="form-select form-select-sm">
<option value="all" {{ 'selected' if rec.target_sex in ('all', None, '') }}>Tous</option>
<option value="male" {{ 'selected' if rec.target_sex == 'male' }}>Hommes ♂</option>
<option value="female" {{ 'selected' if rec.target_sex == 'female' }}>Femmes ♀</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold">Âge min</label>
<input type="number" name="target_age_min" class="form-control form-control-sm"
value="{{ rec.target_age_min or '' }}" placeholder="—" min="0" max="120">
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold">Âge max</label>
<input type="number" name="target_age_max" class="form-control form-control-sm"
value="{{ rec.target_age_max or '' }}" placeholder="—" min="0" max="120">
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label small fw-semibold">Priorité (110)</label>
<input type="number" name="priority" class="form-control form-control-sm"
value="{{ rec.priority }}" min="1" max="10">
</div>
<div class="col-md-8 d-flex align-items-end gap-4 pb-1">
<div class="form-check">
<input type="checkbox" name="not_for_under_18" class="form-check-input"
id="u18-{{ rec.id }}" {{ 'checked' if rec.not_for_under_18 }}>
<label class="form-check-label small" for="u18-{{ rec.id }}">
<span class="tag-pill tag-pill-red">Exclure &lt; 18 ans</span>
</label>
</div>
<div class="form-check">
<input type="checkbox" name="not_for_pregnant" class="form-check-input"
id="preg-{{ rec.id }}" {{ 'checked' if rec.not_for_pregnant }}>
<label class="form-check-label small" for="preg-{{ rec.id }}">
<span class="tag-pill tag-pill-red">Exclure grossesse</span>
</label>
</div>
</div>
</div>
<hr class="my-3">
{# Conditions #}
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label small fw-semibold text-danger">
<i class="fas fa-ban me-1"></i>Conditions exclues
<span class="text-muted fw-normal">(porte dure)</span>
</label>
<div class="multi-check-grid">
{% for c in conditions %}
<label class="multi-check-item form-check">
<input type="checkbox" name="excluded_conditions" value="{{ c }}"
class="form-check-input" {{ 'checked' if c in rec.excluded_conditions }}>
{{ condition_labels.get(c, c) }}
</label>
{% endfor %}
</div>
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold text-primary">
<i class="fas fa-bolt me-1"></i>Conditions déclenchantes
<span class="text-muted fw-normal">(OU)</span>
</label>
<div class="multi-check-grid">
{% for c in conditions %}
<label class="multi-check-item form-check">
<input type="checkbox" name="required_conditions" value="{{ c }}"
class="form-check-input" {{ 'checked' if c in rec.required_conditions }}>
{{ condition_labels.get(c, c) }}
</label>
{% endfor %}
</div>
</div>
</div>
{# Goals + Diet #}
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label small fw-semibold text-primary">
<i class="fas fa-bullseye me-1"></i>Objectifs santé
<span class="text-muted fw-normal">(OU)</span>
</label>
<div class="multi-check-grid">
{% for g in health_goals %}
<label class="multi-check-item form-check">
<input type="checkbox" name="required_health_goals" value="{{ g }}"
class="form-check-input" {{ 'checked' if g in rec.required_health_goals }}>
{{ goal_labels.get(g, g) }}
</label>
{% endfor %}
</div>
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold text-primary">
<i class="fas fa-utensils me-1"></i>Types de régime
<span class="text-muted fw-normal">(OU)</span>
</label>
<div class="multi-check-grid">
{% for d in diet_types %}
<label class="multi-check-item form-check">
<input type="checkbox" name="required_diet_types" value="{{ d }}"
class="form-check-input" {{ 'checked' if d in rec.required_diet_types }}>
{{ diet_labels.get(d, d) }}
</label>
{% endfor %}
</div>
</div>
</div>
{# Activity #}
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label small fw-semibold text-primary">
<i class="fas fa-running me-1"></i>Niveaux d'activité
<span class="text-muted fw-normal">(OU)</span>
</label>
<div class="multi-check-grid">
{% for a in activity_levels %}
<label class="multi-check-item form-check">
<input type="checkbox" name="required_activity_levels" value="{{ a }}"
class="form-check-input" {{ 'checked' if a in rec.required_activity_levels }}>
{{ activity_labels.get(a, a) }}
</label>
{% endfor %}
</div>
</div>
</div>
<hr class="my-3">
<div>
<label class="form-label small fw-semibold">Notes admin</label>
<textarea name="admin_notes" class="form-control form-control-sm" rows="2"
placeholder="Notes internes (non visibles par les utilisateurs)">{{ rec.admin_notes or '' }}</textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
<button type="submit" class="btn btn-sm btn-primary">
<i class="fas fa-save me-1"></i>Enregistrer le ciblage
</button>
</div>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="fas fa-book-medical fa-3x mb-3 opacity-25"></i>
<p class="mb-0">Aucune recommandation approuvée. Allez dans la file d'attente pour en valider.</p>
<a href="{{ url_for('admin_review', status='pending') }}" class="btn btn-primary mt-3">
<i class="fas fa-inbox me-1"></i>Aller à la file d'attente
<i class="fas fa-inbox me-1"></i>File d'attente
</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
// ── Tree filtering ────────────────────────────────────────────────
document.querySelectorAll('.lib-tree-node').forEach(function (btn) {
btn.addEventListener('click', function () {
var domain = this.dataset.filterDomain;
var tag = this.dataset.filterTag;
document.querySelectorAll('.lib-tree-node').forEach(function (b) {
b.classList.remove('active');
});
this.classList.add('active');
var visible = 0;
document.querySelectorAll('.lib-card').forEach(function (card) {
var show;
if (!domain) {
show = true;
} else if (!tag) {
show = card.dataset.domain === domain;
} else {
var tags = (card.dataset.tags || '').split(' ');
show = card.dataset.domain === domain && tags.indexOf(tag) !== -1;
}
card.style.display = show ? '' : 'none';
if (show) visible++;
});
var emptyEl = document.getElementById('lib-empty');
if (emptyEl) emptyEl.classList.toggle('d-none', visible > 0);
});
});
// ── Tree chevron rotation on collapse ────────────────────────────
document.querySelectorAll('.lib-tree-domain-btn[data-bs-toggle="collapse"]').forEach(function (btn) {
var target = document.querySelector(btn.dataset.bsTarget);
if (!target) return;
var chevron = btn.querySelector('.lib-tree-chevron');
if (!chevron) return;
target.addEventListener('show.bs.collapse', function () {
chevron.style.transform = 'rotate(90deg)';
});
target.addEventListener('hide.bs.collapse', function () {
chevron.style.transform = '';
});
});
})();
</script>
{% endblock %}