Files
decision/frontend/app/pages/decisions/index.vue
Yvv 290548703d Boîtes à outils enrichies : ContextMapper, SocioElection, WorkflowMilestones
- ContextMapper : 4 questions contexte → méthode de décision optimale
  (advice process Laloux, vote inertiel WoT, consentement sociocratique, Smith…)
- SocioElection : guide élection sociocratique 6 étapes + advice process + clarté de rôle
- WorkflowMilestones : 11 jalons de protocole (7 essentiels), durées recommandées, principes Ostrom
- WorkspaceSelector : sélecteur de collectif multi-site dans le header
- SectionLayout : toolbox en USlideover droit sur mobile, sidebar sticky desktop
- Décisions : ContextMapper intégré + guide consentement
- Mandats : SocioElection intégré + cycle de mandat
- Documents : guide inertie 4 niveaux + structure + IPFS
- Protocoles : WorkflowMilestones + protocole élection sociocratique ajouté
- Renommage projet Glibredecision → libreDecision (dossier + sources)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:13:08 +01:00

637 lines
16 KiB
Vue

<script setup lang="ts">
/**
* Decisions — page index.
*
* Utilise SectionLayout avec status filters, recherche, tri,
* et sidebar "Boîte à outils" affichant les protocoles de vote.
*/
const decisions = useDecisionsStore()
const protocols = useProtocolsStore()
const auth = useAuthStore()
// Toolbox state
const showConsentModal = ref(false)
const selectedMethod = ref<string | null>(null)
const consentSteps = [
'Présenter la proposition clairement (2 min)',
'Tour de clarification — questions de compréhension uniquement',
'Tour de réaction — chacun réagit brièvement',
'Porteur amende si nécessaire',
'Tour d\'objections — silence = consentement',
'Lever les objections valides par amendement',
'Adopter ou reporter',
]
function handleMethodSelect(method: string) {
selectedMethod.value = method
if (method.toLowerCase().includes('consentement')) {
showConsentModal.value = true
}
else if (method.toLowerCase().includes('avis')) {
// Navigate to advice process guide in mandates toolbox
navigateTo('/mandates')
}
}
const activeStatus = ref<string | null>(null)
const searchQuery = ref('')
const sortBy = ref<'date' | 'title' | 'status'>('date')
const sortOptions = [
{ label: 'Date', value: 'date' },
{ label: 'Titre', value: 'title' },
{ label: 'Statut', value: 'status' },
]
onMounted(async () => {
await Promise.all([
decisions.fetchAll(),
protocols.fetchProtocols(),
])
})
/** Status filter pills with counts. */
const statuses = computed(() => [
{ id: 'draft', label: 'En prépa', count: decisions.list.filter(d => d.status === 'draft').length },
{ id: 'voting', label: 'En vote', count: decisions.list.filter(d => d.status === 'voting' || d.status === 'qualification' || d.status === 'review').length },
{ id: 'executed', label: 'En vigueur', count: decisions.list.filter(d => d.status === 'executed').length },
{ id: 'closed', label: 'Clos', count: decisions.list.filter(d => d.status === 'closed').length },
])
/** Map for the voting pill — include qualification/review under "En vote". */
const statusGroupMap: Record<string, string[]> = {
draft: ['draft'],
voting: ['qualification', 'review', 'voting'],
executed: ['executed'],
closed: ['closed'],
}
/** Filtered and sorted decisions. */
const filteredDecisions = computed(() => {
let list = [...decisions.list]
// Filter by status group
if (activeStatus.value && statusGroupMap[activeStatus.value]) {
const statuses = statusGroupMap[activeStatus.value]
list = list.filter(d => statuses.includes(d.status))
}
// Filter by search query (client-side)
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
list = list.filter(d => d.title.toLowerCase().includes(q))
}
// Sort
switch (sortBy.value) {
case 'title':
list.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
break
case 'status':
list.sort((a, b) => a.status.localeCompare(b.status))
break
case 'date':
default:
list.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
break
}
return list
})
const typeLabel = (decisionType: string) => {
switch (decisionType) {
case 'runtime_upgrade': return 'Runtime upgrade'
case 'document_change': return 'Modif. document'
case 'mandate_vote': return 'Vote de mandat'
case 'parameter_change': return 'Param. change'
case 'other': return 'Autre'
default: return decisionType
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
</script>
<template>
<SectionLayout
title="Décisions"
subtitle="Processus de décision collectifs"
:statuses="statuses"
:active-status="activeStatus"
@update:active-status="activeStatus = $event"
>
<!-- Search / sort bar -->
<template #search>
<div class="search-field">
<UIcon name="i-lucide-search" class="search-field__icon" />
<input
v-model="searchQuery"
type="text"
class="search-field__input"
placeholder="Rechercher une décision..."
/>
</div>
<select v-model="sortBy" class="sort-select">
<option v-for="opt in sortOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<NuxtLink
v-if="auth.isAuthenticated"
to="/decisions/new"
class="action-btn"
>
<UIcon name="i-lucide-plus" class="text-xs" />
<span>Nouvelle</span>
</NuxtLink>
</template>
<!-- Main content: decision list -->
<template #default>
<!-- Error state -->
<div v-if="decisions.error" class="flex items-center gap-3 p-4 rounded-lg" style="background: var(--mood-surface); border: 1px solid var(--mood-border);">
<UIcon name="i-lucide-alert-circle" class="text-xl" style="color: var(--mood-error);" />
<p style="color: var(--mood-text);">{{ decisions.error }}</p>
</div>
<!-- Loading state -->
<div v-else-if="decisions.loading" class="space-y-3">
<LoadingSkeleton v-for="i in 5" :key="i" :lines="2" card />
</div>
<!-- Empty state -->
<div
v-else-if="filteredDecisions.length === 0"
class="text-center py-12"
style="color: var(--mood-text-muted);"
>
<UIcon name="i-lucide-scale" class="text-4xl mb-3 block mx-auto" />
<p>Aucune décision trouvée</p>
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
Essayez de modifier vos filtres
</p>
</div>
<!-- Decision cards -->
<div v-else class="space-y-3">
<div
v-for="decision in filteredDecisions"
:key="decision.id"
class="decision-card"
@click="navigateTo(`/decisions/${decision.id}`)"
>
<div class="decision-card__header">
<div class="decision-card__title-block">
<h3 class="decision-card__title">
{{ decision.title }}
</h3>
<p v-if="decision.description" class="decision-card__description">
{{ decision.description }}
</p>
</div>
<StatusBadge :status="decision.status" type="decision" />
</div>
<div class="decision-card__meta">
<span class="decision-card__type-badge">
{{ typeLabel(decision.decision_type) }}
</span>
<span
v-if="decision.decision_type === 'runtime_upgrade'"
class="decision-card__onchain-badge"
>
<UIcon name="i-lucide-link" class="text-xs" />
on-chain
</span>
<span class="decision-card__steps">
<UIcon name="i-lucide-layers" class="text-xs" />
{{ decision.steps.length }} étape{{ decision.steps.length !== 1 ? 's' : '' }}
</span>
<span class="decision-card__date">
<UIcon name="i-lucide-clock" class="text-xs" />
{{ formatDate(decision.created_at) }}
</span>
</div>
<!-- Protocol link for runtime_upgrade -->
<NuxtLink
v-if="decision.decision_type === 'runtime_upgrade'"
to="/protocols"
class="decision-card__protocol-link"
@click.stop
>
<UIcon name="i-lucide-git-branch" class="text-xs" />
<span>Protocole : Soumission Runtime Upgrade</span>
<UIcon name="i-lucide-arrow-right" class="text-xs" />
</NuxtLink>
</div>
</div>
</template>
<!-- Toolbox sidebar -->
<template #toolbox>
<!-- Context mapper -->
<div class="toolbox-block">
<div class="toolbox-block__head">
<UIcon name="i-lucide-compass" />
<span>Quelle méthode ?</span>
</div>
<ContextMapper @use="handleMethodSelect" />
</div>
<!-- Vote inertiel WoT -->
<ToolboxVignette
title="Vote inertiel WoT"
:bullets="[
'Seuil adaptatif à la participation',
'Faible participation → quasi-unanimité',
'Formule g1vote — tracé on-chain',
]"
:actions="[
{ label: 'Simuler', icon: 'i-lucide-calculator', to: '/protocols/formulas', primary: true },
{ label: 'Protocoles', icon: 'i-lucide-settings', to: '/protocols' },
]"
/>
<!-- Consentement sociocratique -->
<ToolboxVignette
title="Consentement sociocratique"
:bullets="[
'Aucune objection grave = adopté',
'Rapide pour petits groupes',
'Distingue préférence et objection',
]"
:actions="[
{ label: 'Guide', icon: 'i-lucide-book-open', emit: 'consent', primary: true },
]"
/>
<!-- Advice process -->
<ToolboxVignette
title="Processus d'avis (Laloux)"
:bullets="[
'Décisions urgentes : < 2h',
'Consultant experts + impactés',
'Responsabilise le porteur',
]"
:actions="[
{ label: 'Guide', icon: 'i-lucide-message-circle', emit: 'advice', primary: true },
]"
/>
</template>
</SectionLayout>
<!-- Modal consent guide -->
<UModal v-model:open="showConsentModal">
<template #content>
<div class="decision-modal">
<h3 class="decision-modal__title">Consentement sociocratique</h3>
<p class="decision-modal__text">
Une décision est adoptée par consentement quand aucun membre ne soulève d'objection grave.
Une objection grave est une raison pour laquelle la proposition nuit à la mission commune
pas une simple préférence.
</p>
<div class="decision-modal__steps">
<div v-for="(step, i) in consentSteps" :key="i" class="decision-modal__step">
<div class="decision-modal__step-num">{{ i + 1 }}</div>
<div class="decision-modal__step-text">{{ step }}</div>
</div>
</div>
<p class="decision-modal__ref">Référence : "La Sociocracie" Gerard Endenburg, Brian Robertson (Holacracy)</p>
<button class="decision-modal__close" @click="showConsentModal = false">Fermer</button>
</div>
</template>
</UModal>
</template>
<style scoped>
.decision-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background: var(--mood-surface);
border-radius: 16px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
@media (min-width: 640px) {
.decision-card {
gap: 0.625rem;
padding: 1.25rem;
}
}
.decision-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px var(--mood-shadow);
}
.decision-card:active {
transform: translateY(0);
}
.decision-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.decision-card__title-block {
flex: 1;
min-width: 0;
}
.decision-card__title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
line-height: 1.3;
}
@media (min-width: 640px) {
.decision-card__title {
font-size: 1.0625rem;
}
}
.decision-card__description {
margin-top: 0.25rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (min-width: 640px) {
.decision-card__description {
font-size: 0.875rem;
}
}
.decision-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
}
@media (min-width: 640px) {
.decision-card__meta {
gap: 0.5rem;
}
}
.decision-card__steps {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.decision-card__date {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
margin-left: auto;
opacity: 0.7;
}
@media (min-width: 640px) {
.decision-card__date {
font-size: 0.8125rem;
}
}
.decision-card__type-badge {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 3px 10px;
border-radius: 20px;
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
.decision-card__onchain-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 3px 8px;
border-radius: 20px;
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
color: var(--mood-success);
}
.decision-card__protocol-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 20px;
text-decoration: none;
background: color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 10%, transparent);
color: var(--mood-tertiary, var(--mood-accent));
transition: transform 0.12s ease, box-shadow 0.12s ease;
width: fit-content;
}
.decision-card__protocol-link:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px var(--mood-shadow);
}
/* --- Modern search / sort / action --- */
.search-field {
flex: 1;
min-width: 10rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--mood-accent-soft);
border-radius: 12px;
transition: box-shadow 0.15s ease;
}
.search-field:focus-within {
box-shadow: 0 0 0 2.5px var(--mood-accent-soft);
}
.search-field__icon {
color: var(--mood-text-muted);
opacity: 0.5;
font-size: 0.875rem;
flex-shrink: 0;
}
.search-field__input {
flex: 1;
background: none;
font-size: 0.9375rem;
color: var(--mood-text);
min-width: 0;
}
.search-field__input::placeholder {
color: var(--mood-text-muted);
opacity: 0.4;
}
.sort-select {
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
background: var(--mood-accent-soft);
border-radius: 12px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
min-width: 5.5rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
cursor: pointer;
text-decoration: none;
transition: transform 0.12s ease, box-shadow 0.12s ease;
white-space: nowrap;
}
.action-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--mood-shadow);
}
.action-btn:active {
transform: translateY(0);
}
.toolbox-block {
background: var(--mood-accent-soft);
border-radius: 14px;
padding: 0.875rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.toolbox-block__head {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
font-weight: 800;
color: var(--mood-accent);
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* Decision modal */
.decision-modal {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (min-width: 640px) {
.decision-modal { padding: 2rem; gap: 1.25rem; }
}
.decision-modal__title {
font-size: 1.125rem;
font-weight: 800;
color: var(--mood-text);
margin: 0;
}
.decision-modal__text {
font-size: 0.875rem;
color: var(--mood-text-muted);
line-height: 1.6;
margin: 0;
}
.decision-modal__steps {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.decision-modal__step {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.decision-modal__step-num {
width: 1.375rem;
height: 1.375rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--mood-accent);
color: var(--mood-accent-text);
font-size: 0.6875rem;
font-weight: 800;
}
.decision-modal__step-text {
font-size: 0.875rem;
color: var(--mood-text);
padding-top: 0.125rem;
line-height: 1.5;
}
.decision-modal__ref {
font-size: 0.75rem;
color: var(--mood-text-muted);
font-style: italic;
margin: 0;
}
.decision-modal__close {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
cursor: pointer;
align-self: flex-end;
transition: transform 0.1s ease;
}
.decision-modal__close:hover { transform: translateY(-1px); }
</style>