- Accents français partout (seed + composants Vue) - Labels discrets: Engagements, Préambule, Application, Variables - N1.1: présentation visuelle des niveaux d'inertie avec formule - F1.2: paramètres + lecture du curseur d'inertie - MarkdownRenderer: espacement resserré, support code inline - Toutes descriptions et meta en bon français Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
241 lines
6.4 KiB
Vue
241 lines
6.4 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* MiniVoteBoard — compact inline vote status for an engagement item.
|
|
*
|
|
* Shows: vote bar, counts, threshold, pass/fail, and vote buttons.
|
|
* Uses mock data when no vote session is linked (dev mode).
|
|
*/
|
|
import { useVoteFormula } from '~/composables/useVoteFormula'
|
|
|
|
const props = withDefaults(defineProps<{
|
|
votesFor?: number
|
|
votesAgainst?: number
|
|
wotSize?: number
|
|
isPermanent?: boolean
|
|
inertiaPreset?: string
|
|
startsAt?: string | null
|
|
endsAt?: string | null
|
|
}>(), {
|
|
votesFor: 0,
|
|
votesAgainst: 0,
|
|
wotSize: 7224,
|
|
isPermanent: true,
|
|
inertiaPreset: 'standard',
|
|
startsAt: null,
|
|
endsAt: null,
|
|
})
|
|
|
|
const { computeThreshold } = useVoteFormula()
|
|
|
|
const INERTIA_PARAMS: Record<string, { majority_pct: number; base_exponent: number; gradient_exponent: number; constant_base: number }> = {
|
|
low: { majority_pct: 50, base_exponent: 0.1, gradient_exponent: 0.1, constant_base: 0 },
|
|
standard: { majority_pct: 50, base_exponent: 0.1, gradient_exponent: 0.2, constant_base: 0 },
|
|
high: { majority_pct: 60, base_exponent: 0.1, gradient_exponent: 0.4, constant_base: 0 },
|
|
very_high: { majority_pct: 66, base_exponent: 0.1, gradient_exponent: 0.6, constant_base: 0 },
|
|
}
|
|
|
|
const formulaParams = computed(() => INERTIA_PARAMS[props.inertiaPreset] ?? INERTIA_PARAMS.standard!)
|
|
|
|
const totalVotes = computed(() => props.votesFor + props.votesAgainst)
|
|
|
|
const threshold = computed(() => {
|
|
if (totalVotes.value === 0) return 1
|
|
return computeThreshold(props.wotSize, totalVotes.value, formulaParams.value)
|
|
})
|
|
|
|
const isPassing = computed(() => props.votesFor >= threshold.value)
|
|
|
|
const forPct = computed(() => {
|
|
if (totalVotes.value === 0) return 0
|
|
return (props.votesFor / totalVotes.value) * 100
|
|
})
|
|
|
|
const againstPct = computed(() => {
|
|
if (totalVotes.value === 0) return 0
|
|
return (props.votesAgainst / totalVotes.value) * 100
|
|
})
|
|
|
|
const thresholdPct = computed(() => {
|
|
if (totalVotes.value === 0) return 50
|
|
return Math.min((threshold.value / totalVotes.value) * 100, 100)
|
|
})
|
|
|
|
const participationRate = computed(() => {
|
|
if (props.wotSize === 0) return 0
|
|
return (totalVotes.value / props.wotSize) * 100
|
|
})
|
|
|
|
const remaining = computed(() => {
|
|
const diff = threshold.value - props.votesFor
|
|
return diff > 0 ? diff : 0
|
|
})
|
|
|
|
function formatDate(d: string): string {
|
|
return new Date(d).toLocaleDateString('fr-FR', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="mini-board">
|
|
<!-- Vote type + status on same line -->
|
|
<div class="mini-board__header">
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<template v-if="isPermanent">
|
|
<UIcon name="i-lucide-infinity" class="text-xs" style="color: var(--mood-accent)" />
|
|
<span class="text-xs font-semibold" style="color: var(--mood-text-muted)">Vote permanent :</span>
|
|
</template>
|
|
<template v-else>
|
|
<UIcon name="i-lucide-clock" class="text-xs" style="color: var(--mood-accent)" />
|
|
<span class="text-xs font-semibold" style="color: var(--mood-text-muted)">Vote temporaire :</span>
|
|
<span v-if="startsAt && endsAt" class="text-xs" style="color: var(--mood-text-muted)">
|
|
{{ formatDate(startsAt) }} - {{ formatDate(endsAt) }}
|
|
</span>
|
|
</template>
|
|
<UBadge
|
|
:color="isPassing ? 'success' : 'warning'"
|
|
:variant="isPassing ? 'solid' : 'subtle'"
|
|
size="xs"
|
|
>
|
|
{{ isPassing ? 'Adopté' : 'En attente' }}
|
|
</UBadge>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progress bar -->
|
|
<div class="mini-board__bar">
|
|
<div
|
|
class="mini-board__bar-for"
|
|
:style="{ width: `${forPct}%` }"
|
|
/>
|
|
<div
|
|
class="mini-board__bar-against"
|
|
:style="{ left: `${forPct}%`, width: `${againstPct}%` }"
|
|
/>
|
|
<!-- Threshold marker -->
|
|
<div
|
|
v-if="totalVotes > 0"
|
|
class="mini-board__bar-threshold"
|
|
:style="{ left: `${thresholdPct}%` }"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Stats row -->
|
|
<div class="mini-board__stats">
|
|
<div class="flex items-center gap-3">
|
|
<span class="mini-board__stat mini-board__stat--for">
|
|
{{ votesFor }} pour
|
|
</span>
|
|
<span class="mini-board__stat mini-board__stat--against">
|
|
{{ votesAgainst }} contre
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<span class="mini-board__stat">
|
|
{{ votesFor }}/{{ threshold }} requis
|
|
</span>
|
|
<span v-if="remaining > 0" class="mini-board__stat mini-board__stat--remaining">
|
|
{{ remaining }} manquant{{ remaining > 1 ? 's' : '' }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Participation -->
|
|
<div class="mini-board__footer">
|
|
<span class="text-xs" style="color: var(--mood-text-muted)">
|
|
{{ totalVotes }} vote{{ totalVotes !== 1 ? 's' : '' }} / {{ wotSize }} membres
|
|
({{ participationRate.toFixed(2) }}%)
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.mini-board {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.375rem;
|
|
padding: 0.5rem 0.75rem;
|
|
border-radius: 8px;
|
|
background: color-mix(in srgb, var(--mood-accent) 3%, var(--mood-bg));
|
|
}
|
|
|
|
.mini-board__header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.mini-board__bar {
|
|
position: relative;
|
|
height: 6px;
|
|
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
|
|
border-radius: 3px;
|
|
overflow: visible;
|
|
}
|
|
|
|
.mini-board__bar-for {
|
|
position: absolute;
|
|
inset: 0;
|
|
right: auto;
|
|
background: #22c55e;
|
|
border-radius: 3px 0 0 3px;
|
|
transition: width 0.4s ease;
|
|
}
|
|
|
|
.mini-board__bar-against {
|
|
position: absolute;
|
|
inset: 0;
|
|
right: auto;
|
|
background: #ef4444;
|
|
transition: width 0.4s ease, left 0.4s ease;
|
|
}
|
|
|
|
.mini-board__bar-threshold {
|
|
position: absolute;
|
|
top: -3px;
|
|
bottom: -3px;
|
|
width: 2px;
|
|
background: #facc15;
|
|
border-radius: 1px;
|
|
transform: translateX(-50%);
|
|
transition: left 0.4s ease;
|
|
z-index: 2;
|
|
}
|
|
|
|
.mini-board__stats {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-wrap: wrap;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.mini-board__stat {
|
|
font-size: 0.625rem;
|
|
font-weight: 600;
|
|
color: var(--mood-text-muted);
|
|
}
|
|
|
|
.mini-board__stat--for {
|
|
color: #22c55e;
|
|
}
|
|
|
|
.mini-board__stat--against {
|
|
color: #ef4444;
|
|
}
|
|
|
|
.mini-board__stat--remaining {
|
|
color: #f59e0b;
|
|
}
|
|
|
|
.mini-board__footer {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
</style>
|