Refonte design : 4 humeurs, onboarding, sections avec boite a outils

- Systeme de themes adaptatifs : Peps (light chaud), Zen (light calme),
  Chagrine (dark violet), Grave (dark ambre) avec CSS custom properties
- Dashboard d'accueil orienté onboarding avec cartes-portes et teaser
  boite a outils
- SectionLayout reutilisable : liste + sidebar toolbox + status pills
  cliquables (En prepa / En vote / En vigueur / Clos)
- ToolboxVignette : vignettes Contexte / Tutos / Choisir / Demarrer
- Seed : Acte engagement certification + forgeron, Runtime Upgrade
  (decision on-chain), 3 modalites de vote (majoritaire, quadratique,
  permanent)
- Backend adapte SQLite (Uuid portable, 204 fix, pool conditionnel)
- Correction noms composants (pathPrefix: false), pinia/nuxt ^0.11

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-28 17:44:48 +01:00
parent 403b94fa2c
commit 77dceb49c3
49 changed files with 20628 additions and 1180 deletions

View File

@@ -200,7 +200,7 @@ async function handleDelete() {
<UBadge variant="subtle" color="primary">
{{ typeLabel(mandates.current.mandate_type) }}
</UBadge>
<CommonStatusBadge :status="mandates.current.status" type="mandate" />
<StatusBadge :status="mandates.current.status" type="mandate" />
</div>
</div>
@@ -323,7 +323,7 @@ async function handleDelete() {
Etapes du mandat
</h2>
<MandatesMandateTimeline
<MandateTimeline
:steps="mandates.current.steps"
:current-status="mandates.current.status"
/>

View File

@@ -1,46 +1,28 @@
<script setup lang="ts">
/**
* Mandats — page index.
*
* Utilise SectionLayout avec status filters, recherche,
* et sidebar "Boite a outils" affichant les protocoles de vote.
* Etat vide enrichi avec onboarding expliquant le concept de mandat.
*/
import type { MandateCreate } from '~/stores/mandates'
const mandates = useMandatesStore()
const protocols = useProtocolsStore()
const auth = useAuthStore()
const filterType = ref<string | undefined>(undefined)
const filterStatus = ref<string | undefined>(undefined)
const activeStatus = ref<string | null>(null)
const searchQuery = ref('')
const sortBy = ref<'date' | 'title' | 'status'>('date')
const typeOptions = [
{ label: 'Tous les types', value: undefined },
{ label: 'Comite technique', value: 'techcomm' },
{ label: 'Forgeron', value: 'smith' },
{ label: 'Personnalise', value: 'custom' },
const sortOptions = [
{ label: 'Date', value: 'date' },
{ label: 'Titre', value: 'title' },
{ label: 'Statut', value: 'status' },
]
const statusOptions = [
{ label: 'Tous les statuts', value: undefined },
{ label: 'Brouillon', value: 'draft' },
{ label: 'Candidature', value: 'candidacy' },
{ label: 'En vote', value: 'voting' },
{ label: 'Actif', value: 'active' },
{ label: 'Rapport', value: 'reporting' },
{ label: 'Termine', value: 'completed' },
{ label: 'Revoque', value: 'revoked' },
]
async function loadMandates() {
await mandates.fetchAll({
mandate_type: filterType.value,
status: filterStatus.value,
})
}
onMounted(() => {
loadMandates()
})
watch([filterType, filterStatus], () => {
loadMandates()
})
// --- Create mandate modal ---
// Create mandate modal state
const showCreateModal = ref(false)
const mandateTypeOptions = [
{ label: 'Comite technique', value: 'techcomm' },
@@ -55,6 +37,80 @@ const newMandate = ref<MandateCreate>({
})
const creating = ref(false)
onMounted(async () => {
await Promise.all([
mandates.fetchAll(),
protocols.fetchProtocols(),
])
})
/** Status filter pills with counts. */
const statuses = computed(() => [
{ id: 'draft', label: 'En prepa', count: mandates.list.filter(m => m.status === 'draft' || m.status === 'candidacy').length },
{ id: 'voting', label: 'En vote', count: mandates.list.filter(m => m.status === 'voting').length },
{ id: 'active', label: 'En vigueur', count: mandates.list.filter(m => m.status === 'active' || m.status === 'reporting').length },
{ id: 'closed', label: 'Clos', count: mandates.list.filter(m => m.status === 'completed' || m.status === 'revoked').length },
])
/** Map for status group filtering. */
const statusGroupMap: Record<string, string[]> = {
draft: ['draft', 'candidacy'],
voting: ['voting'],
active: ['active', 'reporting'],
closed: ['completed', 'revoked'],
}
/** Filtered and sorted mandates. */
const filteredMandates = computed(() => {
let list = [...mandates.list]
// Filter by status group
if (activeStatus.value && statusGroupMap[activeStatus.value]) {
const allowedStatuses = statusGroupMap[activeStatus.value]
list = list.filter(m => allowedStatuses.includes(m.status))
}
// Filter by search query (client-side)
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
list = list.filter(m => m.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 = (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',
})
}
async function handleCreate() {
creating.value = true
try {
@@ -64,146 +120,372 @@ async function handleCreate() {
if (mandate) {
navigateTo(`/mandates/${mandate.id}`)
}
} catch {
}
catch {
// Error handled by store
} finally {
}
finally {
creating.value = false
}
}
</script>
<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-start justify-between">
<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>
<SectionLayout
title="Mandats"
subtitle="Un contexte, un objectif, une duree, une ou plusieurs nominations ; par defaut : nomination d'un binome."
:statuses="statuses"
:active-status="activeStatus"
@update:active-status="activeStatus = $event"
>
<!-- Search / sort bar -->
<template #search>
<UInput
v-model="searchQuery"
placeholder="Rechercher un mandat..."
icon="i-lucide-search"
size="sm"
class="w-full sm:w-64"
/>
<USelect
v-model="sortBy"
:items="sortOptions"
size="sm"
class="w-36"
/>
<UButton
v-if="auth.isAuthenticated"
label="Nouveau"
icon="i-lucide-plus"
label="Nouveau mandat"
color="primary"
size="sm"
@click="showCreateModal = true"
/>
</div>
</template>
<!-- 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="mandates.loading">
<div class="space-y-3">
<USkeleton v-for="i in 4" :key="i" class="h-12 w-full" />
<!-- Main content: mandates list -->
<template #default>
<!-- Error state -->
<div v-if="mandates.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);">{{ mandates.error }}</p>
</div>
</template>
<!-- Error state -->
<template v-else-if="mandates.error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ mandates.error }}</p>
<!-- Loading state -->
<div v-else-if="mandates.loading" class="space-y-3">
<LoadingSkeleton v-for="i in 4" :key="i" :lines="2" card />
</div>
<!-- Onboarding empty state -->
<div
v-else-if="mandates.list.length === 0 && !activeStatus && !searchQuery"
class="mandate-onboarding"
>
<div class="mandate-onboarding__icon">
<UIcon name="i-lucide-user-check" class="text-3xl" />
</div>
</UCard>
</template>
<!-- Empty state -->
<template v-else-if="mandates.list.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>
<h3 class="mandate-onboarding__title">
Qu'est-ce qu'un mandat ?
</h3>
<p class="mandate-onboarding__text">
Un mandat definit un contexte, un objectif et une duree pour une mission de gouvernance.
Il peut porter sur le comite technique, les forgerons, ou tout role specifique de la communaute.
</p>
<p class="mandate-onboarding__text">
Par defaut, un mandat nomme un binome pour assurer la continuite.
Le processus comprend : candidature, vote communautaire, periode active et rapport final.
</p>
<div class="mandate-onboarding__actions">
<UButton
v-if="auth.isAuthenticated"
label="Creer un premier mandat"
icon="i-lucide-plus"
color="primary"
size="sm"
@click="showCreateModal = true"
/>
<UButton
to="/protocols"
label="Decouvrir les protocoles"
variant="outline"
size="sm"
icon="i-lucide-wrench"
/>
</div>
</UCard>
</template>
</div>
<!-- Mandates list -->
<template v-else>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<MandatesMandateCard
v-for="mandate in mandates.list"
<!-- Filtered empty state -->
<div
v-else-if="filteredMandates.length === 0"
class="text-center py-12"
style="color: var(--mood-text-muted);"
>
<UIcon name="i-lucide-user-check" class="text-4xl mb-3 block mx-auto" />
<p>Aucun mandat trouve</p>
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
Essayez de modifier vos filtres
</p>
</div>
<!-- Mandate cards -->
<div v-else class="space-y-3">
<div
v-for="mandate in filteredMandates"
:key="mandate.id"
:mandate="mandate"
/>
class="mandate-card"
@click="navigateTo(`/mandates/${mandate.id}`)"
>
<div class="mandate-card__header">
<div class="mandate-card__title-block">
<h3 class="mandate-card__title">
{{ mandate.title }}
</h3>
<p v-if="mandate.description" class="mandate-card__description">
{{ mandate.description }}
</p>
</div>
<StatusBadge :status="mandate.status" type="mandate" />
</div>
<div class="mandate-card__meta">
<UBadge variant="subtle" color="primary" size="xs">
{{ typeLabel(mandate.mandate_type) }}
</UBadge>
<span class="mandate-card__steps">
<UIcon name="i-lucide-layers" class="text-xs" />
{{ mandate.steps.length }} etape{{ mandate.steps.length !== 1 ? 's' : '' }}
</span>
<span v-if="mandate.mandatee_id" class="mandate-card__mandatee">
<UIcon name="i-lucide-user" class="text-xs" />
{{ mandate.mandatee_id.slice(0, 8) }}...
</span>
</div>
<div class="mandate-card__dates">
<span>Debut : {{ formatDate(mandate.starts_at) }}</span>
<span>Fin : {{ formatDate(mandate.ends_at) }}</span>
</div>
</div>
</div>
</template>
<!-- Create mandate modal -->
<UModal v-model:open="showCreateModal">
<template #content>
<form class="p-6 space-y-4" @submit.prevent="handleCreate">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Nouveau mandat
</h3>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Titre <span class="text-red-500">*</span>
</label>
<UInput
v-model="newMandate.title"
placeholder="Titre du mandat..."
required
/>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Description
</label>
<UTextarea
v-model="newMandate.description"
placeholder="Description du mandat..."
:rows="3"
/>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Type de mandat <span class="text-red-500">*</span>
</label>
<USelect
v-model="newMandate.mandate_type"
:items="mandateTypeOptions"
/>
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showCreateModal = false"
/>
<UButton
type="submit"
label="Creer"
icon="i-lucide-plus"
color="primary"
:loading="creating"
:disabled="!newMandate.title?.trim()"
/>
</div>
</form>
<!-- Toolbox sidebar -->
<template #toolbox>
<div class="toolbox-section-title">
Modalites de vote
</div>
<template v-if="protocols.protocols.length > 0">
<ToolboxVignette
v-for="protocol in protocols.protocols"
:key="protocol.id"
:title="protocol.name"
:description="protocol.description || undefined"
context-label="Mandats"
:actions="[
{ label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
]"
/>
</template>
</UModal>
</div>
<p v-else class="toolbox-empty-text">
Aucun protocole configure
</p>
</template>
</SectionLayout>
<!-- Create mandate modal -->
<UModal v-model:open="showCreateModal">
<template #content>
<form class="p-6 space-y-4" @submit.prevent="handleCreate">
<h3 class="text-lg font-semibold" style="color: var(--mood-text);">
Nouveau mandat
</h3>
<div class="space-y-1">
<label class="block text-sm font-medium" style="color: var(--mood-text-muted);">
Titre <span style="color: var(--mood-error);">*</span>
</label>
<UInput
v-model="newMandate.title"
placeholder="Titre du mandat..."
required
/>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium" style="color: var(--mood-text-muted);">
Description
</label>
<UTextarea
v-model="newMandate.description"
placeholder="Description du mandat..."
:rows="3"
/>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium" style="color: var(--mood-text-muted);">
Type de mandat <span style="color: var(--mood-error);">*</span>
</label>
<USelect
v-model="newMandate.mandate_type"
:items="mandateTypeOptions"
/>
</div>
<div class="flex justify-end gap-2 pt-4" style="border-top: 1px solid var(--mood-border);">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showCreateModal = false"
/>
<UButton
type="submit"
label="Creer"
icon="i-lucide-plus"
color="primary"
:loading="creating"
:disabled="!newMandate.title?.trim()"
/>
</div>
</form>
</template>
</UModal>
</template>
<style scoped>
.mandate-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.875rem 1rem;
background: var(--mood-surface);
border: 1px solid var(--mood-border);
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.mandate-card:hover {
border-color: var(--mood-accent);
box-shadow: 0 2px 8px var(--mood-shadow);
}
.mandate-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.mandate-card__title-block {
flex: 1;
min-width: 0;
}
.mandate-card__title {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
line-height: 1.3;
}
.mandate-card__description {
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.mandate-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.mandate-card__steps {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
.mandate-card__mandatee {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
.mandate-card__dates {
display: flex;
gap: 1rem;
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
/* Onboarding empty state */
.mandate-onboarding {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.75rem;
padding: 2.5rem 1.5rem;
background: var(--mood-surface);
border: 1px dashed var(--mood-border);
border-radius: 0.75rem;
}
.mandate-onboarding__icon {
display: flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
.mandate-onboarding__title {
font-size: 1.125rem;
font-weight: 700;
color: var(--mood-text);
}
.mandate-onboarding__text {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
max-width: 32rem;
}
.mandate-onboarding__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.toolbox-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--mood-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.25rem;
}
.toolbox-empty-text {
font-size: 0.75rem;
color: var(--mood-text-muted);
}
</style>