Sprint 1 : scaffolding complet de Glibredecision

Plateforme de decisions collectives pour Duniter/G1.
Backend FastAPI async + PostgreSQL (14 tables, 8 routers, 6 services,
moteur de vote avec formule d'inertie WoT/Smith/TechComm).
Frontend Nuxt 4 + Nuxt UI v3 + Pinia (9 pages, 5 stores).
Infrastructure Docker + Woodpecker CI + Traefik.
Documentation technique et utilisateur (15 fichiers).
Seed : Licence G1, Engagement Forgeron v2.0.0, 4 protocoles de vote.
30 tests unitaires (formules, mode params, vote nuance) -- tous verts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-28 12:46:11 +01:00
commit 25437f24e3
100 changed files with 10236 additions and 0 deletions

View File

@@ -0,0 +1,222 @@
<script setup lang="ts">
const { $api } = useApi()
interface MandateStep {
id: string
mandate_id: string
step_order: number
step_type: string
title: string | null
description: string | null
status: string
vote_session_id: string | null
outcome: string | null
created_at: string
}
interface Mandate {
id: string
title: string
description: string | null
mandate_type: string
status: string
mandatee_id: string | null
decision_id: string | null
starts_at: string | null
ends_at: string | null
created_at: string
updated_at: string
steps: MandateStep[]
}
const mandates = ref<Mandate[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const filterType = ref<string | undefined>(undefined)
const filterStatus = ref<string | undefined>(undefined)
const typeOptions = [
{ label: 'Tous les types', value: undefined },
{ label: 'Comite technique', value: 'techcomm' },
{ label: 'Forgeron', value: 'smith' },
{ label: 'Personnalise', value: 'custom' },
]
const statusOptions = [
{ label: 'Tous les statuts', value: undefined },
{ label: 'Brouillon', value: 'draft' },
{ label: 'Actif', value: 'active' },
{ label: 'Expire', value: 'expired' },
{ label: 'Revoque', value: 'revoked' },
]
async function loadMandates() {
loading.value = true
error.value = null
try {
const query: Record<string, string> = {}
if (filterType.value) query.mandate_type = filterType.value
if (filterStatus.value) query.status = filterStatus.value
mandates.value = await $api<Mandate[]>('/mandates/', { query })
} catch (err: any) {
error.value = err?.data?.detail || err?.message || 'Erreur lors du chargement des mandats'
} finally {
loading.value = false
}
}
onMounted(() => {
loadMandates()
})
watch([filterType, filterStatus], () => {
loadMandates()
})
const statusColor = (status: string) => {
switch (status) {
case 'active': return 'success'
case 'draft': return 'warning'
case 'expired': return 'neutral'
case 'revoked': return 'error'
default: return 'neutral'
}
}
const statusLabel = (status: string) => {
switch (status) {
case 'active': return 'Actif'
case 'draft': return 'Brouillon'
case 'expired': return 'Expire'
case 'revoked': return 'Revoque'
default: return status
}
}
const typeLabel = (mandateType: string) => {
switch (mandateType) {
case 'techcomm': return 'Comite technique'
case 'smith': return 'Forgeron'
case 'custom': return 'Personnalise'
default: return mandateType
}
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
</script>
<template>
<div class="space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Mandats
</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Mandats de gouvernance : comite technique, forgerons et roles specifiques
</p>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-4">
<USelect
v-model="filterType"
:items="typeOptions"
placeholder="Type de mandat"
class="w-56"
/>
<USelect
v-model="filterStatus"
:items="statusOptions"
placeholder="Statut"
class="w-48"
/>
</div>
<!-- Loading state -->
<template v-if="loading">
<div class="space-y-3">
<USkeleton v-for="i in 4" :key="i" class="h-12 w-full" />
</div>
</template>
<!-- Error state -->
<template v-else-if="error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ error }}</p>
</div>
</UCard>
</template>
<!-- Empty state -->
<template v-else-if="mandates.length === 0">
<UCard>
<div class="text-center py-8">
<UIcon name="i-lucide-user-check" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucun mandat pour le moment</p>
</div>
</UCard>
</template>
<!-- Mandates list -->
<template v-else>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UCard
v-for="mandate in mandates"
:key="mandate.id"
class="hover:shadow-md transition-shadow"
>
<div class="space-y-3">
<div class="flex items-start justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-user-check" class="text-gray-400" />
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ mandate.title }}
</h3>
</div>
<UBadge :color="statusColor(mandate.status)" variant="subtle" size="xs">
{{ statusLabel(mandate.status) }}
</UBadge>
</div>
<p v-if="mandate.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{{ mandate.description }}
</p>
<div class="flex items-center gap-3 flex-wrap">
<UBadge variant="subtle" color="primary" size="xs">
{{ typeLabel(mandate.mandate_type) }}
</UBadge>
<span class="text-xs text-gray-500">
{{ mandate.steps.length }} etape(s)
</span>
</div>
<div class="grid grid-cols-2 gap-2 text-xs text-gray-500">
<div>
<span class="block font-medium">Debut</span>
{{ formatDate(mandate.starts_at) }}
</div>
<div>
<span class="block font-medium">Fin</span>
{{ formatDate(mandate.ends_at) }}
</div>
</div>
</div>
</UCard>
</div>
</template>
</div>
</template>