forked from yvv/decision
- 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>
199 lines
8.2 KiB
Vue
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>
|