- 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>
287 lines
9.3 KiB
Vue
287 lines
9.3 KiB
Vue
<script setup lang="ts">
|
|
// <!-- ld-v2 --> « S'instruire » block (framing/voting): compact summary always
|
|
// visible (title, sought effects, « Ce que ça engage ») + internal disclosures
|
|
// (body, 2-column versions with folded author, deposited advices/objections,
|
|
// provenance). Auto-collapsed on later visits (local preference per decision).
|
|
import type { Decision } from '~/types/domain'
|
|
import { ENGAGES_LABEL, INSTRUCT_BLOCK } from '~/lexicon'
|
|
import { useCollectiveStore } from '~/stores/collective'
|
|
import { ADVICE_LABELS } from './decisionUi'
|
|
|
|
const props = defineProps<{ decision: Decision }>()
|
|
|
|
const col = useCollectiveStore()
|
|
|
|
// ── Repli automatique aux visites suivantes ──
|
|
const storageKey = `ld2-instruct-${props.decision.id}`
|
|
const collapsed = ref(false)
|
|
onMounted(() => {
|
|
collapsed.value = localStorage.getItem(storageKey) === '1'
|
|
localStorage.setItem(storageKey, '1')
|
|
})
|
|
|
|
const effects = computed(() => props.decision.brief?.effects ?? [])
|
|
const resources = computed(() => props.decision.resources)
|
|
|
|
const advices = computed(() =>
|
|
col.advices
|
|
.filter(a => a.decisionId === props.decision.id)
|
|
.map(a => ({
|
|
...a,
|
|
who: col.people.find(p => p.id === a.personId)?.displayName ?? '—',
|
|
label: ADVICE_LABELS[a.position],
|
|
})))
|
|
|
|
const objections = computed(() =>
|
|
col.objections
|
|
.filter(o => o.decisionId === props.decision.id)
|
|
.map(o => ({
|
|
...o,
|
|
who: col.people.find(p => p.id === o.personId)?.displayName ?? '—',
|
|
})))
|
|
|
|
// ── Versions (clause visée) : diff simple 2 colonnes, auteur replié ──
|
|
const clause = computed(() =>
|
|
props.decision.amendsClauseId
|
|
? col.clauses.find(c => c.id === props.decision.amendsClauseId)
|
|
: undefined)
|
|
|
|
const currentVersion = computed(() =>
|
|
clause.value
|
|
? col.versions.find(v => v.clauseId === clause.value!.id && v.status === 'current')
|
|
: undefined)
|
|
|
|
const proposedVersions = computed(() =>
|
|
clause.value
|
|
? col.versions
|
|
.filter(v => v.clauseId === clause.value!.id && v.status === 'proposed')
|
|
.map((v) => {
|
|
const origin = col.decisions.find(d => d.id === v.decisionId)
|
|
const author = origin
|
|
? col.people.find(p => p.id === origin.authorId)?.displayName
|
|
: undefined
|
|
return { ...v, author: author ?? '—' }
|
|
})
|
|
: [])
|
|
|
|
// ── Provenance (voteRecord du document de la clause visée) ──
|
|
const record = computed(() => {
|
|
if (!clause.value) return undefined
|
|
const doc = col.docs.find(d => d.id === clause.value!.docId)
|
|
return doc?.provenance?.voteRecord
|
|
})
|
|
const recordLine = computed(() => {
|
|
const r = record.value
|
|
if (!r) return ''
|
|
const res = r.result
|
|
return `${res.for} oui · ${res.against} non`
|
|
+ `${res.invalid !== undefined ? ` · ${res.invalid} nuls` : ''}`
|
|
+ ` — seuil ${res.thresholdRequired}, ${res.wotSize} inscrits (${r.period})`
|
|
})
|
|
|
|
const hasDetails = computed(() =>
|
|
!!props.decision.body || proposedVersions.value.length > 0
|
|
|| advices.value.length > 0 || objections.value.length > 0 || !!record.value)
|
|
</script>
|
|
|
|
<template>
|
|
<!-- ld-v2 -->
|
|
<section class="ins">
|
|
<button type="button" class="ins__head no-print" @click="collapsed = !collapsed">
|
|
<UIcon name="i-lucide-book-open" />
|
|
<span class="ins__head-title">{{ INSTRUCT_BLOCK }}</span>
|
|
<UIcon :name="collapsed ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'" />
|
|
</button>
|
|
|
|
<div v-show="!collapsed" class="ins__body">
|
|
<!-- Résumé compact — toujours visible quand le bloc est ouvert -->
|
|
<p class="ins__title">{{ decision.title }}</p>
|
|
|
|
<div v-if="effects.length > 0" class="ins__effects">
|
|
<p class="ins__label">Effets recherchés</p>
|
|
<ul class="ins__effect-list">
|
|
<li v-for="(effect, i) in effects" :key="i">
|
|
<span>{{ effect.label }}</span>
|
|
<span v-if="effect.target" class="ins__target">cible : {{ effect.target }}</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div v-if="resources" class="ins__engages">
|
|
<p class="ins__label">{{ ENGAGES_LABEL }}</p>
|
|
<p class="ins__engages-note">
|
|
{{ resources.note }}
|
|
<strong v-if="resources.amount !== undefined">
|
|
— {{ resources.amount.toLocaleString('fr-FR') }} {{ resources.unit ?? '' }}
|
|
</strong>
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Dépliables internes -->
|
|
<template v-if="hasDetails">
|
|
<details v-if="decision.body" class="ins__fold">
|
|
<summary>Le texte</summary>
|
|
<p class="ins__prose">{{ decision.body }}</p>
|
|
</details>
|
|
|
|
<details v-if="proposedVersions.length > 0" class="ins__fold">
|
|
<summary>Versions proposées</summary>
|
|
<div v-for="v in proposedVersions" :key="v.id" class="ins__diff">
|
|
<div class="ins__diff-col">
|
|
<p class="ins__diff-head">Aujourd'hui</p>
|
|
<p class="ins__prose">{{ currentVersion?.content ?? '— (nouvelle clause)' }}</p>
|
|
</div>
|
|
<div class="ins__diff-col ins__diff-col--proposed">
|
|
<p class="ins__diff-head">Proposé ({{ v.versionLabel }})</p>
|
|
<p class="ins__prose">{{ v.content }}</p>
|
|
<details class="ins__author">
|
|
<summary>Qui propose ?</summary>
|
|
<p>{{ v.author }}</p>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
<details v-if="advices.length > 0" class="ins__fold">
|
|
<summary>Avis déposés ({{ advices.length }})</summary>
|
|
<ul class="ins__voice-list">
|
|
<li v-for="a in advices" :key="a.id">
|
|
<strong>{{ a.who }}</strong> — {{ a.label }}
|
|
<span v-if="a.note" class="ins__voice-note">« {{ a.note }} »</span>
|
|
</li>
|
|
</ul>
|
|
</details>
|
|
|
|
<details v-if="objections.length > 0" class="ins__fold">
|
|
<summary>Objections ({{ objections.length }})</summary>
|
|
<ul class="ins__voice-list">
|
|
<li v-for="o in objections" :key="o.id">
|
|
<strong>{{ o.who }}</strong>
|
|
<span class="ins__obj-status">({{ o.status === 'open' ? 'ouverte' : o.status === 'withdrawn' ? 'retirée' : o.status === 'integrated' ? 'intégrée' : 'escaladée' }})</span>
|
|
— « {{ o.argument }} »
|
|
</li>
|
|
</ul>
|
|
</details>
|
|
|
|
<details v-if="record" class="ins__fold">
|
|
<summary>Provenance</summary>
|
|
<p class="ins__prose">{{ recordLine }}</p>
|
|
<p v-if="record.url" class="ins__prose">
|
|
<a :href="record.url" target="_blank" rel="noopener">source du vote</a>
|
|
— {{ record.modeParams }}
|
|
</p>
|
|
</details>
|
|
</template>
|
|
</div>
|
|
|
|
<p v-show="collapsed" class="ins__collapsed-hint">
|
|
{{ decision.title }} — déplié à ta première visite, replié depuis.
|
|
{{ effects.length > 0 ? `${effects.length} effet${effects.length > 1 ? 's' : ''} recherché${effects.length > 1 ? 's' : ''}.` : '' }}
|
|
</p>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.ins { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
.ins__head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
background: var(--mood-accent-soft);
|
|
color: var(--mood-accent);
|
|
border-radius: var(--r-input);
|
|
padding: 0.625rem 0.875rem;
|
|
font-weight: 800;
|
|
font-size: 0.9375rem;
|
|
cursor: pointer;
|
|
width: 100%;
|
|
text-align: left;
|
|
}
|
|
.ins__head-title { flex: 1; }
|
|
.ins__body { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
.ins__title { margin: 0; font-size: 1rem; font-weight: 700; }
|
|
.ins__label {
|
|
margin: 0 0 0.25rem;
|
|
font-size: 0.8125rem;
|
|
font-weight: 700;
|
|
color: var(--mood-text-muted);
|
|
}
|
|
.ins__effect-list {
|
|
margin: 0;
|
|
padding-left: 1.125rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
font-size: 0.9375rem;
|
|
}
|
|
.ins__target {
|
|
margin-left: 0.5rem;
|
|
font-size: 0.8125rem;
|
|
font-weight: 700;
|
|
color: var(--mood-status-vote);
|
|
background: var(--mood-status-vote-bg);
|
|
padding: 2px 8px;
|
|
border-radius: var(--r-pill);
|
|
}
|
|
.ins__engages-note { margin: 0; font-size: 0.9375rem; }
|
|
.ins__fold summary {
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
font-weight: 700;
|
|
color: var(--mood-accent);
|
|
padding: 0.25rem 0;
|
|
user-select: none;
|
|
}
|
|
.ins__prose {
|
|
margin: 0.375rem 0 0;
|
|
font-size: 0.9375rem;
|
|
line-height: 1.55;
|
|
white-space: pre-wrap;
|
|
}
|
|
.ins__diff {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 0.75rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
@media (min-width: 768px) {
|
|
.ins__diff { grid-template-columns: 1fr 1fr; }
|
|
}
|
|
.ins__diff-col {
|
|
padding: 0.75rem 0.875rem;
|
|
border-radius: var(--r-input);
|
|
background: var(--mood-accent-soft);
|
|
}
|
|
.ins__diff-col--proposed {
|
|
background: var(--mood-status-vote-bg);
|
|
}
|
|
.ins__diff-head {
|
|
margin: 0;
|
|
font-size: 0.8125rem;
|
|
font-weight: 800;
|
|
color: var(--mood-text-muted);
|
|
}
|
|
.ins__author { margin-top: 0.5rem; }
|
|
.ins__author summary {
|
|
font-size: 0.8125rem;
|
|
color: var(--mood-text-muted);
|
|
cursor: pointer;
|
|
}
|
|
.ins__author p { margin: 0.25rem 0 0; font-size: 0.875rem; font-weight: 700; }
|
|
.ins__voice-list {
|
|
margin: 0.375rem 0 0;
|
|
padding-left: 1.125rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.375rem;
|
|
font-size: 0.9375rem;
|
|
}
|
|
.ins__voice-note { font-style: italic; color: var(--mood-text-muted); }
|
|
.ins__obj-status { font-size: 0.8125rem; color: var(--mood-text-muted); }
|
|
.ins__collapsed-hint {
|
|
margin: 0;
|
|
font-size: 0.875rem;
|
|
color: var(--mood-text-muted);
|
|
}
|
|
</style>
|