forked from yvv/decision
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,123 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Cadrage form component for creating or editing a decision.
|
||||
*
|
||||
* Provides all fields needed for the initial decision setup:
|
||||
* title, description, context, decision type, and voting protocol.
|
||||
*/
|
||||
import type { DecisionCreate } from '~/stores/decisions'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: DecisionCreate
|
||||
submitting?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: DecisionCreate]
|
||||
'submit': []
|
||||
}>()
|
||||
|
||||
const decisionTypeOptions = [
|
||||
{ label: 'Runtime upgrade', value: 'runtime_upgrade' },
|
||||
{ label: 'Modification de document', value: 'document_change' },
|
||||
{ label: 'Vote de mandat', value: 'mandate_vote' },
|
||||
{ label: 'Changement de parametre', value: 'parameter_change' },
|
||||
{ label: 'Autre', value: 'other' },
|
||||
]
|
||||
|
||||
function updateField<K extends keyof DecisionCreate>(field: K, value: DecisionCreate[K]) {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
const isValid = computed(() => {
|
||||
return props.modelValue.title?.trim() && props.modelValue.decision_type
|
||||
})
|
||||
|
||||
function onSubmit() {
|
||||
if (isValid.value) {
|
||||
emit('submit')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="space-y-6" @submit.prevent="onSubmit">
|
||||
<!-- Titre -->
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Titre <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<UInput
|
||||
:model-value="modelValue.title"
|
||||
placeholder="Titre de la decision..."
|
||||
required
|
||||
@update:model-value="updateField('title', $event as string)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Description <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<UTextarea
|
||||
:model-value="modelValue.description ?? ''"
|
||||
placeholder="Decrivez l'objet de cette decision..."
|
||||
:rows="4"
|
||||
@update:model-value="updateField('description', $event as string)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Contexte -->
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Contexte
|
||||
</label>
|
||||
<UTextarea
|
||||
:model-value="modelValue.context ?? ''"
|
||||
placeholder="Contexte, motivations, liens utiles..."
|
||||
:rows="3"
|
||||
@update:model-value="updateField('context', $event as string)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Type de decision -->
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Type de decision <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<USelect
|
||||
:model-value="modelValue.decision_type"
|
||||
:items="decisionTypeOptions"
|
||||
placeholder="Selectionnez un type..."
|
||||
@update:model-value="updateField('decision_type', $event as string)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Protocole de vote -->
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Protocole de vote
|
||||
</label>
|
||||
<ProtocolPicker
|
||||
:model-value="modelValue.voting_protocol_id ?? null"
|
||||
@update:model-value="updateField('voting_protocol_id', $event)"
|
||||
/>
|
||||
<p class="text-xs text-gray-500">
|
||||
Optionnel. Peut etre defini ulterieurement pour chaque etape.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<UButton
|
||||
type="submit"
|
||||
label="Creer la decision"
|
||||
icon="i-lucide-plus"
|
||||
color="primary"
|
||||
:loading="submitting"
|
||||
:disabled="!isValid"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -1,71 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Card component for displaying a decision in a list.
|
||||
*
|
||||
* Shows title, type badge, status badge, step count, and creation date.
|
||||
* Navigates to the decision detail page on click.
|
||||
*/
|
||||
import type { Decision } from '~/stores/decisions'
|
||||
|
||||
const props = defineProps<{
|
||||
decision: Decision
|
||||
}>()
|
||||
|
||||
const typeLabel = (decisionType: string) => {
|
||||
switch (decisionType) {
|
||||
case 'runtime_upgrade': return 'Runtime upgrade'
|
||||
case 'document_change': return 'Modif. document'
|
||||
case 'mandate_vote': return 'Vote de mandat'
|
||||
case 'parameter_change': return 'Param. change'
|
||||
case 'other': return 'Autre'
|
||||
default: return decisionType
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function navigate() {
|
||||
navigateTo(`/decisions/${props.decision.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard
|
||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 hover:shadow-md transition-all"
|
||||
@click="navigate"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-scale" class="text-gray-400" />
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ decision.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<StatusBadge :status="decision.status" type="decision" />
|
||||
</div>
|
||||
|
||||
<p v-if="decision.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{{ decision.description }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<UBadge variant="subtle" color="primary" size="xs">
|
||||
{{ typeLabel(decision.decision_type) }}
|
||||
</UBadge>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ decision.steps.length }} etape(s)
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ formatDate(decision.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Chain block: clickable parent and children with their chain
|
||||
// kind in plain French (ratification / révision / révocation / élément).
|
||||
import type { Decision } from '~/types/domain'
|
||||
import { STATUS_LABELS } from '~/lexicon'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { CHAIN_LABELS } from './decisionUi'
|
||||
|
||||
const props = defineProps<{ decision: Decision }>()
|
||||
|
||||
const col = useCollectiveStore()
|
||||
|
||||
const parent = computed(() =>
|
||||
props.decision.parentDecisionId
|
||||
? col.decisions.find(d => d.id === props.decision.parentDecisionId)
|
||||
: undefined)
|
||||
|
||||
const parentLabel = computed(() =>
|
||||
props.decision.chainKind
|
||||
? `${CHAIN_LABELS[props.decision.chainKind]} de`
|
||||
: 'liée à')
|
||||
|
||||
const children = computed(() =>
|
||||
col.decisions
|
||||
.filter(d => d.parentDecisionId === props.decision.id)
|
||||
.map(d => ({
|
||||
...d,
|
||||
kindLabel: d.chainKind ? CHAIN_LABELS[d.chainKind] : 'liée',
|
||||
})))
|
||||
|
||||
const hasChain = computed(() => parent.value !== undefined || children.value.length > 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<section v-if="hasChain" class="chain">
|
||||
<h2 class="chain__title">Chaînage</h2>
|
||||
|
||||
<NuxtLink
|
||||
v-if="parent"
|
||||
:to="`/decisions/${parent.id}`"
|
||||
class="chain__link"
|
||||
>
|
||||
<UIcon name="i-lucide-corner-left-up" />
|
||||
<span class="chain__kind">{{ parentLabel }}</span>
|
||||
<span class="chain__name">{{ parent.title }}</span>
|
||||
<span class="status-pill" :class="`status-${parent.status}`">
|
||||
{{ STATUS_LABELS[parent.status] }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
v-for="child in children"
|
||||
:key="child.id"
|
||||
:to="`/decisions/${child.id}`"
|
||||
class="chain__link"
|
||||
>
|
||||
<UIcon name="i-lucide-corner-down-right" />
|
||||
<span class="chain__kind">{{ child.kindLabel }}</span>
|
||||
<span class="chain__name">{{ child.title }}</span>
|
||||
<span class="status-pill" :class="`status-${child.status}`">
|
||||
{{ STATUS_LABELS[child.status] }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chain { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.chain__title {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.chain__link {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-text);
|
||||
text-decoration: none;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
.chain__link:hover { transform: translateY(-1px); }
|
||||
.chain__kind {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-accent);
|
||||
text-transform: none;
|
||||
}
|
||||
.chain__name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
min-width: 10rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,288 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> ÉLÉMENTS tab of a split dossier: element children with states
|
||||
// and deadlines, 0-3 stake weighting by the concerned (Concern.priority — feeds
|
||||
// the closing cartography, never the voting right), auto-generated closing
|
||||
// cartography, and the steward's « dossier complet » closing gesture.
|
||||
import type { Decision } from '~/types/domain'
|
||||
import { DOSSIER_COMPLETE_CARD, STATUS_LABELS } from '~/lexicon'
|
||||
import { TERMINAL_STATUSES } from '~/engine'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { useDecisionsStore } from '~/stores/decisions'
|
||||
import { dateFr } from './decisionUi'
|
||||
|
||||
const props = defineProps<{ decision: Decision }>()
|
||||
|
||||
const col = useCollectiveStore()
|
||||
const store = useDecisionsStore()
|
||||
|
||||
const elements = computed(() =>
|
||||
col.decisions.filter(d =>
|
||||
d.parentDecisionId === props.decision.id && d.chainKind === 'element'))
|
||||
|
||||
const meId = computed(() => col.me?.id ?? null)
|
||||
|
||||
function priorityStats(elementId: string) {
|
||||
const priorities = col.concerns
|
||||
.filter(c => c.decisionId === elementId && c.priority !== undefined)
|
||||
.map(c => c.priority as number)
|
||||
if (priorities.length === 0) return { avg: null as number | null, count: 0 }
|
||||
const avg = priorities.reduce((a, b) => a + b, 0) / priorities.length
|
||||
return { avg: Math.round(avg * 10) / 10, count: priorities.length }
|
||||
}
|
||||
|
||||
function myPriority(elementId: string): number | undefined {
|
||||
if (!meId.value) return undefined
|
||||
return col.concerns.find(
|
||||
c => c.decisionId === elementId && c.personId === meId.value,
|
||||
)?.priority
|
||||
}
|
||||
|
||||
/** « Pondère tes enjeux » — set my priority on this element (0-3). */
|
||||
function setPriority(elementId: string, priority: 0 | 1 | 2 | 3) {
|
||||
if (!meId.value || !col.current) return
|
||||
let mine = col.concerns.find(
|
||||
c => c.decisionId === elementId && c.personId === meId.value,
|
||||
)
|
||||
if (!mine) {
|
||||
const created = store.declareConcern(elementId, meId.value)
|
||||
if ('ok' in created) return
|
||||
mine = created
|
||||
}
|
||||
mine.priority = priority
|
||||
col.stamp(mine)
|
||||
col.persist()
|
||||
}
|
||||
|
||||
const allTerminal = computed(() =>
|
||||
elements.value.length > 0
|
||||
&& elements.value.every(e => TERMINAL_STATUSES.includes(e.status)))
|
||||
|
||||
const isSteward = computed(() => {
|
||||
if (!meId.value) return false
|
||||
if (props.decision.stewardIds.length > 0) {
|
||||
return props.decision.stewardIds.includes(meId.value)
|
||||
}
|
||||
return props.decision.authorId === meId.value
|
||||
})
|
||||
|
||||
const closeError = ref('')
|
||||
function closeDossier() {
|
||||
const result = store.transition(props.decision.id, 'closed')
|
||||
closeError.value = result.ok ? '' : result.reason
|
||||
}
|
||||
|
||||
const PRIORITIES = [0, 1, 2, 3] as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<section class="els">
|
||||
<h2 class="els__title">Éléments du dossier</h2>
|
||||
<p class="els__hint">
|
||||
Chaque élément suit son propre chemin — pondère tes enjeux de 0 à 3 :
|
||||
la pondération nourrit la cartographie, jamais le droit de vote.
|
||||
</p>
|
||||
|
||||
<ul class="els__list">
|
||||
<li v-for="el in elements" :key="el.id" class="els__item">
|
||||
<div class="els__item-main">
|
||||
<NuxtLink :to="`/decisions/${el.id}`" class="els__item-title">
|
||||
{{ el.title }}
|
||||
</NuxtLink>
|
||||
<span class="status-pill" :class="`status-${el.status}`">
|
||||
{{ STATUS_LABELS[el.status] }}
|
||||
</span>
|
||||
<LdCountdown
|
||||
v-if="el.windowEndsAt"
|
||||
:ends-at="el.windowEndsAt"
|
||||
:suspended-at="el.windowSuspendedAt"
|
||||
/>
|
||||
</div>
|
||||
<div class="els__weighting no-print">
|
||||
<span class="els__weighting-label">Mes enjeux</span>
|
||||
<button
|
||||
v-for="p in PRIORITIES"
|
||||
:key="p"
|
||||
type="button"
|
||||
class="els__prio"
|
||||
:class="{ 'els__prio--on': myPriority(el.id) === p }"
|
||||
@click="setPriority(el.id, p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Cartographie de clôture — auto-générée -->
|
||||
<div class="els__map">
|
||||
<h3 class="els__map-title">Cartographie de clôture</h3>
|
||||
<table class="els__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Élément</th>
|
||||
<th>État</th>
|
||||
<th>Enjeu moyen</th>
|
||||
<th>Échéance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="el in elements" :key="el.id">
|
||||
<td>{{ el.title }}</td>
|
||||
<td>
|
||||
<span class="status-pill" :class="`status-${el.status}`">
|
||||
{{ STATUS_LABELS[el.status] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="priorityStats(el.id).avg !== null">
|
||||
{{ priorityStats(el.id).avg }} / 3
|
||||
<span class="els__map-count">({{ priorityStats(el.id).count }})</span>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</td>
|
||||
<td>{{ el.windowEndsAt ? dateFr(el.windowEndsAt) : (el.decidedAt ? dateFr(el.decidedAt) : '—') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Dossier complet — le geste du garant, jamais automatique -->
|
||||
<div v-if="allTerminal" class="els__complete">
|
||||
<p class="els__complete-text">
|
||||
<UIcon name="i-lucide-flag" />
|
||||
<span>{{ DOSSIER_COMPLETE_CARD }}</span>
|
||||
</p>
|
||||
<button
|
||||
v-if="isSteward"
|
||||
type="button"
|
||||
class="ld-btn no-print"
|
||||
@click="closeDossier()"
|
||||
>
|
||||
<UIcon name="i-lucide-stamp" />
|
||||
<span>Clore le dossier</span>
|
||||
</button>
|
||||
<p v-else class="els__hint">Le geste appartient au garant du dossier.</p>
|
||||
<p v-if="closeError" class="els__error">{{ closeError }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.els { display: flex; flex-direction: column; gap: 0.875rem; }
|
||||
.els__title {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.els__hint {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.els__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.els__item {
|
||||
padding: 0.75rem 0.875rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--mood-accent-soft);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.els__item-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.els__item-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
text-decoration: none;
|
||||
flex: 1;
|
||||
min-width: 10rem;
|
||||
}
|
||||
.els__item-title:hover { color: var(--mood-accent); }
|
||||
.els__weighting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.els__weighting-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.els__prio {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 50%;
|
||||
background: var(--mood-surface);
|
||||
font-weight: 800;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--mood-text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.els__prio:hover { transform: translateY(-1px); }
|
||||
.els__prio--on {
|
||||
background: var(--mood-accent);
|
||||
color: var(--mood-accent-text);
|
||||
}
|
||||
.els__map-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.els__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.els__table th {
|
||||
text-align: left;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
padding: 0.375rem 0.5rem;
|
||||
}
|
||||
.els__table td {
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: var(--mood-accent-soft);
|
||||
}
|
||||
.els__table tbody tr td:first-child { border-radius: var(--r-input) 0 0 var(--r-input); }
|
||||
.els__table tbody tr td:last-child { border-radius: 0 var(--r-input) var(--r-input) 0; }
|
||||
.els__map-count { color: var(--mood-text-muted); font-size: 0.8125rem; }
|
||||
.els__complete {
|
||||
padding: 1rem 1.125rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--mood-status-vigueur-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.els__complete-text {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-status-vigueur);
|
||||
}
|
||||
.els__error {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-error);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
<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>
|
||||
@@ -0,0 +1,353 @@
|
||||
<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>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Sheet actions + proof card: « Imprimer » (A4 PV — the print
|
||||
// stylesheet lives in moods.css), « Graver » → truncated copyable sha256 mono
|
||||
// fingerprint + « empreinte locale — démo ».
|
||||
import type { Decision } from '~/types/domain'
|
||||
import { PROOF_LOCAL } from '~/lexicon'
|
||||
import { useDecisionsStore } from '~/stores/decisions'
|
||||
import { dateFr } from './decisionUi'
|
||||
|
||||
const props = defineProps<{ decision: Decision }>()
|
||||
|
||||
const store = useDecisionsStore()
|
||||
|
||||
const engraving = computed(() => props.decision.engraving)
|
||||
const shortHash = computed(() =>
|
||||
engraving.value ? `${engraving.value.sha256.slice(0, 20)}…` : '')
|
||||
|
||||
const busy = ref(false)
|
||||
async function engraveNow() {
|
||||
busy.value = true
|
||||
try { await store.engrave(props.decision.id) }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
|
||||
const copied = ref(false)
|
||||
async function copyHash() {
|
||||
if (!engraving.value) return
|
||||
await navigator.clipboard.writeText(engraving.value.sha256)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 1500)
|
||||
}
|
||||
|
||||
function printSheet() { window.print() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<div class="proof">
|
||||
<div class="proof__actions no-print">
|
||||
<button type="button" class="ld-btn ld-btn--ghost" @click="printSheet()">
|
||||
<UIcon name="i-lucide-printer" />
|
||||
<span>Imprimer</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="!engraving"
|
||||
type="button"
|
||||
class="ld-btn ld-btn--ghost"
|
||||
:disabled="busy"
|
||||
@click="engraveNow()"
|
||||
>
|
||||
<span>井 Graver</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="engraving" class="ld-card proof__card">
|
||||
<p class="proof__title">Fiche de preuve</p>
|
||||
<button
|
||||
type="button"
|
||||
class="proof__hash no-print"
|
||||
title="Copier l'empreinte complète"
|
||||
@click="copyHash()"
|
||||
>
|
||||
<code>{{ shortHash }}</code>
|
||||
<UIcon :name="copied ? 'i-lucide-check' : 'i-lucide-copy'" />
|
||||
</button>
|
||||
<code class="print-only proof__hash-full">{{ engraving.sha256 }}</code>
|
||||
<p class="proof__meta">
|
||||
{{ PROOF_LOCAL }} — gravée le {{ dateFr(engraving.engravedAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.proof { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.proof__actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.proof__card {
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.proof__title { margin: 0; font-weight: 800; font-size: 0.9375rem; }
|
||||
.proof__hash {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: fit-content;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--mood-accent-soft);
|
||||
cursor: pointer;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
.proof__hash code,
|
||||
.proof__hash-full {
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.proof__meta {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Registry card: title, tinted route icon, weight, deadline,
|
||||
// mini vote gauge, 井 badge when engraved, urgency badge. Links to the fiche.
|
||||
import type { Decision } from '~/types/domain'
|
||||
import { ROUTE_ICONS, ROUTE_SHORT, URGENT_BADGE } from '~/lexicon'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { useDecisionsStore } from '~/stores/decisions'
|
||||
import { WEIGHT_SHORT, displayStatus, displayStatusLabel } from './decisionUi'
|
||||
|
||||
const props = defineProps<{ decision: Decision }>()
|
||||
|
||||
const col = useCollectiveStore()
|
||||
const store = useDecisionsStore()
|
||||
|
||||
const session = computed(() =>
|
||||
col.sessions
|
||||
.filter(s => s.decisionId === props.decision.id)
|
||||
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0],
|
||||
)
|
||||
|
||||
const shown = computed(() => displayStatus(props.decision, session.value))
|
||||
|
||||
/** Mini gauge — cast voices over the arrested list, voting only. */
|
||||
const gauge = computed(() => {
|
||||
const s = session.value
|
||||
if (!s || props.decision.status !== 'voting') return null
|
||||
const size = Math.max(s.corpusSize, 1)
|
||||
const cast = store.activeVotes(s.id).length
|
||||
return { cast, size, pct: Math.min(100, Math.round((cast / size) * 100)) }
|
||||
})
|
||||
|
||||
const gaugeTitle = computed(() =>
|
||||
gauge.value ? `${gauge.value.cast} voix sur ${gauge.value.size}` : '')
|
||||
|
||||
const deadline = computed(() => {
|
||||
if (props.decision.windowEndsAt) return props.decision.windowEndsAt
|
||||
if (session.value?.status === 'open') return session.value.closesAt
|
||||
return undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<NuxtLink :to="`/decisions/${decision.id}`" class="ld-card ld-card--hover reg-card">
|
||||
<span class="reg-card__icon" :style="{ color: `var(--route-${decision.route})` }">
|
||||
<UIcon :name="ROUTE_ICONS[decision.route]" />
|
||||
</span>
|
||||
|
||||
<span class="reg-card__body">
|
||||
<span class="reg-card__title">{{ decision.title }}</span>
|
||||
|
||||
<span class="reg-card__meta">
|
||||
<span class="status-pill" :class="`status-${shown}`">{{ displayStatusLabel(shown) }}</span>
|
||||
<span class="reg-card__chip">{{ ROUTE_SHORT[decision.route] }}</span>
|
||||
<span class="reg-card__chip">{{ WEIGHT_SHORT[decision.weight] }}</span>
|
||||
<LdCountdown
|
||||
v-if="deadline"
|
||||
:ends-at="deadline"
|
||||
:suspended-at="decision.windowSuspendedAt"
|
||||
/>
|
||||
<span v-if="decision.urgent" class="reg-card__urgent">
|
||||
<UIcon name="i-lucide-siren" />
|
||||
<span>{{ URGENT_BADGE }}</span>
|
||||
</span>
|
||||
<span v-if="decision.engraving" class="reg-card__well" title="gravée">井</span>
|
||||
</span>
|
||||
|
||||
<span v-if="gauge" class="reg-card__gauge" :title="gaugeTitle">
|
||||
<span class="reg-card__gauge-fill" :style="{ width: `${gauge.pct}%` }" />
|
||||
</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reg-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.875rem;
|
||||
padding: 1rem 1.125rem;
|
||||
text-decoration: none;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
.reg-card__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--r-icon);
|
||||
font-size: 1.25rem;
|
||||
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||
}
|
||||
.reg-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.reg-card__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.reg-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.reg-card__chip {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
background: var(--mood-accent-soft);
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.reg-card__urgent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--route-urgent);
|
||||
background: color-mix(in srgb, var(--route-urgent) 11%, transparent);
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.reg-card__well {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-status-vigueur);
|
||||
transform: rotate(-10deg);
|
||||
}
|
||||
.reg-card__gauge {
|
||||
display: block;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--mood-status-vote-bg);
|
||||
overflow: hidden;
|
||||
max-width: 16rem;
|
||||
}
|
||||
.reg-card__gauge-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--mood-status-vote);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,215 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Registry filters: circles grouped by kind (lieu/thème/équipe
|
||||
// icons), tags, « me concerne », weight, engraved 井, recorded entries.
|
||||
import type { CircleKind, Weight } from '~/types/domain'
|
||||
import { CONCERN_ME, ROUTE_SHORT } from '~/lexicon'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { KIND_ICONS, KIND_LABELS, WEIGHT_SHORT } from './decisionUi'
|
||||
|
||||
const circleId = defineModel<string | null>('circleId', { default: null })
|
||||
const tag = defineModel<string | null>('tag', { default: null })
|
||||
const mine = defineModel<boolean>('mine', { default: false })
|
||||
const weight = defineModel<Weight | null>('weight', { default: null })
|
||||
const engraved = defineModel<boolean>('engraved', { default: false })
|
||||
const recorded = defineModel<boolean>('recorded', { default: false })
|
||||
|
||||
const col = useCollectiveStore()
|
||||
|
||||
const KINDS: CircleKind[] = ['place', 'theme', 'team']
|
||||
|
||||
/** Circles grouped by kind — untyped circles last, under « Cercles ». */
|
||||
const circleGroups = computed(() => {
|
||||
const groups = KINDS
|
||||
.map(kind => ({
|
||||
kind,
|
||||
icon: KIND_ICONS[kind],
|
||||
label: KIND_LABELS[kind],
|
||||
circles: col.circles.filter(c => c.kind === kind),
|
||||
}))
|
||||
.filter(g => g.circles.length > 0)
|
||||
const untyped = col.circles.filter(c => c.kind === undefined)
|
||||
if (untyped.length > 0) {
|
||||
groups.push({ kind: 'team', icon: 'i-lucide-circle-dashed', label: 'Cercles', circles: untyped })
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
const allTags = computed(() =>
|
||||
[...new Set(col.decisions.flatMap(d => d.tags))].sort((a, b) => a.localeCompare(b, 'fr')))
|
||||
|
||||
const WEIGHTS: Weight[] = ['light', 'binding', 'structural']
|
||||
|
||||
function toggleCircle(id: string) { circleId.value = circleId.value === id ? null : id }
|
||||
function toggleTag(t: string) { tag.value = tag.value === t ? null : t }
|
||||
function toggleWeight(w: Weight) { weight.value = weight.value === w ? null : w }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<details class="reg-filters">
|
||||
<summary class="reg-filters__summary">
|
||||
<UIcon name="i-lucide-sliders-horizontal" />
|
||||
<span>Filtres</span>
|
||||
<span
|
||||
v-if="circleId || tag || mine || weight || engraved || recorded"
|
||||
class="reg-filters__dot"
|
||||
/>
|
||||
</summary>
|
||||
|
||||
<div class="reg-filters__body">
|
||||
<div v-for="group in circleGroups" :key="group.label" class="reg-filters__row">
|
||||
<span class="reg-filters__label">
|
||||
<UIcon :name="group.icon" />
|
||||
<span>{{ group.label }}</span>
|
||||
</span>
|
||||
<button
|
||||
v-for="circle in group.circles"
|
||||
:key="circle.id"
|
||||
type="button"
|
||||
class="reg-filters__chip"
|
||||
:class="{ 'reg-filters__chip--on': circleId === circle.id }"
|
||||
@click="toggleCircle(circle.id)"
|
||||
>
|
||||
{{ circle.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="allTags.length > 0" class="reg-filters__row">
|
||||
<span class="reg-filters__label">
|
||||
<UIcon name="i-lucide-hash" />
|
||||
<span>Tags</span>
|
||||
</span>
|
||||
<button
|
||||
v-for="t in allTags"
|
||||
:key="t"
|
||||
type="button"
|
||||
class="reg-filters__chip"
|
||||
:class="{ 'reg-filters__chip--on': tag === t }"
|
||||
@click="toggleTag(t)"
|
||||
>
|
||||
#{{ t }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="reg-filters__row">
|
||||
<span class="reg-filters__label">
|
||||
<UIcon name="i-lucide-anchor" />
|
||||
<span>Poids</span>
|
||||
</span>
|
||||
<button
|
||||
v-for="w in WEIGHTS"
|
||||
:key="w"
|
||||
type="button"
|
||||
class="reg-filters__chip"
|
||||
:class="{ 'reg-filters__chip--on': weight === w }"
|
||||
@click="toggleWeight(w)"
|
||||
>
|
||||
{{ WEIGHT_SHORT[w] }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="reg-filters__row">
|
||||
<span class="reg-filters__label">
|
||||
<UIcon name="i-lucide-filter" />
|
||||
<span>Et aussi</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="reg-filters__chip"
|
||||
:class="{ 'reg-filters__chip--on': mine }"
|
||||
@click="mine = !mine"
|
||||
>
|
||||
<UIcon name="i-lucide-hand" />
|
||||
<span>{{ CONCERN_ME }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="reg-filters__chip"
|
||||
:class="{ 'reg-filters__chip--on': engraved }"
|
||||
@click="engraved = !engraved"
|
||||
>
|
||||
<span>井 gravées</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="reg-filters__chip"
|
||||
:class="{ 'reg-filters__chip--on': recorded }"
|
||||
@click="recorded = !recorded"
|
||||
>
|
||||
<UIcon name="i-lucide-notebook-pen" />
|
||||
<span>{{ ROUTE_SHORT.record }}es</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reg-filters__summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.375rem 1rem;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-accent);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
}
|
||||
.reg-filters__summary::-webkit-details-marker { display: none; }
|
||||
.reg-filters__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--mood-accent);
|
||||
}
|
||||
.reg-filters__body {
|
||||
margin-top: 0.75rem;
|
||||
padding: 1rem 1.125rem;
|
||||
border-radius: var(--r-card);
|
||||
background: var(--mood-surface);
|
||||
box-shadow: var(--shadow-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.reg-filters__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.reg-filters__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text-muted);
|
||||
min-width: 6rem;
|
||||
}
|
||||
.reg-filters__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.25rem 0.875rem;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
.reg-filters__chip:hover { transform: translateY(-1px); color: var(--mood-text); }
|
||||
.reg-filters__chip:active { transform: translateY(0); }
|
||||
.reg-filters__chip--on {
|
||||
background: var(--mood-accent);
|
||||
color: var(--mood-accent-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Session block: link to the vote room, arrested-list sentence
|
||||
// (« Qui vote : N personnes — liste arrêtée le … »), frozen banner while the
|
||||
// steward's crystallization gesture is awaited, outcome once closed.
|
||||
import type { Decision } from '~/types/domain'
|
||||
import { FROZEN_BANNER, WHO_VOTES } from '~/lexicon'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { dateFr } from './decisionUi'
|
||||
|
||||
const props = defineProps<{ decision: Decision }>()
|
||||
|
||||
const col = useCollectiveStore()
|
||||
|
||||
const session = computed(() =>
|
||||
col.sessions
|
||||
.filter(s => s.decisionId === props.decision.id)
|
||||
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0])
|
||||
|
||||
const protocol = computed(() =>
|
||||
session.value
|
||||
? col.protocols.find(p => p.id === session.value!.protocolId)
|
||||
: undefined)
|
||||
|
||||
const whoVotes = computed(() =>
|
||||
session.value
|
||||
? WHO_VOTES(session.value.corpusSize, dateFr(session.value.opensAt))
|
||||
: '')
|
||||
|
||||
const crystallizedBy = computed(() => {
|
||||
const s = session.value
|
||||
if (!s?.crystallizedById) return ''
|
||||
const who = col.people.find(p => p.id === s.crystallizedById)?.displayName ?? '—'
|
||||
return `Cristallisée par ${who} le ${dateFr(s.crystallizedAt)}`
|
||||
})
|
||||
|
||||
const outcomeLabel = computed(() => {
|
||||
switch (session.value?.outcome) {
|
||||
case 'adopted': return 'adoptée'
|
||||
case 'rejected': return 'rejetée'
|
||||
case 'tie': return 'égalité — vous départagez, jamais l\'outil'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<section v-if="session" class="ses">
|
||||
<div class="ses__head">
|
||||
<h2 class="ses__title">Session de vote</h2>
|
||||
<span v-if="protocol" class="ses__protocol">{{ protocol.name }}</span>
|
||||
</div>
|
||||
|
||||
<p class="ses__who">{{ whoVotes }}</p>
|
||||
|
||||
<p v-if="session.status === 'frozen'" class="ses__frozen">
|
||||
<UIcon name="i-lucide-stamp" />
|
||||
<span>{{ FROZEN_BANNER }}</span>
|
||||
</p>
|
||||
|
||||
<template v-if="session.status === 'closed'">
|
||||
<p class="ses__result">
|
||||
Résultat : <strong>{{ outcomeLabel }}</strong>
|
||||
<span class="ses__result-date">— clôture le {{ dateFr(session.closesAt) }}</span>
|
||||
</p>
|
||||
<p v-if="crystallizedBy" class="ses__crystal">{{ crystallizedBy }}</p>
|
||||
</template>
|
||||
|
||||
<div v-else class="ses__actions no-print">
|
||||
<NuxtLink :to="`/decisions/${decision.id}/vote`" class="ld-btn">
|
||||
<UIcon name="i-lucide-vote" />
|
||||
<span>{{ session.status === 'open' ? 'Entrer dans la salle de vote' : 'Voir la salle de vote' }}</span>
|
||||
</NuxtLink>
|
||||
<LdCountdown v-if="session.status === 'open'" :ends-at="session.closesAt" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ses { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.ses__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ses__title {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.ses__protocol {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-status-vote);
|
||||
background: var(--mood-status-vote-bg);
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.ses__who {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ses__frozen {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: fit-content;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: var(--r-input);
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-status-fige);
|
||||
background: var(--mood-status-fige-bg);
|
||||
}
|
||||
.ses__result { margin: 0; font-size: 0.9375rem; }
|
||||
.ses__result-date { color: var(--mood-text-muted); font-size: 0.875rem; }
|
||||
.ses__crystal {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.ses__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,269 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> « En vigueur » block: stewards, measurers, « Ce que ça
|
||||
// engage », sunset, and the reality check when due — the sought/target/
|
||||
// observed table ABOVE the three buttons. Measurers record observations here.
|
||||
import type { Decision } from '~/types/domain'
|
||||
import {
|
||||
ENGAGES_LABEL, REVIEW_QUESTION, REVIEW_TITLE, REVIEW_VERDICTS,
|
||||
} from '~/lexicon'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { useDecisionsStore } from '~/stores/decisions'
|
||||
import { dateFr } from './decisionUi'
|
||||
|
||||
const props = defineProps<{ decision: Decision }>()
|
||||
|
||||
const col = useCollectiveStore()
|
||||
const store = useDecisionsStore()
|
||||
|
||||
const reviewLabels = REVIEW_VERDICTS
|
||||
|
||||
const names = (ids: string[]) =>
|
||||
ids.map(id => col.people.find(p => p.id === id)?.displayName ?? '—').join(', ')
|
||||
|
||||
const stewards = computed(() => names(props.decision.stewardIds))
|
||||
const measurers = computed(() => names(props.decision.measurerIds))
|
||||
|
||||
const meId = computed(() => col.me?.id ?? null)
|
||||
const iMeasure = computed(() =>
|
||||
meId.value !== null && props.decision.measurerIds.includes(meId.value))
|
||||
|
||||
const effects = computed(() => props.decision.brief?.effects ?? [])
|
||||
|
||||
const review = computed(() => props.decision.review)
|
||||
const reviewDue = computed(() =>
|
||||
review.value !== undefined
|
||||
&& review.value.verdict === undefined
|
||||
&& new Date(review.value.dueAt).getTime() <= Date.now())
|
||||
|
||||
const reviewDoneLabel = computed(() => {
|
||||
const r = review.value
|
||||
if (!r?.verdict) return ''
|
||||
return `${reviewLabels[r.verdict]} — le ${dateFr(r.decidedAt)}`
|
||||
})
|
||||
|
||||
/** One line for the facts list — done label, or the planned date. */
|
||||
const reviewLine = computed(() => {
|
||||
const r = review.value
|
||||
if (!r) return ''
|
||||
return r.verdict ? reviewDoneLabel.value : `prévue le ${dateFr(r.dueAt)}`
|
||||
})
|
||||
|
||||
// ── Le geste d'épreuve ──
|
||||
const reviewNote = ref('')
|
||||
const feedback = ref('')
|
||||
async function giveReview(kind: 'confirmed' | 'revise' | 'revoke') {
|
||||
const note = reviewNote.value.trim()
|
||||
const result = store.reviewVerdict(props.decision.id, kind, note.length > 0 ? note : undefined)
|
||||
if ('ok' in result) { feedback.value = result.reason; return }
|
||||
feedback.value = ''
|
||||
// revise/revoke chain a child decision under the ORIGINAL protocol — go there.
|
||||
if (result.id !== props.decision.id) await navigateTo(`/decisions/${result.id}`)
|
||||
}
|
||||
|
||||
// ── Saisie de mesure (measurers) ──
|
||||
const measureDrafts = ref<Record<number, string>>({})
|
||||
function saveMeasure(index: number) {
|
||||
const note = (measureDrafts.value[index] ?? '').trim()
|
||||
if (note.length === 0 || !meId.value) return
|
||||
const effect = props.decision.brief?.effects[index]
|
||||
if (!effect) return
|
||||
effect.measured = { note, at: col.now(), byId: meId.value }
|
||||
col.stamp(props.decision)
|
||||
col.persist()
|
||||
measureDrafts.value[index] = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<section class="vig">
|
||||
<h2 class="vig__title">En vigueur</h2>
|
||||
|
||||
<dl class="vig__facts">
|
||||
<template v-if="stewards">
|
||||
<dt>Garant·e·s</dt>
|
||||
<dd>{{ stewards }}</dd>
|
||||
</template>
|
||||
<template v-if="measurers">
|
||||
<dt>Mesureur·e·s</dt>
|
||||
<dd>{{ measurers }}</dd>
|
||||
</template>
|
||||
<template v-if="decision.resources">
|
||||
<dt>{{ ENGAGES_LABEL }}</dt>
|
||||
<dd>
|
||||
{{ decision.resources.note }}
|
||||
<strong v-if="decision.resources.amount !== undefined">
|
||||
— {{ decision.resources.amount.toLocaleString('fr-FR') }} {{ decision.resources.unit ?? '' }}
|
||||
</strong>
|
||||
</dd>
|
||||
</template>
|
||||
<template v-if="decision.sunsetAt">
|
||||
<dt>Échéance de fin</dt>
|
||||
<dd>{{ dateFr(decision.sunsetAt) }}</dd>
|
||||
</template>
|
||||
<template v-if="review">
|
||||
<dt>{{ REVIEW_TITLE }}</dt>
|
||||
<dd>{{ reviewLine }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
|
||||
<p v-if="review?.note" class="vig__review-note">« {{ review.note }} »</p>
|
||||
|
||||
<!-- L'épreuve du réel — due -->
|
||||
<div v-if="reviewDue" class="vig__review">
|
||||
<h3 class="vig__review-title">{{ REVIEW_TITLE }}</h3>
|
||||
<p class="vig__review-question">{{ REVIEW_QUESTION }}</p>
|
||||
|
||||
<table v-if="effects.length > 0" class="vig__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Effet recherché</th>
|
||||
<th>Cible</th>
|
||||
<th>Constaté</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(effect, i) in effects" :key="i">
|
||||
<td>{{ effect.label }}</td>
|
||||
<td>{{ effect.target ?? '—' }}</td>
|
||||
<td>
|
||||
<template v-if="effect.measured">
|
||||
{{ effect.measured.note }}
|
||||
<span class="vig__measured-at">({{ dateFr(effect.measured.at) }})</span>
|
||||
</template>
|
||||
<div v-else-if="iMeasure" class="vig__measure no-print">
|
||||
<input
|
||||
v-model="measureDrafts[i]"
|
||||
type="text"
|
||||
placeholder="Ce que tu constates…"
|
||||
>
|
||||
<button type="button" class="ld-btn ld-btn--ghost" @click="saveMeasure(i)">
|
||||
Consigner
|
||||
</button>
|
||||
</div>
|
||||
<template v-else>—</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="vig__hint">
|
||||
Aucun effet recherché n'avait été formulé — l'épreuve se joue sur la mémoire du collectif.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
v-model="reviewNote"
|
||||
rows="2"
|
||||
class="no-print"
|
||||
placeholder="Un mot sur ce que le réel a montré (optionnel)…"
|
||||
/>
|
||||
<div class="vig__actions no-print">
|
||||
<button type="button" class="ld-btn" @click="giveReview('confirmed')">
|
||||
{{ reviewLabels.confirmed }}
|
||||
</button>
|
||||
<button type="button" class="ld-btn ld-btn--ghost" @click="giveReview('revise')">
|
||||
{{ reviewLabels.revise }}
|
||||
</button>
|
||||
<button type="button" class="ld-btn ld-btn--ghost" @click="giveReview('revoke')">
|
||||
{{ reviewLabels.revoke }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="feedback" class="vig__error">{{ feedback }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vig { display: flex; flex-direction: column; gap: 0.875rem; }
|
||||
.vig__title {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.vig__facts {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.375rem 1rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.vig__facts dt {
|
||||
font-weight: 700;
|
||||
color: var(--mood-text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.vig__facts dd { margin: 0; }
|
||||
.vig__review-note {
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
color: var(--mood-text-muted);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.vig__review {
|
||||
padding: 1rem 1.125rem;
|
||||
border-radius: var(--r-input);
|
||||
background: color-mix(in srgb, var(--mood-tertiary) 9%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.vig__review-title { margin: 0; font-size: 1rem; font-weight: 800; }
|
||||
.vig__review-question {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-style: italic;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.vig__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.vig__table th {
|
||||
text-align: left;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
padding: 0.375rem 0.5rem;
|
||||
}
|
||||
.vig__table td {
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: var(--mood-surface);
|
||||
vertical-align: top;
|
||||
}
|
||||
.vig__measured-at { color: var(--mood-text-muted); font-size: 0.8125rem; }
|
||||
.vig__measure {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.vig__measure input {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.vig__review textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
font-size: 0.9375rem;
|
||||
resize: vertical;
|
||||
}
|
||||
.vig__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.vig__hint {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.vig__error {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-error);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Window block. Objection: « Ça me va / J'objecte » inline —
|
||||
// silence counts as agreement ONLY when easily reversible, otherwise the
|
||||
// explicit-agreement counter + « il manque un accord explicite ». Advice:
|
||||
// three inline positions, the author then decides.
|
||||
import type { Advice, Decision } from '~/types/domain'
|
||||
import { ASSENT_MISSING, WINDOW_OBJECT, WINDOW_OK } from '~/lexicon'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { useDecisionsStore } from '~/stores/decisions'
|
||||
import { ADVICE_LABELS } from './decisionUi'
|
||||
|
||||
const props = defineProps<{ decision: Decision }>()
|
||||
|
||||
const col = useCollectiveStore()
|
||||
const store = useDecisionsStore()
|
||||
|
||||
const meId = computed(() => col.me?.id ?? null)
|
||||
const isAuthor = computed(() => meId.value === props.decision.authorId)
|
||||
|
||||
// ── Assents (« Ça me va » stocké) ──
|
||||
const assents = computed(() =>
|
||||
col.assents.filter(a => a.decisionId === props.decision.id))
|
||||
const thirdPartyCount = computed(() =>
|
||||
assents.value.filter(a => a.personId !== props.decision.authorId).length)
|
||||
const iAssented = computed(() =>
|
||||
meId.value !== null && assents.value.some(a => a.personId === meId.value))
|
||||
|
||||
function assent() { store.assentTo(props.decision.id) }
|
||||
|
||||
// ── Objection de fond ──
|
||||
const objectOpen = ref(false)
|
||||
const objectArg = ref('')
|
||||
const feedback = ref('')
|
||||
function object() {
|
||||
const result = store.objectTo(props.decision.id, 'content', objectArg.value)
|
||||
if ('ok' in result) { feedback.value = result.reason; return }
|
||||
objectOpen.value = false
|
||||
objectArg.value = ''
|
||||
feedback.value = ''
|
||||
}
|
||||
|
||||
// ── Avis ──
|
||||
const myAdvice = computed(() =>
|
||||
col.advices.find(a => a.decisionId === props.decision.id && a.personId === meId.value))
|
||||
const adviceNote = ref('')
|
||||
function advise(position: Advice['position']) {
|
||||
const note = adviceNote.value.trim()
|
||||
store.adviseOn(props.decision.id, position, note.length > 0 ? note : undefined)
|
||||
adviceNote.value = ''
|
||||
}
|
||||
|
||||
/** The author decides once instructed — the ONE state gate answers. */
|
||||
function authorAdopts() {
|
||||
const result = store.transition(props.decision.id, 'adopted')
|
||||
feedback.value = result.ok ? '' : result.reason
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<section class="win">
|
||||
<div class="win__head">
|
||||
<h2 class="win__title">
|
||||
{{ decision.status === 'advice' ? 'Fenêtre d\'avis' : 'Fenêtre d\'objection' }}
|
||||
</h2>
|
||||
<LdCountdown
|
||||
v-if="decision.windowEndsAt"
|
||||
:ends-at="decision.windowEndsAt"
|
||||
:suspended-at="decision.windowSuspendedAt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="decision.status === 'objection'">
|
||||
<p v-if="decision.reversibility === 'easy'" class="win__rule">
|
||||
À l'échéance sans objection, c'est adopté — le silence vaut accord,
|
||||
parce que c'est facilement réversible.
|
||||
</p>
|
||||
<template v-else>
|
||||
<p class="win__rule">
|
||||
{{ thirdPartyCount }} accord{{ thirdPartyCount > 1 ? 's' : '' }} explicite{{ thirdPartyCount > 1 ? 's' : '' }}
|
||||
— hors du réversible, l'accord est un geste, pas une absence.
|
||||
</p>
|
||||
<p v-if="thirdPartyCount === 0" class="win__missing">{{ ASSENT_MISSING }}</p>
|
||||
</template>
|
||||
|
||||
<div class="win__actions no-print">
|
||||
<button
|
||||
type="button"
|
||||
class="ld-btn"
|
||||
:disabled="iAssented"
|
||||
@click="assent()"
|
||||
>
|
||||
<UIcon name="i-lucide-check" />
|
||||
<span>{{ iAssented ? `${WINDOW_OK} — déposé` : WINDOW_OK }}</span>
|
||||
</button>
|
||||
<button type="button" class="ld-btn ld-btn--ghost" @click="objectOpen = !objectOpen">
|
||||
{{ WINDOW_OBJECT }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="objectOpen" class="win__form no-print">
|
||||
<textarea
|
||||
v-model="objectArg"
|
||||
rows="2"
|
||||
placeholder="Une objection s'argumente — écris pourquoi."
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="ld-btn"
|
||||
:disabled="objectArg.trim().length === 0"
|
||||
@click="object()"
|
||||
>
|
||||
Déposer l'objection
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="decision.status === 'advice'">
|
||||
<p class="win__rule">
|
||||
L'auteur·e écoute, puis décide — les avis restent annexés à la décision.
|
||||
</p>
|
||||
<div v-if="!myAdvice" class="win__form no-print">
|
||||
<textarea
|
||||
v-model="adviceNote"
|
||||
rows="2"
|
||||
placeholder="Un mot pour éclairer (optionnel)…"
|
||||
/>
|
||||
<div class="win__actions">
|
||||
<button type="button" class="ld-btn" @click="advise('favorable')">
|
||||
{{ ADVICE_LABELS.favorable }}
|
||||
</button>
|
||||
<button type="button" class="ld-btn ld-btn--ghost" @click="advise('reserved')">
|
||||
{{ ADVICE_LABELS.reserved }}
|
||||
</button>
|
||||
<button type="button" class="ld-btn ld-btn--ghost" @click="advise('unfavorable')">
|
||||
{{ ADVICE_LABELS.unfavorable }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="win__rule">Ton avis est déposé — {{ ADVICE_LABELS[myAdvice.position] }}.</p>
|
||||
|
||||
<div v-if="isAuthor" class="win__actions no-print">
|
||||
<button type="button" class="ld-btn" @click="authorAdopts()">
|
||||
<UIcon name="i-lucide-check-check" />
|
||||
<span>Je décide — adopter</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="feedback" class="win__feedback">{{ feedback }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.win { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.win__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.win__title {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.win__rule {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.win__missing {
|
||||
margin: 0;
|
||||
width: fit-content;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-surface);
|
||||
background: var(--mood-status-fenetre);
|
||||
}
|
||||
.win__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.win__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.win__form textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
font-size: 0.9375rem;
|
||||
resize: vertical;
|
||||
}
|
||||
.win__feedback {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-status-fenetre);
|
||||
}
|
||||
</style>
|
||||
@@ -1,147 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Visual stepper/timeline showing the decision workflow.
|
||||
*
|
||||
* Displays each step with its type icon, status badge, and dates.
|
||||
* The active step is highlighted, completed steps show a checkmark.
|
||||
*/
|
||||
import type { DecisionStep } from '~/stores/decisions'
|
||||
|
||||
const props = defineProps<{
|
||||
steps: DecisionStep[]
|
||||
currentStatus: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'create-vote-session': [step: DecisionStep]
|
||||
}>()
|
||||
|
||||
const sortedSteps = computed(() => {
|
||||
return [...props.steps].sort((a, b) => a.step_order - b.step_order)
|
||||
})
|
||||
|
||||
const stepTypeLabel = (stepType: string) => {
|
||||
switch (stepType) {
|
||||
case 'qualification': return 'Qualification'
|
||||
case 'review': return 'Revue'
|
||||
case 'vote': return 'Vote'
|
||||
case 'execution': return 'Execution'
|
||||
case 'reporting': return 'Compte rendu'
|
||||
default: return stepType
|
||||
}
|
||||
}
|
||||
|
||||
const stepTypeIcon = (stepType: string) => {
|
||||
switch (stepType) {
|
||||
case 'qualification': return 'i-lucide-check-square'
|
||||
case 'review': return 'i-lucide-eye'
|
||||
case 'vote': return 'i-lucide-vote'
|
||||
case 'execution': return 'i-lucide-play'
|
||||
case 'reporting': return 'i-lucide-file-text'
|
||||
default: return 'i-lucide-circle'
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="sortedSteps.length === 0" class="text-center py-8">
|
||||
<UIcon name="i-lucide-list-checks" class="text-4xl text-gray-400 mb-3" />
|
||||
<p class="text-gray-500">Aucune etape definie pour cette decision</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="relative">
|
||||
<!-- Timeline line -->
|
||||
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="step in sortedSteps"
|
||||
:key="step.id"
|
||||
class="relative pl-12"
|
||||
>
|
||||
<!-- Timeline dot -->
|
||||
<div
|
||||
class="absolute left-2 w-5 h-5 rounded-full border-2 flex items-center justify-center"
|
||||
:class="{
|
||||
'bg-green-500 border-green-500': step.status === 'completed',
|
||||
'bg-primary border-primary': step.status === 'active' || step.status === 'in_progress',
|
||||
'bg-yellow-400 border-yellow-400': step.status === 'pending',
|
||||
'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600': step.status === 'draft',
|
||||
}"
|
||||
>
|
||||
<UIcon
|
||||
v-if="step.status === 'completed'"
|
||||
name="i-lucide-check"
|
||||
class="text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon :name="stepTypeIcon(step.step_type)" class="text-gray-500" />
|
||||
<span class="text-sm font-mono text-gray-400">Etape {{ step.step_order }}</span>
|
||||
<UBadge variant="subtle" color="neutral" size="xs">
|
||||
{{ stepTypeLabel(step.step_type) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<StatusBadge :status="step.status" type="decision" />
|
||||
</div>
|
||||
|
||||
<h3 v-if="step.title" class="font-medium text-gray-900 dark:text-white">
|
||||
{{ step.title }}
|
||||
</h3>
|
||||
|
||||
<p v-if="step.description" class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
|
||||
<div class="text-xs text-gray-500">
|
||||
Cree le {{ formatDate(step.created_at) }}
|
||||
</div>
|
||||
|
||||
<div v-if="step.outcome" class="flex items-center gap-2 mt-2">
|
||||
<UIcon name="i-lucide-flag" class="text-gray-400" />
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Resultat : {{ step.outcome }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Vote session actions -->
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<UButton
|
||||
v-if="step.vote_session_id"
|
||||
size="xs"
|
||||
variant="soft"
|
||||
color="primary"
|
||||
icon="i-lucide-vote"
|
||||
label="Voir la session de vote"
|
||||
/>
|
||||
<UButton
|
||||
v-else-if="step.step_type === 'vote' && (step.status === 'active' || step.status === 'pending')"
|
||||
size="xs"
|
||||
variant="soft"
|
||||
color="primary"
|
||||
icon="i-lucide-plus"
|
||||
label="Creer une session de vote"
|
||||
@click.stop="emit('create-vote-session', step)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Shared UI helpers of the decisions screens (registry + fiche) — v2.
|
||||
* Labels that complement app/lexicon.ts locally (short weight forms, chain
|
||||
* kinds, circle-kind iconography) live here; the lexicon stays the single
|
||||
* source for everything it already names.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ChainKind,
|
||||
CircleKind,
|
||||
Decision,
|
||||
DecisionStatus,
|
||||
VoteSession,
|
||||
Weight,
|
||||
} from '~/types/domain'
|
||||
import { FROZEN_LABEL, STATUS_LABELS } from '~/lexicon'
|
||||
|
||||
/** Short weight forms — registry pills and card metadata. */
|
||||
export const WEIGHT_SHORT: Record<Weight, string> = {
|
||||
light: 'léger',
|
||||
binding: 'engageant',
|
||||
structural: 'structurant',
|
||||
}
|
||||
|
||||
/** Chain kinds in plain French (fiche chain block). */
|
||||
export const CHAIN_LABELS: Record<ChainKind, string> = {
|
||||
ratification: 'ratification',
|
||||
revision: 'révision',
|
||||
revocation: 'révocation',
|
||||
element: 'élément',
|
||||
}
|
||||
|
||||
/** Circle kind iconography — lieu / thème / équipe (never a right). */
|
||||
export const KIND_ICONS: Record<CircleKind, string> = {
|
||||
place: 'i-lucide-map-pin',
|
||||
theme: 'i-lucide-tag',
|
||||
team: 'i-lucide-users-round',
|
||||
}
|
||||
export const KIND_LABELS: Record<CircleKind, string> = {
|
||||
place: 'Lieux',
|
||||
theme: 'Thèmes',
|
||||
team: 'Équipes',
|
||||
}
|
||||
|
||||
/** Advice positions in plain French. */
|
||||
export const ADVICE_LABELS = {
|
||||
favorable: 'favorable',
|
||||
reserved: 'réservé',
|
||||
unfavorable: 'défavorable',
|
||||
} as const
|
||||
|
||||
/** Display status: 'frozen' overlays 'voting' when the session is frozen. */
|
||||
export type DisplayStatus = DecisionStatus | 'frozen'
|
||||
|
||||
export function displayStatus(decision: Decision, session?: VoteSession): DisplayStatus {
|
||||
if (decision.status === 'voting' && session?.status === 'frozen') return 'frozen'
|
||||
return decision.status
|
||||
}
|
||||
|
||||
export function displayStatusLabel(status: DisplayStatus): string {
|
||||
return status === 'frozen' ? FROZEN_LABEL : STATUS_LABELS[status as DecisionStatus]
|
||||
}
|
||||
|
||||
/** Timeline color mapping — the ONE state→color mapping (moods.css tokens). */
|
||||
export const STATUS_TOKEN: Record<DisplayStatus, string> = {
|
||||
draft: 'prepa',
|
||||
advice: 'fenetre',
|
||||
objection: 'fenetre',
|
||||
framing: 'prepa',
|
||||
voting: 'vote',
|
||||
frozen: 'fige',
|
||||
adopted: 'vigueur',
|
||||
rejected: 'clos',
|
||||
revoked: 'revoque',
|
||||
transmitted: 'fige',
|
||||
closed: 'clos',
|
||||
}
|
||||
|
||||
/** Case/accent-insensitive folding — same logic as useSearch. */
|
||||
export function fold(text: string): string {
|
||||
return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
/** Short French date (« 12 août 2026 »). */
|
||||
export function dateFr(iso?: string): string {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric', month: 'short', year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
/** French date + time for timeline milestones. */
|
||||
export function dateTimeFr(iso?: string): string {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString('fr-FR', {
|
||||
day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user