- 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>
354 lines
10 KiB
Vue
354 lines
10 KiB
Vue
<script setup lang="ts">
|
|
// <!-- ld-v2 --> Perimeter panel: first-place concerned (computed, full dot,
|
|
// tap → inclusion reason), second-place (declared, raised hand, public note),
|
|
// « Ça me concerne », arrested-list countdown, NON-IGNORABLE influx banner
|
|
// (widen one notch / keep with a public note), boundary objection.
|
|
import type { Concern, Decision, Id, Person } from '~/types/domain'
|
|
import {
|
|
CONCERN_FIRST, CONCERN_ME, CONCERN_SECOND, PERIMETER_OVERFLOW,
|
|
SCOPE_KEEP, SCOPE_WIDEN,
|
|
} from '~/lexicon'
|
|
import { computeConcerned } from '~/engine'
|
|
import { useCollectiveStore } from '~/stores/collective'
|
|
import { useDecisionsStore } from '~/stores/decisions'
|
|
|
|
const props = defineProps<{ decision: Decision }>()
|
|
|
|
const col = useCollectiveStore()
|
|
const store = useDecisionsStore()
|
|
|
|
const concerns = computed(() =>
|
|
col.concerns.filter(c => c.decisionId === props.decision.id))
|
|
|
|
function entriesOf(origin: Concern['origin']) {
|
|
return concerns.value
|
|
.filter(c => c.origin === origin)
|
|
.map((c) => {
|
|
const person = col.people.find(p => p.id === c.personId)
|
|
return person ? { person, origin, reason: c.reason, concern: c } : null
|
|
})
|
|
.filter((e): e is { person: Person; origin: Concern['origin']; reason: string; concern: Concern } => e !== null)
|
|
}
|
|
|
|
const firstPlace = computed(() => entriesOf('computed'))
|
|
const secondPlace = computed(() => entriesOf('declared'))
|
|
|
|
// ── Popover : raison d'inclusion en un tap ──
|
|
const selected = ref<{ name: string; title: string; reason: string; note?: string; consultative: boolean } | null>(null)
|
|
|
|
function onTap(entry: { person: Person; origin?: string; reason?: string }) {
|
|
const concern = concerns.value.find(c => c.personId === entry.person.id)
|
|
const parts: string[] = []
|
|
if (concern?.reason) parts.push(concern.reason)
|
|
selected.value = {
|
|
name: entry.person.displayName,
|
|
title: concern?.origin === 'declared' ? CONCERN_SECOND : CONCERN_FIRST,
|
|
reason: parts.join(' — '),
|
|
...(concern?.declaredNote ? { note: concern.declaredNote } : {}),
|
|
consultative: concern?.origin === 'declared' && concern.beforeSnapshot === false,
|
|
}
|
|
}
|
|
|
|
// ── « Ça me concerne » ──
|
|
const meId = computed(() => col.me?.id ?? null)
|
|
const alreadyConcerned = computed(() =>
|
|
meId.value !== null
|
|
&& (props.decision.authorId === meId.value
|
|
|| concerns.value.some(c => c.personId === meId.value)))
|
|
|
|
function declareMe() {
|
|
if (meId.value) store.declareConcern(props.decision.id, meId.value)
|
|
}
|
|
|
|
// ── Compte à rebours de la liste arrêtée (session à venir) ──
|
|
const arrestAt = computed(() =>
|
|
props.decision.status === 'framing' ? props.decision.windowEndsAt : undefined)
|
|
|
|
// ── Bannière d'affluence — non-ignorable ──
|
|
const ACTIVE = ['draft', 'advice', 'objection', 'framing', 'voting']
|
|
const overflow = computed(() => {
|
|
if (!ACTIVE.includes(props.decision.status) || props.decision.scopeKeptNote) return false
|
|
const computedCount = firstPlace.value.length
|
|
const declaredCount = secondPlace.value.length
|
|
const ratio = col.settings?.triage.concernEscalateRatio ?? 0.5
|
|
return computedCount > 0 && declaredCount >= ratio * computedCount
|
|
})
|
|
|
|
function widen() {
|
|
const d = props.decision
|
|
if (!col.current) return
|
|
const next = new Set<Id>()
|
|
for (const id of d.scope.circleIds) {
|
|
const circle = col.circles.find(c => c.id === id)
|
|
next.add(circle?.parentCircleId ?? id)
|
|
}
|
|
if (next.size === 0) next.add(col.current.collective.rootCircleId)
|
|
d.scope.circleIds = [...next]
|
|
// Re-persist the widened computation — every inclusion keeps its reason.
|
|
const activeMandates = col.mandates.filter(m => m.status === 'active')
|
|
const known = new Set(concerns.value.map(c => c.personId))
|
|
const now = col.now()
|
|
for (const entry of computeConcerned(d.scope, col.circles, activeMandates, d.authorId)) {
|
|
if (known.has(entry.personId)) continue
|
|
col.current.concerns.push({
|
|
id: col.newId(),
|
|
collectiveId: d.collectiveId,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
decisionId: d.id,
|
|
personId: entry.personId,
|
|
origin: 'computed',
|
|
reason: entry.reason,
|
|
beforeSnapshot: !col.sessions.some(s => s.decisionId === d.id),
|
|
})
|
|
}
|
|
col.stamp(d)
|
|
col.persist()
|
|
}
|
|
|
|
const keepOpen = ref(false)
|
|
const keepNote = ref('')
|
|
function keepScope() {
|
|
const note = keepNote.value.trim()
|
|
if (note.length === 0) return
|
|
props.decision.scopeKeptNote = note
|
|
col.stamp(props.decision)
|
|
col.persist()
|
|
keepOpen.value = false
|
|
}
|
|
|
|
// ── Objection de frontière ──
|
|
const boundaryOpen = ref(false)
|
|
const boundaryArg = ref('')
|
|
const boundaryError = ref('')
|
|
function sendBoundary() {
|
|
const result = store.objectTo(props.decision.id, 'boundary', boundaryArg.value)
|
|
if ('ok' in result) { boundaryError.value = result.reason; return }
|
|
boundaryOpen.value = false
|
|
boundaryArg.value = ''
|
|
boundaryError.value = ''
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<!-- ld-v2 -->
|
|
<section class="peri">
|
|
<h2 class="peri__title">Périmètre</h2>
|
|
|
|
<div v-if="overflow" class="peri__overflow no-print">
|
|
<p class="peri__overflow-text">
|
|
<UIcon name="i-lucide-waves" />
|
|
<span>{{ PERIMETER_OVERFLOW }}</span>
|
|
</p>
|
|
<div class="peri__overflow-actions">
|
|
<button type="button" class="ld-btn" @click="widen()">{{ SCOPE_WIDEN }}</button>
|
|
<button type="button" class="ld-btn ld-btn--ghost" @click="keepOpen = !keepOpen">
|
|
{{ SCOPE_KEEP }}
|
|
</button>
|
|
</div>
|
|
<div v-if="keepOpen" class="peri__keep">
|
|
<textarea
|
|
v-model="keepNote"
|
|
rows="2"
|
|
placeholder="Pourquoi le périmètre reste-t-il ainsi ? Cette note sera publique."
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="ld-btn"
|
|
:disabled="keepNote.trim().length === 0"
|
|
@click="keepScope()"
|
|
>
|
|
Publier la note et maintenir
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="peri__group">
|
|
<p class="peri__label">{{ CONCERN_FIRST }}</p>
|
|
<LdAvatarStack
|
|
v-if="firstPlace.length > 0"
|
|
:people="firstPlace"
|
|
:max="8"
|
|
@tap="onTap"
|
|
/>
|
|
<p v-else class="peri__none">Personne d'autre — décision sur soi.</p>
|
|
</div>
|
|
|
|
<div class="peri__group">
|
|
<p class="peri__label">{{ CONCERN_SECOND }}</p>
|
|
<LdAvatarStack
|
|
v-if="secondPlace.length > 0"
|
|
:people="secondPlace"
|
|
:max="8"
|
|
@tap="onTap"
|
|
/>
|
|
<p v-else class="peri__none">Personne ne s'est déclaré·e pour l'instant.</p>
|
|
</div>
|
|
|
|
<div v-if="selected" class="peri__popover" @click="selected = null">
|
|
<p class="peri__popover-name">{{ selected.name }}</p>
|
|
<p class="peri__popover-origin">{{ selected.title }}</p>
|
|
<p v-if="selected.reason" class="peri__popover-reason">{{ selected.reason }}</p>
|
|
<p v-if="selected.note" class="peri__popover-note">« {{ selected.note }} »</p>
|
|
<p v-if="selected.consultative" class="peri__popover-consult">
|
|
Déclaré·e après l'arrêt de la liste — voix consultative.
|
|
</p>
|
|
</div>
|
|
|
|
<div v-if="arrestAt" class="peri__arrest">
|
|
<LdCountdown :ends-at="arrestAt" />
|
|
<p class="peri__arrest-rule">
|
|
Déclaré·e avant l'arrêt de la liste : tu votes. Après : voix consultative.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="peri__actions no-print">
|
|
<button
|
|
v-if="!alreadyConcerned"
|
|
type="button"
|
|
class="ld-btn ld-btn--ghost"
|
|
@click="declareMe()"
|
|
>
|
|
<UIcon name="i-lucide-hand" />
|
|
<span>{{ CONCERN_ME }}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="ld-btn ld-btn--quiet"
|
|
@click="boundaryOpen = !boundaryOpen"
|
|
>
|
|
<UIcon name="i-lucide-user-plus" />
|
|
<span>J'ai été oublié·e / il manque quelqu'un</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="boundaryOpen" class="peri__keep no-print">
|
|
<textarea
|
|
v-model="boundaryArg"
|
|
rows="2"
|
|
placeholder="Qui manque, et pourquoi cette personne est concernée ?"
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="ld-btn"
|
|
:disabled="boundaryArg.trim().length === 0"
|
|
@click="sendBoundary()"
|
|
>
|
|
Contester la frontière
|
|
</button>
|
|
<p v-if="boundaryError" class="peri__error">{{ boundaryError }}</p>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.peri {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.875rem;
|
|
}
|
|
.peri__title {
|
|
margin: 0;
|
|
font-size: 1.0625rem;
|
|
font-weight: 800;
|
|
letter-spacing: -0.01em;
|
|
}
|
|
.peri__group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
}
|
|
.peri__label {
|
|
margin: 0;
|
|
font-size: 0.8125rem;
|
|
font-weight: 700;
|
|
color: var(--mood-text-muted);
|
|
}
|
|
.peri__none {
|
|
margin: 0;
|
|
font-size: 0.875rem;
|
|
color: var(--mood-text-muted);
|
|
font-style: italic;
|
|
}
|
|
.peri__popover {
|
|
padding: 0.75rem 1rem;
|
|
border-radius: var(--r-input);
|
|
background: var(--mood-accent-soft);
|
|
cursor: pointer;
|
|
}
|
|
.peri__popover-name { margin: 0; font-weight: 800; font-size: 0.9375rem; }
|
|
.peri__popover-origin {
|
|
margin: 0.125rem 0 0;
|
|
font-size: 0.8125rem;
|
|
font-weight: 600;
|
|
color: var(--mood-accent);
|
|
}
|
|
.peri__popover-reason { margin: 0.25rem 0 0; font-size: 0.875rem; }
|
|
.peri__popover-note {
|
|
margin: 0.25rem 0 0;
|
|
font-size: 0.875rem;
|
|
font-style: italic;
|
|
color: var(--mood-text-muted);
|
|
}
|
|
.peri__popover-consult {
|
|
margin: 0.25rem 0 0;
|
|
font-size: 0.8125rem;
|
|
font-weight: 600;
|
|
color: var(--mood-status-fenetre);
|
|
}
|
|
.peri__arrest {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
}
|
|
.peri__arrest-rule {
|
|
margin: 0;
|
|
font-size: 0.8125rem;
|
|
color: var(--mood-text-muted);
|
|
}
|
|
.peri__actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
}
|
|
.peri__overflow {
|
|
padding: 1rem 1.125rem;
|
|
border-radius: var(--r-input);
|
|
background: color-mix(in srgb, var(--mood-status-fenetre) 14%, transparent);
|
|
box-shadow: inset 0 0 0 2px var(--mood-status-fenetre);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
.peri__overflow-text {
|
|
margin: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-weight: 800;
|
|
color: var(--mood-status-fenetre);
|
|
}
|
|
.peri__overflow-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
}
|
|
.peri__keep {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
.peri__keep textarea {
|
|
width: 100%;
|
|
padding: 0.625rem 0.875rem;
|
|
font-size: 0.9375rem;
|
|
resize: vertical;
|
|
}
|
|
.peri__error {
|
|
margin: 0;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: var(--mood-error);
|
|
}
|
|
</style>
|