7
0
forked from yvv/decision
Files
decision/frontend/app/pages/mandates/new.vue
T
Yvv 3ba9c43ce3 Mandat : wizard création 4 étapes + boîte à outils élection
- Mandate model : champ origin (contexte/déclencheur) + migration
- MandateCreate schema : origin, starts_at, ends_at
- mandates/new.vue : wizard complet
  · Étape 1 : type · nom · origine · description · durée (relative ou dates)
  · Étape 2 : auto-désignation (ratification) ou désignation collective
  · Étape 3 : boîte à outils — sans/avec candidature, 6 modalités,
    paramètres configurables (durée, quorum, seuil, slider+input)
  · Étape 4 : récap + génération automatique des étapes du mandat
- Tous les boutons "Nouveau mandat" pointent vers /mandates/new
- decisions/new.vue : "Demander un mandat" → /mandates/new

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 05:29:13 +02:00

1008 lines
40 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 { MandateCreate, MandateStepCreate } from '~/stores/mandates'
const mandates = useMandatesStore()
const { $api } = useApi()
// ── Wizard steps ──────────────────────────────────────────────────────────────
type WizardStep = 'info' | 'nomination' | 'toolbox' | 'summary'
const wizardStep = ref<WizardStep>('info')
// ── Step 1 : Infos de base ────────────────────────────────────────────────────
const title = ref('')
const origin = ref('')
const description = ref('')
const mandateType = ref<'techcomm' | 'smith' | 'custom'>('custom')
// Durée
type DurationMode = 'relative' | 'dates'
const durationMode = ref<DurationMode>('relative')
const durationValue = ref(3)
const durationUnit = ref<'days' | 'weeks' | 'months'>('months')
const startsAt = ref('')
const endsAt = ref('')
function computedDates(): { starts_at: string | null; ends_at: string | null } {
if (durationMode.value === 'dates') {
return {
starts_at: startsAt.value || null,
ends_at: endsAt.value || null,
}
}
const now = new Date()
const end = new Date(now)
if (durationUnit.value === 'days') end.setDate(end.getDate() + durationValue.value)
else if (durationUnit.value === 'weeks') end.setDate(end.getDate() + durationValue.value * 7)
else end.setMonth(end.getMonth() + durationValue.value)
return {
starts_at: now.toISOString(),
ends_at: end.toISOString(),
}
}
const durationLabel = computed(() => {
if (durationMode.value === 'dates') {
if (!startsAt.value && !endsAt.value) return 'Dates non définies'
return `${startsAt.value ? new Date(startsAt.value).toLocaleDateString('fr-FR') : '?'}${endsAt.value ? new Date(endsAt.value).toLocaleDateString('fr-FR') : '?'}`
}
const u = durationUnit.value === 'days' ? 'jour' : durationUnit.value === 'weeks' ? 'semaine' : 'mois'
return `${durationValue.value} ${u}${durationValue.value > 1 && durationUnit.value !== 'months' ? 's' : ''}`
})
const canGoToNomination = computed(() =>
title.value.trim().length > 0 && (
durationMode.value === 'relative' ||
(startsAt.value !== '' && endsAt.value !== '')
)
)
// ── Step 2 : Nomination ───────────────────────────────────────────────────────
type NominationCase = 'self' | 'election'
const nominationCase = ref<NominationCase | null>(null)
// Case 1 — self : ratification vote params
const selfParams = reactive({ duration_days: 7, quorum_pct: 30, majority_pct: 51 })
// ── Step 3 : Boîte à outils élection ─────────────────────────────────────────
type ElectionMode = 'direct' | 'candidacy'
const electionMode = ref<ElectionMode>('direct')
const selectedModalityId = ref<string | null>(null)
interface ModalityParam {
id: string
label: string
unit: string
min: number
max: number
default: number
}
interface Modality {
id: string
icon: string
title: string
desc: string
recommended: boolean
recommendedReason?: string
params: ModalityParam[]
requiresCandidacy: boolean
}
const MODALITIES: Modality[] = [
// ── Sans candidature ──
{
id: 'vote_simple',
icon: 'i-lucide-check-square',
title: 'Vote de désignation',
desc: 'Vote direct parmi les membres du cercle concerné. Simple et rapide pour les petits groupes.',
recommended: mandateType.value !== 'smith',
recommendedReason: 'Adapté aux petits collectifs et décisions peu contestées.',
params: [
{ id: 'duration_days', label: 'Durée du vote', unit: 'jours', min: 1, max: 30, default: 7 },
{ id: 'quorum_pct', label: 'Quorum', unit: '%', min: 10, max: 100, default: 30 },
{ id: 'majority_pct', label: 'Seuil de majorité', unit: '%', min: 51, max: 100, default: 51 },
],
requiresCandidacy: false,
},
{
id: 'vote_wot',
icon: 'i-lucide-network',
title: 'Vote WoT',
desc: 'Vote pondéré par la Toile de Confiance. Le poids de chaque voix dépend de la distance certificationnelle.',
recommended: true,
recommendedReason: 'Recommandé pour les mandats impliquant la communauté G1 au-delà des 10 personnes.',
params: [
{ id: 'duration_days', label: 'Durée du vote', unit: 'jours', min: 1, max: 60, default: 14 },
{ id: 'quorum_pct', label: 'Quorum WoT', unit: '% de la WoT', min: 5, max: 100, default: 20 },
{ id: 'majority_pct', label: 'Seuil', unit: '%', min: 51, max: 100, default: 51 },
],
requiresCandidacy: false,
},
{
id: 'vote_smith',
icon: 'i-lucide-hammer',
title: 'Vote Smith',
desc: 'Réservé aux Forgerons (critère de Smith). Utilisé pour les décisions techniques du réseau.',
recommended: mandateType.value === 'smith' || mandateType.value === 'techcomm',
recommendedReason: 'Standard pour les mandats Forgeron et Comité Technique.',
params: [
{ id: 'duration_days', label: 'Durée du vote', unit: 'jours', min: 1, max: 30, default: 14 },
{ id: 'smith_min', label: 'Forgerons minimum', unit: 'forgerons', min: 1, max: 20, default: 3 },
],
requiresCandidacy: false,
},
// ── Avec candidature ──
{
id: 'candidacy_vote_majoritaire',
icon: 'i-lucide-user-check',
title: 'Candidature + Vote majoritaire',
desc: 'Appel à candidature ouvert, suivi d\'un vote binaire (Pour/Contre) pour chaque candidat.',
recommended: false,
params: [
{ id: 'candidacy_days', label: 'Fenêtre de candidature', unit: 'jours', min: 1, max: 30, default: 7 },
{ id: 'duration_days', label: 'Durée du vote', unit: 'jours', min: 1, max: 30, default: 7 },
{ id: 'quorum_pct', label: 'Quorum', unit: '%', min: 10, max: 100, default: 30 },
{ id: 'majority_pct', label: 'Seuil', unit: '%', min: 51, max: 100, default: 51 },
],
requiresCandidacy: true,
},
{
id: 'candidacy_vote_preferentiel',
icon: 'i-lucide-list-ordered',
title: 'Candidature + Vote préférentiel',
desc: 'Candidature ouverte, puis classement des candidats par les votants (méthode Condorcet ou Borda).',
recommended: true,
recommendedReason: 'Recommandé dès 3 candidats pour éviter les effets de vote utile.',
params: [
{ id: 'candidacy_days', label: 'Fenêtre de candidature', unit: 'jours', min: 1, max: 30, default: 7 },
{ id: 'duration_days', label: 'Durée du vote', unit: 'jours', min: 1, max: 60, default: 14 },
],
requiresCandidacy: true,
},
{
id: 'candidacy_vote_wot',
icon: 'i-lucide-network',
title: 'Candidature + Vote WoT',
desc: 'Candidature ouverte, puis vote pondéré par la Toile de Confiance.',
recommended: false,
params: [
{ id: 'candidacy_days', label: 'Fenêtre de candidature', unit: 'jours', min: 1, max: 30, default: 7 },
{ id: 'duration_days', label: 'Durée du vote', unit: 'jours', min: 1, max: 60, default: 14 },
{ id: 'majority_pct', label: 'Seuil WoT', unit: '%', min: 51, max: 100, default: 51 },
],
requiresCandidacy: true,
},
]
const visibleModalities = computed(() =>
MODALITIES.filter(m => m.requiresCandidacy === (electionMode.value === 'candidacy'))
)
// Paramètres par modalité (valeurs mutable)
const paramValues = reactive<Record<string, Record<string, number>>>({})
function getParam(modalityId: string, paramId: string): number {
if (!paramValues[modalityId]) {
const m = MODALITIES.find(x => x.id === modalityId)
if (m) paramValues[modalityId] = Object.fromEntries(m.params.map(p => [p.id, p.default]))
}
return paramValues[modalityId]?.[paramId] ?? 0
}
function setParam(modalityId: string, paramId: string, value: number) {
if (!paramValues[modalityId]) getParam(modalityId, paramId)
paramValues[modalityId][paramId] = value
}
// ── Génération des steps à créer ──────────────────────────────────────────────
interface StepDef { step_type: string; title: string; description: string }
const stepsToCreate = computed<StepDef[]>(() => {
const steps: StepDef[] = []
steps.push({ step_type: 'formulation', title: 'Rédaction du mandat', description: 'Définition du périmètre, des objectifs et des attendus.' })
if (nominationCase.value === 'self') {
steps.push({ step_type: 'vote', title: 'Ratification', description: `Vote de ratification — durée ${selfParams.duration_days}j, quorum ${selfParams.quorum_pct}%, seuil ${selfParams.majority_pct}%.` })
steps.push({ step_type: 'assignment', title: 'Prise de mandat', description: 'La personne entre en mandat après ratification.' })
} else if (nominationCase.value === 'election' && selectedModalityId.value) {
const m = MODALITIES.find(x => x.id === selectedModalityId.value)
if (m?.requiresCandidacy) {
const cdays = getParam(m.id, 'candidacy_days')
steps.push({ step_type: 'candidacy', title: 'Appel à candidature', description: `Fenêtre de candidature : ${cdays} jour${cdays > 1 ? 's' : ''}.` })
}
if (m) {
const pv = paramValues[m.id] ?? {}
const paramStr = Object.entries(pv).filter(([k]) => k !== 'candidacy_days').map(([k, v]) => {
const p = m.params.find(x => x.id === k)
return p ? `${p.label} : ${v} ${p.unit}` : ''
}).filter(Boolean).join(', ')
steps.push({ step_type: 'vote', title: `Élection — ${m.title}`, description: paramStr })
}
steps.push({ step_type: 'assignment', title: 'Désignation du mandataire', description: 'Le candidat élu entre en mandat.' })
}
steps.push({ step_type: 'reporting', title: 'Compte-rendu', description: 'Rapport d\'activité en fin de mandat.' })
steps.push({ step_type: 'completion', title: 'Clôture', description: 'Validation de la fin du mandat.' })
return steps
})
const canGoToSummary = computed(() => {
if (nominationCase.value === 'self') return true
if (nominationCase.value === 'election') return selectedModalityId.value !== null
return false
})
// ── Création ──────────────────────────────────────────────────────────────────
const submitting = ref(false)
const submitError = ref<string | null>(null)
async function createMandate() {
submitting.value = true
submitError.value = null
try {
const dates = computedDates()
const mandate = await mandates.create({
title: title.value.trim(),
origin: origin.value.trim() || null,
description: description.value.trim() || null,
mandate_type: mandateType.value,
starts_at: dates.starts_at,
ends_at: dates.ends_at,
})
if (!mandate) throw new Error('Erreur création mandat')
// Create steps
for (let i = 0; i < stepsToCreate.value.length; i++) {
const s = stepsToCreate.value[i]
await $api(`/mandates/${mandate.id}/steps`, {
method: 'POST',
body: { step_order: i, step_type: s.step_type, title: s.title, description: s.description },
})
}
navigateTo(`/mandates/${mandate.id}`)
} catch (e: any) {
submitError.value = e?.message ?? 'Erreur lors de la création'
} finally {
submitting.value = false
}
}
// ── Navigation ────────────────────────────────────────────────────────────────
function goBack() {
if (wizardStep.value === 'summary') wizardStep.value = nominationCase.value === 'election' ? 'toolbox' : 'nomination'
else if (wizardStep.value === 'toolbox') wizardStep.value = 'nomination'
else if (wizardStep.value === 'nomination') wizardStep.value = 'info'
}
function goToNomination() { if (canGoToNomination.value) wizardStep.value = 'nomination' }
function goToToolbox() { wizardStep.value = 'toolbox' }
function goToSummary() { if (canGoToSummary.value) wizardStep.value = 'summary' }
// Typage
const MANDATE_TYPE_OPTIONS = [
{ value: 'custom', label: 'Mandat libre', icon: 'i-lucide-circle' },
{ value: 'techcomm', label: 'Comité Technique', icon: 'i-lucide-cpu' },
{ value: 'smith', label: 'Forgeron', icon: 'i-lucide-hammer' },
]
</script>
<template>
<div class="mwiz">
<!-- Nav -->
<div class="mwiz__nav">
<button v-if="wizardStep !== 'info'" class="mwiz__back" @click="goBack">
<UIcon name="i-lucide-arrow-left" class="text-sm" />
<span>Retour</span>
</button>
<NuxtLink v-else to="/mandates" class="mwiz__back">
<UIcon name="i-lucide-arrow-left" class="text-sm" />
<span>Mandats</span>
</NuxtLink>
<!-- Progress pills -->
<div class="mwiz__progress">
<span class="mwiz__pill" :class="{ 'mwiz__pill--active': wizardStep === 'info', 'mwiz__pill--done': wizardStep !== 'info' }">1 Infos</span>
<span class="mwiz__sep"></span>
<span class="mwiz__pill" :class="{ 'mwiz__pill--active': wizardStep === 'nomination', 'mwiz__pill--done': ['toolbox','summary'].includes(wizardStep) }">2 Nomination</span>
<span class="mwiz__sep"></span>
<span v-if="nominationCase === 'election'" class="mwiz__pill" :class="{ 'mwiz__pill--active': wizardStep === 'toolbox', 'mwiz__pill--done': wizardStep === 'summary' }">3 Modalités</span>
<span v-if="nominationCase === 'election'" class="mwiz__sep"></span>
<span class="mwiz__pill" :class="{ 'mwiz__pill--active': wizardStep === 'summary' }">{{ nominationCase === 'election' ? '4' : '3' }} Récap</span>
</div>
</div>
<Transition name="slide-fade" mode="out-in">
<!-- ÉTAPE 1 : Infos de base -->
<div v-if="wizardStep === 'info'" key="info" class="mwiz__step">
<div class="mwiz__header">
<h1 class="mwiz__title">Nouveau mandat</h1>
<p class="mwiz__subtitle">Définissez le périmètre et la durée du mandat</p>
</div>
<div class="mwiz__form">
<!-- Type de mandat -->
<div class="mwiz__section">
<p class="mwiz__label">Type de mandat</p>
<div class="mwiz__type-grid">
<button
v-for="opt in MANDATE_TYPE_OPTIONS"
:key="opt.value"
class="mwiz__type-btn"
:class="{ 'mwiz__type-btn--active': mandateType === opt.value }"
@click="mandateType = opt.value as any"
>
<UIcon :name="opt.icon" class="text-base" />
<span>{{ opt.label }}</span>
</button>
</div>
</div>
<!-- Nom -->
<div class="mwiz__section">
<label class="mwiz__label">Nom du mandat <span class="mwiz__req">*</span></label>
<input
v-model="title"
type="text"
class="mwiz__input"
lang="fr"
spellcheck="true"
placeholder="Ex : Responsable communication, Délégué technique G1..."
/>
</div>
<!-- Origine -->
<div class="mwiz__section">
<label class="mwiz__label">Origine <span class="mwiz__optional">(optionnel)</span></label>
<textarea
v-model="origin"
class="mwiz__textarea"
rows="2"
lang="fr"
spellcheck="true"
placeholder="Qui propose ce mandat, dans quel contexte, suite à quelle décision ou besoin ?"
/>
</div>
<!-- Description -->
<div class="mwiz__section">
<label class="mwiz__label">Description du mandat <span class="mwiz__optional">(optionnel)</span></label>
<textarea
v-model="description"
class="mwiz__textarea"
rows="3"
lang="fr"
spellcheck="true"
placeholder="Périmètre, responsabilités, attendus du mandataire..."
/>
</div>
<!-- Durée -->
<div class="mwiz__section">
<p class="mwiz__label">Durée du mandat</p>
<div class="mwiz__dur-toggle">
<button class="mwiz__dur-btn" :class="{ 'mwiz__dur-btn--active': durationMode === 'relative' }" @click="durationMode = 'relative'">
<UIcon name="i-lucide-clock" class="text-sm" /> Durée
</button>
<button class="mwiz__dur-btn" :class="{ 'mwiz__dur-btn--active': durationMode === 'dates' }" @click="durationMode = 'dates'">
<UIcon name="i-lucide-calendar" class="text-sm" /> Dates fixes
</button>
</div>
<div v-if="durationMode === 'relative'" class="mwiz__dur-relative">
<input v-model.number="durationValue" type="number" class="mwiz__dur-number" min="1" max="999" />
<div class="mwiz__type-grid mwiz__type-grid--sm">
<button class="mwiz__type-btn" :class="{ 'mwiz__type-btn--active': durationUnit === 'days' }" @click="durationUnit = 'days'">Jours</button>
<button class="mwiz__type-btn" :class="{ 'mwiz__type-btn--active': durationUnit === 'weeks' }" @click="durationUnit = 'weeks'">Semaines</button>
<button class="mwiz__type-btn" :class="{ 'mwiz__type-btn--active': durationUnit === 'months' }" @click="durationUnit = 'months'">Mois</button>
</div>
</div>
<div v-else class="mwiz__dur-dates">
<div class="mwiz__dur-date-field">
<label class="mwiz__label-sm">Début</label>
<input v-model="startsAt" type="date" class="mwiz__input" />
</div>
<UIcon name="i-lucide-arrow-right" class="text-sm mwiz__dur-arrow" />
<div class="mwiz__dur-date-field">
<label class="mwiz__label-sm">Fin</label>
<input v-model="endsAt" type="date" class="mwiz__input" />
</div>
</div>
</div>
<button class="mwiz__submit" :disabled="!canGoToNomination" @click="goToNomination">
Étape suivante Nomination
<UIcon name="i-lucide-arrow-right" class="text-sm" />
</button>
</div>
</div>
<!-- ÉTAPE 2 : Nomination -->
<div v-else-if="wizardStep === 'nomination'" key="nomination" class="mwiz__step">
<div class="mwiz__header">
<h1 class="mwiz__title">Processus de nomination</h1>
<p class="mwiz__subtitle">Ce mandat est-il demandé par son futur titulaire, ou faut-il désigner quelqu'un ?</p>
</div>
<div class="mwiz__nom-grid">
<!-- Cas 1 : soi-même -->
<button
class="mwiz__nom-card"
:class="{ 'mwiz__nom-card--active': nominationCase === 'self' }"
@click="nominationCase = 'self'"
>
<div class="mwiz__nom-icon">
<UIcon name="i-lucide-user-check" class="text-2xl" />
</div>
<div class="mwiz__nom-body">
<p class="mwiz__nom-title">Auto-désignation</p>
<p class="mwiz__nom-desc">La personne à l'origine du mandat le demande pour elle-même. Un vote de ratification valide la prise de mandat.</p>
</div>
</button>
<!-- Cas 2 : élection -->
<button
class="mwiz__nom-card"
:class="{ 'mwiz__nom-card--active': nominationCase === 'election' }"
@click="nominationCase = 'election'"
>
<div class="mwiz__nom-icon">
<UIcon name="i-lucide-users" class="text-2xl" />
</div>
<div class="mwiz__nom-body">
<p class="mwiz__nom-title">Désignation par le collectif</p>
<p class="mwiz__nom-desc">Le mandataire est élu ou désigné par le groupe. La boîte à outils propose les modalités d'élection adaptées.</p>
</div>
</button>
</div>
<!-- Cas 1 : paramètres ratification -->
<Transition name="slide-down">
<div v-if="nominationCase === 'self'" class="mwiz__self-params">
<p class="mwiz__label">Paramètres du vote de ratification</p>
<div class="mwiz__param-row">
<div class="mwiz__param-field">
<label class="mwiz__label-sm">Durée du vote</label>
<div class="mwiz__param-input-row">
<input v-model.number="selfParams.duration_days" type="number" class="mwiz__param-input" min="1" max="30" />
<span class="mwiz__param-unit">jours</span>
</div>
</div>
<div class="mwiz__param-field">
<label class="mwiz__label-sm">Quorum</label>
<div class="mwiz__param-input-row">
<input v-model.number="selfParams.quorum_pct" type="number" class="mwiz__param-input" min="10" max="100" />
<span class="mwiz__param-unit">%</span>
</div>
</div>
<div class="mwiz__param-field">
<label class="mwiz__label-sm">Seuil de majorité</label>
<div class="mwiz__param-input-row">
<input v-model.number="selfParams.majority_pct" type="number" class="mwiz__param-input" min="51" max="100" />
<span class="mwiz__param-unit">%</span>
</div>
</div>
</div>
<button class="mwiz__submit" @click="goToSummary">
Récapitulatif
<UIcon name="i-lucide-arrow-right" class="text-sm" />
</button>
</div>
</Transition>
<Transition name="slide-down">
<div v-if="nominationCase === 'election'" class="mwiz__election-cta">
<button class="mwiz__submit" @click="goToToolbox">
Choisir les modalités d'élection
<UIcon name="i-lucide-arrow-right" class="text-sm" />
</button>
</div>
</Transition>
</div>
<!-- ÉTAPE 3 : Boîte à outils élection -->
<div v-else-if="wizardStep === 'toolbox'" key="toolbox" class="mwiz__step">
<div class="mwiz__header">
<h1 class="mwiz__title">Modalités d'élection</h1>
<p class="mwiz__subtitle">Choisissez le mode de désignation et configurez les paramètres</p>
</div>
<!-- Mode toggle -->
<div class="mwiz__mode-toggle">
<button class="mwiz__mode-btn" :class="{ 'mwiz__mode-btn--active': electionMode === 'direct' }" @click="electionMode = 'direct'">
<UIcon name="i-lucide-vote" class="text-sm" />
Sans candidature
</button>
<button class="mwiz__mode-btn" :class="{ 'mwiz__mode-btn--active': electionMode === 'candidacy' }" @click="electionMode = 'candidacy'">
<UIcon name="i-lucide-user-plus" class="text-sm" />
Avec candidature
</button>
</div>
<!-- Modalités -->
<div class="mwiz__modalities">
<div
v-for="m in visibleModalities"
:key="m.id"
class="mwiz__modality"
:class="{
'mwiz__modality--selected': selectedModalityId === m.id,
'mwiz__modality--recommended': m.recommended,
}"
@click="selectedModalityId = m.id"
>
<div class="mwiz__modality-head">
<div class="mwiz__modality-icon">
<UIcon :name="m.icon" class="text-lg" />
</div>
<div class="mwiz__modality-info">
<p class="mwiz__modality-title">{{ m.title }}</p>
<p class="mwiz__modality-desc">{{ m.desc }}</p>
</div>
<div class="mwiz__modality-check">
<UIcon v-if="selectedModalityId === m.id" name="i-lucide-circle-check" class="text-lg" />
<UIcon v-else name="i-lucide-circle" class="text-lg" />
</div>
</div>
<span v-if="m.recommended" class="mwiz__modality-badge">
<UIcon name="i-lucide-sparkles" class="text-xs" />
Recommandé
</span>
<p v-if="m.recommended && m.recommendedReason" class="mwiz__modality-reason">{{ m.recommendedReason }}</p>
<!-- Paramètres (visibles si sélectionné) -->
<Transition name="slide-down">
<div v-if="selectedModalityId === m.id" class="mwiz__modality-params">
<div v-for="p in m.params" :key="p.id" class="mwiz__param-field">
<label class="mwiz__label-sm">{{ p.label }}</label>
<div class="mwiz__param-input-row">
<input
:value="getParam(m.id, p.id)"
type="number"
class="mwiz__param-input"
:min="p.min"
:max="p.max"
@input="setParam(m.id, p.id, +($event.target as HTMLInputElement).value)"
/>
<span class="mwiz__param-unit">{{ p.unit }}</span>
<input
:value="getParam(m.id, p.id)"
type="range"
class="mwiz__param-slider"
:min="p.min"
:max="p.max"
@input="setParam(m.id, p.id, +($event.target as HTMLInputElement).value)"
/>
</div>
</div>
</div>
</Transition>
</div>
</div>
<button class="mwiz__submit" :disabled="!canGoToSummary" @click="goToSummary">
Récapitulatif
<UIcon name="i-lucide-arrow-right" class="text-sm" />
</button>
</div>
<!-- ─── ÉTAPE 4 : Récapitulatif ──────────────────────────────────── -->
<div v-else-if="wizardStep === 'summary'" key="summary" class="mwiz__step">
<div class="mwiz__header">
<h1 class="mwiz__title">Récapitulatif</h1>
<p class="mwiz__subtitle">Vérifiez avant de créer le mandat</p>
</div>
<div class="mwiz__recap">
<!-- Infos -->
<div class="mwiz__recap-block">
<p class="mwiz__recap-label">Mandat</p>
<p class="mwiz__recap-title">{{ title }}</p>
<p v-if="origin" class="mwiz__recap-meta">Origine : {{ origin }}</p>
<p v-if="description" class="mwiz__recap-meta">{{ description }}</p>
<p class="mwiz__recap-meta">
<UIcon name="i-lucide-clock" class="text-xs" />
{{ durationLabel }}
</p>
<p class="mwiz__recap-meta">
<UIcon :name="MANDATE_TYPE_OPTIONS.find(o => o.value === mandateType)?.icon ?? 'i-lucide-circle'" class="text-xs" />
{{ MANDATE_TYPE_OPTIONS.find(o => o.value === mandateType)?.label }}
</p>
</div>
<!-- Nomination -->
<div class="mwiz__recap-block">
<p class="mwiz__recap-label">Nomination</p>
<p class="mwiz__recap-value">
{{ nominationCase === 'self' ? 'Auto-désignation avec ratification' : `Élection — ${MODALITIES.find(m => m.id === selectedModalityId)?.title ?? ''}` }}
</p>
<p v-if="nominationCase === 'self'" class="mwiz__recap-meta">
Durée : {{ selfParams.duration_days }}j · Quorum : {{ selfParams.quorum_pct }}% · Seuil : {{ selfParams.majority_pct }}%
</p>
</div>
<!-- Steps -->
<div class="mwiz__recap-block">
<p class="mwiz__recap-label">Étapes générées ({{ stepsToCreate.length }})</p>
<ol class="mwiz__steps-list">
<li v-for="(s, i) in stepsToCreate" :key="i" class="mwiz__steps-item">
<span class="mwiz__steps-num">{{ i + 1 }}</span>
<div>
<p class="mwiz__steps-title">{{ s.title }}</p>
<p class="mwiz__steps-desc">{{ s.description }}</p>
</div>
</li>
</ol>
</div>
</div>
<p v-if="submitError" class="mwiz__error">{{ submitError }}</p>
<div class="mwiz__recap-actions">
<button class="mwiz__back-btn" @click="goBack">
<UIcon name="i-lucide-arrow-left" class="text-sm" /> Modifier
</button>
<button class="mwiz__create-btn" :disabled="submitting" @click="createMandate">
<UIcon v-if="submitting" name="i-lucide-loader-2" class="animate-spin text-sm" />
<UIcon v-else name="i-lucide-check" class="text-sm" />
{{ submitting ? 'Création' : 'Créer le mandat' }}
</button>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.mwiz {
max-width: 720px;
margin: 0 auto;
padding: 1.5rem 1rem 4rem;
}
/* Nav */
.mwiz__nav {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.mwiz__back {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
color: var(--mood-muted);
cursor: pointer;
transition: color 0.1s;
}
.mwiz__back:hover { color: var(--mood-text); }
.mwiz__progress {
display: flex;
align-items: center;
gap: 0.375rem;
margin-left: auto;
flex-wrap: wrap;
}
.mwiz__sep { color: var(--mood-muted); font-size: 0.75rem; }
.mwiz__pill {
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.625rem;
border-radius: 20px;
color: var(--mood-muted);
background: var(--mood-surface);
}
.mwiz__pill--active {
background: var(--mood-accent);
color: var(--mood-accent-text);
}
.mwiz__pill--done {
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
/* Header */
.mwiz__header { margin-bottom: 2rem; }
.mwiz__title { font-size: clamp(1.375rem, 3vw, 1.75rem); font-weight: 800; color: var(--mood-text); margin-bottom: 0.375rem; }
.mwiz__subtitle { font-size: 0.9375rem; color: var(--mood-muted); }
/* Form */
.mwiz__form { display: flex; flex-direction: column; gap: 1.5rem; }
.mwiz__section { display: flex; flex-direction: column; gap: 0.5rem; }
.mwiz__label { font-size: 0.9375rem; font-weight: 600; color: var(--mood-text); }
.mwiz__label-sm { font-size: 0.8125rem; font-weight: 600; color: var(--mood-muted); }
.mwiz__req { color: var(--mood-accent); }
.mwiz__optional { font-weight: 400; font-size: 0.8125rem; color: var(--mood-muted); }
.mwiz__input {
width: 100%;
padding: 0.6875rem 1rem;
font-size: 0.9375rem;
color: var(--mood-text);
background: var(--mood-accent-soft);
border-radius: 12px;
outline: none;
}
.mwiz__textarea {
width: 100%;
padding: 0.6875rem 1rem;
font-size: 0.9375rem;
color: var(--mood-text);
background: var(--mood-accent-soft);
border-radius: 12px;
outline: none;
resize: vertical;
font-family: inherit;
}
/* Type grid */
.mwiz__type-grid {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.mwiz__type-grid--sm .mwiz__type-btn { padding: 0.375rem 0.75rem; font-size: 0.875rem; }
.mwiz__type-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--mood-muted);
background: var(--mood-surface);
border-radius: 16px;
cursor: pointer;
transition: background 0.1s, color 0.1s, transform 0.1s;
}
.mwiz__type-btn:hover { transform: translateY(-1px); }
.mwiz__type-btn--active { background: var(--mood-accent); color: var(--mood-accent-text); }
/* Duration */
.mwiz__dur-toggle {
display: inline-flex;
background: var(--mood-surface);
border-radius: 12px;
padding: 0.25rem;
gap: 0.25rem;
margin-bottom: 0.75rem;
}
.mwiz__dur-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.875rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-muted);
border-radius: 10px;
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.mwiz__dur-btn--active { background: var(--mood-accent); color: var(--mood-accent-text); }
.mwiz__dur-relative { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.mwiz__dur-number {
width: 5rem;
padding: 0.5rem 0.75rem;
font-size: 1.125rem;
font-weight: 700;
text-align: center;
background: var(--mood-accent-soft);
border-radius: 10px;
outline: none;
color: var(--mood-text);
}
.mwiz__dur-dates { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.mwiz__dur-date-field { display: flex; flex-direction: column; gap: 0.25rem; flex: 1; min-width: 140px; }
.mwiz__dur-arrow { color: var(--mood-muted); }
/* Submit */
.mwiz__submit {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.75rem;
font-size: 1rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
align-self: flex-start;
margin-top: 0.5rem;
}
.mwiz__submit:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 4px 16px var(--mood-shadow); }
.mwiz__submit:disabled { opacity: 0.4; cursor: not-allowed; }
/* Nomination cards */
.mwiz__nom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.5rem; }
@media (max-width: 480px) { .mwiz__nom-grid { grid-template-columns: 1fr; } }
.mwiz__nom-card {
display: flex;
flex-direction: column;
gap: 0.875rem;
padding: 1.25rem;
background: var(--mood-surface);
border-radius: 16px;
cursor: pointer;
text-align: left;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.mwiz__nom-card:hover { transform: translateY(-3px); box-shadow: 0 6px 20px var(--mood-shadow); }
.mwiz__nom-card--active { box-shadow: 0 0 0 2px var(--mood-accent), 0 6px 20px var(--mood-shadow); background: var(--mood-accent-soft); }
.mwiz__nom-icon { color: var(--mood-accent); }
.mwiz__nom-title { font-weight: 700; font-size: 1rem; color: var(--mood-text); margin-bottom: 0.25rem; }
.mwiz__nom-desc { font-size: 0.875rem; color: var(--mood-muted); line-height: 1.5; }
/* Params */
.mwiz__self-params, .mwiz__election-cta { display: flex; flex-direction: column; gap: 1rem; }
.mwiz__param-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.mwiz__param-field { display: flex; flex-direction: column; gap: 0.375rem; min-width: 120px; }
.mwiz__param-input-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.mwiz__param-input {
width: 5rem;
padding: 0.375rem 0.5rem;
font-size: 0.9375rem;
font-weight: 700;
text-align: center;
background: var(--mood-accent-soft);
border-radius: 8px;
outline: none;
color: var(--mood-text);
}
.mwiz__param-unit { font-size: 0.8125rem; color: var(--mood-muted); white-space: nowrap; }
.mwiz__param-slider { flex: 1; min-width: 80px; accent-color: var(--mood-accent); }
/* Mode toggle (toolbox) */
.mwiz__mode-toggle {
display: inline-flex;
background: var(--mood-surface);
border-radius: 14px;
padding: 0.3125rem;
gap: 0.25rem;
margin-bottom: 1.5rem;
}
.mwiz__mode-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1.125rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--mood-muted);
border-radius: 10px;
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.mwiz__mode-btn--active { background: var(--mood-accent); color: var(--mood-accent-text); }
/* Modalities */
.mwiz__modalities { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; }
.mwiz__modality {
background: var(--mood-surface);
border-radius: 16px;
padding: 1.125rem 1.25rem;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
border-left: 3px solid transparent;
}
.mwiz__modality:hover { transform: translateY(-2px); box-shadow: 0 4px 14px var(--mood-shadow); }
.mwiz__modality--selected { border-left-color: var(--mood-accent); box-shadow: 0 4px 14px var(--mood-shadow); }
.mwiz__modality--recommended { border-left-color: var(--mood-success, var(--mood-accent)); }
.mwiz__modality--selected.mwiz__modality--recommended { border-left-color: var(--mood-accent); }
.mwiz__modality-head { display: flex; align-items: flex-start; gap: 0.875rem; }
.mwiz__modality-icon {
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--mood-accent-soft);
color: var(--mood-accent);
border-radius: 12px;
flex-shrink: 0;
}
.mwiz__modality-info { flex: 1; }
.mwiz__modality-title { font-weight: 700; font-size: 0.9375rem; color: var(--mood-text); margin-bottom: 0.25rem; }
.mwiz__modality-desc { font-size: 0.8125rem; color: var(--mood-muted); line-height: 1.5; }
.mwiz__modality-check { color: var(--mood-accent); margin-top: 0.125rem; }
.mwiz__modality-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.625rem;
font-size: 0.75rem;
font-weight: 700;
color: var(--mood-success, var(--mood-accent));
background: color-mix(in srgb, var(--mood-success, var(--mood-accent)) 12%, transparent);
border-radius: 12px;
padding: 0.1875rem 0.625rem;
}
.mwiz__modality-reason { font-size: 0.8125rem; color: var(--mood-muted); margin-top: 0.25rem; font-style: italic; }
.mwiz__modality-params {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--mood-border, rgba(0,0,0,0.06));
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
/* Recap */
.mwiz__recap { display: flex; flex-direction: column; gap: 1.25rem; margin-bottom: 2rem; }
.mwiz__recap-block {
background: var(--mood-surface);
border-radius: 16px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.mwiz__recap-label { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--mood-muted); margin-bottom: 0.25rem; }
.mwiz__recap-title { font-size: 1.125rem; font-weight: 800; color: var(--mood-text); }
.mwiz__recap-value { font-size: 0.9375rem; font-weight: 600; color: var(--mood-text); }
.mwiz__recap-meta { font-size: 0.875rem; color: var(--mood-muted); display: flex; align-items: center; gap: 0.375rem; }
/* Steps list */
.mwiz__steps-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.625rem; }
.mwiz__steps-item { display: flex; gap: 0.75rem; align-items: flex-start; }
.mwiz__steps-num {
width: 1.5rem; height: 1.5rem;
display: flex; align-items: center; justify-content: center;
font-size: 0.75rem; font-weight: 700;
background: var(--mood-accent-soft);
color: var(--mood-accent);
border-radius: 50%;
flex-shrink: 0;
margin-top: 0.125rem;
}
.mwiz__steps-title { font-weight: 600; font-size: 0.875rem; color: var(--mood-text); }
.mwiz__steps-desc { font-size: 0.8125rem; color: var(--mood-muted); }
/* Recap actions */
.mwiz__recap-actions { display: flex; gap: 1rem; align-items: center; }
.mwiz__back-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.25rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--mood-muted);
background: var(--mood-surface);
border-radius: 16px;
cursor: pointer;
transition: transform 0.1s;
}
.mwiz__back-btn:hover { transform: translateY(-1px); }
.mwiz__create-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.75rem;
font-size: 1rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.mwiz__create-btn:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 4px 16px var(--mood-shadow); }
.mwiz__create-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.mwiz__error { color: var(--mood-danger, #e53e3e); font-size: 0.875rem; padding: 0.5rem 0; }
/* Transitions */
.slide-fade-enter-active, .slide-fade-leave-active { transition: all 0.2s ease; }
.slide-fade-enter-from { opacity: 0; transform: translateX(20px); }
.slide-fade-leave-to { opacity: 0; transform: translateX(-20px); }
.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; }
</style>