- 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>
1144 lines
43 KiB
Vue
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>
|