- overlays Nuxt UI (UModal/USlideover) : géométrie posée en CSS (assets/css/overlays.css) — les utilitaires tailwind attendus par @nuxt/ui ne sont pas générés (UnoCSS ne scanne pas node_modules) : drawer mobile et modales de confirmation se rendaient hors écran - a11y : title/description sur les 3 overlays (DialogTitle/Description rendus, warnings reka-ui éteints), fermeture du drawer ancrée et testée - suppression du collectif actif : bascule sur un collectif restant — jamais d'accueil orphelin (test store ajouté, 343 vitest verts) - observatoire : la route « transmis » entre dans les barres (base 100 %) - overflows mobiles à zéro : formules KaTeX (défilement), table vigueur (défilement < 768px), pill cible (retour à la ligne), inputs decider et mandat (border-box) - vérifié navigateur : drawer, modale, 14 routes sans débordement, zéro erreur console Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
390 lines
15 KiB
Vue
390 lines
15 KiB
Vue
<script setup lang="ts">
|
||
// <!-- ld-v2 --> /mandats/nouveau — wizard 3 étapes porté du v1 (la structure
|
||
// à pépite : pills de progression, cartes cliquables, transitions), branché sur
|
||
// le pivot : la nomination EST une décision (createsMandate, route collective).
|
||
// Pré-remplissage par query ?titre=&tags= (suggestion R6 du chemin).
|
||
import type { Id, MandateDraft, NominationMethod, TriageInput } from '~/types/domain'
|
||
import type { ApplyPathEdits } from '~/stores/decisions'
|
||
import { useCollectiveStore } from '~/stores/collective'
|
||
import { useDecisionsStore } from '~/stores/decisions'
|
||
import { MANDATE_CLAIM, NOMINATION_LABELS } from '~/lexicon'
|
||
|
||
const col = useCollectiveStore()
|
||
const decisions = useDecisionsStore()
|
||
const route = useRoute()
|
||
|
||
const STEPS = ['Rôle et domaine', 'Nomination', 'Durée et comptes'] as const
|
||
const step = ref(0)
|
||
|
||
// ── Étape 1 : rôle et domaine ──
|
||
const title = ref(typeof route.query.titre === 'string' ? route.query.titre : '')
|
||
const mission = ref('')
|
||
const circleIds = ref<Id[]>([])
|
||
const tags = ref<string[]>(
|
||
typeof route.query.tags === 'string'
|
||
? route.query.tags.split(',').map(t => t.trim()).filter(t => t.length > 0)
|
||
: [],
|
||
)
|
||
const tagInput = ref('')
|
||
|
||
function toggleCircle(id: Id): void {
|
||
circleIds.value = circleIds.value.includes(id)
|
||
? circleIds.value.filter(c => c !== id)
|
||
: [...circleIds.value, id]
|
||
}
|
||
function addTag(): void {
|
||
const tag = tagInput.value.trim().replace(/^#/, '').toLowerCase()
|
||
if (tag && !tags.value.includes(tag)) tags.value.push(tag)
|
||
tagInput.value = ''
|
||
}
|
||
function removeTag(tag: string): void {
|
||
tags.value = tags.value.filter(t => t !== tag)
|
||
}
|
||
|
||
// ── Étape 2 : modalité de nomination (6) ──
|
||
const method = ref<NominationMethod | null>(null)
|
||
|
||
// ── Étape 3 : durée bornée, cadence, cercle électeur ──
|
||
const durationDays = ref(180)
|
||
const withReports = ref(true)
|
||
const reportEveryDays = ref(90)
|
||
const electorCircleId = ref<Id>(col.current?.collective.rootCircleId ?? '')
|
||
|
||
const canNext = computed(() => {
|
||
if (step.value === 0) return title.value.trim().length > 0
|
||
if (step.value === 1) return method.value !== null
|
||
return durationDays.value >= 1 && electorCircleId.value !== ''
|
||
})
|
||
|
||
const electorCircle = computed(() => col.circles.find(c => c.id === electorCircleId.value))
|
||
const domainNames = computed(() =>
|
||
circleIds.value
|
||
.map(id => col.circles.find(c => c.id === id)?.name)
|
||
.filter(n => n !== undefined),
|
||
)
|
||
|
||
/** La modalité choisie résout le protocole via le Pacte (repli : consentement). */
|
||
function protocolFor(m: NominationMethod): Id | undefined {
|
||
const byRange = col.settings?.protocolByRange
|
||
if (!byRange) return undefined
|
||
if (m === 'election-no-candidate') return byRange.election ?? byRange.consent
|
||
if (m === 'nuanced-vote') return byRange.nuanced ?? byRange.consent
|
||
return byRange.consent // ratification par consentement (auto-désignation, tirage, rotation)
|
||
}
|
||
|
||
// ── Sortie : une Decision createsMandate, route collective ──
|
||
const submitting = ref(false)
|
||
const error = ref<string | null>(null)
|
||
|
||
function submit(): void {
|
||
if (!method.value || submitting.value) return
|
||
submitting.value = true
|
||
error.value = null
|
||
const captured = decisions.capture(`Confier le mandat « ${title.value.trim()} »`)
|
||
if ('ok' in captured) { error.value = captured.reason; submitting.value = false; return }
|
||
|
||
const scope = {
|
||
selfOnly: false,
|
||
// Le cercle électeur EN PREMIER : il devient le périmètre d'élection et de révocation.
|
||
circleIds: [electorCircleId.value, ...circleIds.value.filter(id => id !== electorCircleId.value)],
|
||
personIds: [] as Id[],
|
||
}
|
||
const input: TriageInput = {
|
||
title: captured.title,
|
||
tags: tags.value,
|
||
scope,
|
||
reversibility: 'costly',
|
||
weight: 'binding',
|
||
urgent: false,
|
||
}
|
||
const path = decisions.runTriage(input)
|
||
if ('ok' in path) { error.value = path.reason; submitting.value = false; return }
|
||
|
||
const draft: MandateDraft = {
|
||
title: title.value.trim(),
|
||
domainCircleIds: [...circleIds.value],
|
||
domainTags: [...tags.value],
|
||
durationDays: durationDays.value,
|
||
...(withReports.value ? { reportEveryDays: reportEveryDays.value } : {}),
|
||
}
|
||
const protocolId = protocolFor(method.value)
|
||
const edits: ApplyPathEdits = {
|
||
route: 'collective',
|
||
scope,
|
||
reversibility: 'costly',
|
||
weight: 'binding',
|
||
tags: [...tags.value],
|
||
visibility: 'collective',
|
||
createsMandate: draft,
|
||
...(mission.value.trim() ? { body: mission.value.trim() } : {}),
|
||
...(protocolId !== undefined ? { protocolId } : {}),
|
||
}
|
||
const applied = decisions.applyPath(captured, path, edits)
|
||
if ('ok' in applied) { error.value = applied.reason; submitting.value = false; return }
|
||
navigateTo(`/decisions/${applied.id}`)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<!-- ld-v2 -->
|
||
<div class="mwiz">
|
||
<nav class="mwiz__nav">
|
||
<button v-if="step > 0" type="button" class="mwiz__back" @click="step--">
|
||
<UIcon name="i-lucide-arrow-left" />
|
||
Retour
|
||
</button>
|
||
<NuxtLink v-else to="/mandats" class="mwiz__back">
|
||
<UIcon name="i-lucide-arrow-left" />
|
||
Mandats
|
||
</NuxtLink>
|
||
<div class="mwiz__progress">
|
||
<template v-for="(label, i) in STEPS" :key="label">
|
||
<span v-if="i > 0" class="mwiz__sep">›</span>
|
||
<span
|
||
class="mwiz__pill"
|
||
:class="{ 'mwiz__pill--active': step === i, 'mwiz__pill--done': step > i }"
|
||
>
|
||
{{ i + 1 }} · {{ label }}
|
||
</span>
|
||
</template>
|
||
</div>
|
||
</nav>
|
||
|
||
<Transition name="slide-fade" mode="out-in">
|
||
<!-- ── ÉTAPE 1 : rôle et domaine ── -->
|
||
<div v-if="step === 0" key="role" class="mwiz__step">
|
||
<header class="mwiz__header">
|
||
<h1 class="mwiz__title">{{ MANDATE_CLAIM }}</h1>
|
||
<p class="mwiz__sub">Un rôle nommé, un domaine explicite — le pouvoir se voit.</p>
|
||
</header>
|
||
|
||
<label class="mwiz__label" for="mwiz-title">Titre du rôle</label>
|
||
<input
|
||
id="mwiz-title"
|
||
v-model="title"
|
||
type="text"
|
||
class="mwiz__input"
|
||
lang="fr"
|
||
spellcheck="true"
|
||
placeholder="ex : Trésorerie, Animation du jardin…"
|
||
>
|
||
|
||
<label class="mwiz__label" for="mwiz-mission">La mission, en une phrase</label>
|
||
<input
|
||
id="mwiz-mission"
|
||
v-model="mission"
|
||
type="text"
|
||
class="mwiz__input"
|
||
lang="fr"
|
||
spellcheck="true"
|
||
placeholder="ex : Tenir les comptes et rendre la caisse lisible par tous."
|
||
>
|
||
|
||
<p class="mwiz__label">Cercles du domaine</p>
|
||
<div class="mwiz__chips">
|
||
<button
|
||
v-for="c in col.circles"
|
||
:key="c.id"
|
||
type="button"
|
||
class="mwiz__chip"
|
||
:class="{ 'mwiz__chip--on': circleIds.includes(c.id) }"
|
||
@click="toggleCircle(c.id)"
|
||
>
|
||
{{ c.name }}
|
||
</button>
|
||
</div>
|
||
|
||
<label class="mwiz__label" for="mwiz-tag">Tags du domaine</label>
|
||
<div class="mwiz__chips">
|
||
<span v-for="tag in tags" :key="tag" class="mwiz__chip mwiz__chip--on">
|
||
#{{ tag }}
|
||
<button type="button" class="mwiz__chip-x" :aria-label="`Retirer ${tag}`" @click="removeTag(tag)">
|
||
<UIcon name="i-lucide-x" />
|
||
</button>
|
||
</span>
|
||
<input
|
||
id="mwiz-tag"
|
||
v-model="tagInput"
|
||
type="text"
|
||
class="mwiz__input mwiz__input--tag"
|
||
placeholder="ajouter un tag ⏎"
|
||
@keydown.enter.prevent="addTag"
|
||
@blur="addTag"
|
||
>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── ÉTAPE 2 : modalité de nomination ── -->
|
||
<div v-else-if="step === 1" key="nomination" class="mwiz__step">
|
||
<header class="mwiz__header">
|
||
<h1 class="mwiz__title">Comment nommer ?</h1>
|
||
<p class="mwiz__sub">Six modalités, chacune avec sa pédagogie — le cercle électeur tranchera.</p>
|
||
</header>
|
||
<MandateNominationPicker v-model="method" />
|
||
</div>
|
||
|
||
<!-- ── ÉTAPE 3 : durée bornée, cadence, cercle électeur ── -->
|
||
<div v-else key="terms" class="mwiz__step">
|
||
<header class="mwiz__header">
|
||
<h1 class="mwiz__title">Borné, redevable</h1>
|
||
<p class="mwiz__sub">Un mandat a toujours une fin — et des comptes à rendre avant.</p>
|
||
</header>
|
||
|
||
<label class="mwiz__label" for="mwiz-duration">Durée du mandat (jours)</label>
|
||
<div class="mwiz__row">
|
||
<input
|
||
id="mwiz-duration"
|
||
v-model.number="durationDays"
|
||
type="number"
|
||
class="mwiz__input mwiz__input--num"
|
||
min="7"
|
||
max="730"
|
||
>
|
||
<input
|
||
v-model.number="durationDays"
|
||
type="range"
|
||
class="mwiz__slider"
|
||
min="7"
|
||
max="730"
|
||
step="1"
|
||
aria-label="Durée du mandat en jours"
|
||
>
|
||
</div>
|
||
|
||
<label class="mwiz__check">
|
||
<input v-model="withReports" type="checkbox" class="mwiz__checkbox">
|
||
<span>Avec rapports réguliers</span>
|
||
</label>
|
||
<div v-if="withReports" class="mwiz__row">
|
||
<label class="mwiz__label mwiz__label--inline" for="mwiz-cadence">tous les</label>
|
||
<input
|
||
id="mwiz-cadence"
|
||
v-model.number="reportEveryDays"
|
||
type="number"
|
||
class="mwiz__input mwiz__input--num"
|
||
min="7"
|
||
:max="durationDays"
|
||
>
|
||
<span class="mwiz__unit">jours</span>
|
||
</div>
|
||
|
||
<label class="mwiz__label" for="mwiz-elector">Cercle électeur — il nomme, il révoque</label>
|
||
<select id="mwiz-elector" v-model="electorCircleId" class="mwiz__input">
|
||
<option v-for="c in col.circles" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||
</select>
|
||
|
||
<div class="mwiz__recap">
|
||
<p class="mwiz__recap-title">{{ title }}</p>
|
||
<p v-if="mission" class="mwiz__recap-line">{{ mission }}</p>
|
||
<p class="mwiz__recap-line">
|
||
{{ method ? NOMINATION_LABELS[method] : '—' }} ·
|
||
{{ durationDays }} jours<template v-if="withReports"> · rapport tous les {{ reportEveryDays }} jours</template>
|
||
</p>
|
||
<p v-if="domainNames.length || tags.length" class="mwiz__recap-line">
|
||
Domaine : {{ [...domainNames, ...tags.map(t => `#${t}`)].join(' · ') }}
|
||
</p>
|
||
<p class="mwiz__recap-note">
|
||
La nomination est une décision — elle part au vote du cercle
|
||
« {{ electorCircle?.name ?? '—' }} ».
|
||
</p>
|
||
</div>
|
||
|
||
<p v-if="error" class="mwiz__error">{{ error }}</p>
|
||
</div>
|
||
</Transition>
|
||
|
||
<div class="mwiz__actions">
|
||
<button
|
||
v-if="step < 2"
|
||
type="button"
|
||
class="ld-btn"
|
||
:disabled="!canNext"
|
||
@click="step++"
|
||
>
|
||
Continuer
|
||
<UIcon name="i-lucide-arrow-right" />
|
||
</button>
|
||
<button
|
||
v-else
|
||
type="button"
|
||
class="ld-btn"
|
||
:disabled="!canNext || !method || submitting"
|
||
@click="submit"
|
||
>
|
||
<UIcon name="i-lucide-scale" />
|
||
{{ submitting ? 'Ouverture…' : 'Soumettre au cercle électeur' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.mwiz { max-width: 46rem; margin: 0 auto; width: 100%; display: flex; flex-direction: column; gap: 1.5rem; }
|
||
.mwiz__nav { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
||
.mwiz__back {
|
||
display: inline-flex; align-items: center; gap: 0.375rem;
|
||
font-size: 0.875rem; font-weight: 600; color: var(--mood-text-muted);
|
||
background: none; cursor: pointer; text-decoration: none;
|
||
}
|
||
.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-text-muted); font-size: 0.75rem; }
|
||
.mwiz__pill {
|
||
font-size: 0.75rem; font-weight: 600; padding: 0.25rem 0.625rem;
|
||
border-radius: var(--r-pill); color: var(--mood-text-muted);
|
||
background: var(--mood-surface); white-space: nowrap;
|
||
}
|
||
.mwiz__pill--active { background: var(--mood-accent); color: var(--mood-accent-text); }
|
||
.mwiz__pill--done { background: var(--mood-accent-soft); color: var(--mood-accent); }
|
||
|
||
.mwiz__step { display: flex; flex-direction: column; gap: 0.75rem; }
|
||
.mwiz__header { margin-bottom: 0.5rem; }
|
||
.mwiz__title { margin: 0 0 0.25rem; font-size: clamp(1.25rem, 3vw, 1.625rem); font-weight: 800; }
|
||
.mwiz__sub { margin: 0; font-size: 0.9375rem; color: var(--mood-text-muted); }
|
||
|
||
.mwiz__label { font-size: 0.875rem; font-weight: 700; margin-top: 0.375rem; }
|
||
.mwiz__label--inline { margin-top: 0; }
|
||
.mwiz__input {
|
||
width: 100%; box-sizing: border-box; padding: 0.6875rem 1rem; font: inherit; font-size: 0.9375rem;
|
||
color: var(--mood-text); background: var(--mood-input-bg); border: none;
|
||
border-radius: var(--r-input); box-shadow: inset 0 0 0 1px var(--mood-input-border);
|
||
}
|
||
.mwiz__input:focus { outline: none; box-shadow: inset 0 0 0 2px var(--mood-input-focus); }
|
||
.mwiz__input--num { width: 6rem; text-align: center; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||
.mwiz__input--tag { width: 11rem; padding: 0.375rem 0.75rem; font-size: 0.875rem; }
|
||
.mwiz__row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
|
||
.mwiz__slider { flex: 1; min-width: 10rem; accent-color: var(--mood-accent); }
|
||
.mwiz__unit { font-size: 0.875rem; color: var(--mood-text-muted); }
|
||
.mwiz__chips { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
|
||
.mwiz__chip {
|
||
display: inline-flex; align-items: center; gap: 0.375rem;
|
||
padding: 0.375rem 0.875rem; font-size: 0.875rem; font-weight: 600;
|
||
color: var(--mood-text-muted); background: var(--mood-surface);
|
||
border-radius: var(--r-pill); box-shadow: var(--shadow-card);
|
||
cursor: pointer; transition: transform 0.1s ease;
|
||
}
|
||
.mwiz__chip:hover { transform: translateY(-1px); }
|
||
.mwiz__chip--on { background: var(--mood-accent); color: var(--mood-accent-text); }
|
||
.mwiz__chip-x { display: inline-flex; background: none; color: inherit; cursor: pointer; padding: 0; }
|
||
.mwiz__check {
|
||
display: flex; align-items: center; gap: 0.625rem; margin-top: 0.375rem;
|
||
font-size: 0.9375rem; font-weight: 600; cursor: pointer;
|
||
}
|
||
.mwiz__checkbox { width: 1.125rem; height: 1.125rem; accent-color: var(--mood-accent); }
|
||
|
||
.mwiz__recap {
|
||
margin-top: 0.75rem; padding: 1.125rem 1.25rem; border-radius: var(--r-card);
|
||
background: var(--mood-surface); box-shadow: var(--shadow-card);
|
||
display: flex; flex-direction: column; gap: 0.375rem;
|
||
}
|
||
.mwiz__recap-title { margin: 0; font-size: 1.0625rem; font-weight: 800; }
|
||
.mwiz__recap-line { margin: 0; font-size: 0.875rem; color: var(--mood-text-muted); }
|
||
.mwiz__recap-note { margin: 0.375rem 0 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-accent); }
|
||
.mwiz__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
|
||
.mwiz__actions { display: flex; justify-content: flex-end; }
|
||
|
||
.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); }
|
||
</style>
|