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:
@@ -0,0 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Vertical lifecycle timeline: full dots linked by a background
|
||||
// line, traversed states with timestamps, ONE state→color mapping. The paused
|
||||
// dot is striped when the boundary is contested; an extended non-easy window
|
||||
// shows deep amber + « il manque un accord explicite ».
|
||||
import type { Decision, DecisionStatus, VoteSession } from '~/types/domain'
|
||||
import {
|
||||
ASSENT_MISSING, BOUNDARY_SUSPENDED, FROZEN_LABEL, STATUS_LABELS,
|
||||
} from '~/lexicon'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { STATUS_TOKEN, dateTimeFr, type DisplayStatus } from './decisionUi'
|
||||
|
||||
const props = defineProps<{ decision: Decision; session?: VoteSession }>()
|
||||
|
||||
const col = useCollectiveStore()
|
||||
|
||||
interface Step {
|
||||
key: DisplayStatus
|
||||
label: string
|
||||
at?: string
|
||||
state: 'done' | 'current' | 'ahead'
|
||||
token: string
|
||||
suspended?: boolean
|
||||
extended?: boolean
|
||||
}
|
||||
|
||||
const hasThirdPartyAssent = computed(() =>
|
||||
col.assents.some(a =>
|
||||
a.decisionId === props.decision.id && a.personId !== props.decision.authorId))
|
||||
|
||||
/** Extended window: non-easy deadline passed without an explicit agreement. */
|
||||
const isExtended = computed(() => {
|
||||
const d = props.decision
|
||||
return d.status === 'objection'
|
||||
&& d.reversibility !== 'easy'
|
||||
&& !d.windowSuspendedAt
|
||||
&& d.windowEndsAt !== undefined
|
||||
&& new Date(d.windowEndsAt).getTime() < Date.now()
|
||||
&& !hasThirdPartyAssent.value
|
||||
})
|
||||
|
||||
/** The path of this decision — traversed states first, expected end last. */
|
||||
const steps = computed<Step[]>(() => {
|
||||
const d = props.decision
|
||||
const s = props.session
|
||||
const path: { key: DisplayStatus; at?: string }[] = [{ key: 'draft', at: d.createdAt }]
|
||||
|
||||
if (d.route === 'mandate') path.push({ key: 'objection', at: d.createdAt })
|
||||
else if (d.route === 'advice') path.push({ key: 'advice', at: d.createdAt })
|
||||
else if (d.route === 'collective') {
|
||||
const framed = d.status === 'framing'
|
||||
|| (d.status === 'closed' && !d.decidedAt)
|
||||
|| col.decisions.some(c => c.parentDecisionId === d.id && c.chainKind === 'element')
|
||||
if (framed) path.push({ key: 'framing', at: d.createdAt })
|
||||
if (d.status !== 'framing' || s) path.push({ key: 'voting', at: s?.opensAt })
|
||||
if (s?.status === 'frozen') path.push({ key: 'frozen', at: s.closesAt })
|
||||
}
|
||||
|
||||
const terminals: DecisionStatus[] = ['adopted', 'rejected', 'revoked', 'closed', 'transmitted']
|
||||
if (d.status === 'revoked') {
|
||||
path.push({ key: 'adopted', at: d.decidedAt }, { key: 'revoked', at: d.updatedAt })
|
||||
} else if (d.status === 'closed' && d.decidedAt) {
|
||||
path.push({ key: 'adopted', at: d.decidedAt }, { key: 'closed', at: d.updatedAt })
|
||||
} else if (terminals.includes(d.status)) {
|
||||
path.push({ key: d.status, at: d.decidedAt ?? d.updatedAt })
|
||||
} else {
|
||||
// Expected landing of the route, shown ahead.
|
||||
path.push({ key: d.route === 'transmit' ? 'transmitted' : 'adopted' })
|
||||
}
|
||||
|
||||
const shownKey: DisplayStatus = d.status === 'voting' && s?.status === 'frozen'
|
||||
? 'frozen'
|
||||
: d.status
|
||||
const currentIndex = path.findIndex(p => p.key === shownKey)
|
||||
|
||||
return path.map((p, i) => ({
|
||||
key: p.key,
|
||||
label: p.key === 'frozen' ? FROZEN_LABEL : STATUS_LABELS[p.key as DecisionStatus],
|
||||
...(p.at !== undefined ? { at: p.at } : {}),
|
||||
state: currentIndex === -1 || i < currentIndex
|
||||
? 'done'
|
||||
: i === currentIndex ? 'current' : 'ahead',
|
||||
token: STATUS_TOKEN[p.key],
|
||||
suspended: i === currentIndex && !!d.windowSuspendedAt
|
||||
&& (p.key === 'objection' || p.key === 'advice'),
|
||||
extended: i === currentIndex && p.key === 'objection' && isExtended.value,
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<ol class="tl" aria-label="Cycle de vie">
|
||||
<li
|
||||
v-for="step in steps"
|
||||
:key="step.key"
|
||||
class="tl__step"
|
||||
:class="[`tl__step--${step.state}`, { 'tl__step--extended': step.extended }]"
|
||||
:style="{
|
||||
'--step-color': `var(--mood-status-${step.token})`,
|
||||
'--step-bg': `var(--mood-status-${step.token}-bg)`,
|
||||
}"
|
||||
>
|
||||
<span class="tl__dot" :class="{ 'tl__dot--paused': step.suspended }">
|
||||
<UIcon v-if="step.suspended" name="i-lucide-pause" class="tl__pause" />
|
||||
</span>
|
||||
<span class="tl__body">
|
||||
<span class="tl__label">{{ step.label }}</span>
|
||||
<span v-if="step.at && step.state !== 'ahead'" class="tl__date">
|
||||
{{ dateTimeFr(step.at) }}
|
||||
</span>
|
||||
<span v-if="step.suspended" class="tl__note tl__note--paused">
|
||||
{{ BOUNDARY_SUSPENDED }}
|
||||
</span>
|
||||
<span v-else-if="step.extended" class="tl__note tl__note--extended">
|
||||
{{ ASSENT_MISSING }} — la fenêtre se prolonge
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tl {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.tl__step {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0 0 1.125rem 0;
|
||||
}
|
||||
.tl__step:last-child { padding-bottom: 0; }
|
||||
/* Trait de fond reliant les pastilles */
|
||||
.tl__step:not(:last-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 18px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--mood-accent-soft);
|
||||
}
|
||||
.tl__dot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--step-color);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.tl__step--ahead .tl__dot {
|
||||
background: var(--step-bg);
|
||||
box-shadow: inset 0 0 0 2px var(--step-color);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.tl__dot--paused {
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
var(--mood-status-fenetre),
|
||||
var(--mood-status-fenetre) 3px,
|
||||
var(--mood-status-fenetre-bg) 3px,
|
||||
var(--mood-status-fenetre-bg) 6px
|
||||
);
|
||||
}
|
||||
.tl__pause {
|
||||
font-size: 0.625rem;
|
||||
color: var(--mood-surface);
|
||||
}
|
||||
.tl__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.tl__label {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--step-color);
|
||||
line-height: 1.25;
|
||||
}
|
||||
.tl__step--ahead .tl__label { opacity: 0.5; }
|
||||
.tl__step--current .tl__label { font-size: 1rem; }
|
||||
.tl__date {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.tl__note {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
width: fit-content;
|
||||
}
|
||||
.tl__note--paused {
|
||||
color: var(--mood-status-fenetre);
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
var(--mood-status-fenetre-bg),
|
||||
var(--mood-status-fenetre-bg) 6px,
|
||||
transparent 6px,
|
||||
transparent 10px
|
||||
);
|
||||
}
|
||||
.tl__note--extended {
|
||||
color: var(--mood-surface);
|
||||
background: var(--mood-status-fenetre);
|
||||
}
|
||||
.tl__step--extended .tl__dot { box-shadow: 0 0 0 3px var(--mood-status-fenetre-bg); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user