Files
decision/frontend/app/pages/decisions/new.vue
Yvv 9a8f10efdf Groupes d'identités : modèle DB, router, store, UI cercles + Protocoles
- Group + GroupMember : modèle SQLAlchemy + migration + router CRUD
- /api/v1/groups : liste, création, suppression, membres (add/remove)
- groups.ts : store Pinia (fetchAll, getGroup, create, remove, addMember, removeMember)
- decisions/new.vue : cercles 1 & 2 en mode texte libre OU groupe prédéfini
  (affected_count calculé depuis le member_count du groupe)
- protocols/index.vue : section Groupes avec expand/collapse, ajout/suppression membres
- lang="fr" + spellcheck sur tous les textareas ; placeholder cercle 2 corrigé
- n8n channels : prévu sprint futur (texte libre → webhook appel à contribution)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:25:44 +02:00

1144 lines
43 KiB
Vue

<script setup lang="ts">
import type { DecisionCreate } from '~/stores/decisions'
const decisions = useDecisionsStore()
const protocols = useProtocolsStore()
const mandates = useMandatesStore()
const groupsStore = useGroupsStore()
const { $api } = useApi()
// ── Wizard steps ──────────────────────────────────────────────────────────────
type WizardStep = 'qualify' | 'ai-questions' | 'result' | 'form'
const wizardStep = ref<WizardStep>('qualify')
// ── Qualification inputs ──────────────────────────────────────────────────────
const withinMandate = ref<boolean | null>(null)
const isStructural = ref(false)
const contextDescription = ref('')
// Cercles de personnes concernées
// Chaque cercle peut être en mode texte libre ou groupe prédéfini
type CircleMode = 'text' | 'group'
const circle1Mode = ref<CircleMode>('text')
const circle1Text = ref('')
const circle1GroupId = ref<string | null>(null)
const circle2Mode = ref<CircleMode>('text')
const circle2Text = ref('')
const circle2GroupId = ref<string | null>(null)
const circle3Open = ref(true)
// Résolution du cercle en liste de noms (pour le count et la sauvegarde)
function circleNames(mode: CircleMode, text: string, groupId: string | null): string[] {
if (mode === 'group' && groupId) {
const g = groupsStore.list.find(g => g.id === groupId)
return g ? [`[Groupe: ${g.name}]`] : []
}
return text.split(/[,;\n]/).map(s => s.trim()).filter(Boolean)
}
// Affected count derivé des cercles
const affectedCountFromCircles = computed(() => {
const c1names = circleNames(circle1Mode.value, circle1Text.value, circle1GroupId.value)
const c2names = circleNames(circle2Mode.value, circle2Text.value, circle2GroupId.value)
let count = 0
if (circle1Mode.value === 'group' && circle1GroupId.value) {
const g = groupsStore.list.find(g => g.id === circle1GroupId.value)
count += g?.member_count ?? 1
} else {
count += c1names.length
}
if (circle2Mode.value === 'group' && circle2GroupId.value) {
const g = groupsStore.list.find(g => g.id === circle2GroupId.value)
count += g?.member_count ?? 0
} else {
count += c2names.length
}
return count >= 2 ? count : (count === 1 ? 2 : null)
})
// Résumé lisible des cercles (pour la sauvegarde dans le contexte)
const circlesSummary = computed(() => {
const c1 = circle1Mode.value === 'group' && circle1GroupId.value
? `Groupe: ${groupsStore.list.find(g => g.id === circle1GroupId.value)?.name ?? '?'}`
: circle1Text.value.trim()
const c2 = circle2Mode.value === 'group' && circle2GroupId.value
? `Groupe: ${groupsStore.list.find(g => g.id === circle2GroupId.value)?.name ?? '?'}`
: circle2Text.value.trim()
return { c1, c2 }
})
// ── AI conversation ───────────────────────────────────────────────────────────
interface AIQuestion { id: string; text: string; options: string[] }
interface AIResult {
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[]
}
interface AIChatResponse {
done: boolean
questions: AIQuestion[]
result: AIResult | null
explanation: string | null
}
const aiQuestions = ref<AIQuestion[]>([])
const aiAnswers = ref<Record<string, string>>({})
const aiExplanation = ref<string | null>(null)
const aiMessages = ref<{ role: string; content: string }[]>([])
const qualifying = ref(false)
const submitting = ref(false)
const qualifyError = ref<string | null>(null)
const qualifyResult = ref<AIResult | null>(null)
const chosenType = ref<'individual' | 'collective' | null>(null)
// ── Form ──────────────────────────────────────────────────────────────────────
const formData = ref<DecisionCreate>({
title: '',
description: '',
context: '',
decision_type: 'other',
voting_protocol_id: null,
})
// ── Mandates list (active) ────────────────────────────────────────────────────
const activeMandates = computed(() =>
mandates.list.filter(m => ['active', 'voting'].includes(m.status))
)
onMounted(() => {
mandates.fetchAll()
groupsStore.fetchAll()
})
// ── Modality metadata ─────────────────────────────────────────────────────────
const MODALITY_META: Record<string, { icon: string; title: string; desc: string; color: string }> = {
vote_wot: {
icon: 'i-lucide-users',
title: 'Vote WoT',
desc: 'Seuil adaptatif selon la taille de la Toile de Confiance et la participation réelle.',
color: 'var(--mood-accent)',
},
vote_smith: {
icon: 'i-lucide-hammer',
title: 'Vote Smith',
desc: 'Implique le sous-ensemble des Forgerons (Smith criterion).',
color: 'var(--mood-secondary, var(--mood-accent))',
},
consultation_avis: {
icon: 'i-lucide-message-circle',
title: 'Consultation d\'avis',
desc: 'Recueil d\'avis non contraignant — la décision reste individuelle.',
color: 'var(--mood-tertiary, var(--mood-accent))',
},
election: {
icon: 'i-lucide-vote',
title: 'Élection',
desc: 'Désignation d\'une personne ou d\'une option par le collectif.',
color: 'var(--mood-success)',
},
}
function modalityMeta(slug: string) {
return MODALITY_META[slug] ?? { icon: 'i-lucide-circle', title: slug, desc: '', color: 'var(--mood-accent)' }
}
// ── Validation ────────────────────────────────────────────────────────────────
const canQualify = computed(() => {
if (withinMandate.value === null) return false
if (withinMandate.value === false) {
if (circle1Mode.value === 'text') return circle1Text.value.trim().length > 0
return circle1GroupId.value !== null
}
return true
})
// ── Step 1 : lance la conversation AI ────────────────────────────────────────
async function startQualify() {
if (!canQualify.value) return
qualifying.value = true
qualifyError.value = null
aiMessages.value = []
aiAnswers.value = {}
try {
const resp = await $api<AIChatResponse>('/qualify/ai-chat', {
method: 'POST',
body: {
within_mandate: withinMandate.value,
affected_count: affectedCountFromCircles.value,
is_structural: isStructural.value,
context: contextDescription.value.trim() || null,
messages: [],
},
})
if (resp.done) {
qualifyResult.value = resp.result
aiExplanation.value = resp.explanation
chosenType.value = resp.result?.decision_type ?? null
wizardStep.value = 'result'
} else {
aiQuestions.value = resp.questions
// Pré-sélectionner la 1ère option pour chaque question
for (const q of resp.questions) {
if (q.options.length > 0) aiAnswers.value[q.id] = q.options[0]
}
wizardStep.value = 'ai-questions'
}
} catch (err: any) {
qualifyError.value = err?.message ?? 'Erreur lors de la qualification'
} finally {
qualifying.value = false
}
}
// ── Step 2 : envoie les réponses AI ──────────────────────────────────────────
async function submitAiAnswers() {
qualifying.value = true
qualifyError.value = null
try {
// Build answer message
const answerContent = Object.entries(aiAnswers.value)
.map(([id, val]) => `${id}:${val}`)
.join('|')
const messages = [
...aiMessages.value,
...aiQuestions.value.map(q => ({ role: 'assistant', content: q.text })),
{ role: 'user', content: answerContent },
]
aiMessages.value = messages
const resp = await $api<AIChatResponse>('/qualify/ai-chat', {
method: 'POST',
body: {
within_mandate: withinMandate.value,
affected_count: affectedCountFromCircles.value,
is_structural: isStructural.value,
context: contextDescription.value.trim() || null,
messages,
},
})
qualifyResult.value = resp.result
aiExplanation.value = resp.explanation
chosenType.value = resp.result?.decision_type ?? null
wizardStep.value = 'result'
} catch (err: any) {
qualifyError.value = err?.message ?? 'Erreur lors de la qualification'
} finally {
qualifying.value = false
}
}
// ── Choix de la modalité ──────────────────────────────────────────────────────
function chooseType(type: 'individual' | 'collective') {
chosenType.value = type
protocols.fetchProtocols()
wizardStep.value = 'form'
}
// ── Soumission de la décision ─────────────────────────────────────────────────
async function onSubmit() {
if (!formData.value.title.trim()) return
submitting.value = true
try {
const { c1, c2 } = circlesSummary.value
const circlesContext = [
c1 ? `Cercle 1 (directement concernés) : ${c1}` : '',
c2 ? `Cercle 2 (indirectement concernés) : ${c2}` : '',
circle3Open.value ? 'Cercle 3 : ouvert à toute personne se sentant concernée.' : '',
].filter(Boolean).join('\n')
const decision = await decisions.create({
...formData.value,
context: [
contextDescription.value.trim(),
circlesContext,
formData.value.context ?? '',
].filter(Boolean).join('\n\n'),
})
if (decision) navigateTo(`/decisions/${decision.id}`)
} catch {
} finally {
submitting.value = false
}
}
// ── Navigation ────────────────────────────────────────────────────────────────
function goBack() {
if (wizardStep.value === 'form') wizardStep.value = 'result'
else if (wizardStep.value === 'result') wizardStep.value = withinMandate.value ? 'qualify' : 'ai-questions'
else if (wizardStep.value === 'ai-questions') { 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 : Formulaire de 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 la situation l'IA détermine le parcours adapté</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>
<!-- Branche : mandat -->
<Transition name="slide-down">
<div v-if="withinMandate === true" class="qualify-mandate-branch">
<p class="qualify-hint">
Sélectionnez le mandat concerné ou demandez-en un nouveau.
</p>
<!-- Liste des mandats actifs -->
<div v-if="activeMandates.length > 0" class="mandate-list">
<NuxtLink
v-for="m in activeMandates"
:key="m.id"
:to="`/mandates/${m.id}`"
class="mandate-item"
>
<div class="mandate-item__icon">
<UIcon name="i-lucide-user-check" />
</div>
<div class="mandate-item__body">
<span class="mandate-item__title">{{ m.title }}</span>
<span class="mandate-item__type">{{ m.mandate_type }}</span>
</div>
<UIcon name="i-lucide-arrow-right" class="mandate-item__arrow" />
</NuxtLink>
</div>
<p v-else class="qualify-hint qualify-hint--muted">
Aucun mandat actif pour l'espace de travail sélectionné.
</p>
<NuxtLink to="/mandates" class="mandate-request-btn">
<UIcon name="i-lucide-plus" />
Demander un mandat
</NuxtLink>
</div>
</Transition>
<!-- Branche : hors mandat → 3 cercles -->
<Transition name="slide-down">
<div v-if="withinMandate === false" class="qualify-circles">
<!-- Cercle 1 -->
<div class="qualify-section">
<div class="circle-header">
<span class="circle-badge circle-badge--1">1</span>
<p class="qualify-label">Premier cercle — personnes directement concernées <span class="qualify-req">*</span></p>
<div class="circle-mode-toggle">
<button class="circle-mode-btn" :class="{ 'circle-mode-btn--active': circle1Mode === 'text' }" @click="circle1Mode = 'text'">
<UIcon name="i-lucide-type" class="text-xs" /> Texte libre
</button>
<button class="circle-mode-btn" :class="{ 'circle-mode-btn--active': circle1Mode === 'group' }" @click="circle1Mode = 'group'">
<UIcon name="i-lucide-users-round" class="text-xs" /> Groupe
</button>
</div>
</div>
<textarea
v-if="circle1Mode === 'text'"
v-model="circle1Text"
class="qualify-textarea"
rows="2"
lang="fr"
spellcheck="true"
placeholder="Alice, Bob, Charlie — une par ligne ou séparées par des virgules"
/>
<div v-else class="circle-group-select">
<select v-model="circle1GroupId" class="qualify-select">
<option :value="null">— Sélectionner un groupe —</option>
<option v-for="g in groupsStore.list" :key="g.id" :value="g.id">
{{ g.name }} ({{ g.member_count }} membre{{ g.member_count > 1 ? 's' : '' }})
</option>
</select>
<p v-if="groupsStore.list.length === 0" class="qualify-hint qualify-hint--warn">
Aucun groupe défini. <NuxtLink to="/protocols" class="qualify-link">Créer un groupe</NuxtLink> dans Protocoles & Fonctionnement.
</p>
</div>
<p class="qualify-hint">Personnes dont la décision modifie directement la situation.</p>
</div>
<!-- Cercle 2 -->
<div class="qualify-section">
<div class="circle-header">
<span class="circle-badge circle-badge--2">2</span>
<p class="qualify-label">Deuxième cercle <span class="qualify-optional">(optionnel)</span></p>
<div class="circle-mode-toggle">
<button class="circle-mode-btn" :class="{ 'circle-mode-btn--active': circle2Mode === 'text' }" @click="circle2Mode = 'text'">
<UIcon name="i-lucide-type" class="text-xs" /> Texte libre
</button>
<button class="circle-mode-btn" :class="{ 'circle-mode-btn--active': circle2Mode === 'group' }" @click="circle2Mode = 'group'">
<UIcon name="i-lucide-users-round" class="text-xs" /> Groupe
</button>
</div>
</div>
<textarea
v-if="circle2Mode === 'text'"
v-model="circle2Text"
class="qualify-textarea"
rows="2"
lang="fr"
spellcheck="true"
placeholder="Personnes impactées indirectement ou parties prenantes de second niveau"
/>
<div v-else class="circle-group-select">
<select v-model="circle2GroupId" class="qualify-select">
<option :value="null">— Sélectionner un groupe —</option>
<option v-for="g in groupsStore.list" :key="g.id" :value="g.id">
{{ g.name }} ({{ g.member_count }} membre{{ g.member_count > 1 ? 's' : '' }})
</option>
</select>
</div>
</div>
<!-- Cercle 3 -->
<div class="qualify-section">
<div class="circle-header">
<span class="circle-badge circle-badge--3">3</span>
<p class="qualify-label">Troisième cercle — ouvert</p>
</div>
<div class="qualify-toggle">
<button class="qualify-toggle__btn" :class="{ 'qualify-toggle__btn--active': circle3Open }" @click="circle3Open = true">
<UIcon name="i-lucide-globe" />
Toute personne se sentant concernée (défaut)
</button>
<button class="qualify-toggle__btn" :class="{ 'qualify-toggle__btn--active': !circle3Open }" @click="circle3Open = false">
<UIcon name="i-lucide-lock" />
Fermé — cercles 1 et 2 uniquement
</button>
</div>
</div>
<!-- Count indicator -->
<div v-if="affectedCountFromCircles" class="qualify-count-indicator">
<UIcon name="i-lucide-users" class="text-sm" />
{{ affectedCountFromCircles }} personne{{ affectedCountFromCircles > 1 ? 's' : '' }} identifiée{{ affectedCountFromCircles > 1 ? 's' : '' }}
<span v-if="circle3Open" class="qualify-count-plus">+ cercle ouvert</span>
</div>
</div>
</Transition>
<!-- Décision structurante -->
<div v-if="withinMandate !== null" 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 ê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 on-chain
</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 -->
<div v-if="withinMandate !== null" class="qualify-section">
<p class="qualify-label">Décrivez brièvement le contexte <span class="qualify-optional">(optionnel — enrichit les propositions de l'IA)</span></p>
<textarea v-model="contextDescription" class="qualify-textarea" rows="3" lang="fr" spellcheck="true" placeholder="En quelques mots : de quoi s'agit-il ? Quels enjeux ? Quelle contrainte ?" />
</div>
<p v-if="qualifyError" class="qualify-error">{{ qualifyError }}</p>
<button
v-if="withinMandate !== null"
class="qualify-submit"
:disabled="!canQualify || qualifying"
@click="startQualify"
>
<UIcon v-if="qualifying" name="i-lucide-loader-2" class="animate-spin" />
<UIcon v-else name="i-lucide-sparkles" />
{{ qualifying ? 'L\'IA analyse…' : 'Qualifier la décision' }}
</button>
</div>
</div>
<!-- ÉTAPE 2 : Questions de l'IA ────────────────────────────────── -->
<div v-else-if="wizardStep === 'ai-questions'" key="ai-questions" class="decide__step">
<div class="decide__header">
<h1 class="decide__title">Quelques précisions</h1>
<p class="decide__subtitle">L'IA a besoin de 2 informations supplémentaires pour proposer les modalités les plus adaptées</p>
</div>
<div class="ai-questions">
<div v-for="q in aiQuestions" :key="q.id" class="ai-question">
<div class="ai-question__header">
<div class="ai-question__avatar">
<UIcon name="i-lucide-sparkles" />
</div>
<p class="ai-question__text">{{ q.text }}</p>
</div>
<div class="ai-question__options">
<button
v-for="opt in q.options"
:key="opt"
class="ai-question__opt"
:class="{ 'ai-question__opt--selected': aiAnswers[q.id] === opt }"
@click="aiAnswers[q.id] = opt"
>
<UIcon v-if="aiAnswers[q.id] === opt" name="i-lucide-check-circle" class="text-sm" />
<UIcon v-else name="i-lucide-circle" class="text-sm" />
{{ opt }}
</button>
</div>
</div>
</div>
<p v-if="qualifyError" class="qualify-error">{{ qualifyError }}</p>
<button
class="qualify-submit"
:disabled="qualifying || aiQuestions.some(q => !aiAnswers[q.id])"
@click="submitAiAnswers"
>
<UIcon v-if="qualifying" name="i-lucide-loader-2" class="animate-spin" />
<UIcon v-else name="i-lucide-arrow-right" />
{{ qualifying ? 'Analyse en cours…' : 'Continuer' }}
</button>
</div>
<!-- ÉTAPE 3 : Résultat + Boîte à outils contextuelle -->
<div v-else-if="wizardStep === 'result' && qualifyResult" key="result" class="decide__step">
<div class="decide__header">
<h1 class="decide__title">Boîte à outils</h1>
<p class="decide__subtitle">L'IA recommande — vous choisissez et validez</p>
</div>
<div class="qresult">
<!-- Type principal -->
<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>
<!-- Explication IA -->
<div v-if="aiExplanation" class="qresult__ai-note">
<UIcon name="i-lucide-sparkles" class="text-sm" />
<span>{{ aiExplanation }}</span>
</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__flag qresult__flag--observatory">
<UIcon name="i-lucide-bookmark" />
Consignée dans l'<strong>Observatoire des décisions</strong>
</div>
<!-- On-chain -->
<div v-if="qualifyResult.recommend_onchain" class="qresult__flag qresult__flag--onchain">
<UIcon name="i-lucide-cpu" />
<div>
<strong>Gravure on-chain recommandée</strong>
<p>{{ qualifyResult.onchain_reason }}</p>
</div>
</div>
<!-- Boîte à outils modalités -->
<div v-if="qualifyResult.recommended_modalities.length > 0" class="qresult__toolbox">
<p class="qresult__toolbox-title">
<UIcon name="i-lucide-wrench" class="text-sm" />
Modalités disponibles
</p>
<div class="qresult__modalities">
<div
v-for="slug in qualifyResult.recommended_modalities"
:key="slug"
class="qresult__modality"
:style="{ '--mod-color': modalityMeta(slug).color }"
>
<div class="qresult__modality-icon">
<UIcon :name="modalityMeta(slug).icon" />
</div>
<div class="qresult__modality-body">
<span class="qresult__modality-title">{{ modalityMeta(slug).title }}</span>
<span class="qresult__modality-desc">{{ modalityMeta(slug).desc }}</span>
</div>
</div>
</div>
</div>
<!-- Cercles recap -->
<div v-if="withinMandate === false" class="qresult__circles">
<p class="qresult__circles-title">Cercles concernés</p>
<div class="qresult__circles-list">
<span v-if="circlesSummary.c1" class="qresult__circle">
<span class="circle-badge circle-badge--1 circle-badge--sm">1</span> {{ circlesSummary.c1 }}
</span>
<span v-if="circlesSummary.c2" class="qresult__circle">
<span class="circle-badge circle-badge--2 circle-badge--sm">2</span> {{ circlesSummary.c2 }}
</span>
<span class="qresult__circle">
<span class="circle-badge circle-badge--3 circle-badge--sm">3</span>
{{ circle3Open ? 'Ouvert à toute personne se sentant concernée' : 'Fermé' }}
</span>
</div>
</div>
</div>
<!-- Actions -->
<div class="qresult__actions">
<button class="qresult__action qresult__action--primary" @click="chooseType(qualifyResult.decision_type)">
<UIcon name="i-lucide-check" />
Confirmer et créer la décision
</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 4 : 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</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 : options, enjeux, contraintes…" />
</div>
<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;
}
.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); }
.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);
}
/* ── Qualify 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-hint--muted { opacity: 0.7; font-style: italic; }
.qualify-optional { font-weight: 400; color: var(--mood-text-muted); font-size: 0.8125rem; }
.qualify-req { color: var(--mood-accent); }
.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;
text-align: left;
}
.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-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: 4rem;
}
.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; }
/* ── Circles ── */
.qualify-circles { display: flex; flex-direction: column; gap: 1.25rem; }
.circle-header { display: flex; align-items: center; gap: 0.625rem; }
.circle-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 800;
flex-shrink: 0;
}
.circle-badge--1 { background: var(--mood-accent); color: var(--mood-accent-text); }
.circle-badge--2 { background: var(--mood-secondary, var(--mood-accent)); color: var(--mood-accent-text); opacity: 0.85; }
.circle-badge--3 { background: var(--mood-surface); color: var(--mood-text-muted); border: 1px solid var(--mood-text-muted); }
.circle-badge--sm { width: 1.25rem; height: 1.25rem; font-size: 0.6875rem; }
.qualify-count-indicator {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-accent);
background: var(--mood-accent-soft);
padding: 0.375rem 0.875rem;
border-radius: 20px;
align-self: flex-start;
}
.qualify-count-plus { font-weight: 400; color: var(--mood-text-muted); }
/* ── Mandate branch ── */
.qualify-mandate-branch { display: flex; flex-direction: column; gap: 1rem; }
.mandate-list { display: flex; flex-direction: column; gap: 0.5rem; }
.mandate-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
background: var(--mood-surface);
border-radius: 12px;
text-decoration: none;
transition: all 0.12s ease;
}
.mandate-item:hover { transform: translateX(2px); box-shadow: 0 2px 12px var(--mood-shadow); }
.mandate-item__icon {
width: 2.25rem;
height: 2.25rem;
border-radius: 10px;
background: var(--mood-accent-soft);
color: var(--mood-accent);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.mandate-item__body { flex: 1; min-width: 0; }
.mandate-item__title { display: block; font-size: 0.9375rem; font-weight: 700; color: var(--mood-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mandate-item__type { display: block; font-size: 0.75rem; color: var(--mood-text-muted); }
.mandate-item__arrow { color: var(--mood-text-muted); opacity: 0.4; flex-shrink: 0; }
.mandate-request-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.125rem;
border-radius: 20px;
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 0.875rem;
font-weight: 700;
text-decoration: none;
cursor: pointer;
transition: all 0.12s ease;
align-self: flex-start;
}
.mandate-request-btn:hover { background: var(--mood-accent); color: var(--mood-accent-text); }
/* ── AI Questions ── */
.ai-questions { display: flex; flex-direction: column; gap: 1.25rem; }
.ai-question {
background: var(--mood-surface);
border-radius: 16px;
overflow: hidden;
}
.ai-question__header {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem 1rem 0.75rem;
}
.ai-question__avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--mood-accent);
color: var(--mood-accent-text);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
flex-shrink: 0;
}
.ai-question__text {
font-size: 0.9375rem;
font-weight: 600;
color: var(--mood-text);
line-height: 1.5;
padding-top: 0.25rem;
}
.ai-question__options {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 0.5rem 0.5rem;
}
.ai-question__opt {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.75rem 0.875rem;
border-radius: 10px;
font-size: 0.875rem;
font-weight: 500;
color: var(--mood-text-muted);
cursor: pointer;
transition: all 0.1s ease;
text-align: left;
}
.ai-question__opt:hover { background: var(--mood-accent-soft); color: var(--mood-text); }
.ai-question__opt--selected {
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-weight: 700;
}
/* ── 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-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__ai-note {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--mood-text-muted);
font-style: italic;
padding: 0 0.25rem;
}
.qresult__reasons { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.375rem; }
.qresult__reasons li { display: flex; align-items: flex-start; gap: 0.375rem; font-size: 0.875rem; color: var(--mood-text-muted); }
.qresult__flag {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.75rem 0.875rem;
border-radius: 10px;
font-size: 0.875rem;
font-weight: 600;
}
.qresult__flag--observatory {
background: color-mix(in srgb, var(--mood-success, #38a169) 12%, transparent);
color: var(--mood-success, #38a169);
}
.qresult__flag--onchain {
background: color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 12%, transparent);
color: var(--mood-tertiary, var(--mood-accent));
}
.qresult__flag--onchain p { margin-top: 0.25rem; color: var(--mood-text-muted); font-weight: 400; line-height: 1.5; }
.qresult__toolbox-title {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-text-muted);
margin-bottom: 0.625rem;
}
.qresult__modalities { display: flex; flex-direction: column; gap: 0.5rem; }
.qresult__modality {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 10px;
background: var(--mood-accent-soft);
border-left: 3px solid var(--mod-color, var(--mood-accent));
}
.qresult__modality-icon {
width: 2rem;
height: 2rem;
flex-shrink: 0;
border-radius: 8px;
background: var(--mod-color, var(--mood-accent));
color: var(--mood-accent-text);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
}
.qresult__modality-title { display: block; font-size: 0.875rem; font-weight: 700; color: var(--mood-text); }
.qresult__modality-desc { display: block; font-size: 0.8125rem; color: var(--mood-text-muted); line-height: 1.4; margin-top: 0.125rem; }
.qresult__circles { border-top: 1px solid var(--mood-accent-soft); padding-top: 0.875rem; }
.qresult__circles-title { font-size: 0.8125rem; font-weight: 700; color: var(--mood-text-muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.5rem; }
.qresult__circles-list { display: flex; flex-direction: column; gap: 0.375rem; }
.qresult__circle { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; color: var(--mood-text-muted); }
.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: 600px; }
/* Circle mode toggle */
.circle-header { flex-wrap: wrap; }
.circle-mode-toggle {
display: inline-flex;
gap: 0.25rem;
margin-left: auto;
background: var(--mood-accent-soft);
border-radius: 10px;
padding: 0.1875rem;
}
.circle-mode-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--mood-muted);
border-radius: 8px;
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.circle-mode-btn--active {
background: var(--mood-accent);
color: var(--mood-accent-text);
}
.circle-group-select { margin-bottom: 0.25rem; }
.qualify-select {
width: 100%;
padding: 0.625rem 0.875rem;
font-size: 0.9375rem;
color: var(--mood-text);
background: var(--mood-accent-soft);
border-radius: 12px;
outline: none;
cursor: pointer;
}
.qualify-hint--warn { color: var(--mood-warning, var(--mood-muted)); }
.qualify-link { color: var(--mood-accent); text-decoration: underline; }
</style>