Sprint 3 : protocoles de vote et boite a outils
Backend: - Sessions de vote : list, close, tally, threshold details, auto-expiration - Protocoles : update, simulate, meta-gouvernance, formulas CRUD - Service vote enrichi : close_session, get_threshold_details, nuanced breakdown - Schemas : ThresholdDetailOut, VoteResultOut, FormulaSimulationRequest/Result - WebSocket broadcast sur chaque vote + fermeture session - 25 nouveaux tests (threshold details, close, nuanced, simulation) Frontend: - 5 composants vote : VoteBinary, VoteNuanced, ThresholdGauge, FormulaDisplay, VoteHistory - 3 composants protocoles : ProtocolPicker, FormulaEditor, ModeParamsDisplay - Simulateur de formules interactif (page /protocols/formulas) - Page detail protocole (/protocols/[id]) - Composable useWebSocket (live updates) - Composable useVoteFormula (calcul client-side reactif) - Integration KaTeX pour rendu LaTeX des formules Documentation: - API reference : 8 nouveaux endpoints documentes - Formules : tables d'inertie, parametres detailles, simulation API - Guide vote : vote binaire/nuance, jauge, historique, simulateur, meta-gouvernance 55 tests passes (+ 1 skipped), 126 fichiers total. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
187
frontend/app/components/votes/VoteNuanced.vue
Normal file
187
frontend/app/components/votes/VoteNuanced.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 6-level nuanced vote component.
|
||||
*
|
||||
* Displays 6 vote levels from CONTRE (0) to TOUT A FAIT D'ACCORD (5),
|
||||
* each with a distinctive color. Negative votes (0-2) optionally include
|
||||
* a comment textarea.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
sessionId: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const votes = useVotesStore()
|
||||
|
||||
const submitting = ref(false)
|
||||
const selectedLevel = ref<number | null>(null)
|
||||
const comment = ref('')
|
||||
const showConfirm = ref(false)
|
||||
|
||||
interface NuancedLevel {
|
||||
level: number
|
||||
label: string
|
||||
color: string
|
||||
bgClass: string
|
||||
textClass: string
|
||||
ringClass: string
|
||||
}
|
||||
|
||||
const levels: NuancedLevel[] = [
|
||||
{ level: 0, label: 'CONTRE', color: 'red', bgClass: 'bg-red-500', textClass: 'text-red-600 dark:text-red-400', ringClass: 'ring-red-500' },
|
||||
{ level: 1, label: 'PAS DU TOUT D\'ACCORD', color: 'orange-red', bgClass: 'bg-orange-600', textClass: 'text-orange-700 dark:text-orange-400', ringClass: 'ring-orange-600' },
|
||||
{ level: 2, label: 'PAS D\'ACCORD', color: 'orange', bgClass: 'bg-orange-400', textClass: 'text-orange-600 dark:text-orange-300', ringClass: 'ring-orange-400' },
|
||||
{ level: 3, label: 'NEUTRE', color: 'gray', bgClass: 'bg-gray-400', textClass: 'text-gray-600 dark:text-gray-400', ringClass: 'ring-gray-400' },
|
||||
{ level: 4, label: 'D\'ACCORD', color: 'light-green', bgClass: 'bg-green-400', textClass: 'text-green-600 dark:text-green-400', ringClass: 'ring-green-400' },
|
||||
{ level: 5, label: 'TOUT A FAIT D\'ACCORD', color: 'green', bgClass: 'bg-green-600', textClass: 'text-green-700 dark:text-green-300', ringClass: 'ring-green-600' },
|
||||
]
|
||||
|
||||
/** Check if the current user has already voted in this session. */
|
||||
const userVote = computed(() => {
|
||||
if (!auth.identity) return null
|
||||
return votes.votes.find(v => v.voter_id === auth.identity!.id && v.is_active)
|
||||
})
|
||||
|
||||
/** Initialize selected level from existing vote. */
|
||||
watchEffect(() => {
|
||||
if (userVote.value?.nuanced_level !== undefined && userVote.value?.nuanced_level !== null) {
|
||||
selectedLevel.value = userVote.value.nuanced_level
|
||||
}
|
||||
})
|
||||
|
||||
const isDisabled = computed(() => {
|
||||
return props.disabled || !auth.isAuthenticated || !votes.isSessionOpen || submitting.value
|
||||
})
|
||||
|
||||
/** Whether the comment field should be shown (negative votes). */
|
||||
const showComment = computed(() => {
|
||||
return selectedLevel.value !== null && selectedLevel.value <= 2
|
||||
})
|
||||
|
||||
function selectLevel(level: number) {
|
||||
if (isDisabled.value) return
|
||||
selectedLevel.value = level
|
||||
showConfirm.value = true
|
||||
}
|
||||
|
||||
async function confirmVote() {
|
||||
if (selectedLevel.value === null) return
|
||||
showConfirm.value = false
|
||||
submitting.value = true
|
||||
|
||||
const voteValue = selectedLevel.value >= 3 ? 'pour' : 'contre'
|
||||
|
||||
try {
|
||||
await votes.submitVote({
|
||||
session_id: props.sessionId,
|
||||
vote_value: voteValue,
|
||||
nuanced_level: selectedLevel.value,
|
||||
comment: showComment.value && comment.value.trim() ? comment.value.trim() : null,
|
||||
signature: 'pending',
|
||||
signed_payload: 'pending',
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelVote() {
|
||||
showConfirm.value = false
|
||||
}
|
||||
|
||||
function getLevelLabel(level: number): string {
|
||||
return levels.find(l => l.level === level)?.label ?? ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Level buttons -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<button
|
||||
v-for="lvl in levels"
|
||||
:key="lvl.level"
|
||||
:disabled="isDisabled"
|
||||
class="relative flex flex-col items-center p-4 rounded-lg border-2 transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="[
|
||||
selectedLevel === lvl.level || userVote?.nuanced_level === lvl.level
|
||||
? `ring-3 ${lvl.ringClass} border-transparent`
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600',
|
||||
]"
|
||||
@click="selectLevel(lvl.level)"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-lg mb-2"
|
||||
:class="lvl.bgClass"
|
||||
>
|
||||
{{ lvl.level }}
|
||||
</div>
|
||||
<span class="text-xs font-medium text-center leading-tight" :class="lvl.textClass">
|
||||
{{ lvl.label }}
|
||||
</span>
|
||||
<!-- Selected indicator -->
|
||||
<div
|
||||
v-if="selectedLevel === lvl.level || userVote?.nuanced_level === lvl.level"
|
||||
class="absolute -top-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center text-white text-xs"
|
||||
:class="lvl.bgClass"
|
||||
>
|
||||
<UIcon name="i-lucide-check" class="w-3 h-3" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comment for negative votes -->
|
||||
<div v-if="showComment" class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Commentaire (optionnel pour les votes negatifs)
|
||||
</label>
|
||||
<UTextarea
|
||||
v-model="comment"
|
||||
placeholder="Expliquez votre position..."
|
||||
:rows="3"
|
||||
:disabled="isDisabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Status messages -->
|
||||
<div v-if="!auth.isAuthenticated" class="text-sm text-amber-600 dark:text-amber-400 text-center">
|
||||
Connectez-vous pour voter
|
||||
</div>
|
||||
<div v-else-if="!votes.isSessionOpen" class="text-sm text-gray-500 text-center">
|
||||
Cette session de vote est fermee
|
||||
</div>
|
||||
<div v-else-if="userVote" class="text-sm text-green-600 dark:text-green-400 text-center">
|
||||
Vous avez vote : {{ getLevelLabel(userVote.nuanced_level ?? 0) }}
|
||||
</div>
|
||||
|
||||
<!-- Error display -->
|
||||
<div v-if="votes.error" class="text-sm text-red-500 text-center">
|
||||
{{ votes.error }}
|
||||
</div>
|
||||
|
||||
<!-- Confirmation modal -->
|
||||
<UModal v-model:open="showConfirm">
|
||||
<template #content>
|
||||
<div class="p-6 space-y-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Confirmation du vote
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Vous etes sur le point de voter :
|
||||
<strong>{{ getLevelLabel(selectedLevel ?? 0) }}</strong> (niveau {{ selectedLevel }}).
|
||||
Cette action est definitive.
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<UButton variant="ghost" color="neutral" @click="cancelVote">
|
||||
Annuler
|
||||
</UButton>
|
||||
<UButton color="primary" :loading="submitting" @click="confirmVote">
|
||||
Confirmer le vote
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user