Files
decision/maquette/frontend/app/pages/mandats/nouveau.vue
T
YvvandClaude Opus 5.5 67ddee28b2 v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C),
  coquille / changement de fond, assistant d'édition à règles, composition
  d'un document sous vote permanent (adoptions, aperçus, rivales).
- crates/trustin-access : qui voit, qui ouvre, qui gère les accès.
- crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de
  connect.monnaie-libre.fr), invitations, entrée en vigueur automatique,
  signalements de contradiction, traductions éditoriales, publication git,
  taille de la toile lue sur la chaîne.
- seeds/acte-engagement-certification.json : 33 articles, fr + en + es ;
  en vigueur : préambule, E1, E2 (seed a minima).
- maquette/ : frontend, backend et docker v2 déplacés tels quels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 02:04:42 +02:00

390 lines
15 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">
// <!-- 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>