7
0
forked from yvv/decision
Files
Yvv f56d84e76b
ci/woodpecker/push/woodpecker Pipeline was successful
Mandats : origin→FK identité + nomination auto + boutons + tests intégration
- origin TEXT → origin_id UUID FK duniter_identities (migration e3f4a5b6c7d8)
- GET /auth/identities?q= : recherche d'identités par nom/adresse
- MandateCreate.nomination_mode : auto (auto-assign auteur), collective, postpone
- Wizard new.vue : champ origine = picker identité, checkbox "Démarrer maintenant"
- [id].vue : modal "Assigner" = search-picker (résout UUID vs adresse SS58), affiche
  origin_display_name + mandatee_display_name, inputs natifs (<input>/<textarea>)
- Erreurs API visibles dans l'UI (plus de catch silencieux)
- test_mandate_flows.py : 17 tests intégration SQLite réels (origin, nomination,
  assign, lifecycle, revocation, interactions croisées) — 241 tests total OK

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

1282 lines
50 KiB
Vue
Raw Permalink 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 } from '~/stores/mandates'
const mandates = useMandatesStore()
const { $api } = useApi()
// ── Wizard ────────────────────────────────────────────────────────────────────
type WizardStep = 'nomination' | 'info' | 'summary'
const wizardStep = ref<WizardStep>('nomination')
// ── Étape 1 : Processus de nomination ────────────────────────────────────────
type NominationCase = 'self' | 'collective'
const nominationCase = ref<NominationCase | null>(null)
// Cas 1 — Auto-désignation
type SelfMode = 'period' | 'meeting'
const selfMode = ref<SelfMode>('period')
const selfPeriodParams = reactive({ duration_days: 14, quorum_pct: 30, majority_pct: 51 })
const selfMeetingName = ref('')
const selfMeetingMajority = ref(51)
// Cas 2 — Désignation collective
type CandidacyMode = 'direct' | 'candidacy'
const candidacyMode = ref<CandidacyMode>('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[] = [
{
id: 'vote_simple',
icon: 'i-lucide-check-square',
title: 'Vote de désignation',
desc: 'Vote direct parmi les membres. Simple et rapide pour les petits groupes.',
recommended: false,
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à de 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). Pour les décisions techniques du réseau.',
recommended: false,
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,
},
{
id: 'candidacy_vote_majoritaire',
icon: 'i-lucide-user-check',
title: 'Candidature + Vote majoritaire',
desc: 'Appel à candidature ouvert, puis 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 (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 === (candidacyMode.value === 'candidacy'))
)
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
}
const canGoToInfo = computed(() => {
if (!nominationCase.value) return false
if (nominationCase.value === 'collective') return selectedModalityId.value !== null
return true
})
// ── Étape 2 : Infos du mandat ────────────────────────────────────────────────
type MandateType = 'statutory' | 'functional'
const mandateType = ref<MandateType>('functional')
const title = ref('')
const description = ref('')
// Origin : identité Duniter (non texte libre)
interface IdentityResult { id: string; address: string; display_name: string | null }
const originQuery = ref('')
const originResults = ref<IdentityResult[]>([])
const originId = ref<string | null>(null)
const originSearching = ref(false)
let originTimer: ReturnType<typeof setTimeout> | null = null
async function searchOrigin(q: string) {
if (q.length < 2) { originResults.value = []; return }
originSearching.value = true
try {
originResults.value = await $api<IdentityResult[]>('/auth/identities', { query: { q } })
} catch { originResults.value = [] } finally { originSearching.value = false }
}
function onOriginInput(q: string) {
originQuery.value = q
originId.value = null
if (originTimer) clearTimeout(originTimer)
originTimer = setTimeout(() => searchOrigin(q), 300)
}
function selectOrigin(r: IdentityResult) {
originId.value = r.id
originQuery.value = r.display_name || r.address
originResults.value = []
}
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 canGoToSummary = computed(() =>
title.value.trim().length > 0 && (
durationMode.value === 'relative' || (startsAt.value !== '' && endsAt.value !== '')
)
)
const MANDATE_TYPE_OPTIONS = [
{
value: 'statutory',
label: 'Statutaire',
desc: 'Lié aux statuts de l\'organisation (Président, Trésorier, Secrétaire…)',
icon: 'i-lucide-landmark',
},
{
value: 'functional',
label: 'Fonctionnel',
desc: 'Mission ou rôle opérationnel lié au fonctionnement de l\'organisation',
icon: 'i-lucide-settings-2',
},
]
// ── Génération des étapes ─────────────────────────────────────────────────────
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') {
if (selfMode.value === 'period') {
steps.push({
step_type: 'vote',
title: 'Ratification sur période',
description: `Vote de ratification ouvert à tous les adhérents — ${selfPeriodParams.duration_days}j, quorum ${selfPeriodParams.quorum_pct}%, seuil ${selfPeriodParams.majority_pct}%.`,
})
} else {
const mn = selfMeetingName.value.trim() || 'réunion'
steps.push({
step_type: 'vote',
title: `Ratification en réunion`,
description: `Ratification ponctuelle lors de "${mn}" — présents votants, seuil ${selfMeetingMajority.value}%.`,
})
}
steps.push({ step_type: 'assignment', title: 'Prise de mandat', description: 'Entrée en mandat après ratification.' })
} else if (nominationCase.value === 'collective' && selectedModalityId.value) {
const m = MODALITIES.find(x => x.id === selectedModalityId.value)
if (m?.requiresCandidacy) {
const cd = getParam(m.id, 'candidacy_days')
steps.push({ step_type: 'candidacy', title: 'Appel à candidature', description: `Fenêtre de candidature : ${cd} jour${cd > 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
})
// ── Récap helpers ─────────────────────────────────────────────────────────────
const nominationSummary = computed(() => {
if (nominationCase.value === 'self') {
if (selfMode.value === 'period') return `Auto-désignation — ratification sur ${selfPeriodParams.duration_days}j (quorum ${selfPeriodParams.quorum_pct}%)`
const mn = selfMeetingName.value.trim() || 'réunion'
return `Auto-désignation — ratification ponctuelle en "${mn}"`
}
const m = MODALITIES.find(x => x.id === selectedModalityId.value)
return `Désignation collective — ${m?.title ?? ''}`
})
// ── Création ──────────────────────────────────────────────────────────────────
const startImmediately = ref(true)
const submitting = ref(false)
const submitError = ref<string | null>(null)
const auth = useAuthStore()
async function createMandate() {
submitting.value = true
submitError.value = null
try {
const dates = computedDates()
const nominationMode = nominationCase.value === 'self' ? 'auto' : 'collective'
const mandate = await mandates.create({
title: title.value.trim(),
origin_id: originId.value,
description: description.value.trim() || null,
mandate_type: mandateType.value,
nomination_mode: nominationMode,
starts_at: dates.starts_at,
ends_at: dates.ends_at,
})
if (!mandate) throw new Error('Erreur création mandat')
// Créer les étapes
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 },
})
}
// Démarrer le processus si demandé
if (startImmediately.value) {
await $api(`/mandates/${mandate.id}/advance`, { method: 'POST' })
}
navigateTo(`/mandates/${mandate.id}`)
} catch (e: any) {
submitError.value = e?.data?.detail ?? e?.message ?? 'Erreur lors de la création'
} finally {
submitting.value = false
}
}
// ── Navigation ────────────────────────────────────────────────────────────────
function goBack() {
if (wizardStep.value === 'summary') wizardStep.value = 'info'
else if (wizardStep.value === 'info') wizardStep.value = 'nomination'
}
function selectNomination(c: NominationCase) {
nominationCase.value = c
}
</script>
<template>
<div class="mwiz">
<!-- Nav -->
<div class="mwiz__nav">
<button v-if="wizardStep !== 'nomination'" 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>
<div class="mwiz__progress">
<span class="mwiz__pill" :class="{ 'mwiz__pill--active': wizardStep === 'nomination', 'mwiz__pill--done': wizardStep !== 'nomination' }">
1 Nomination
</span>
<span class="mwiz__sep"></span>
<span class="mwiz__pill" :class="{ 'mwiz__pill--active': wizardStep === 'info', 'mwiz__pill--done': wizardStep === 'summary' }">
2 Mandat
</span>
<span class="mwiz__sep"></span>
<span class="mwiz__pill" :class="{ 'mwiz__pill--active': wizardStep === 'summary' }">
3 Récap
</span>
</div>
</div>
<Transition name="slide-fade" mode="out-in">
<!-- ÉTAPE 1 : Nomination -->
<div v-if="wizardStep === 'nomination'" key="nomination" class="mwiz__step">
<div class="mwiz__header">
<h1 class="mwiz__title">Processus de nomination</h1>
<p class="mwiz__subtitle">Choisissez comment le mandataire sera désigné</p>
</div>
<!-- Deux grandes cartes -->
<div class="mwiz__nom-grid">
<!-- Cas 1 : Auto-désignation -->
<div
class="mwiz__nom-card"
:class="{ 'mwiz__nom-card--active': nominationCase === 'self' }"
@click="selectNomination('self')"
>
<div class="mwiz__nom-head">
<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">
Une personne demande un mandat pour elle-même afin de mener sa mission de façon légitime.
Un vote de ratification valide la prise de mandat.
</p>
</div>
<div class="mwiz__nom-check">
<UIcon v-if="nominationCase === 'self'" name="i-lucide-circle-check" class="text-lg" />
<UIcon v-else name="i-lucide-circle" class="text-lg" />
</div>
</div>
<!-- Sous-options ratification -->
<Transition name="slide-down">
<div v-if="nominationCase === 'self'" class="mwiz__ratif" @click.stop>
<div class="mwiz__ratif-toggle">
<button
class="mwiz__ratif-btn"
:class="{ 'mwiz__ratif-btn--active': selfMode === 'period' }"
@click="selfMode = 'period'"
>
<UIcon name="i-lucide-calendar-range" class="text-sm" />
Sur période
</button>
<button
class="mwiz__ratif-btn"
:class="{ 'mwiz__ratif-btn--active': selfMode === 'meeting' }"
@click="selfMode = 'meeting'"
>
<UIcon name="i-lucide-users" class="text-sm" />
Ponctuelle (réunion)
</button>
</div>
<!-- Params : sur période -->
<Transition name="slide-down">
<div v-if="selfMode === 'period'" 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="selfPeriodParams.duration_days" type="number" class="mwiz__param-input" min="1" max="60" />
<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="selfPeriodParams.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="selfPeriodParams.majority_pct" type="number" class="mwiz__param-input" min="51" max="100" />
<span class="mwiz__param-unit">%</span>
</div>
</div>
</div>
</Transition>
<!-- Params : ponctuelle réunion -->
<Transition name="slide-down">
<div v-if="selfMode === 'meeting'" class="mwiz__meeting-params">
<div class="mwiz__param-field" style="flex: 1; min-width: 200px;">
<label class="mwiz__label-sm">Nom de la réunion</label>
<input
v-model="selfMeetingName"
type="text"
class="mwiz__input"
lang="fr"
spellcheck="true"
placeholder="ex : Visio du lundi, AG 2025…"
/>
</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="selfMeetingMajority" type="number" class="mwiz__param-input" min="51" max="100" />
<span class="mwiz__param-unit">% des présents</span>
</div>
</div>
</div>
</Transition>
<p class="mwiz__ratif-note">
<UIcon name="i-lucide-info" class="text-xs" />
<span v-if="selfMode === 'period'">Vote ouvert à tous les adhérents de l'organisation.</span>
<span v-else>Vote restreint aux personnes présentes lors de la réunion.</span>
</p>
</div>
</Transition>
</div>
<!-- Cas 2 : Désignation collective -->
<div
class="mwiz__nom-card"
:class="{ 'mwiz__nom-card--active': nominationCase === 'collective' }"
@click="selectNomination('collective')"
>
<div class="mwiz__nom-head">
<div class="mwiz__nom-icon">
<UIcon name="i-lucide-users-round" 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">
L'organisation identifie un besoin ou une mission, et procède à une nomination collective.
Avec ou sans appel à candidature.
</p>
</div>
<div class="mwiz__nom-check">
<UIcon v-if="nominationCase === 'collective'" name="i-lucide-circle-check" class="text-lg" />
<UIcon v-else name="i-lucide-circle" class="text-lg" />
</div>
</div>
<!-- Boîte à outils contextuelle -->
<Transition name="slide-down">
<div v-if="nominationCase === 'collective'" class="mwiz__toolbox" @click.stop>
<p class="mwiz__toolbox-title">
<UIcon name="i-lucide-wrench" class="text-sm" />
Boîte à outils — Modalités d'élection
</p>
<!-- Toggle candidature -->
<div class="mwiz__mode-toggle">
<button
class="mwiz__mode-btn"
:class="{ 'mwiz__mode-btn--active': candidacyMode === 'direct' }"
@click="candidacyMode = 'direct'; selectedModalityId = null"
>
<UIcon name="i-lucide-vote" class="text-sm" />
Sans candidature
</button>
<button
class="mwiz__mode-btn"
:class="{ 'mwiz__mode-btn--active': candidacyMode === 'candidacy' }"
@click="candidacyMode = 'candidacy'; selectedModalityId = null"
>
<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.stop="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>
<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>
</div>
</Transition>
</div>
</div>
<button class="mwiz__submit" :disabled="!canGoToInfo" @click="wizardStep = 'info'">
Continuer Infos du mandat
<UIcon name="i-lucide-arrow-right" class="text-sm" />
</button>
</div>
<!-- ÉTAPE 2 : Infos du mandat -->
<div v-else-if="wizardStep === 'info'" key="info" class="mwiz__step">
<div class="mwiz__header">
<h1 class="mwiz__title">Infos du 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-card"
:class="{ 'mwiz__type-card--active': mandateType === opt.value }"
@click="mandateType = opt.value as MandateType"
>
<UIcon :name="opt.icon" class="text-xl" />
<div>
<p class="mwiz__type-card-label">{{ opt.label }}</p>
<p class="mwiz__type-card-desc">{{ opt.desc }}</p>
</div>
</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 (personne) -->
<div class="mwiz__section">
<label class="mwiz__label">Proposé par <span class="mwiz__optional">(optionnel)</span></label>
<div class="relative">
<input
:value="originQuery"
type="text"
class="mwiz__input"
placeholder="Rechercher un membre de la communauté…"
@input="onOriginInput(($event.target as HTMLInputElement).value)"
/>
<div
v-if="originResults.length"
class="absolute z-10 mt-1 w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow-lg overflow-hidden"
>
<button
v-for="r in originResults"
:key="r.id"
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
@click="selectOrigin(r)"
>
<UIcon name="i-lucide-user" class="text-gray-400 shrink-0" />
<div>
<p class="font-medium text-gray-900 dark:text-white">{{ r.display_name || r.address }}</p>
<p class="text-xs text-gray-500 font-mono">{{ r.address.slice(0, 20) }}</p>
</div>
</button>
</div>
</div>
<p v-if="originId" class="mwiz__hint text-green-600">
<UIcon name="i-lucide-check-circle" class="text-xs" /> {{ originQuery }} sélectionné
</p>
</div>
<!-- Description -->
<div class="mwiz__section">
<label class="mwiz__label">Description <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="!canGoToSummary" @click="wizardStep = 'summary'">
Récapitulatif
<UIcon name="i-lucide-arrow-right" class="text-sm" />
</button>
</div>
</div>
<!-- ÉTAPE 3 : 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">
<!-- Nomination -->
<div class="mwiz__recap-block">
<p class="mwiz__recap-label">Nomination</p>
<p class="mwiz__recap-value">{{ nominationSummary }}</p>
</div>
<!-- Mandat -->
<div class="mwiz__recap-block">
<p class="mwiz__recap-label">Mandat</p>
<p class="mwiz__recap-title">{{ title }}</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>
<p v-if="originId" class="mwiz__recap-meta">Proposé par : {{ originQuery }}</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>
</div>
<!-- Étapes générées -->
<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>
<!-- Option démarrage -->
<div class="mwiz__start-option">
<label class="mwiz__start-label">
<input v-model="startImmediately" type="checkbox" class="mwiz__checkbox" />
<span>Démarrer le processus de nomination immédiatement</span>
</label>
<p class="mwiz__start-hint">
<template v-if="startImmediately">Le mandat passera en phase de nomination dès la création.</template>
<template v-else>Le mandat restera en brouillon à démarrer manuellement depuis la fiche.</template>
</p>
</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: 760px;
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 (buttons) */
.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); }
/* Mandate type cards (Statutaire / Fonctionnel) */
.mwiz__type-card {
display: flex;
align-items: flex-start;
gap: 0.875rem;
padding: 1rem 1.25rem;
background: var(--mood-surface);
border-radius: 14px;
cursor: pointer;
text-align: left;
flex: 1;
min-width: 200px;
transition: transform 0.12s ease, box-shadow 0.12s ease;
color: var(--mood-muted);
}
.mwiz__type-card:hover { transform: translateY(-2px); box-shadow: 0 4px 14px var(--mood-shadow); }
.mwiz__type-card--active {
box-shadow: 0 0 0 2px var(--mood-accent), 0 4px 14px var(--mood-shadow);
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
.mwiz__type-card-label { font-weight: 700; font-size: 0.9375rem; color: var(--mood-text); margin-bottom: 0.125rem; }
.mwiz__type-card-desc { font-size: 0.8125rem; color: var(--mood-muted); line-height: 1.45; }
/* 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.75rem;
}
.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: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 0;
}
.mwiz__nom-card {
background: var(--mood-surface);
border-radius: 16px;
overflow: hidden;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
border: 2px solid transparent;
}
.mwiz__nom-card:hover { transform: translateY(-2px); box-shadow: 0 6px 20px var(--mood-shadow); }
.mwiz__nom-card--active { border-color: var(--mood-accent); background: var(--mood-accent-soft); }
.mwiz__nom-head {
display: flex;
align-items: flex-start;
gap: 0.875rem;
padding: 1.25rem;
}
.mwiz__nom-icon { color: var(--mood-accent); flex-shrink: 0; }
.mwiz__nom-body { flex: 1; }
.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; }
.mwiz__nom-check { color: var(--mood-accent); margin-top: 0.125rem; flex-shrink: 0; }
/* Ratification sub-panel */
.mwiz__ratif {
padding: 1.25rem;
padding-top: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.mwiz__ratif-toggle {
display: inline-flex;
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
border-radius: 12px;
padding: 0.25rem;
gap: 0.25rem;
}
.mwiz__ratif-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-muted);
border-radius: 10px;
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.mwiz__ratif-btn--active { background: var(--mood-accent); color: var(--mood-accent-text); }
.mwiz__ratif-note {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
color: var(--mood-muted);
font-style: italic;
}
.mwiz__meeting-params {
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: flex-end;
}
/* Params */
.mwiz__param-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.mwiz__param-field { display: flex; flex-direction: column; gap: 0.375rem; min-width: 100px; }
.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: color-mix(in srgb, var(--mood-accent) 15%, transparent);
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); }
/* Toolbox (collective) */
.mwiz__toolbox {
padding: 1.25rem;
padding-top: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.mwiz__toolbox-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-accent);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.mwiz__mode-toggle {
display: inline-flex;
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
border-radius: 14px;
padding: 0.3125rem;
gap: 0.25rem;
}
.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.625rem; }
.mwiz__modality {
background: color-mix(in srgb, var(--mood-bg) 60%, transparent);
border-radius: 14px;
padding: 1rem 1.125rem;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
border-left: 3px solid transparent;
}
.mwiz__modality:hover { transform: translateY(-1px); box-shadow: 0 3px 10px var(--mood-shadow); }
.mwiz__modality--selected { border-left-color: var(--mood-accent); box-shadow: 0 3px 10px 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.75rem; }
.mwiz__modality-icon {
width: 2.25rem;
height: 2.25rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--mood-accent-soft);
color: var(--mood-accent);
border-radius: 10px;
flex-shrink: 0;
}
.mwiz__modality-info { flex: 1; }
.mwiz__modality-title { font-weight: 700; font-size: 0.9rem; color: var(--mood-text); margin-bottom: 0.125rem; }
.mwiz__modality-desc { font-size: 0.8rem; color: var(--mood-muted); line-height: 1.4; }
.mwiz__modality-check { color: var(--mood-accent); margin-top: 0.125rem; flex-shrink: 0; }
.mwiz__modality-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.5rem;
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.8rem; color: var(--mood-muted); margin-top: 0.25rem; font-style: italic; }
.mwiz__modality-params {
margin-top: 0.875rem;
padding-top: 0.875rem;
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; }
/* Start option */
.mwiz__start-option {
background: var(--mood-surface);
border-radius: 16px;
padding: 1.125rem 1.25rem;
margin-bottom: 1rem;
}
.mwiz__start-label {
display: flex;
align-items: center;
gap: 0.625rem;
font-weight: 600;
font-size: 0.9375rem;
color: var(--mood-text);
cursor: pointer;
}
.mwiz__checkbox {
width: 1.125rem;
height: 1.125rem;
accent-color: var(--mood-accent);
cursor: pointer;
flex-shrink: 0;
}
.mwiz__start-hint { font-size: 0.8125rem; color: var(--mood-muted); margin-top: 0.375rem; padding-left: 1.75rem; }
.mwiz__hint { font-size: 0.8125rem; margin-top: 0.25rem; }
/* 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.22s 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: 800px; }
</style>