forked from yvv/decision
v2 : les 14 écrans — le tour complet de la démocratie d'exercice
- Aujourd'hui (Fil 13 sections + capture sticky) + Le chemin (tunnel 2 gestes, Q0 inline, 3 chips, dérogation asymétrique, alternatives réglage/consignation) - Registre + fiche décision (timeline, périmètre premier/second lieu auditable, affluence non-ignorable, S'instruire condensé, éléments + cartographie de clôture, épreuve du réel, Remettre en question, PV A4, gravure) - Salle de vote 5 modalités (consentement, nuancé+histogramme, binaire hérité avec jauge inertielle, Réglage collectif complet — faisceau, médiane basse, Pour moi, Explorer, cristallisation-geste —, élection à départage humain) - Textes (Pacte en clair, document vivant, diff, vue projetée, Atelier des formules porté du v1 sur le moteur unique) + Mandats (faits comptés, feux de la rampe, wizard 3 étapes) + Observatoire (consigner→observer→protocoliser) - Onboarding 7 gabarits + Données locales (export/import, attributs, atelier) - voting→framing gardé (Reformuler d'un réglage figé non cristallisé) - Pages v1 supprimées (login, documents, mandates, protocols, sanctuary, tools, decisions/new) — 342 tests verts, build zéro erreur Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,187 +1,192 @@
|
||||
<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.
|
||||
*/
|
||||
// <!-- ld-v2 --> Nuancé 6 niveaux — segments en gradient sémantique dérivé des
|
||||
// custom properties du mood (color-mix error→success, jamais de couleurs crues),
|
||||
// commentaire obligatoire sous 0-1, histogramme de la distribution en cours.
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { useDecisionsStore } from '~/stores/decisions'
|
||||
import { NUANCED_LABELS } from '~/lexicon'
|
||||
import type { Decision, Id, NuancedValue, Protocol, VoteSession } from '~/types/domain'
|
||||
|
||||
const props = defineProps<{
|
||||
sessionId: string
|
||||
disabled?: boolean
|
||||
decision: Decision
|
||||
session: VoteSession
|
||||
protocol: Protocol
|
||||
secret?: boolean
|
||||
canAct?: boolean
|
||||
asPersonId?: Id
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const votes = useVotesStore()
|
||||
const col = useCollectiveStore()
|
||||
const store = useDecisionsStore()
|
||||
|
||||
const LEVELS: NuancedValue[] = [0, 1, 2, 3, 4, 5]
|
||||
/** % de success dans le mélange sémantique error→success, par niveau. */
|
||||
const MIX = [6, 24, 44, 62, 80, 94] as const
|
||||
function levelColor(level: number): string {
|
||||
return `color-mix(in oklab, var(--mood-success) ${MIX[level] ?? 50}%, var(--mood-error))`
|
||||
}
|
||||
|
||||
const active = computed(() => store.activeVotes(props.session.id))
|
||||
const voterId = computed(() => props.asPersonId ?? col.me?.id)
|
||||
const myVote = computed(() => active.value.find(v => v.voterId === voterId.value))
|
||||
|
||||
const pending = ref<NuancedValue | null>(null)
|
||||
watch(myVote, (vote) => {
|
||||
if (pending.value === null && typeof vote?.value === 'number') pending.value = vote.value as NuancedValue
|
||||
}, { immediate: true })
|
||||
|
||||
const submitting = ref(false)
|
||||
const selectedLevel = ref<number | null>(null)
|
||||
const comment = ref('')
|
||||
const showConfirm = ref(false)
|
||||
const error = ref('')
|
||||
const needsComment = computed(() => pending.value === 0 || pending.value === 1)
|
||||
const canDeposit = computed(() =>
|
||||
props.canAct && pending.value !== null && (!needsComment.value || comment.value.trim().length > 0),
|
||||
)
|
||||
|
||||
interface NuancedLevel {
|
||||
level: number
|
||||
label: string
|
||||
color: string
|
||||
bgClass: string
|
||||
textClass: string
|
||||
ringClass: string
|
||||
function deposit() {
|
||||
if (pending.value === null) return
|
||||
error.value = ''
|
||||
const result = store.castVote(props.session.id, {
|
||||
value: pending.value,
|
||||
...(comment.value.trim() ? { comment: comment.value.trim() } : {}),
|
||||
...(props.asPersonId ? { asPersonId: props.asPersonId } : {}),
|
||||
})
|
||||
if ('ok' in result) { error.value = result.reason; return }
|
||||
comment.value = ''
|
||||
}
|
||||
|
||||
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
|
||||
// ── Distribution (recalculée depuis les derniers votes actifs) ──
|
||||
const counts = computed(() => {
|
||||
const perLevel = [0, 0, 0, 0, 0, 0]
|
||||
for (const vote of active.value) {
|
||||
if (typeof vote.value === 'number') perLevel[vote.value] = (perLevel[vote.value] ?? 0) + 1
|
||||
}
|
||||
return perLevel
|
||||
})
|
||||
const maxCount = computed(() => Math.max(1, ...counts.value))
|
||||
const total = computed(() => active.value.length)
|
||||
|
||||
const isDisabled = computed(() => {
|
||||
return props.disabled || !auth.isAuthenticated || !votes.isSessionOpen || submitting.value
|
||||
const result = computed(() => {
|
||||
const tallied = store.tally(props.session)
|
||||
return !('ok' in tallied) && tallied.method === 'nuanced' ? tallied.result : null
|
||||
})
|
||||
const thresholdPct = computed(() => props.protocol.formula.nuancedThresholdPct ?? 80)
|
||||
const comments = computed(() => active.value.filter(v => v.comment?.trim()))
|
||||
|
||||
/** 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 ?? ''
|
||||
function name(id: Id): string {
|
||||
return col.people.find(p => p.id === id)?.displayName ?? '—'
|
||||
}
|
||||
</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">
|
||||
<!-- ld-v2 -->
|
||||
<section class="ld-card vn">
|
||||
<!-- La jauge : 6 segments -->
|
||||
<div class="vn__scale" role="radiogroup" aria-label="Nuance">
|
||||
<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)"
|
||||
v-for="level in LEVELS"
|
||||
:key="level"
|
||||
type="button"
|
||||
class="vn__segment"
|
||||
:class="{ 'vn__segment--picked': pending === level, 'vn__segment--mine': typeof myVote?.value === 'number' && myVote.value === level }"
|
||||
:style="{ '--seg': levelColor(level) }"
|
||||
:disabled="!canAct"
|
||||
@click="pending = 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>
|
||||
<span class="vn__segment-value">{{ level }}</span>
|
||||
<span class="vn__segment-label">{{ NUANCED_LABELS[level] }}</span>
|
||||
</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"
|
||||
/>
|
||||
<!-- Commentaire obligatoire sous 0-1 -->
|
||||
<div v-if="needsComment && session.status === 'open'" class="vn__comment">
|
||||
<label>Dis pourquoi — un commentaire accompagne toute position négative.</label>
|
||||
<textarea v-model="comment" rows="2" placeholder="Ton argument…" />
|
||||
</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 v-if="session.status === 'open'" class="vn__actions">
|
||||
<button class="ld-btn" type="button" :disabled="!canDeposit" @click="deposit()">
|
||||
{{ myVote ? 'Remplacer mon vote' : 'Déposer mon vote' }}
|
||||
</button>
|
||||
<span v-if="myVote && typeof myVote.value === 'number'" class="vn__mine">
|
||||
Ta nuance actuelle : {{ NUANCED_LABELS[myVote.value as NuancedValue] }}
|
||||
</span>
|
||||
</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) }}
|
||||
<p v-if="error" class="vn__error">{{ error }}</p>
|
||||
|
||||
<!-- Histogramme des 6 niveaux — la distribution, pas seulement le résultat -->
|
||||
<div class="vn__histogram" aria-label="Distribution des nuances">
|
||||
<div v-for="level in LEVELS" :key="level" class="vn__bar-col">
|
||||
<span class="vn__bar-count">{{ counts[level] }}</span>
|
||||
<div
|
||||
class="vn__bar"
|
||||
:style="{ height: `${4 + ((counts[level] ?? 0) / maxCount) * 64}px`, background: levelColor(level) }"
|
||||
/>
|
||||
<span class="vn__bar-label">{{ level }}</span>
|
||||
</div>
|
||||
<span class="vn__total">{{ total }} vote{{ total > 1 ? 's' : '' }}</span>
|
||||
</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>
|
||||
<!-- Résultat -->
|
||||
<p v-if="result && total > 0" class="vn__result" :class="{ 'vn__result--closed': session.status === 'closed' }">
|
||||
{{ result.positive_count }} nuance{{ result.positive_count > 1 ? 's' : '' }} positive{{ result.positive_count > 1 ? 's' : '' }}
|
||||
sur {{ result.total }} — {{ result.positive_pct.toLocaleString('fr-FR') }} %
|
||||
(seuil {{ thresholdPct.toLocaleString('fr-FR') }} %).
|
||||
<template v-if="session.status === 'closed'">
|
||||
{{ session.outcome === 'adopted' ? 'Le collectif adopte.' : 'Le collectif n’adopte pas.' }}
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</p>
|
||||
|
||||
<!-- Les arguments, toujours en liste -->
|
||||
<ul v-if="comments.length" class="vn__comments">
|
||||
<li v-for="vote in comments" :key="vote.id">
|
||||
<span v-if="!secret" class="vn__comment-who">{{ name(vote.voterId) }}</span>
|
||||
<span class="vn__comment-level" :style="{ color: levelColor(Number(vote.value)) }">
|
||||
{{ typeof vote.value === 'number' ? NUANCED_LABELS[vote.value as NuancedValue] : '' }}
|
||||
</span>
|
||||
<p>{{ vote.comment }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vn { padding: 1.25rem; display: flex; flex-direction: column; gap: 0.9rem; }
|
||||
.vn__scale { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; }
|
||||
.vn__segment {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.2rem;
|
||||
padding: 0.6rem 0.2rem; min-height: 3.5rem; border-radius: var(--r-input);
|
||||
background: color-mix(in srgb, var(--seg) 14%, var(--mood-surface));
|
||||
color: var(--seg); cursor: pointer; transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
}
|
||||
.vn__segment:hover:not(:disabled) { transform: translateY(-1px); }
|
||||
.vn__segment:disabled { cursor: not-allowed; opacity: 0.6; }
|
||||
.vn__segment--picked { background: var(--seg); color: var(--mood-surface); box-shadow: 0 2px 8px var(--mood-shadow); }
|
||||
.vn__segment--mine:not(.vn__segment--picked) { box-shadow: inset 0 0 0 2px var(--seg); }
|
||||
.vn__segment-value { font-size: 1.05rem; font-weight: 800; }
|
||||
.vn__segment-label { font-size: 0.66rem; font-weight: 700; text-align: center; line-height: 1.15; }
|
||||
.vn__comment { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.vn__comment label { font-size: 0.8125rem; font-weight: 600; color: var(--mood-warning); }
|
||||
.vn__comment textarea { padding: 0.55rem 0.75rem; font-size: 0.9375rem; resize: vertical; }
|
||||
.vn__actions { display: flex; align-items: center; flex-wrap: wrap; gap: 0.75rem; }
|
||||
.vn__mine { font-size: 0.8125rem; color: var(--mood-text-muted); }
|
||||
.vn__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
|
||||
.vn__histogram {
|
||||
display: flex; align-items: flex-end; gap: 6px;
|
||||
padding: 0.75rem 0.5rem 0.4rem; background: var(--mood-bg); border-radius: var(--r-input);
|
||||
}
|
||||
.vn__bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px; }
|
||||
.vn__bar { width: 100%; max-width: 44px; border-radius: 4px 4px 2px 2px; opacity: 0.85; transition: height 0.2s ease; }
|
||||
.vn__bar-count { font-size: 0.75rem; font-weight: 700; color: var(--mood-text-muted); }
|
||||
.vn__bar-label { font-size: 0.7rem; font-weight: 700; color: var(--mood-text-muted); }
|
||||
.vn__total { align-self: flex-end; font-size: 0.75rem; color: var(--mood-text-muted); padding: 0 0.25rem 0.35rem; white-space: nowrap; }
|
||||
.vn__result { margin: 0; font-size: 0.9375rem; }
|
||||
.vn__result--closed { font-weight: 700; }
|
||||
.vn__comments { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.vn__comments li { background: var(--mood-bg); border-radius: var(--r-input); padding: 0.55rem 0.8rem; font-size: 0.875rem; }
|
||||
.vn__comments p { margin: 0.2rem 0 0; }
|
||||
.vn__comment-who { font-weight: 700; margin-right: 0.5rem; }
|
||||
.vn__comment-level { font-weight: 700; font-size: 0.8125rem; }
|
||||
@media (max-width: 480px) {
|
||||
.vn__scale { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user