Files
decision/frontend/app/components/votes/VoteElection.vue
T
YvvandClaude Fable 5 e164b5f6c1 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>
2026-08-11 13:47:20 +02:00

199 lines
8.2 KiB
Vue

<script setup lang="ts">
// <!-- ld-v2 --> Élection — désignation par avatar parmi la liste arrêtée,
// vote blanc (participation, pas désignation), règle affichée en une phrase.
// Égalité : l'outil ne départage JAMAIS — session de départage chaînée.
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import { BLANK_VOTE, ELECTION_RULE } from '~/lexicon'
import type { Decision, Id, Person, Protocol, VoteSession } from '~/types/domain'
const props = defineProps<{
decision: Decision
session: VoteSession
protocol: Protocol
secret?: boolean
canAct?: boolean
asPersonId?: Id
}>()
const col = useCollectiveStore()
const store = useDecisionsStore()
const eligible = computed<Person[]>(() =>
col.people.filter(p => props.session.corpusPersonIds.includes(p.id)))
const active = computed(() => store.activeVotes(props.session.id))
const counts = computed(() => {
const tallyMap: Record<Id, number> = {}
for (const vote of active.value) {
if (vote.choicePersonId) tallyMap[vote.choicePersonId] = (tallyMap[vote.choicePersonId] ?? 0) + 1
}
return tallyMap
})
const blanks = computed(() => active.value.filter(v => !v.choicePersonId).length)
const voterId = computed(() => props.asPersonId ?? col.me?.id)
const myVote = computed(() => active.value.find(v => v.voterId === voterId.value))
const selected = ref<Id | null>(null)
const error = ref('')
function cast(choicePersonId?: Id) {
error.value = ''
const result = store.castVote(props.session.id, {
...(choicePersonId !== undefined ? { choicePersonId } : {}),
...(props.asPersonId ? { asPersonId: props.asPersonId } : {}),
})
if ('ok' in result) error.value = result.reason
else selected.value = null
}
const result = computed(() => {
if (props.session.status !== 'closed') return null
const tallied = store.tally(props.session)
return !('ok' in tallied) && tallied.method === 'election' ? tallied.result : null
})
function name(id: Id): string {
return col.people.find(p => p.id === id)?.displayName ?? '—'
}
function initials(personName: string): string {
return personName.split(/[\s-]+/).map(w => w[0] ?? '').join('').slice(0, 2).toUpperCase()
}
function tint(id: string): string {
let h = 0
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) % 360
return `oklch(0.72 0.09 ${h})`
}
function openRunoff() {
navigateTo({
path: '/decider',
query: {
parent: props.decision.id,
chain: 'revision',
title: `Départager — ${props.decision.title}`,
},
})
}
const drawPlanned = computed(() => props.protocol.formula.tieBreak === 'draw')
</script>
<template>
<!-- ld-v2 -->
<section class="ld-card ve">
<p class="ve__rule">
<UIcon name="i-lucide-scale" />
<span>{{ ELECTION_RULE }}</span>
</p>
<!-- La grille de désignation -->
<div class="ve__grid" role="radiogroup" aria-label="Désigner une personne">
<button
v-for="person in eligible"
:key="person.id"
type="button"
class="ve__tile"
:class="{
've__tile--picked': selected === person.id,
've__tile--mine': myVote?.choicePersonId === person.id,
've__tile--winner': result?.outcome === 'elected' && result.winnerId === person.id,
}"
:disabled="!canAct || session.status !== 'open'"
@click="selected = selected === person.id ? null : person.id"
>
<span class="ve__avatar" :style="{ background: tint(person.id) }">{{ initials(person.displayName) }}</span>
<span class="ve__name">{{ person.displayName }}</span>
<span v-if="(counts[person.id] ?? 0) > 0" class="ve__count">{{ counts[person.id] }}</span>
</button>
</div>
<div v-if="session.status === 'open'" class="ve__actions">
<button class="ld-btn" type="button" :disabled="!canAct || !selected" @click="cast(selected ?? undefined)">
{{ selected ? `Désigner ${name(selected)}` : 'Désigner' }}
</button>
<button class="ld-btn ld-btn--ghost" type="button" :disabled="!canAct" @click="cast()">
{{ BLANK_VOTE }}
</button>
<span v-if="myVote" class="ve__mine">
Ton geste actuel : {{ myVote.choicePersonId ? name(myVote.choicePersonId) : BLANK_VOTE.toLowerCase() }}
</span>
</div>
<p v-if="error" class="ve__error">{{ error }}</p>
<p class="ve__tally">
{{ active.length }} participant{{ active.length > 1 ? 's' : '' }}
· {{ blanks }} vote{{ blanks > 1 ? 's' : '' }} blanc{{ blanks > 1 ? 's' : '' }}
<template v-if="protocol.formula.electionMinParticipants !== undefined">
· quorum : {{ protocol.formula.electionMinParticipants }}
</template>
</p>
<!-- Clôture -->
<div v-if="result" class="ve__result">
<p v-if="result.outcome === 'elected'" class="ve__elected">
<UIcon name="i-lucide-award" />
<span><strong>{{ name(result.winnerId) }}</strong> est désigné·e à la pluralité simple.</span>
</p>
<template v-else-if="result.outcome === 'tie'">
<p class="ve__tie">
Égalité entre {{ result.exAequoIds.map(name).join(' et ') }}
{{ drawPlanned ? 'le Pacte prévoit un tirage au sort entre ex æquo.' : 'à vous de départager.' }}
</p>
<button class="ld-btn" type="button" @click="openRunoff()">
Ouvrir la session de départage
</button>
</template>
<p v-else class="ve__rejected">
<template v-if="result.reason === 'quorum'">
{{ result.participants }} participant{{ result.participants > 1 ? 's' : '' }} sur
{{ result.required }} requis le quorum n'est pas atteint.
</template>
<template v-else>
Personne n'a été désigné·e tous les votes sont blancs.
</template>
</p>
</div>
</section>
</template>
<style scoped>
.ve { padding: 1.25rem; display: flex; flex-direction: column; gap: 0.9rem; }
.ve__rule {
display: flex; align-items: baseline; gap: 0.45rem; margin: 0;
font-size: 0.875rem; color: var(--mood-text-muted); line-height: 1.5;
}
.ve__grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr)); gap: 0.6rem;
}
.ve__tile {
position: relative; display: flex; flex-direction: column; align-items: center; gap: 0.4rem;
padding: 0.8rem 0.5rem; border-radius: var(--r-icon); cursor: pointer;
background: var(--mood-bg); transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.ve__tile:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 3px 10px var(--mood-shadow); }
.ve__tile:disabled { cursor: default; }
.ve__tile--picked { box-shadow: 0 0 0 2.5px var(--mood-accent); background: var(--mood-accent-soft); }
.ve__tile--mine:not(.ve__tile--picked) { box-shadow: inset 0 0 0 2px var(--mood-accent); }
.ve__tile--winner { background: var(--mood-status-vigueur-bg); box-shadow: 0 0 0 2.5px var(--mood-status-vigueur); }
.ve__avatar {
width: 2.75rem; height: 2.75rem; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 0.9375rem; color: rgba(255, 255, 255, 0.95);
}
.ve__name { font-size: 0.8125rem; font-weight: 600; text-align: center; line-height: 1.2; }
.ve__count {
position: absolute; top: 0.4rem; right: 0.4rem;
min-width: 1.35rem; height: 1.35rem; padding: 0 0.3rem;
display: inline-flex; align-items: center; justify-content: center;
border-radius: var(--r-pill); font-size: 0.75rem; font-weight: 800;
background: var(--mood-accent); color: var(--mood-accent-text);
}
.ve__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; }
.ve__mine { font-size: 0.8125rem; color: var(--mood-text-muted); }
.ve__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
.ve__tally { margin: 0; font-size: 0.8125rem; color: var(--mood-text-muted); }
.ve__result { display: flex; flex-direction: column; gap: 0.6rem; }
.ve__elected { display: flex; align-items: baseline; gap: 0.45rem; margin: 0; font-size: 1rem; color: var(--mood-status-vigueur); }
.ve__tie { margin: 0; font-weight: 600; font-size: 0.9375rem; color: var(--mood-warning); }
.ve__result .ld-btn { align-self: flex-start; }
.ve__rejected { margin: 0; font-weight: 600; font-size: 0.9375rem; color: var(--mood-text-muted); }
</style>