Files
decision/frontend/app/pages/decisions/new.vue
Yvv 5c51cffc93 Qualifier : corrections R2/R6 + router + modèle DB + wizard frontend
Corrections moteur (TDD) :
- R2 : within_mandate → record_in_observatory=True (Observatoire des décisions)
- R6 : >50 personnes → collective recommandé, pas obligatoire (confidence=recommended)
- R3 supprimée : affected_count=1 hors périmètre de l'outil
- R9-R12 renommés G1-G4 (garde-fous internes)
- 23 tests, 213/213 verts

Étape 1 — Router /api/v1/qualify :
- POST / → qualify() avec config depuis DB ou defaults
- GET /protocol → protocole actif
- POST /protocol → créer/remplacer (auth requise)

Étape 2 — Modèle QualificationProtocol :
- Table qualification_protocols (seuils configurables via admin)
- Migration Alembic + seed du protocole par défaut

Étape 3 — Wizard frontend decisions/new.vue :
- Étape 1 : formulaire de qualification (mandat, affected_count, structurant, contexte)
- Étape 2 : résultat (type, raisons, modalités, observatoire, on-chain)
- Étape 3 : formulaire de décision (titre, description, protocole si collectif)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 19:12:01 +02:00

867 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { DecisionCreate } from '~/stores/decisions'
const decisions = useDecisionsStore()
const protocols = useProtocolsStore()
const { $api } = useApi()
// ── Wizard state ─────────────────────────────────────────────────────────────
type WizardStep = 'qualify' | 'result' | 'form'
const wizardStep = ref<WizardStep>('qualify')
const qualifying = ref(false)
const submitting = ref(false)
// ── Qualification inputs ──────────────────────────────────────────────────────
const withinMandate = ref<boolean | null>(null)
const affectedCount = ref<number | null>(null)
const isStructural = ref(false)
const contextDescription = ref('')
// ── Qualification result (from /api/v1/qualify) ───────────────────────────────
interface QualifyResult {
decision_type: 'individual' | 'collective'
process: string
recommended_modalities: string[]
recommend_onchain: boolean
onchain_reason: string | null
confidence: string
collective_available: boolean
record_in_observatory: boolean
reasons: string[]
}
const qualifyResult = ref<QualifyResult | null>(null)
const qualifyError = ref<string | null>(null)
// ── Decision form ─────────────────────────────────────────────────────────────
const chosenType = ref<'individual' | 'collective' | null>(null)
const formData = ref<DecisionCreate>({
title: '',
description: '',
context: '',
decision_type: 'other',
voting_protocol_id: null,
})
// ── Modality labels ───────────────────────────────────────────────────────────
const MODALITY_LABELS: Record<string, { icon: string; title: string; desc: string }> = {
vote_wot: {
icon: 'i-lucide-users',
title: 'Vote WoT',
desc: 'Seuil adaptatif selon la taille de la Toile de Confiance et la participation',
},
vote_smith: {
icon: 'i-lucide-hammer',
title: 'Vote Smith',
desc: 'Implique le sous-ensemble des Forgerons (Smith criterion)',
},
consultation_avis: {
icon: 'i-lucide-message-circle',
title: 'Consultation d\'avis',
desc: 'Recueil d\'avis non contraignant — la décision reste individuelle',
},
election: {
icon: 'i-lucide-vote',
title: 'Élection',
desc: 'Désignation d\'une personne par le collectif',
},
}
function modalityLabel(slug: string) {
return MODALITY_LABELS[slug] ?? { icon: 'i-lucide-circle', title: slug, desc: '' }
}
// ── Step 1 : Qualify ──────────────────────────────────────────────────────────
const canQualify = computed(() => withinMandate.value !== null)
async function runQualify() {
if (!canQualify.value) return
qualifying.value = true
qualifyError.value = null
try {
const payload: Record<string, unknown> = {
within_mandate: withinMandate.value,
is_structural: isStructural.value,
}
if (withinMandate.value === false && affectedCount.value !== null) {
payload.affected_count = affectedCount.value
}
if (contextDescription.value.trim()) {
payload.context_description = contextDescription.value.trim()
}
qualifyResult.value = await $api<QualifyResult>('/qualify/', {
method: 'POST',
body: payload,
})
chosenType.value = qualifyResult.value.decision_type
wizardStep.value = 'result'
} catch (err: any) {
qualifyError.value = err?.message ?? 'Erreur lors de la qualification'
} finally {
qualifying.value = false
}
}
// ── Step 2 : Result → user confirms or adjusts ───────────────────────────────
function chooseType(type: 'individual' | 'collective') {
chosenType.value = type
formData.value.decision_type = type === 'collective' ? 'other' : 'other'
protocols.fetchProtocols()
wizardStep.value = 'form'
}
// ── Step 3 : Submit ───────────────────────────────────────────────────────────
async function onSubmit() {
if (!formData.value.title.trim()) return
submitting.value = true
try {
const decision = await decisions.create({
...formData.value,
context: (contextDescription.value ? `[Contexte]\n${contextDescription.value}\n\n` : '')
+ (formData.value.context ?? ''),
})
if (decision) navigateTo(`/decisions/${decision.id}`)
} catch {
// handled by store
} finally {
submitting.value = false
}
}
// ── Navigation ────────────────────────────────────────────────────────────────
function goBack() {
if (wizardStep.value === 'form') {
wizardStep.value = 'result'
} else if (wizardStep.value === 'result') {
wizardStep.value = 'qualify'
qualifyResult.value = null
}
}
const confidenceLabel: Record<string, string> = {
required: 'Obligatoire',
recommended: 'Recommandé',
optional: 'Optionnel',
}
</script>
<template>
<div class="decide">
<!-- Nav -->
<div class="decide__nav">
<button v-if="wizardStep !== 'qualify'" class="decide__back-btn" @click="goBack">
<UIcon name="i-lucide-arrow-left" class="text-sm" />
<span>Retour</span>
</button>
<NuxtLink v-else to="/decisions" class="decide__back-btn">
<UIcon name="i-lucide-arrow-left" class="text-sm" />
<span>Décisions</span>
</NuxtLink>
</div>
<Transition name="slide-fade" mode="out-in">
<!-- ÉTAPE 1 : Qualification -->
<div v-if="wizardStep === 'qualify'" key="qualify" class="decide__step">
<div class="decide__header">
<h1 class="decide__title">Quelle décision prendre ?</h1>
<p class="decide__subtitle">Qualifiez d'abord la situation — le parcours s'adapte</p>
</div>
<div class="qualify-form">
<!-- Mandat -->
<div class="qualify-section">
<p class="qualify-label">Cette décision entre-t-elle dans le périmètre d'un mandat existant ?</p>
<div class="qualify-toggle">
<button
class="qualify-toggle__btn"
:class="{ 'qualify-toggle__btn--active': withinMandate === true }"
@click="withinMandate = true"
>
<UIcon name="i-lucide-user-check" />
Oui, un mandat couvre ce sujet
</button>
<button
class="qualify-toggle__btn"
:class="{ 'qualify-toggle__btn--active': withinMandate === false }"
@click="withinMandate = false"
>
<UIcon name="i-lucide-circle-slash" />
Non, hors mandat
</button>
</div>
</div>
<!-- Personnes concernées — seulement si hors mandat -->
<Transition name="slide-down">
<div v-if="withinMandate === false" class="qualify-section">
<p class="qualify-label">Combien de personnes sont concernées ou impactées ?</p>
<div class="qualify-count">
<input
v-model.number="affectedCount"
type="number"
min="2"
step="1"
placeholder="ex : 12"
class="qualify-count__input"
/>
<span class="qualify-count__hint">personnes (minimum 2 — si seulement vous, cette décision sort du périmètre de l'outil)</span>
</div>
<div class="qualify-presets">
<button class="qualify-preset" @click="affectedCount = 3">25</button>
<button class="qualify-preset" @click="affectedCount = 20">650</button>
<button class="qualify-preset" @click="affectedCount = 100">&gt; 50</button>
</div>
</div>
</Transition>
<!-- Caractère structurant -->
<div class="qualify-section">
<p class="qualify-label">Cette décision est-elle structurante ?</p>
<p class="qualify-hint">
Une décision structurante a valeur de loi au sein de la communauté,
ou déclenche une action machine (ex : runtime upgrade).
La gravure on-chain peut alors être recommandée.
</p>
<div class="qualify-toggle">
<button
class="qualify-toggle__btn"
:class="{ 'qualify-toggle__btn--active': isStructural === true }"
@click="isStructural = true"
>
<UIcon name="i-lucide-cpu" />
Oui, décision structurante
</button>
<button
class="qualify-toggle__btn"
:class="{ 'qualify-toggle__btn--active': isStructural === false }"
@click="isStructural = false"
>
<UIcon name="i-lucide-leaf" />
Non, décision ordinaire
</button>
</div>
</div>
<!-- Contexte optionnel -->
<div class="qualify-section">
<p class="qualify-label">Décrivez brièvement le contexte <span class="qualify-optional">(optionnel)</span></p>
<textarea
v-model="contextDescription"
class="qualify-textarea"
rows="3"
placeholder="En quelques mots : de quoi s'agit-il ? Les modalités proposées seront plus précises."
/>
</div>
<!-- Error -->
<p v-if="qualifyError" class="qualify-error">{{ qualifyError }}</p>
<!-- Submit -->
<button
class="qualify-submit"
:disabled="!canQualify || qualifying"
@click="runQualify"
>
<UIcon v-if="qualifying" name="i-lucide-loader-2" class="animate-spin" />
<UIcon v-else name="i-lucide-arrow-right" />
{{ qualifying ? 'Qualification en cours…' : 'Qualifier la décision' }}
</button>
</div>
</div>
<!-- ÉTAPE 2 : Résultat de qualification -->
<div v-else-if="wizardStep === 'result' && qualifyResult" key="result" class="decide__step">
<div class="decide__header">
<h1 class="decide__title">Résultat de qualification</h1>
<p class="decide__subtitle">Voici ce que l'outil recommande — vous pouvez ajuster</p>
</div>
<!-- Recommandation principale -->
<div class="qresult">
<div class="qresult__type" :class="`qresult__type--${qualifyResult.decision_type}`">
<UIcon
:name="qualifyResult.decision_type === 'individual' ? 'i-lucide-user' : 'i-lucide-users'"
class="text-2xl"
/>
<div>
<span class="qresult__type-label">
{{ qualifyResult.decision_type === 'individual' ? 'Décision individuelle' : 'Décision collective' }}
</span>
<span class="qresult__confidence">
{{ confidenceLabel[qualifyResult.confidence] ?? qualifyResult.confidence }}
</span>
</div>
</div>
<!-- Raisons -->
<ul class="qresult__reasons">
<li v-for="(r, i) in qualifyResult.reasons" :key="i">
<UIcon name="i-lucide-info" class="text-xs" />
{{ r }}
</li>
</ul>
<!-- Observatoire -->
<div v-if="qualifyResult.record_in_observatory" class="qresult__observatory">
<UIcon name="i-lucide-bookmark" />
Cette décision sera consignée dans l'<strong>Observatoire des décisions</strong>
</div>
<!-- On-chain -->
<div v-if="qualifyResult.recommend_onchain" class="qresult__onchain">
<UIcon name="i-lucide-cpu" class="text-base" />
<div>
<strong>Gravure on-chain recommandée</strong>
<p>{{ qualifyResult.onchain_reason }}</p>
</div>
</div>
<!-- Modalités collectives -->
<div v-if="qualifyResult.recommended_modalities.length > 0" class="qresult__modalities">
<p class="qresult__modalities-title">Modalités disponibles</p>
<div class="qresult__modalities-list">
<div
v-for="slug in qualifyResult.recommended_modalities"
:key="slug"
class="qresult__modality"
>
<div class="qresult__modality-icon">
<UIcon :name="modalityLabel(slug).icon" />
</div>
<div class="qresult__modality-body">
<span class="qresult__modality-title">{{ modalityLabel(slug).title }}</span>
<span class="qresult__modality-desc">{{ modalityLabel(slug).desc }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Choix de validation / ajustement -->
<div class="qresult__actions">
<button
class="qresult__action qresult__action--primary"
@click="chooseType(qualifyResult.decision_type)"
>
<UIcon name="i-lucide-check" />
Confirmer et continuer
</button>
<button
v-if="qualifyResult.collective_available && qualifyResult.decision_type === 'individual'"
class="qresult__action qresult__action--alt"
@click="chooseType('collective')"
>
<UIcon name="i-lucide-users" />
Passer en collectif quand même
</button>
</div>
</div>
<!-- ÉTAPE 3 : Formulaire de décision -->
<div v-else-if="wizardStep === 'form'" key="form" class="decide__step">
<div class="decide__header">
<h1 class="decide__title">
{{ chosenType === 'collective' ? 'Décision collective' : 'Décision individuelle' }}
</h1>
<p class="decide__subtitle">Renseignez les détails de votre décision</p>
</div>
<form class="decide-form" @submit.prevent="onSubmit">
<div class="decide-form__field">
<label class="decide-form__label">Titre <span class="decide-form__req">*</span></label>
<input
v-model="formData.title"
class="decide-form__input"
type="text"
placeholder="Nommez la décision en une phrase"
required
/>
</div>
<div class="decide-form__field">
<label class="decide-form__label">Description</label>
<textarea
v-model="formData.description"
class="decide-form__textarea"
rows="4"
placeholder="Exposé complet de la décision, des options, des enjeux…"
/>
</div>
<!-- Protocole de vote seulement si collectif -->
<div v-if="chosenType === 'collective'" class="decide-form__field">
<label class="decide-form__label">Protocole de vote</label>
<select v-model="formData.voting_protocol_id" class="decide-form__select">
<option :value="null"> Choisir un protocole </option>
<option
v-for="p in protocols.protocols"
:key="p.id"
:value="p.id"
>
{{ p.name }}
</option>
</select>
</div>
<button type="submit" class="decide-form__submit" :disabled="submitting || !formData.title.trim()">
<UIcon v-if="submitting" name="i-lucide-loader-2" class="animate-spin" />
<UIcon v-else name="i-lucide-save" />
{{ submitting ? 'Enregistrement…' : 'Créer la décision' }}
</button>
</form>
</div>
</Transition>
</div>
</template>
<style scoped>
.decide {
max-width: 42rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Nav */
.decide__nav {
display: flex;
align-items: center;
}
.decide__back-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text-muted);
text-decoration: none;
background: none;
cursor: pointer;
transition: color 0.12s;
}
.decide__back-btn:hover { color: var(--mood-text); }
/* Header */
.decide__header { padding: 0.25rem 0; }
.decide__title {
font-size: 1.5rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.02em;
}
.decide__subtitle {
margin-top: 0.25rem;
font-size: 0.9375rem;
color: var(--mood-text-muted);
}
/* ── Qualification form ── */
.qualify-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.qualify-section {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.qualify-label {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
}
.qualify-hint {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
}
.qualify-optional {
font-weight: 400;
color: var(--mood-text-muted);
font-size: 0.8125rem;
}
.qualify-toggle {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@media (min-width: 480px) {
.qualify-toggle { flex-direction: row; }
}
.qualify-toggle__btn {
flex: 1;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 12px;
background: var(--mood-surface);
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text-muted);
cursor: pointer;
transition: all 0.12s ease;
}
.qualify-toggle__btn:hover {
background: var(--mood-accent-soft);
color: var(--mood-text);
}
.qualify-toggle__btn--active {
background: var(--mood-accent);
color: var(--mood-accent-text);
}
.qualify-count {
display: flex;
align-items: flex-start;
gap: 0.75rem;
flex-wrap: wrap;
}
.qualify-count__input {
width: 7rem;
padding: 0.625rem 0.875rem;
border-radius: 12px;
background: var(--mood-surface);
color: var(--mood-text);
font-size: 1rem;
font-weight: 700;
text-align: center;
flex-shrink: 0;
}
.qualify-count__hint {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
padding-top: 0.5rem;
}
.qualify-presets {
display: flex;
gap: 0.5rem;
}
.qualify-preset {
padding: 0.375rem 0.875rem;
border-radius: 20px;
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 0.8125rem;
font-weight: 700;
cursor: pointer;
transition: background 0.1s;
}
.qualify-preset:hover { background: var(--mood-accent); color: var(--mood-accent-text); }
.qualify-textarea {
width: 100%;
padding: 0.75rem 1rem;
border-radius: 12px;
background: var(--mood-surface);
color: var(--mood-text);
font-size: 0.9375rem;
resize: vertical;
min-height: 4.5rem;
}
.qualify-error {
font-size: 0.875rem;
color: var(--mood-danger, #e53e3e);
padding: 0.625rem 1rem;
background: color-mix(in srgb, var(--mood-danger, #e53e3e) 10%, transparent);
border-radius: 10px;
}
.qualify-submit {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.875rem 1.5rem;
border-radius: 20px;
background: var(--mood-accent);
color: var(--mood-accent-text);
font-size: 1rem;
font-weight: 800;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
align-self: flex-start;
}
.qualify-submit:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 16px var(--mood-shadow);
}
.qualify-submit:disabled { opacity: 0.5; cursor: default; }
/* ── Result ── */
.qresult {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem;
background: var(--mood-surface);
border-radius: 16px;
}
.qresult__type {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 1rem;
border-radius: 12px;
background: var(--mood-accent-soft);
}
.qresult__type--collective { background: color-mix(in srgb, var(--mood-accent) 15%, transparent); }
.qresult__type-label {
display: block;
font-size: 1.125rem;
font-weight: 800;
color: var(--mood-text);
}
.qresult__confidence {
display: block;
font-size: 0.75rem;
font-weight: 700;
color: var(--mood-accent);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.qresult__reasons {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0;
margin: 0;
}
.qresult__reasons li {
display: flex;
align-items: flex-start;
gap: 0.375rem;
font-size: 0.875rem;
color: var(--mood-text-muted);
}
.qresult__observatory {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.875rem;
border-radius: 10px;
background: color-mix(in srgb, var(--mood-success, #38a169) 12%, transparent);
color: var(--mood-success, #38a169);
font-size: 0.875rem;
font-weight: 600;
}
.qresult__onchain {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.875rem;
border-radius: 12px;
background: color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 12%, transparent);
color: var(--mood-tertiary, var(--mood-accent));
font-size: 0.875rem;
}
.qresult__onchain p {
margin-top: 0.25rem;
color: var(--mood-text-muted);
font-weight: 400;
line-height: 1.5;
}
.qresult__modalities-title {
font-size: 0.8125rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-text-muted);
margin-bottom: 0.5rem;
}
.qresult__modalities-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.qresult__modality {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: 10px;
background: var(--mood-accent-soft);
}
.qresult__modality-icon {
width: 2rem;
height: 2rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--mood-accent);
color: var(--mood-accent-text);
font-size: 0.875rem;
}
.qresult__modality-body {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.qresult__modality-title {
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text);
}
.qresult__modality-desc {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.4;
}
.qresult__actions {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
@media (min-width: 480px) {
.qresult__actions { flex-direction: row; }
}
.qresult__action {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border-radius: 20px;
font-size: 0.9375rem;
font-weight: 700;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.qresult__action--primary {
background: var(--mood-accent);
color: var(--mood-accent-text);
}
.qresult__action--alt {
background: var(--mood-surface);
color: var(--mood-text);
}
.qresult__action:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--mood-shadow);
}
/* ── Decision form ── */
.decide-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.decide-form__field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.decide-form__label {
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text);
}
.decide-form__req { color: var(--mood-accent); }
.decide-form__input,
.decide-form__textarea,
.decide-form__select {
width: 100%;
padding: 0.75rem 1rem;
border-radius: 12px;
background: var(--mood-surface);
color: var(--mood-text);
font-size: 0.9375rem;
}
.decide-form__textarea { resize: vertical; min-height: 7rem; }
.decide-form__submit {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.875rem 1.5rem;
border-radius: 20px;
background: var(--mood-accent);
color: var(--mood-accent-text);
font-size: 1rem;
font-weight: 800;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
align-self: flex-start;
}
.decide-form__submit:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 16px var(--mood-shadow);
}
.decide-form__submit:disabled { opacity: 0.5; cursor: default; }
/* ── Transitions ── */
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: all 0.18s ease;
}
.slide-fade-enter-from {
opacity: 0;
transform: translateX(16px);
}
.slide-fade-leave-to {
opacity: 0;
transform: translateX(-16px);
}
.slide-down-enter-active,
.slide-down-leave-active {
transition: all 0.2s ease;
overflow: hidden;
}
.slide-down-enter-from,
.slide-down-leave-to {
opacity: 0;
max-height: 0;
}
.slide-down-enter-to,
.slide-down-leave-from {
max-height: 200px;
}
</style>