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,142 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Binary vote component: Pour / Contre.
|
||||
*
|
||||
* Displays two large buttons for binary voting with confirmation modal.
|
||||
* Integrates with the votes store and auth store for submission and access control.
|
||||
*/
|
||||
// <!-- ld-v2 --> Binaire inertiel hérité — jauge SIGNATURE : arc 270°, le
|
||||
// remplissage suit les voix pour, le curseur de seuil DESCEND quand la
|
||||
// participation monte (wotThreshold recalculé en direct). Les lettres de la
|
||||
// formule vivent à l'Atelier ; ici, des chiffres clairs et une phrase.
|
||||
import { wotThreshold } from '~/engine'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { useDecisionsStore } from '~/stores/decisions'
|
||||
import { thresholdSentence } from '~/lexicon'
|
||||
import type { Decision, Id, 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 submitting = ref(false)
|
||||
const pendingVote = ref<'pour' | 'contre' | null>(null)
|
||||
const showConfirm = ref(false)
|
||||
const eligible = computed(() => Math.max(props.session.corpusSize, 1))
|
||||
const active = computed(() => store.activeVotes(props.session.id))
|
||||
const votesFor = computed(() => active.value.filter(v => v.value === 'for').length)
|
||||
const votesAgainst = computed(() => active.value.filter(v => v.value === 'against').length)
|
||||
const total = computed(() => votesFor.value + votesAgainst.value)
|
||||
|
||||
/** 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)
|
||||
const formula = computed(() => props.protocol.formula)
|
||||
const threshold = computed(() => wotThreshold(
|
||||
eligible.value, total.value,
|
||||
formula.value.majorityPct, formula.value.baseExponent,
|
||||
formula.value.gradientExponent, formula.value.constantBase,
|
||||
))
|
||||
|
||||
const sentence = computed(() =>
|
||||
total.value > 0 ? thresholdSentence(eligible.value, total.value, threshold.value) : '')
|
||||
const formulaLink = computed(() => {
|
||||
const f = formula.value
|
||||
return `/textes/formules?W=${eligible.value}&T=${total.value}&M=${f.majorityPct}`
|
||||
+ `&B=${f.baseExponent}&G=${f.gradientExponent}&C=${f.constantBase}`
|
||||
})
|
||||
|
||||
const isDisabled = computed(() => {
|
||||
return props.disabled || !auth.isAuthenticated || !votes.isSessionOpen || submitting.value
|
||||
})
|
||||
// ── Arc 270° : de 135° à 405°, sens horaire ──
|
||||
const R = 46
|
||||
function pt(frac: number): { x: number; y: number } {
|
||||
const a = ((135 + 270 * frac) * Math.PI) / 180
|
||||
return { x: 60 + R * Math.cos(a), y: 60 + R * Math.sin(a) }
|
||||
}
|
||||
function arc(from: number, to: number): string {
|
||||
const p1 = pt(from)
|
||||
const p2 = pt(to)
|
||||
const large = (to - from) > 2 / 3 ? 1 : 0
|
||||
return `M ${p1.x.toFixed(2)} ${p1.y.toFixed(2)} A ${R} ${R} 0 ${large} 1 ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`
|
||||
}
|
||||
function tick(frac: number): string {
|
||||
const a = ((135 + 270 * frac) * Math.PI) / 180
|
||||
const x1 = 60 + (R - 11) * Math.cos(a)
|
||||
const y1 = 60 + (R - 11) * Math.sin(a)
|
||||
const x2 = 60 + (R + 11) * Math.cos(a)
|
||||
const y2 = 60 + (R + 11) * Math.sin(a)
|
||||
return `M ${x1.toFixed(2)} ${y1.toFixed(2)} L ${x2.toFixed(2)} ${y2.toFixed(2)}`
|
||||
}
|
||||
const trackPath = arc(0, 1)
|
||||
const forFrac = computed(() => (total.value > 0 ? votesFor.value / total.value : 0))
|
||||
const againstFrac = computed(() => (total.value > 0 ? votesAgainst.value / total.value : 0))
|
||||
const thrFrac = computed(() => (total.value > 0 ? Math.min(threshold.value / total.value, 1) : 1))
|
||||
const fillPath = computed(() => (forFrac.value > 0 ? arc(0, forFrac.value) : ''))
|
||||
const againstPath = computed(() => (againstFrac.value > 0 ? arc(1 - againstFrac.value, 1) : ''))
|
||||
const thresholdTick = computed(() => tick(thrFrac.value))
|
||||
|
||||
function requestVote(value: 'pour' | 'contre') {
|
||||
if (isDisabled.value) return
|
||||
pendingVote.value = value
|
||||
showConfirm.value = true
|
||||
// ── Le geste ──
|
||||
const voterId = computed(() => props.asPersonId ?? col.me?.id)
|
||||
const myVote = computed(() => active.value.find(v => v.voterId === voterId.value))
|
||||
const refusing = ref(false)
|
||||
const comment = ref('')
|
||||
const error = ref('')
|
||||
|
||||
function cast(value: 'for' | 'against') {
|
||||
error.value = ''
|
||||
const result = store.castVote(props.session.id, {
|
||||
value,
|
||||
...(comment.value.trim() ? { comment: comment.value.trim() } : {}),
|
||||
...(props.asPersonId ? { asPersonId: props.asPersonId } : {}),
|
||||
})
|
||||
if ('ok' in result) { error.value = result.reason; return }
|
||||
comment.value = ''
|
||||
refusing.value = false
|
||||
}
|
||||
|
||||
async function confirmVote() {
|
||||
if (!pendingVote.value) return
|
||||
showConfirm.value = false
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
await votes.submitVote({
|
||||
session_id: props.sessionId,
|
||||
vote_value: pendingVote.value,
|
||||
signature: 'pending',
|
||||
signed_payload: 'pending',
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
pendingVote.value = null
|
||||
}
|
||||
const comments = computed(() => active.value.filter(v => v.comment?.trim()))
|
||||
function name(id: Id): string {
|
||||
return col.people.find(p => p.id === id)?.displayName ?? '—'
|
||||
}
|
||||
|
||||
function cancelVote() {
|
||||
showConfirm.value = false
|
||||
pendingVote.value = null
|
||||
}
|
||||
|
||||
const confirmLabel = computed(() => {
|
||||
return pendingVote.value === 'pour'
|
||||
? 'Confirmer le vote POUR'
|
||||
: 'Confirmer le vote CONTRE'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Vote buttons -->
|
||||
<div class="flex gap-4">
|
||||
<UButton
|
||||
size="xl"
|
||||
:color="userVote?.vote_value === 'pour' ? 'success' : 'neutral'"
|
||||
:variant="userVote?.vote_value === 'pour' ? 'solid' : 'outline'"
|
||||
:disabled="isDisabled"
|
||||
:loading="submitting && pendingVote === 'pour'"
|
||||
icon="i-lucide-thumbs-up"
|
||||
class="flex-1 justify-center py-6 text-lg"
|
||||
@click="requestVote('pour')"
|
||||
>
|
||||
<!-- ld-v2 -->
|
||||
<section class="ld-card vb">
|
||||
<!-- La jauge inertielle -->
|
||||
<div class="vb__gauge">
|
||||
<svg viewBox="0 0 120 114" class="vb__svg" aria-hidden="true">
|
||||
<path :d="trackPath" class="vb__track" />
|
||||
<path v-if="againstPath" :d="againstPath" class="vb__against" />
|
||||
<path v-if="fillPath" :d="fillPath" class="vb__fill" />
|
||||
<path :d="thresholdTick" class="vb__threshold" />
|
||||
</svg>
|
||||
<div class="vb__center">
|
||||
<span class="vb__for">{{ votesFor.toLocaleString('fr-FR') }}</span>
|
||||
<span class="vb__for-label">pour</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Les chiffres, clairs -->
|
||||
<dl class="vb__stats">
|
||||
<div><dt>seuil requis</dt><dd>{{ threshold.toLocaleString('fr-FR') }}</dd></div>
|
||||
<div><dt>votants</dt><dd>{{ total.toLocaleString('fr-FR') }}</dd></div>
|
||||
<div><dt>inscrits</dt><dd>{{ eligible.toLocaleString('fr-FR') }}</dd></div>
|
||||
</dl>
|
||||
|
||||
<p v-if="sentence" class="vb__sentence">{{ sentence }}</p>
|
||||
<p v-else class="vb__sentence vb__sentence--muted">Personne n'a encore voté — le seuil part de l'unanimité et descend avec la participation.</p>
|
||||
<NuxtLink :to="formulaLink" class="vb__link">
|
||||
<UIcon name="i-lucide-graduation-cap" />
|
||||
<span>comprendre ce seuil</span>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Le geste -->
|
||||
<div v-if="session.status === 'open'" class="vb__actions">
|
||||
<button class="ld-btn vb__yes" type="button" :disabled="!canAct" @click="refusing = false; cast('for')">
|
||||
Pour
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
size="xl"
|
||||
:color="userVote?.vote_value === 'contre' ? 'error' : 'neutral'"
|
||||
:variant="userVote?.vote_value === 'contre' ? 'solid' : 'outline'"
|
||||
:disabled="isDisabled"
|
||||
:loading="submitting && pendingVote === 'contre'"
|
||||
icon="i-lucide-thumbs-down"
|
||||
class="flex-1 justify-center py-6 text-lg"
|
||||
@click="requestVote('contre')"
|
||||
>
|
||||
</button>
|
||||
<button class="ld-btn ld-btn--ghost vb__no" type="button" :disabled="!canAct" @click="refusing = !refusing">
|
||||
Contre
|
||||
</UButton>
|
||||
</button>
|
||||
<span v-if="myVote" class="vb__mine">
|
||||
Ta position actuelle : {{ myVote.value === 'for' ? 'pour' : 'refus argumenté' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="refusing && session.status === 'open'" class="vb__argue">
|
||||
<textarea v-model="comment" rows="2" placeholder="Dis pourquoi — un commentaire accompagne toute position négative." />
|
||||
<button class="ld-btn" type="button" :disabled="!comment.trim()" @click="cast('against')">
|
||||
Déposer ce refus
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="vb__error">{{ error }}</p>
|
||||
|
||||
<!-- 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 : {{ userVote.vote_value === 'pour' ? 'Pour' : 'Contre' }}
|
||||
</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 :class="pendingVote === 'pour' ? 'text-green-600' : 'text-red-600'">
|
||||
{{ pendingVote === 'pour' ? 'POUR' : 'CONTRE' }}
|
||||
</strong>.
|
||||
Cette action est definitive.
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<UButton variant="ghost" color="neutral" @click="cancelVote">
|
||||
Annuler
|
||||
</UButton>
|
||||
<UButton
|
||||
:color="pendingVote === 'pour' ? 'success' : 'error'"
|
||||
@click="confirmVote"
|
||||
>
|
||||
{{ confirmLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
<!-- Clôture : la distribution complète -->
|
||||
<p v-if="session.status === 'closed'" class="vb__outcome">
|
||||
{{ votesFor.toLocaleString('fr-FR') }} pour · {{ votesAgainst.toLocaleString('fr-FR') }} refus ·
|
||||
seuil {{ threshold.toLocaleString('fr-FR') }} —
|
||||
{{ session.outcome === 'adopted' ? 'le collectif adopte.' : 'le collectif n’adopte pas.' }}
|
||||
</p>
|
||||
<ul v-if="comments.length" class="vb__comments">
|
||||
<li v-for="vote in comments" :key="vote.id">
|
||||
<span v-if="!secret" class="vb__comment-who">{{ name(vote.voterId) }}</span>
|
||||
<p>{{ vote.comment }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vb { padding: 1.25rem; display: flex; flex-direction: column; align-items: center; gap: 0.8rem; }
|
||||
.vb__gauge { position: relative; width: min(240px, 70vw); }
|
||||
.vb__svg { width: 100%; display: block; }
|
||||
.vb__track { fill: none; stroke: var(--mood-accent-soft); stroke-width: 9; stroke-linecap: round; }
|
||||
.vb__fill { fill: none; stroke: var(--mood-accent); stroke-width: 9; stroke-linecap: round; transition: d 0.25s ease; }
|
||||
.vb__against { fill: none; stroke: color-mix(in srgb, var(--mood-error) 45%, transparent); stroke-width: 9; stroke-linecap: round; }
|
||||
.vb__threshold { fill: none; stroke: var(--mood-secondary); stroke-width: 3.5; stroke-linecap: round; transition: d 0.25s ease; }
|
||||
.vb__center {
|
||||
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; pointer-events: none;
|
||||
}
|
||||
.vb__for { font-size: clamp(1.6rem, 8vw, 2.2rem); font-weight: 800; color: var(--mood-accent); line-height: 1; }
|
||||
.vb__for-label { font-size: 0.8125rem; font-weight: 700; color: var(--mood-text-muted); }
|
||||
.vb__stats { display: flex; gap: 1.5rem; margin: 0; }
|
||||
.vb__stats div { display: flex; flex-direction: column; align-items: center; }
|
||||
.vb__stats dt { font-size: 0.72rem; font-weight: 700; color: var(--mood-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.vb__stats dd { margin: 0; font-size: 1.125rem; font-weight: 800; }
|
||||
.vb__stats div:first-child dd { color: var(--mood-secondary); }
|
||||
.vb__sentence { margin: 0; text-align: center; font-size: 0.9375rem; line-height: 1.5; max-width: 34rem; }
|
||||
.vb__sentence--muted { color: var(--mood-text-muted); }
|
||||
.vb__link {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.8125rem;
|
||||
font-weight: 700; color: var(--mood-accent); text-decoration: none;
|
||||
}
|
||||
.vb__link:hover { text-decoration: underline; }
|
||||
.vb__actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: center; gap: 0.75rem; }
|
||||
.vb__yes { min-width: 7rem; }
|
||||
.vb__no { min-width: 7rem; color: var(--mood-error); background: color-mix(in srgb, var(--mood-error) 10%, transparent); }
|
||||
.vb__mine { font-size: 0.8125rem; color: var(--mood-text-muted); width: 100%; text-align: center; }
|
||||
.vb__argue { width: 100%; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.vb__argue textarea { padding: 0.55rem 0.75rem; font-size: 0.9375rem; resize: vertical; }
|
||||
.vb__argue .ld-btn { align-self: flex-end; }
|
||||
.vb__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
|
||||
.vb__outcome { margin: 0; font-weight: 700; font-size: 0.9375rem; text-align: center; }
|
||||
.vb__comments { list-style: none; margin: 0; padding: 0; width: 100%; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.vb__comments li { background: var(--mood-bg); border-radius: var(--r-input); padding: 0.55rem 0.8rem; font-size: 0.875rem; }
|
||||
.vb__comments p { margin: 0.2rem 0 0; }
|
||||
.vb__comment-who { font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Consentement — « Ça me va » est un geste stocké (Assent),
|
||||
// l'objection s'argumente toujours. Zéro objection maintenue = adopté.
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { useDecisionsStore } from '~/stores/decisions'
|
||||
import { WINDOW_OBJECT, WINDOW_OK } from '~/lexicon'
|
||||
import type { Decision, Id, VoteSession } from '~/types/domain'
|
||||
|
||||
const props = defineProps<{
|
||||
decision: Decision
|
||||
session: VoteSession
|
||||
secret?: boolean
|
||||
canAct?: boolean
|
||||
asPersonId?: Id
|
||||
}>()
|
||||
|
||||
const col = useCollectiveStore()
|
||||
const store = useDecisionsStore()
|
||||
|
||||
const assents = computed(() => col.assents.filter(a => a.decisionId === props.decision.id))
|
||||
const objections = computed(() => col.objections.filter(o => o.decisionId === props.decision.id))
|
||||
const openObjections = computed(() => objections.value.filter(o => o.status === 'open'))
|
||||
const liftedObjections = computed(() => objections.value.filter(o => o.status !== 'open'))
|
||||
const assentPeople = computed(() =>
|
||||
assents.value
|
||||
.map(a => col.people.find(p => p.id === a.personId))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined)
|
||||
.map(person => ({ person })),
|
||||
)
|
||||
|
||||
const gaveAssent = computed(() => {
|
||||
const id = props.asPersonId ?? col.me?.id
|
||||
return assents.value.some(a => a.personId === id)
|
||||
})
|
||||
|
||||
const objecting = ref(false)
|
||||
const argument = ref('')
|
||||
const error = ref('')
|
||||
|
||||
function name(id: Id): string {
|
||||
return col.people.find(p => p.id === id)?.displayName ?? '—'
|
||||
}
|
||||
function assent() {
|
||||
error.value = ''
|
||||
const result = store.assentTo(props.decision.id, props.asPersonId)
|
||||
if ('ok' in result) error.value = result.reason
|
||||
}
|
||||
function object() {
|
||||
error.value = ''
|
||||
const result = store.objectTo(props.decision.id, 'content', argument.value, props.asPersonId)
|
||||
if ('ok' in result) { error.value = result.reason; return }
|
||||
argument.value = ''
|
||||
objecting.value = false
|
||||
}
|
||||
|
||||
const STATUS_LABEL = { withdrawn: 'levée', integrated: 'intégrée', escalated: 'escaladée' } as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<section class="ld-card vc">
|
||||
<!-- Les deux gestes -->
|
||||
<div v-if="session.status === 'open'" class="vc__gestures">
|
||||
<button class="vc__ok" type="button" :disabled="!canAct || gaveAssent" @click="assent()">
|
||||
<UIcon name="i-lucide-check" class="vc__gesture-icon" />
|
||||
<span>{{ WINDOW_OK }}</span>
|
||||
<span v-if="gaveAssent" class="vc__done">c'est noté</span>
|
||||
</button>
|
||||
<button class="vc__no" type="button" :disabled="!canAct" @click="objecting = !objecting">
|
||||
<UIcon name="i-lucide-hand" class="vc__gesture-icon" />
|
||||
<span>{{ WINDOW_OBJECT }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="objecting && session.status === 'open'" class="vc__argue">
|
||||
<textarea
|
||||
v-model="argument"
|
||||
rows="3"
|
||||
placeholder="Une objection s'argumente — écris pourquoi."
|
||||
/>
|
||||
<button class="ld-btn" type="button" :disabled="!argument.trim()" @click="object()">
|
||||
Déposer l'objection
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="vc__error">{{ error }}</p>
|
||||
|
||||
<!-- Décomptes agrégés -->
|
||||
<div class="vc__tally">
|
||||
<span class="vc__count">
|
||||
<strong>{{ assents.length }}</strong>
|
||||
{{ assents.length > 1 ? 'accords' : 'accord' }}
|
||||
</span>
|
||||
<LdAvatarStack v-if="!secret && assentPeople.length" :people="assentPeople" :size="26" />
|
||||
<span class="vc__count">
|
||||
<strong>{{ openObjections.length }}</strong>
|
||||
{{ openObjections.length > 1 ? 'objections ouvertes' : 'objection ouverte' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Objections ouvertes puis levées -->
|
||||
<ul v-if="objections.length" class="vc__objections">
|
||||
<li v-for="objection in openObjections" :key="objection.id" class="vc__objection vc__objection--open">
|
||||
<span class="vc__objection-state">ouverte</span>
|
||||
<span v-if="!secret" class="vc__objection-who">{{ name(objection.personId) }}</span>
|
||||
<p>{{ objection.argument }}</p>
|
||||
</li>
|
||||
<li v-for="objection in liftedObjections" :key="objection.id" class="vc__objection">
|
||||
<span class="vc__objection-state vc__objection-state--lifted">
|
||||
{{ STATUS_LABEL[objection.status as keyof typeof STATUS_LABEL] ?? objection.status }}
|
||||
</span>
|
||||
<span v-if="!secret" class="vc__objection-who">{{ name(objection.personId) }}</span>
|
||||
<p>{{ objection.argument }}</p>
|
||||
<p v-if="objection.resolutionNote" class="vc__resolution">{{ objection.resolutionNote }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Clôture -->
|
||||
<p v-if="session.status === 'closed'" class="vc__outcome">
|
||||
<template v-if="session.outcome === 'adopted'">
|
||||
Aucune objection maintenue à l'échéance — le collectif consent.
|
||||
</template>
|
||||
<template v-else>
|
||||
Des objections restaient ouvertes à l'échéance — le collectif ne consent pas encore.
|
||||
</template>
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vc { padding: 1.25rem; display: flex; flex-direction: column; gap: 0.9rem; }
|
||||
.vc__gestures { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
||||
.vc__ok, .vc__no {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.35rem;
|
||||
padding: 1.1rem 1rem; border-radius: var(--r-card); cursor: pointer;
|
||||
font-size: 1.0625rem; font-weight: 800;
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
}
|
||||
.vc__ok { background: color-mix(in srgb, var(--mood-success) 14%, var(--mood-surface)); color: var(--mood-success); }
|
||||
.vc__no { background: color-mix(in srgb, var(--mood-warning) 13%, var(--mood-surface)); color: var(--mood-warning); }
|
||||
.vc__ok:hover:not(:disabled), .vc__no:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 12px var(--mood-shadow); }
|
||||
.vc__ok:active, .vc__no:active { transform: translateY(0); }
|
||||
.vc__ok:disabled, .vc__no:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.vc__gesture-icon { font-size: 1.4rem; }
|
||||
.vc__done { font-size: 0.75rem; font-weight: 600; opacity: 0.8; }
|
||||
.vc__argue { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.vc__argue textarea { padding: 0.6rem 0.8rem; font-size: 0.9375rem; resize: vertical; }
|
||||
.vc__argue .ld-btn { align-self: flex-end; }
|
||||
.vc__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
|
||||
.vc__tally { display: flex; align-items: center; flex-wrap: wrap; gap: 0.9rem; }
|
||||
.vc__count { font-size: 0.9375rem; color: var(--mood-text-muted); }
|
||||
.vc__count strong { color: var(--mood-text); font-size: 1.0625rem; }
|
||||
.vc__objections { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.vc__objection { background: var(--mood-bg); border-radius: var(--r-input); padding: 0.6rem 0.85rem; font-size: 0.9rem; }
|
||||
.vc__objection--open { background: color-mix(in srgb, var(--mood-warning) 9%, var(--mood-bg)); }
|
||||
.vc__objection p { margin: 0.25rem 0 0; }
|
||||
.vc__objection-state {
|
||||
font-size: 0.75rem; font-weight: 700; color: var(--mood-warning);
|
||||
text-transform: uppercase; letter-spacing: 0.04em; margin-right: 0.5rem;
|
||||
}
|
||||
.vc__objection-state--lifted { color: var(--mood-success); }
|
||||
.vc__objection-who { font-weight: 700; font-size: 0.8125rem; }
|
||||
.vc__resolution { color: var(--mood-text-muted); font-style: italic; }
|
||||
.vc__outcome { margin: 0; font-weight: 600; font-size: 0.9375rem; }
|
||||
@media (max-width: 480px) {
|
||||
.vc__gestures { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,198 @@
|
||||
<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>
|
||||
@@ -1,122 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Vote history list for a session.
|
||||
*
|
||||
* Displays a table of all votes cast in a session, sorted by date descending.
|
||||
* For nuanced votes, shows the level label and color. Shows Smith/TechComm badges.
|
||||
*/
|
||||
import type { Vote } from '~/stores/votes'
|
||||
|
||||
const props = defineProps<{
|
||||
votes: Vote[]
|
||||
}>()
|
||||
|
||||
/** Nuanced level labels matching VoteNuanced component. */
|
||||
const nuancedLabels: Record<number, { label: string; color: string }> = {
|
||||
0: { label: 'CONTRE', color: 'error' },
|
||||
1: { label: 'PAS DU TOUT D\'ACCORD', color: 'warning' },
|
||||
2: { label: 'PAS D\'ACCORD', color: 'warning' },
|
||||
3: { label: 'NEUTRE', color: 'neutral' },
|
||||
4: { label: 'D\'ACCORD', color: 'success' },
|
||||
5: { label: 'TOUT A FAIT D\'ACCORD', color: 'success' },
|
||||
}
|
||||
|
||||
/** Sorted votes by date descending. */
|
||||
const sortedVotes = computed(() => {
|
||||
return [...props.votes].sort((a, b) => {
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
})
|
||||
})
|
||||
|
||||
function truncateAddress(address: string): string {
|
||||
if (address.length <= 16) return address
|
||||
return `${address.slice(0, 8)}...${address.slice(-6)}`
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function voteLabel(vote: Vote): string {
|
||||
if (vote.nuanced_level !== null && vote.nuanced_level !== undefined) {
|
||||
return nuancedLabels[vote.nuanced_level]?.label ?? `Niveau ${vote.nuanced_level}`
|
||||
}
|
||||
return vote.vote_value === 'pour' ? 'Pour' : 'Contre'
|
||||
}
|
||||
|
||||
function voteColor(vote: Vote): string {
|
||||
if (vote.nuanced_level !== null && vote.nuanced_level !== undefined) {
|
||||
return nuancedLabels[vote.nuanced_level]?.color ?? 'neutral'
|
||||
}
|
||||
return vote.vote_value === 'pour' ? 'success' : 'error'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="sortedVotes.length === 0" class="text-center py-8">
|
||||
<UIcon name="i-lucide-vote" class="text-4xl text-gray-400 mb-3" />
|
||||
<p class="text-gray-500">Aucun vote enregistre</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Votant</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Vote</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Statut</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="vote in sortedVotes"
|
||||
:key="vote.id"
|
||||
class="border-b border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
<!-- Voter address -->
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">
|
||||
{{ truncateAddress(vote.voter_id) }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Vote value -->
|
||||
<td class="px-4 py-3">
|
||||
<UBadge :color="(voteColor(vote) as any)" variant="subtle" size="xs">
|
||||
{{ voteLabel(vote) }}
|
||||
</UBadge>
|
||||
</td>
|
||||
|
||||
<!-- Smith / TechComm badges -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-1">
|
||||
<UBadge v-if="vote.voter_is_smith" color="info" variant="subtle" size="xs">
|
||||
Smith
|
||||
</UBadge>
|
||||
<UBadge v-if="vote.voter_is_techcomm" color="purple" variant="subtle" size="xs">
|
||||
TechComm
|
||||
</UBadge>
|
||||
<span v-if="!vote.voter_is_smith && !vote.voter_is_techcomm" class="text-xs text-gray-400">
|
||||
Membre
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Date -->
|
||||
<td class="px-4 py-3 text-xs text-gray-500">
|
||||
{{ formatDate(vote.created_at) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Bloc « S'instruire » condensé — résumé toujours visible
|
||||
// (titre, effets recherchés, ce que ça engage) + dépliables (texte, avis et
|
||||
// objections, provenance). Auto-replié aux visites suivantes (localStorage).
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { BASELINE_PREFIX, ENGAGES_LABEL, INSTRUCT_BLOCK, ROUTE_LABELS } from '~/lexicon'
|
||||
import type { Decision, Id } from '~/types/domain'
|
||||
|
||||
const props = defineProps<{ decision: Decision; secret?: boolean }>()
|
||||
const col = useCollectiveStore()
|
||||
|
||||
const open = ref(true)
|
||||
onMounted(() => {
|
||||
const key = `ld2-instruct-${props.decision.id}`
|
||||
if (localStorage.getItem(key)) open.value = false
|
||||
else localStorage.setItem(key, '1')
|
||||
})
|
||||
|
||||
const effects = computed(() => props.decision.brief?.effects ?? [])
|
||||
const advices = computed(() => col.advices.filter(a => a.decisionId === props.decision.id))
|
||||
const objections = computed(() => col.objections.filter(o => o.decisionId === props.decision.id))
|
||||
const parent = computed(() => col.decisions.find(d => d.id === props.decision.parentDecisionId))
|
||||
|
||||
function name(id: Id): string {
|
||||
return col.people.find(p => p.id === id)?.displayName ?? '—'
|
||||
}
|
||||
function day(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('fr-FR')
|
||||
}
|
||||
|
||||
const POSITION_LABELS = { favorable: 'favorable', reserved: 'réservé', unfavorable: 'défavorable' } as const
|
||||
const OBJECTION_STATUS = { open: 'ouverte', withdrawn: 'levée', integrated: 'intégrée', escalated: 'escaladée' } as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<section class="ld-card vi">
|
||||
<header class="vi__head">
|
||||
<h2 class="vi__title">
|
||||
<UIcon name="i-lucide-book-open" />
|
||||
<span>{{ INSTRUCT_BLOCK }}</span>
|
||||
</h2>
|
||||
<button class="ld-btn ld-btn--quiet vi__toggle" type="button" @click="open = !open">
|
||||
<span>{{ open ? 'Replier' : 'Voir le détail' }}</span>
|
||||
<UIcon :name="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Résumé compact — toujours visible -->
|
||||
<p class="vi__sentence">{{ decision.title }}</p>
|
||||
<ul v-if="effects.length" class="vi__effects">
|
||||
<li v-for="(effect, i) in effects" :key="i">
|
||||
<UIcon name="i-lucide-target" class="vi__effect-icon" />
|
||||
<span>{{ effect.label }}</span>
|
||||
<span v-if="effect.target" class="vi__target">{{ effect.target }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="decision.resources?.note" class="vi__engages">
|
||||
<span class="vi__engages-label">{{ ENGAGES_LABEL }}</span>
|
||||
<span>{{ decision.resources.note }}</span>
|
||||
<strong v-if="decision.resources.amount !== undefined">
|
||||
{{ decision.resources.amount.toLocaleString('fr-FR') }} {{ decision.resources.unit ?? '' }}
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
<!-- Dépliables -->
|
||||
<div v-if="open" class="vi__details">
|
||||
<details v-if="decision.body || decision.baselineNote">
|
||||
<summary>Le texte</summary>
|
||||
<p v-if="decision.baselineNote" class="vi__baseline">
|
||||
{{ BASELINE_PREFIX }} {{ decision.baselineNote }}
|
||||
</p>
|
||||
<p class="vi__body">{{ decision.body ?? decision.title }}</p>
|
||||
</details>
|
||||
|
||||
<details v-if="advices.length || objections.length">
|
||||
<summary>Avis et objections ({{ advices.length + objections.length }})</summary>
|
||||
<ul class="vi__list">
|
||||
<li v-for="advice in advices" :key="advice.id">
|
||||
<span class="vi__who">{{ secret ? 'quelqu’un' : name(advice.personId) }}</span>
|
||||
<span class="vi__pos">{{ POSITION_LABELS[advice.position] }}</span>
|
||||
<span v-if="advice.note" class="vi__note">{{ advice.note }}</span>
|
||||
</li>
|
||||
<li v-for="objection in objections" :key="objection.id">
|
||||
<span class="vi__who">{{ secret ? 'quelqu’un' : name(objection.personId) }}</span>
|
||||
<span class="vi__pos vi__pos--objection">objection {{ OBJECTION_STATUS[objection.status] }}</span>
|
||||
<span class="vi__note">{{ objection.argument }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Provenance</summary>
|
||||
<ul class="vi__list">
|
||||
<li>Proposé par {{ name(decision.authorId) }} le {{ day(decision.createdAt) }}</li>
|
||||
<li>Chemin : {{ ROUTE_LABELS[decision.route] }}</li>
|
||||
<li v-if="parent">
|
||||
Chaînée à
|
||||
<NuxtLink :to="`/decisions/${parent.id}`" class="vi__link">{{ parent.title }}</NuxtLink>
|
||||
</li>
|
||||
<li v-if="decision.decidedHow">{{ decision.decidedHow }}</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vi { padding: 1.1rem 1.25rem; display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.vi__head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
|
||||
.vi__title {
|
||||
display: inline-flex; align-items: center; gap: 0.45rem; margin: 0;
|
||||
font-size: 0.9375rem; font-weight: 700; color: var(--mood-accent);
|
||||
}
|
||||
.vi__toggle { padding: 0.25rem 0.75rem; font-size: 0.8125rem; }
|
||||
.vi__sentence { margin: 0; font-weight: 700; font-size: 1.0625rem; line-height: 1.35; }
|
||||
.vi__effects { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.vi__effects li { display: flex; align-items: baseline; gap: 0.45rem; font-size: 0.9375rem; }
|
||||
.vi__effect-icon { color: var(--mood-tertiary); flex-shrink: 0; transform: translateY(2px); }
|
||||
.vi__target {
|
||||
font-size: 0.75rem; font-weight: 700; color: var(--mood-tertiary);
|
||||
background: color-mix(in srgb, var(--mood-tertiary) 12%, transparent);
|
||||
padding: 1px 8px; border-radius: var(--r-pill); white-space: nowrap;
|
||||
}
|
||||
.vi__engages {
|
||||
margin: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.4rem;
|
||||
font-size: 0.9375rem; color: var(--mood-text);
|
||||
background: var(--mood-accent-soft); border-radius: var(--r-input); padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.vi__engages-label { font-size: 0.75rem; font-weight: 700; color: var(--mood-accent); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.vi__details { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.vi__details details { background: var(--mood-bg); border-radius: var(--r-input); padding: 0.55rem 0.8rem; }
|
||||
.vi__details summary { cursor: pointer; font-weight: 700; font-size: 0.875rem; color: var(--mood-text-muted); user-select: none; }
|
||||
.vi__baseline { font-size: 0.875rem; color: var(--mood-text-muted); font-style: italic; margin: 0.5rem 0 0.25rem; }
|
||||
.vi__body { white-space: pre-wrap; font-size: 0.9375rem; line-height: 1.5; margin: 0.5rem 0 0; }
|
||||
.vi__list { list-style: none; margin: 0.5rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; font-size: 0.875rem; }
|
||||
.vi__who { font-weight: 700; margin-right: 0.35rem; }
|
||||
.vi__pos { color: var(--mood-tertiary); font-weight: 600; margin-right: 0.35rem; }
|
||||
.vi__pos--objection { color: var(--mood-warning); }
|
||||
.vi__note { color: var(--mood-text-muted); }
|
||||
.vi__link { color: var(--mood-accent); font-weight: 600; text-decoration: none; }
|
||||
.vi__link:hover { text-decoration: underline; }
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> MON historique de re-votes — la chaîne supersedes n'est jamais
|
||||
// publique : elle n'est visible que de son auteur·e, ici, dépliée à la demande.
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { BLANK_VOTE, NUANCED_LABELS } from '~/lexicon'
|
||||
import type { Id, NuancedValue, Vote, VoteSession } from '~/types/domain'
|
||||
|
||||
const props = defineProps<{ session: VoteSession; voterId: Id }>()
|
||||
const col = useCollectiveStore()
|
||||
|
||||
const mine = computed<Vote[]>(() =>
|
||||
col.votes
|
||||
.filter(v => v.sessionId === props.session.id && v.voterId === props.voterId)
|
||||
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)),
|
||||
)
|
||||
|
||||
function label(vote: Vote): string {
|
||||
if (vote.value === 'for') return 'pour'
|
||||
if (vote.value === 'against') return 'refus argumenté'
|
||||
if (typeof vote.value === 'number') return NUANCED_LABELS[vote.value as NuancedValue]
|
||||
if (vote.values) return vote.values.map(v => v.toLocaleString('fr-FR', { maximumFractionDigits: 2 })).join(' · ')
|
||||
if (vote.choicePersonId) return col.people.find(p => p.id === vote.choicePersonId)?.displayName ?? '—'
|
||||
return BLANK_VOTE
|
||||
}
|
||||
function when(iso: string): string {
|
||||
return new Date(iso).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<details v-if="mine.length" class="vh">
|
||||
<summary>
|
||||
<UIcon name="i-lucide-history" />
|
||||
<span>Mon historique de re-votes ({{ mine.length }}) — visible par moi seul</span>
|
||||
</summary>
|
||||
<ol class="vh__list">
|
||||
<li v-for="(vote, i) in mine" :key="vote.id" :class="{ 'vh__old': i > 0 }">
|
||||
<span class="vh__when">{{ when(vote.createdAt) }}</span>
|
||||
<span class="vh__label">{{ label(vote) }}</span>
|
||||
<span v-if="i === 0" class="vh__active">actif</span>
|
||||
<span v-if="vote.comment" class="vh__comment">{{ vote.comment }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</details>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vh { background: var(--mood-bg); border-radius: var(--r-input); padding: 0.6rem 0.9rem; }
|
||||
.vh summary {
|
||||
display: flex; align-items: center; gap: 0.45rem; cursor: pointer; user-select: none;
|
||||
font-size: 0.8125rem; font-weight: 700; color: var(--mood-text-muted);
|
||||
}
|
||||
.vh__list { list-style: none; margin: 0.6rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.vh__list li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem; font-size: 0.875rem; }
|
||||
.vh__old { opacity: 0.6; }
|
||||
.vh__old .vh__label { text-decoration: line-through; }
|
||||
.vh__when { font-size: 0.75rem; color: var(--mood-text-muted); font-variant-numeric: tabular-nums; }
|
||||
.vh__label { font-weight: 700; }
|
||||
.vh__active {
|
||||
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: var(--mood-status-vigueur); background: var(--mood-status-vigueur-bg);
|
||||
padding: 1px 8px; border-radius: var(--r-pill);
|
||||
}
|
||||
.vh__comment { width: 100%; color: var(--mood-text-muted); font-size: 0.8125rem; }
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Un curseur du Réglage collectif : libellé métier, valeur en 700,
|
||||
// statu quo losange creux, alternative clavier + champ numérique, poignée ≥2.25rem.
|
||||
// Faisceau strip-plot : points translucides anonymes, médiane trait épais,
|
||||
// baseline marqueur creux. Part « calculé » : rail estompé, jamais saisissable.
|
||||
import { BIMODAL_BANNER, PARAM_DERIVED_LABEL } from '~/lexicon'
|
||||
import type { ParamDef } from '~/types/domain'
|
||||
|
||||
const props = defineProps<{
|
||||
param: ParamDef
|
||||
value: number
|
||||
dots?: number[]
|
||||
median?: number
|
||||
locked?: boolean
|
||||
neutral?: boolean
|
||||
bimodal?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'update:value', v: number): void }>()
|
||||
|
||||
function frac(v: number): number {
|
||||
const span = props.param.max - props.param.min
|
||||
if (span <= 0) return 0
|
||||
return Math.min(1, Math.max(0, (v - props.param.min) / span))
|
||||
}
|
||||
function fmt(v: number): string {
|
||||
return v.toLocaleString('fr-FR', { maximumFractionDigits: 2 })
|
||||
}
|
||||
function onInput(event: Event) {
|
||||
const raw = Number((event.target as HTMLInputElement).value)
|
||||
if (Number.isFinite(raw)) emit('update:value', raw)
|
||||
}
|
||||
const inputId = `vps-${props.param.key}-${Math.random().toString(36).slice(2, 7)}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<div class="vps" :class="{ 'vps--derived': param.derived, 'vps--neutral': neutral, 'vps--locked': locked }">
|
||||
<div class="vps__head">
|
||||
<label class="vps__label" :for="inputId">{{ param.label }}</label>
|
||||
<span v-if="param.derived" class="vps__tag">{{ PARAM_DERIVED_LABEL }}</span>
|
||||
<span class="vps__value">
|
||||
{{ fmt(value) }}<span v-if="param.unit" class="vps__unit"> {{ param.unit }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="vps__row">
|
||||
<div class="vps__rail-zone">
|
||||
<!-- Faisceau + repères, calés sur la course utile de la poignée -->
|
||||
<div class="vps__overlay" aria-hidden="true">
|
||||
<span
|
||||
v-for="(dot, i) in dots ?? []"
|
||||
:key="i"
|
||||
class="vps__dot"
|
||||
:style="{ '--p': frac(dot) }"
|
||||
/>
|
||||
<span
|
||||
v-if="param.baseline !== undefined"
|
||||
class="vps__baseline"
|
||||
:style="{ '--p': frac(param.baseline) }"
|
||||
:title="`Aujourd'hui : ${fmt(param.baseline)}`"
|
||||
/>
|
||||
<span
|
||||
v-if="median !== undefined"
|
||||
class="vps__median"
|
||||
:style="{ '--p': frac(median) }"
|
||||
:title="`Médiane : ${fmt(median)}`"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-if="!param.derived"
|
||||
:id="inputId"
|
||||
class="vps__range"
|
||||
type="range"
|
||||
:min="param.min"
|
||||
:max="param.max"
|
||||
:step="param.step"
|
||||
:value="value"
|
||||
:disabled="locked"
|
||||
:aria-label="param.label"
|
||||
@input="onInput"
|
||||
>
|
||||
<div v-else class="vps__ghost">
|
||||
<div class="vps__ghost-fill" :style="{ width: `${frac(value) * 100}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-if="!param.derived"
|
||||
class="vps__num"
|
||||
type="number"
|
||||
:min="param.min"
|
||||
:max="param.max"
|
||||
:step="param.step"
|
||||
:value="value"
|
||||
:disabled="locked"
|
||||
:aria-label="`${param.label} — saisie directe`"
|
||||
@change="onInput"
|
||||
>
|
||||
</div>
|
||||
|
||||
<p v-if="bimodal" class="vps__bimodal">
|
||||
<UIcon name="i-lucide-split" />
|
||||
<span>{{ BIMODAL_BANNER(param.label) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vps { display: flex; flex-direction: column; gap: 0.2rem; --vps-accent: var(--mood-accent); }
|
||||
.vps--neutral { --vps-accent: var(--mood-text-muted); }
|
||||
.vps__head { display: flex; align-items: baseline; gap: 0.5rem; }
|
||||
.vps__label { font-size: 0.9375rem; font-weight: 600; }
|
||||
.vps__tag {
|
||||
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
color: var(--mood-text-muted); background: var(--mood-bg);
|
||||
padding: 1px 8px; border-radius: var(--r-pill);
|
||||
}
|
||||
.vps__value { margin-left: auto; font-weight: 700; font-size: 1.0625rem; color: var(--vps-accent); }
|
||||
.vps__unit { font-size: 0.8125rem; font-weight: 600; color: var(--mood-text-muted); }
|
||||
.vps__row { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.vps__rail-zone { position: relative; flex: 1; min-width: 0; }
|
||||
|
||||
/* Faisceau et repères : course utile = largeur − poignée (2.25rem) */
|
||||
.vps__overlay { position: absolute; inset: 0; pointer-events: none; z-index: 1; }
|
||||
.vps__overlay > * { position: absolute; top: 50%; left: calc(1.125rem + (100% - 2.25rem) * var(--p)); }
|
||||
.vps__dot {
|
||||
width: 8px; height: 8px; margin: -4px 0 0 -4px; border-radius: 50%;
|
||||
background: var(--vps-accent); opacity: 0.25;
|
||||
}
|
||||
.vps__median {
|
||||
width: 4px; height: 20px; margin: -10px 0 0 -2px; border-radius: 2px;
|
||||
background: var(--vps-accent);
|
||||
}
|
||||
.vps__baseline {
|
||||
width: 10px; height: 10px; margin: -5px 0 0 -5px;
|
||||
transform: rotate(45deg);
|
||||
background: var(--mood-surface);
|
||||
box-shadow: inset 0 0 0 2px var(--mood-text-muted);
|
||||
}
|
||||
|
||||
/* Poignée tactile ≥ 2.25rem, clavier natif */
|
||||
.vps__range {
|
||||
appearance: none; -webkit-appearance: none;
|
||||
display: block; width: 100%; height: 2.5rem;
|
||||
background: transparent; cursor: pointer; position: relative; z-index: 2;
|
||||
}
|
||||
.vps__range:disabled { cursor: not-allowed; opacity: 0.6; }
|
||||
.vps__range::-webkit-slider-runnable-track {
|
||||
height: 8px; border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--vps-accent) 18%, var(--mood-bg));
|
||||
}
|
||||
.vps__range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 2.25rem; height: 2.25rem; margin-top: -14px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at center, var(--vps-accent) 0 7px, color-mix(in srgb, var(--vps-accent) 22%, var(--mood-surface)) 8px);
|
||||
box-shadow: 0 1px 4px var(--mood-shadow);
|
||||
}
|
||||
.vps__range::-moz-range-track {
|
||||
height: 8px; border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--vps-accent) 18%, var(--mood-bg));
|
||||
}
|
||||
.vps__range::-moz-range-thumb {
|
||||
width: 2.25rem; height: 2.25rem; border: none; border-radius: 50%;
|
||||
background: radial-gradient(circle at center, var(--vps-accent) 0 7px, color-mix(in srgb, var(--vps-accent) 22%, var(--mood-surface)) 8px);
|
||||
box-shadow: 0 1px 4px var(--mood-shadow);
|
||||
}
|
||||
|
||||
/* Part calculée : rail estompé, non saisissable */
|
||||
.vps__ghost {
|
||||
height: 8px; margin: 1.05rem 1.125rem;
|
||||
border-radius: 4px; background: var(--mood-bg); opacity: 0.75; overflow: hidden;
|
||||
}
|
||||
.vps__ghost-fill { height: 100%; background: color-mix(in srgb, var(--mood-text-muted) 35%, transparent); transition: width 0.15s ease; }
|
||||
.vps--derived .vps__value { color: var(--mood-text-muted); }
|
||||
|
||||
.vps__num {
|
||||
width: 5.25rem; min-height: 2.25rem; padding: 0.25rem 0.5rem;
|
||||
font-size: 0.9375rem; font-weight: 600; text-align: right;
|
||||
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
|
||||
}
|
||||
.vps__num:focus-visible { box-shadow: inset 0 0 0 1.5px var(--mood-input-focus); }
|
||||
|
||||
.vps__bimodal {
|
||||
display: flex; align-items: baseline; gap: 0.4rem; margin: 0.15rem 0 0;
|
||||
font-size: 0.8125rem; font-weight: 600; color: var(--mood-warning);
|
||||
background: var(--mood-status-fenetre-bg); border-radius: var(--r-input); padding: 0.4rem 0.65rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,403 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Réglage collectif — décider au curseur. Faisceau anonyme,
|
||||
// médiane basse nommée, carte « Pour moi » (jamais de chiffre inventé),
|
||||
// sandbox Explorer, et la CRISTALLISATION : un geste humain daté du garant,
|
||||
// solennel mais léger — jamais un automatisme.
|
||||
import { computeMyImpact, detectBimodality, medianByElement, resolveDerived } from '~/engine'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { useDecisionsStore } from '~/stores/decisions'
|
||||
import {
|
||||
CRYSTALLIZE_ACTION, CRYSTALLIZE_CARD, CRYSTALLIZE_CHOICES, EXPLORE_DISCLAIMER,
|
||||
EXPLORE_LABEL, FROZEN_BANNER, IMPACT_CARD_TITLE, IMPACT_DISCLAIMER,
|
||||
MEDIAN_EXPLANATION, PARAM_CONSTRAINT_LABEL,
|
||||
} from '~/lexicon'
|
||||
import type { Decision, Id, ParamSpec, Protocol, VoteSession } from '~/types/domain'
|
||||
|
||||
const props = defineProps<{
|
||||
decision: Decision
|
||||
session: VoteSession
|
||||
protocol: Protocol
|
||||
secret?: boolean
|
||||
canAct?: boolean
|
||||
asPersonId?: Id
|
||||
isSteward?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'adopted'): void }>()
|
||||
|
||||
const col = useCollectiveStore()
|
||||
const store = useDecisionsStore()
|
||||
|
||||
const spec = computed<ParamSpec>(() => props.decision.paramSpec!)
|
||||
const votable = computed(() => spec.value.params.filter(p => p.derived !== true))
|
||||
/** Index votable → index complet (ordre spec.params). */
|
||||
const votableIndex = computed(() => {
|
||||
const map = new Map<string, number>()
|
||||
votable.value.forEach((p, i) => map.set(p.key, i))
|
||||
return map
|
||||
})
|
||||
|
||||
// ── Les positions déposées (derniers votes actifs, toujours recalculées) ──
|
||||
const active = computed(() => store.activeVotes(props.session.id))
|
||||
const vectors = computed(() => active.value
|
||||
.map(v => v.values)
|
||||
.filter((v): v is number[] => v !== undefined && v.length === votable.value.length))
|
||||
const fullVectors = computed(() => vectors.value.map(v => resolveDerived(spec.value, v)))
|
||||
const medianVotable = computed(() => medianByElement(vectors.value))
|
||||
const medianFull = computed(() =>
|
||||
medianVotable.value.length === votable.value.length && votable.value.length > 0
|
||||
? resolveDerived(spec.value, medianVotable.value)
|
||||
: null)
|
||||
const baselineFull = computed(() => spec.value.params.map(p => p.baseline ?? p.min))
|
||||
const bimodalKeys = computed(() => votable.value
|
||||
.filter((_, i) => detectBimodality(vectors.value.map(v => v[i] ?? Number.NaN)))
|
||||
.map(p => p.key))
|
||||
|
||||
function columnFor(fullIndex: number): number[] {
|
||||
return fullVectors.value.map(v => v[fullIndex] ?? Number.NaN).filter(Number.isFinite)
|
||||
}
|
||||
|
||||
// ── Ma position de travail ──
|
||||
const voterId = computed(() => props.asPersonId ?? col.me?.id)
|
||||
const myVote = computed(() => active.value.find(v => v.voterId === voterId.value))
|
||||
const working = ref<number[]>(
|
||||
(myVote.value?.values && [...myVote.value.values])
|
||||
?? votable.value.map(p => p.baseline ?? p.min),
|
||||
)
|
||||
const workingFull = computed(() => resolveDerived(spec.value, working.value))
|
||||
|
||||
const round6 = (v: number) => Math.round(v * 1e6) / 1e6
|
||||
/** Nouvelle liste avec la valeur vi ajustée : bornes, pas, et part calculée gardée dans ses bornes. */
|
||||
function adjusted(list: number[], vi: number, raw: number): number[] {
|
||||
const p = votable.value[vi]
|
||||
if (!p || !Number.isFinite(raw)) return list
|
||||
const snap = (v: number) => round6(p.min + Math.round((v - p.min) / p.step) * p.step)
|
||||
let v = snap(Math.min(p.max, Math.max(p.min, raw)))
|
||||
const next = [...list]
|
||||
const derived = spec.value.params.find(q => q.derived === true)
|
||||
if (spec.value.constraint === 'sum100' && p.kind === 'share' && derived) {
|
||||
const otherSum = votable.value.reduce(
|
||||
(sum, q, j) => sum + (q.kind === 'share' && j !== vi ? (next[j] ?? 0) : 0), 0)
|
||||
const lo = 100 - derived.max - otherSum
|
||||
const hi = 100 - derived.min - otherSum
|
||||
v = snap(Math.min(hi, Math.max(lo, v)))
|
||||
if (v > hi + 1e-6) v = round6(v - p.step)
|
||||
if (v < lo - 1e-6) v = round6(v + p.step)
|
||||
v = Math.min(p.max, Math.max(p.min, v))
|
||||
}
|
||||
next[vi] = v
|
||||
return next
|
||||
}
|
||||
function setWorking(key: string, v: number) {
|
||||
const vi = votableIndex.value.get(key)
|
||||
if (vi !== undefined) working.value = adjusted(working.value, vi, v)
|
||||
}
|
||||
|
||||
const error = ref('')
|
||||
function deposit() {
|
||||
error.value = ''
|
||||
const result = store.castVote(props.session.id, {
|
||||
values: [...working.value],
|
||||
...(props.asPersonId ? { asPersonId: props.asPersonId } : {}),
|
||||
})
|
||||
if ('ok' in result) error.value = result.reason
|
||||
}
|
||||
|
||||
// ── Carte « Pour moi » — estimation sur données déclarées, sinon rien ──
|
||||
const attrKey = computed(() => spec.value.impactAttrKey)
|
||||
const myAttr = computed(() =>
|
||||
attrKey.value !== undefined ? col.me?.attributes?.[attrKey.value] : undefined)
|
||||
const declaredAttrs = computed(() => {
|
||||
const key = attrKey.value
|
||||
if (key === undefined) return []
|
||||
return col.people
|
||||
.filter(p => props.session.corpusPersonIds.includes(p.id))
|
||||
.map(p => p.attributes?.[key])
|
||||
.filter((v): v is number => typeof v === 'number')
|
||||
})
|
||||
const resources = computed(() => props.decision.resources ?? { note: '' })
|
||||
const impactMine = computed(() =>
|
||||
computeMyImpact(spec.value, resources.value, workingFull.value, myAttr.value, declaredAttrs.value))
|
||||
const impactMedian = computed(() => medianFull.value
|
||||
? computeMyImpact(spec.value, resources.value, medianFull.value, myAttr.value, declaredAttrs.value)
|
||||
: null)
|
||||
const impactBase = computed(() =>
|
||||
computeMyImpact(spec.value, resources.value, baselineFull.value, myAttr.value, declaredAttrs.value))
|
||||
const unit = computed(() => props.decision.resources?.unit ?? '')
|
||||
const fmtAmount = (v?: number) =>
|
||||
v === undefined ? '—' : `${v.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} ${unit.value}`
|
||||
|
||||
// ── Explorer — exploration, ne compte pas ──
|
||||
const exploring = ref(false)
|
||||
const sandbox = ref<number[]>([])
|
||||
function toggleExplore() {
|
||||
if (exploring.value) { exploring.value = false; return }
|
||||
sandbox.value = [...working.value]
|
||||
exploring.value = true
|
||||
}
|
||||
function setSandbox(key: string, v: number) {
|
||||
const vi = votableIndex.value.get(key)
|
||||
if (vi !== undefined) sandbox.value = adjusted(sandbox.value, vi, v)
|
||||
}
|
||||
const sandboxFull = computed(() =>
|
||||
sandbox.value.length === votable.value.length ? resolveDerived(spec.value, sandbox.value) : [])
|
||||
const seedMedian = () => { if (medianVotable.value.length) sandbox.value = [...medianVotable.value] }
|
||||
const seedBase = () => { sandbox.value = votable.value.map(p => p.baseline ?? p.min) }
|
||||
const seedMine = () => { sandbox.value = [...working.value] }
|
||||
function makeMine() { working.value = [...sandbox.value]; exploring.value = false }
|
||||
|
||||
// ── Cristallisation — le geste du garant ──
|
||||
const frozen = computed(() => props.session.status === 'frozen')
|
||||
const locked = computed(() => props.session.status !== 'open' || !props.canAct)
|
||||
const showCrystal = ref(false)
|
||||
const quorum = computed(() => props.protocol.formula.parametricMinParticipants)
|
||||
const quorumShort = computed(() =>
|
||||
quorum.value !== undefined && vectors.value.length < quorum.value)
|
||||
function doCrystallize() {
|
||||
error.value = ''
|
||||
const result = store.crystallize(props.session.id)
|
||||
if ('ok' in result) { error.value = result.reason; return }
|
||||
showCrystal.value = false
|
||||
if (result.outcome === 'adopted') emit('adopted')
|
||||
}
|
||||
function split() {
|
||||
navigateTo({ path: '/decider', query: { parent: props.decision.id, chain: 'element' } })
|
||||
}
|
||||
function reframe() {
|
||||
error.value = ''
|
||||
const result = store.transition(props.decision.id, 'framing')
|
||||
if (!result.ok) { error.value = result.reason; return }
|
||||
showCrystal.value = false
|
||||
}
|
||||
const crystallizer = computed(() => props.session.crystallizedById
|
||||
? col.people.find(p => p.id === props.session.crystallizedById)?.displayName ?? '—'
|
||||
: null)
|
||||
|
||||
const shareParams = computed(() => spec.value.constraint === 'sum100'
|
||||
? spec.value.params.filter(p => p.kind === 'share')
|
||||
: [])
|
||||
const plainParams = computed(() => spec.value.constraint === 'sum100'
|
||||
? spec.value.params.filter(p => p.kind !== 'share')
|
||||
: spec.value.params)
|
||||
function fullIndexOf(key: string): number {
|
||||
return spec.value.params.findIndex(p => p.key === key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<section class="ld-card vp">
|
||||
<p class="vp__explain">{{ MEDIAN_EXPLANATION }}</p>
|
||||
|
||||
<p v-if="frozen" class="vp__frozen">
|
||||
<UIcon name="i-lucide-lock" />
|
||||
<span>{{ FROZEN_BANNER }}</span>
|
||||
</p>
|
||||
|
||||
<!-- Le cadre contraint (parts à 100) puis les curseurs libres -->
|
||||
<div v-if="shareParams.length" class="vp__frame">
|
||||
<span class="vp__frame-tag">{{ PARAM_CONSTRAINT_LABEL }} — les parts totalisent 100</span>
|
||||
<VoteParamSlider
|
||||
v-for="p in shareParams"
|
||||
:key="p.key"
|
||||
:param="p"
|
||||
:value="workingFull[fullIndexOf(p.key)] ?? p.min"
|
||||
:dots="columnFor(fullIndexOf(p.key))"
|
||||
:median="medianFull?.[fullIndexOf(p.key)]"
|
||||
:locked="locked"
|
||||
:bimodal="bimodalKeys.includes(p.key)"
|
||||
@update:value="setWorking(p.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
<VoteParamSlider
|
||||
v-for="p in plainParams"
|
||||
:key="p.key"
|
||||
:param="p"
|
||||
:value="workingFull[fullIndexOf(p.key)] ?? p.min"
|
||||
:dots="columnFor(fullIndexOf(p.key))"
|
||||
:median="medianFull?.[fullIndexOf(p.key)]"
|
||||
:locked="locked"
|
||||
:bimodal="bimodalKeys.includes(p.key)"
|
||||
@update:value="setWorking(p.key, $event)"
|
||||
/>
|
||||
|
||||
<div v-if="session.status === 'open'" class="vp__actions">
|
||||
<button class="ld-btn" type="button" :disabled="!canAct" @click="deposit()">
|
||||
{{ myVote ? 'Remplacer ma position' : 'Déposer ma position' }}
|
||||
</button>
|
||||
<button class="ld-btn ld-btn--ghost" type="button" @click="toggleExplore()">
|
||||
<UIcon name="i-lucide-flask-conical" />
|
||||
<span>{{ EXPLORE_LABEL }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="vp__error">{{ error }}</p>
|
||||
|
||||
<!-- Sandbox Explorer -->
|
||||
<div v-if="exploring" class="vp__sandbox">
|
||||
<p class="vp__sandbox-banner">
|
||||
<UIcon name="i-lucide-flask-conical" />
|
||||
<span>{{ EXPLORE_DISCLAIMER }}</span>
|
||||
</p>
|
||||
<div class="vp__sandbox-seeds">
|
||||
<button class="ld-btn ld-btn--quiet" type="button" :disabled="!medianVotable.length" @click="seedMedian()">Partir de la médiane</button>
|
||||
<button class="ld-btn ld-btn--quiet" type="button" @click="seedBase()">Partir du statu quo</button>
|
||||
<button class="ld-btn ld-btn--quiet" type="button" @click="seedMine()">Partir de ma position</button>
|
||||
</div>
|
||||
<VoteParamSlider
|
||||
v-for="p in spec.params"
|
||||
:key="p.key"
|
||||
:param="p"
|
||||
:value="sandboxFull[fullIndexOf(p.key)] ?? p.min"
|
||||
neutral
|
||||
@update:value="setSandbox(p.key, $event)"
|
||||
/>
|
||||
<button class="ld-btn" type="button" :disabled="session.status !== 'open'" @click="makeMine()">
|
||||
En faire ma position
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Carte « Pour moi » — masquée si rien de déclaré, jamais de chiffre inventé -->
|
||||
<div v-if="impactMine && impactBase" class="vp__impact">
|
||||
<h3 class="vp__impact-title">{{ IMPACT_CARD_TITLE }}</h3>
|
||||
<table class="vp__impact-table">
|
||||
<thead>
|
||||
<tr><th /><th>ma position</th><th>médiane</th><th>statu quo</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(line, i) in impactMine.perParam" :key="line.key">
|
||||
<th>{{ line.label }}</th>
|
||||
<td>{{ fmtAmount(line.amount) }}</td>
|
||||
<td>{{ fmtAmount(impactMedian?.perParam[i]?.amount) }}</td>
|
||||
<td>{{ fmtAmount(impactBase.perParam[i]?.amount) }}</td>
|
||||
</tr>
|
||||
<tr class="vp__impact-total">
|
||||
<th>total</th>
|
||||
<td>{{ fmtAmount(impactMine.total) }}</td>
|
||||
<td>{{ fmtAmount(impactMedian?.total) }}</td>
|
||||
<td>{{ fmtAmount(impactBase.total) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="vp__impact-note">{{ IMPACT_DISCLAIMER }}</p>
|
||||
</div>
|
||||
|
||||
<p class="vp__count">
|
||||
{{ vectors.length }} position{{ vectors.length > 1 ? 's' : '' }} déposée{{ vectors.length > 1 ? 's' : '' }}
|
||||
<template v-if="quorum !== undefined"> · quorum : {{ quorum }}</template>
|
||||
</p>
|
||||
|
||||
<!-- Le geste du garant -->
|
||||
<div v-if="frozen && isSteward" class="vp__crystal">
|
||||
<p class="vp__crystal-card">{{ CRYSTALLIZE_CARD }}</p>
|
||||
<button class="vp__stamp-btn" type="button" @click="showCrystal = true">
|
||||
<UIcon name="i-lucide-stamp" />
|
||||
<span>{{ CRYSTALLIZE_ACTION }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="crystallizer && session.crystallizedAt" class="vp__crystallized">
|
||||
Cristallisée par {{ crystallizer }} le {{ new Date(session.crystallizedAt).toLocaleDateString('fr-FR') }}.
|
||||
</p>
|
||||
|
||||
<UModal v-model:open="showCrystal" :title="CRYSTALLIZE_ACTION">
|
||||
<template #body>
|
||||
<div class="vp__modal">
|
||||
<p v-if="medianFull" class="vp__modal-median">
|
||||
<template v-for="(p, i) in spec.params" :key="p.key">
|
||||
<span>{{ p.label }} : <strong>{{ (medianFull[i] ?? 0).toLocaleString('fr-FR', { maximumFractionDigits: 2 }) }}</strong>{{ p.unit ? ` ${p.unit}` : '' }}</span>
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="quorumShort" class="vp__modal-warn">
|
||||
Le quorum n'est pas atteint ({{ vectors.length }} sur {{ quorum }}) — le geste constatera le rejet.
|
||||
</p>
|
||||
<p v-for="key in bimodalKeys" :key="key" class="vp__modal-warn">
|
||||
<UIcon name="i-lucide-split" />
|
||||
<span>{{ votable.find(p => p.key === key)?.label }} : deux positions distinctes se dessinent.</span>
|
||||
</p>
|
||||
<div class="vp__modal-choices">
|
||||
<template v-if="bimodalKeys.length">
|
||||
<button class="ld-btn" type="button" @click="doCrystallize()">{{ CRYSTALLIZE_CHOICES[0] }}</button>
|
||||
<button class="ld-btn ld-btn--ghost" type="button" @click="split()">{{ CRYSTALLIZE_CHOICES[1] }}</button>
|
||||
<button class="ld-btn ld-btn--ghost" type="button" @click="reframe()">{{ CRYSTALLIZE_CHOICES[2] }}</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="ld-btn" type="button" @click="doCrystallize()">{{ CRYSTALLIZE_ACTION }}</button>
|
||||
<button class="ld-btn ld-btn--quiet" type="button" @click="showCrystal = false">Annuler</button>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="error" class="vp__error">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vp { padding: 1.25rem; display: flex; flex-direction: column; gap: 1rem; }
|
||||
.vp__explain { margin: 0; font-size: 0.875rem; color: var(--mood-text-muted); line-height: 1.5; }
|
||||
.vp__frozen {
|
||||
display: flex; align-items: center; gap: 0.5rem; margin: 0;
|
||||
padding: 0.6rem 0.9rem; border-radius: var(--r-input);
|
||||
background: var(--mood-status-fige-bg); color: var(--mood-status-fige);
|
||||
font-weight: 700; font-size: 0.9375rem;
|
||||
}
|
||||
.vp__frame {
|
||||
display: flex; flex-direction: column; gap: 0.9rem;
|
||||
padding: 0.9rem; border-radius: var(--r-input);
|
||||
background: var(--mood-bg);
|
||||
}
|
||||
.vp__frame-tag {
|
||||
align-self: flex-start; font-size: 0.7rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
color: var(--mood-text-muted); background: var(--mood-surface);
|
||||
padding: 2px 10px; border-radius: var(--r-pill);
|
||||
}
|
||||
.vp__actions { display: flex; flex-wrap: wrap; gap: 0.75rem; }
|
||||
.vp__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
|
||||
.vp__sandbox {
|
||||
display: flex; flex-direction: column; gap: 0.9rem;
|
||||
padding: 0.9rem; border-radius: var(--r-input);
|
||||
background: color-mix(in srgb, var(--mood-text-muted) 7%, var(--mood-bg));
|
||||
}
|
||||
.vp__sandbox-banner {
|
||||
display: flex; align-items: center; gap: 0.45rem; margin: 0;
|
||||
font-size: 0.8125rem; font-weight: 700; color: var(--mood-text-muted);
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.vp__sandbox-seeds { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
.vp__sandbox > .ld-btn { align-self: flex-start; }
|
||||
.vp__impact {
|
||||
background: var(--mood-accent-soft); border-radius: var(--r-input);
|
||||
padding: 0.9rem 1rem; display: flex; flex-direction: column; gap: 0.5rem;
|
||||
}
|
||||
.vp__impact-title { margin: 0; font-size: 0.9375rem; font-weight: 800; color: var(--mood-accent); }
|
||||
.vp__impact-table { border-collapse: collapse; font-size: 0.875rem; width: 100%; }
|
||||
.vp__impact-table th, .vp__impact-table td { text-align: right; padding: 0.2rem 0.45rem; }
|
||||
.vp__impact-table tbody th { text-align: left; font-weight: 600; }
|
||||
.vp__impact-table thead th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--mood-text-muted); }
|
||||
.vp__impact-total { font-weight: 800; }
|
||||
.vp__impact-note { margin: 0; font-size: 0.75rem; font-style: italic; color: var(--mood-text-muted); }
|
||||
.vp__count { margin: 0; font-size: 0.8125rem; color: var(--mood-text-muted); }
|
||||
.vp__crystal {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.6rem;
|
||||
padding: 1rem; border-radius: var(--r-input); background: var(--mood-status-fige-bg);
|
||||
}
|
||||
.vp__crystal-card { margin: 0; font-weight: 600; font-size: 0.9375rem; text-align: center; }
|
||||
.vp__stamp-btn {
|
||||
display: inline-flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.65rem 1.5rem; border-radius: 10px; cursor: pointer;
|
||||
transform: rotate(-2deg);
|
||||
font-weight: 800; font-size: 1rem; letter-spacing: 0.03em;
|
||||
color: var(--mood-accent); background: var(--mood-surface);
|
||||
box-shadow: inset 0 0 0 2.5px var(--mood-accent), 0 2px 8px var(--mood-shadow);
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.vp__stamp-btn:hover { transform: rotate(-2deg) translateY(-1px); }
|
||||
.vp__stamp-btn:active { transform: rotate(-2deg) translateY(0); }
|
||||
.vp__crystallized { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-status-vigueur); }
|
||||
.vp__modal { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.vp__modal-median { margin: 0; display: flex; flex-wrap: wrap; gap: 0.35rem 1rem; font-size: 0.9375rem; }
|
||||
.vp__modal-warn {
|
||||
display: flex; align-items: baseline; gap: 0.4rem; margin: 0;
|
||||
font-size: 0.875rem; font-weight: 600; color: var(--mood-warning);
|
||||
}
|
||||
.vp__modal-choices { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user