Files
decision/frontend/app/pages/decisions/new.vue
Yvv 79e468b40f Multi-tenancy : espaces de travail + fix auth reload (rate limiter OPTIONS)
- Modèles Organization + OrgMember, migration Alembic (SQLite compatible)
- organization_id nullable sur Document, Decision, Mandate, VotingProtocol
- Service, schéma, router /organizations + dependency get_active_org_id
- Seed : Duniter G1 + Axiom Team ; tout le contenu seed attaché à Duniter G1
- Backend : list/create filtrés par header X-Organization
- Frontend : store organizations, WorkspaceSelector réel, useApi injecte l'org
- Fix critique : rate_limiter exclut les requêtes OPTIONS (CORS preflight)
  → résout le bug "Failed to fetch /auth/me" au reload (429 sur preflight)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 15:17:14 +02:00

547 lines
14 KiB
Vue

<script setup lang="ts">
import type { DecisionCreate } from '~/stores/decisions'
const decisions = useDecisionsStore()
const protocols = useProtocolsStore()
type Nature = 'individual' | 'collective' | 'onchain'
const step = ref<1 | 2>(1)
const nature = ref<Nature | null>(null)
const submitting = ref(false)
const openMandate = ref(false)
const formData = ref<DecisionCreate>({
title: '',
description: '',
context: '',
decision_type: 'other',
voting_protocol_id: null,
})
interface NatureOption {
key: Nature
icon: string
title: string
desc: string
color: string
}
const choiceOptions: NatureOption[] = [
{
key: 'individual',
icon: 'i-lucide-user',
title: 'Décision individuelle',
desc: 'Je dois décider seul·e, après avoir consulté et consigné des avis',
color: 'var(--mood-secondary, var(--mood-accent))',
},
{
key: 'collective',
icon: 'i-lucide-users',
title: 'Décision collective',
desc: 'Le collectif tranche ensemble — vote WoT, nuancé ou par protocole',
color: 'var(--mood-accent)',
},
{
key: 'onchain',
icon: 'i-lucide-cpu',
title: 'Exception on-chain',
desc: 'Décision inscrite sur la blockchain Duniter (Runtime Upgrade, etc.)',
color: 'var(--mood-success)',
},
]
const selectedOption = computed(() => choiceOptions.find(o => o.key === nature.value))
onMounted(() => protocols.fetchProtocols())
function selectNature(n: Nature) {
nature.value = n
formData.value.decision_type = n === 'onchain' ? 'runtime_upgrade' : 'other'
step.value = 2
}
function goBack() {
step.value = 1
nature.value = null
formData.value = { title: '', description: '', context: '', decision_type: 'other', voting_protocol_id: null }
openMandate.value = false
}
async function onSubmit() {
if (!formData.value.title.trim()) return
submitting.value = true
try {
const contextPrefix = nature.value === 'individual' ? '[Consultation individuelle]\n' : ''
const decision = await decisions.create({
...formData.value,
context: contextPrefix + (formData.value.context ?? ''),
})
if (decision) {
if (openMandate.value && nature.value === 'individual') {
navigateTo('/mandates')
}
else {
navigateTo(`/decisions/${decision.id}`)
}
}
}
catch {
// handled by store
}
finally {
submitting.value = false
}
}
</script>
<template>
<div class="decide">
<!-- Nav -->
<div class="decide__nav">
<button v-if="step === 2" class="decide__back-btn" @click="goBack">
<UIcon name="i-lucide-arrow-left" class="text-sm" />
<span>Retour</span>
</button>
<NuxtLink v-else to="/decisions" class="decide__back-btn">
<UIcon name="i-lucide-arrow-left" class="text-sm" />
<span>Décisions</span>
</NuxtLink>
</div>
<Transition name="slide-fade" mode="out-in">
<!-- Step 1 : Qualifier -->
<div v-if="step === 1" key="step1" class="decide__step">
<div class="decide__header">
<h1 class="decide__title">Quelle décision prendre ?</h1>
<p class="decide__subtitle">Qualifiez d'abord la nature de votre décision — le parcours s'adapte</p>
</div>
<div class="decide__choices">
<button
v-for="option in choiceOptions"
:key="option.key"
class="decide__choice"
:style="{ '--choice-color': option.color }"
@click="selectNature(option.key)"
>
<div class="decide__choice-icon">
<UIcon :name="option.icon" class="text-xl" />
</div>
<div class="decide__choice-body">
<span class="decide__choice-title">{{ option.title }}</span>
<span class="decide__choice-desc">{{ option.desc }}</span>
</div>
<UIcon name="i-lucide-chevron-right" class="decide__choice-arrow" />
</button>
</div>
<p class="decide__footer-note">
<UIcon name="i-lucide-info" class="text-xs" />
Les décisions on-chain sont des exceptions elles concernent uniquement les opérations blockchain Duniter.
</p>
</div>
<!-- Step 2 : Form -->
<div v-else-if="step === 2 && selectedOption" key="step2" class="decide__step">
<div class="decide__nature-badge" :style="{ '--badge-color': selectedOption.color }">
<UIcon :name="selectedOption.icon" class="text-sm" />
<span>{{ selectedOption.title }}</span>
</div>
<div class="decide__header">
<h1 class="decide__title">
{{ nature === 'individual' ? 'Consultation d\'avis' : nature === 'onchain' ? 'Décision on-chain' : 'Décision collective' }}
</h1>
<p class="decide__subtitle">
<template v-if="nature === 'individual'">
Les avis seront consignés. Vous décidez, informé·e des consultations.
</template>
<template v-else-if="nature === 'onchain'">
Exception réservée aux opérations blockchain. Voir le protocole
<NuxtLink to="/protocols" class="decide__link">Runtime Upgrade</NuxtLink>.
</template>
<template v-else>
Le collectif vote selon le protocole choisi. Seuil adaptatif par la WoT.
</template>
</p>
</div>
<div v-if="decisions.error" class="decide__error">
<UIcon name="i-lucide-alert-circle" />
<span>{{ decisions.error }}</span>
</div>
<div class="decide__fields">
<div class="decide__field">
<label class="decide__label">
{{ nature === 'individual' ? 'Question à consulter' : 'Titre de la décision' }}
<span class="decide__required">*</span>
</label>
<input
v-model="formData.title"
type="text"
class="decide__input"
:placeholder="nature === 'individual' ? 'Ex : Faut-il migrer vers PostgreSQL ?' : 'Ex : Adoption de la v2.0 du protocole…'"
autofocus
/>
</div>
<div class="decide__field">
<label class="decide__label">Description</label>
<textarea
v-model="formData.description"
class="decide__textarea"
rows="3"
:placeholder="nature === 'individual' ? 'Décrivez la question, les options envisagées…' : 'Décrivez l\'objet, les enjeux, les alternatives…'"
/>
</div>
<div class="decide__field">
<label class="decide__label">Contexte et références</label>
<textarea
v-model="formData.context"
class="decide__textarea"
rows="2"
placeholder="Liens forum, documents de référence, historique…"
/>
</div>
<!-- Collective: protocol -->
<div v-if="nature === 'collective'" class="decide__field">
<label class="decide__label">Protocole de vote</label>
<ProtocolPicker
:model-value="formData.voting_protocol_id ?? null"
@update:model-value="formData.voting_protocol_id = $event"
/>
<p class="decide__hint">Optionnel peut être défini à chaque étape</p>
</div>
<!-- Individual: mandate option -->
<label v-if="nature === 'individual'" class="decide__mandate-toggle">
<input v-model="openMandate" type="checkbox" class="decide__mandate-check" />
<div class="decide__mandate-body">
<span class="decide__mandate-label">Ouvrir un mandat en parallèle</span>
<span class="decide__mandate-hint">Si la consultation mène à déléguer une mission</span>
</div>
</label>
</div>
<div class="decide__actions">
<button class="decide__cancel" @click="goBack">
Annuler
</button>
<button
class="decide__submit"
:disabled="!formData.title.trim() || submitting"
:style="{ '--submit-color': selectedOption.color }"
@click="onSubmit"
>
<UIcon v-if="submitting" name="i-lucide-loader-2" class="animate-spin" />
<UIcon v-else name="i-lucide-plus" />
<span>{{ nature === 'individual' ? 'Lancer la consultation' : 'Créer la décision' }}</span>
</button>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.decide {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 42rem;
margin: 0 auto;
}
/* Nav */
.decide__nav { margin-bottom: -0.5rem; }
.decide__back-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text-muted);
background: none;
border-radius: 10px;
cursor: pointer;
text-decoration: none;
transition: color 0.12s, background 0.12s;
}
.decide__back-btn:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
/* Step container */
.decide__step { display: flex; flex-direction: column; gap: 1.5rem; }
/* Header */
.decide__header { display: flex; flex-direction: column; gap: 0.25rem; }
.decide__title {
font-size: 1.5rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.02em;
margin: 0;
}
@media (min-width: 640px) {
.decide__title { font-size: 1.875rem; }
}
.decide__subtitle {
font-size: 0.9375rem;
color: var(--mood-text-muted);
font-weight: 500;
margin: 0;
line-height: 1.5;
}
/* Nature badge */
.decide__nature-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--badge-color, var(--mood-accent));
background: color-mix(in srgb, var(--badge-color, var(--mood-accent)) 12%, transparent);
border-radius: 20px;
width: fit-content;
}
/* Choices (Step 1) */
.decide__choices { display: flex; flex-direction: column; gap: 0.75rem; }
.decide__choice {
display: flex;
align-items: center;
gap: 1rem;
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;
}
.decide__choice:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px var(--mood-shadow);
}
.decide__choice:active { transform: translateY(0); }
.decide__choice-icon {
width: 3rem;
height: 3rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: color-mix(in srgb, var(--choice-color) 12%, transparent);
color: var(--choice-color);
}
.decide__choice-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.decide__choice-title {
font-size: 1.0625rem;
font-weight: 800;
color: var(--mood-text);
}
.decide__choice-desc {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.4;
}
.decide__choice-arrow {
flex-shrink: 0;
color: var(--mood-text-muted);
opacity: 0.3;
transition: all 0.12s;
}
.decide__choice:hover .decide__choice-arrow {
opacity: 1;
color: var(--choice-color);
transform: translateX(3px);
}
.decide__footer-note {
display: flex;
align-items: flex-start;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
line-height: 1.5;
margin: 0;
}
/* Form (Step 2) */
.decide__error {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.875rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-error, #c42b2b);
background: color-mix(in srgb, #c42b2b 8%, transparent);
border-radius: 12px;
}
.decide__fields { display: flex; flex-direction: column; gap: 1rem; }
.decide__field { display: flex; flex-direction: column; gap: 0.375rem; }
.decide__label {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.decide__required { color: var(--mood-error, #c42b2b); }
.decide__input,
.decide__textarea {
width: 100%;
padding: 0.75rem 1rem;
font-size: 0.9375rem;
color: var(--mood-text);
background: var(--mood-surface);
border-radius: 12px;
outline: none;
font-family: inherit;
resize: vertical;
transition: box-shadow 0.15s ease;
}
.decide__input:focus,
.decide__textarea:focus {
box-shadow: 0 0 0 3px var(--mood-accent-soft);
}
.decide__hint {
font-size: 0.75rem;
color: var(--mood-text-muted);
margin: 0;
}
.decide__link {
color: var(--mood-accent);
text-decoration: underline;
text-underline-offset: 2px;
}
/* Mandate toggle */
.decide__mandate-toggle {
display: flex;
align-items: flex-start;
gap: 0.875rem;
padding: 1rem;
background: var(--mood-surface);
border-radius: 14px;
cursor: pointer;
}
.decide__mandate-check {
margin-top: 0.125rem;
width: 1rem;
height: 1rem;
flex-shrink: 0;
accent-color: var(--mood-accent);
cursor: pointer;
}
.decide__mandate-body { display: flex; flex-direction: column; gap: 0.125rem; }
.decide__mandate-label {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
}
.decide__mandate-hint {
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
/* Actions */
.decide__actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 0.25rem;
}
.decide__cancel {
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text-muted);
background: none;
border-radius: 12px;
cursor: pointer;
transition: color 0.12s, background 0.12s;
}
.decide__cancel:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.decide__submit {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.75rem;
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--submit-color, var(--mood-accent));
border-radius: 20px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
min-height: 2.75rem;
}
.decide__submit:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 16px var(--mood-shadow);
}
.decide__submit:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Transition */
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: all 0.2s ease;
}
.slide-fade-enter-from {
opacity: 0;
transform: translateX(12px);
}
.slide-fade-leave-to {
opacity: 0;
transform: translateX(-12px);
}
</style>