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,464 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DecisionStep, DecisionStepCreate } from '~/stores/decisions'
|
||||
|
||||
const route = useRoute()
|
||||
const decisions = useDecisionsStore()
|
||||
|
||||
const decisionId = computed(() => route.params.id as string)
|
||||
|
||||
onMounted(async () => {
|
||||
await decisions.fetchById(decisionId.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
decisions.clearCurrent()
|
||||
})
|
||||
|
||||
watch(decisionId, async (newId) => {
|
||||
if (newId) {
|
||||
await decisions.fetchById(newId)
|
||||
}
|
||||
})
|
||||
|
||||
// --- Status helpers ---
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft': return 'warning'
|
||||
case 'qualification': return 'info'
|
||||
case 'review': return 'info'
|
||||
case 'voting': return 'primary'
|
||||
case 'executed': return 'success'
|
||||
case 'closed': return 'neutral'
|
||||
case 'pending': return 'warning'
|
||||
case 'active': return 'success'
|
||||
case 'in_progress': return 'success'
|
||||
case 'completed': return 'info'
|
||||
default: return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft': return 'Brouillon'
|
||||
case 'qualification': return 'Qualification'
|
||||
case 'review': return 'Revue'
|
||||
case 'voting': return 'En vote'
|
||||
case 'executed': return 'Execute'
|
||||
case 'closed': return 'Clos'
|
||||
case 'pending': return 'En attente'
|
||||
case 'active': return 'Actif'
|
||||
case 'in_progress': return 'En cours'
|
||||
case 'completed': return 'Termine'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
const typeLabel = (decisionType: string) => {
|
||||
switch (decisionType) {
|
||||
case 'runtime_upgrade': return 'Runtime upgrade'
|
||||
case 'document_change': return 'Modification de document'
|
||||
case 'mandate_vote': return 'Vote de mandat'
|
||||
case 'parameter_change': return 'Changement de parametre'
|
||||
case 'other': return 'Autre'
|
||||
default: return decisionType
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
// --- Terminal state check ---
|
||||
|
||||
const terminalStatuses = ['executed', 'closed']
|
||||
const isTerminal = computed(() => {
|
||||
if (!decisions.current) return true
|
||||
return terminalStatuses.includes(decisions.current.status)
|
||||
})
|
||||
|
||||
const isDraft = computed(() => decisions.current?.status === 'draft')
|
||||
|
||||
// --- Advance action ---
|
||||
|
||||
const advancing = ref(false)
|
||||
|
||||
async function handleAdvance() {
|
||||
advancing.value = true
|
||||
try {
|
||||
await decisions.advance(decisionId.value)
|
||||
} catch {
|
||||
// Error handled by store
|
||||
} finally {
|
||||
advancing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Create vote session ---
|
||||
|
||||
async function handleCreateVoteSession(step: DecisionStep) {
|
||||
try {
|
||||
await decisions.createVoteSession(decisionId.value, step.id)
|
||||
} catch {
|
||||
// Error handled by store
|
||||
}
|
||||
}
|
||||
|
||||
// --- Edit modal ---
|
||||
|
||||
const showEditModal = ref(false)
|
||||
const editData = ref({
|
||||
title: '',
|
||||
description: '' as string | null,
|
||||
context: '' as string | null,
|
||||
})
|
||||
const saving = ref(false)
|
||||
|
||||
function openEdit() {
|
||||
if (!decisions.current) return
|
||||
editData.value = {
|
||||
title: decisions.current.title,
|
||||
description: decisions.current.description,
|
||||
context: decisions.current.context,
|
||||
}
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
saving.value = true
|
||||
try {
|
||||
await decisions.update(decisionId.value, editData.value)
|
||||
showEditModal.value = false
|
||||
} catch {
|
||||
// Error handled by store
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Delete ---
|
||||
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
async function handleDelete() {
|
||||
deleting.value = true
|
||||
try {
|
||||
await decisions.delete(decisionId.value)
|
||||
navigateTo('/decisions')
|
||||
} catch {
|
||||
// Error handled by store
|
||||
} finally {
|
||||
deleting.value = false
|
||||
showDeleteConfirm.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Add step ---
|
||||
|
||||
const showAddStep = ref(false)
|
||||
const newStep = ref<DecisionStepCreate>({
|
||||
step_type: 'qualification',
|
||||
title: '',
|
||||
description: '',
|
||||
})
|
||||
const addingStep = ref(false)
|
||||
|
||||
const stepTypeOptions = [
|
||||
{ label: 'Qualification', value: 'qualification' },
|
||||
{ label: 'Revue', value: 'review' },
|
||||
{ label: 'Vote', value: 'vote' },
|
||||
{ label: 'Execution', value: 'execution' },
|
||||
{ label: 'Compte rendu', value: 'reporting' },
|
||||
]
|
||||
|
||||
async function handleAddStep() {
|
||||
addingStep.value = true
|
||||
try {
|
||||
await decisions.addStep(decisionId.value, newStep.value)
|
||||
showAddStep.value = false
|
||||
newStep.value = { step_type: 'qualification', title: '', description: '' }
|
||||
} catch {
|
||||
// Error handled by store
|
||||
} finally {
|
||||
addingStep.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Back link -->
|
||||
<div>
|
||||
<UButton
|
||||
to="/decisions"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
icon="i-lucide-arrow-left"
|
||||
label="Retour aux decisions"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<template v-if="decisions.loading">
|
||||
<div class="space-y-4">
|
||||
<USkeleton class="h-8 w-96" />
|
||||
<USkeleton class="h-4 w-64" />
|
||||
<div class="space-y-3 mt-8">
|
||||
<USkeleton v-for="i in 4" :key="i" class="h-20 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error state -->
|
||||
<template v-else-if="decisions.error">
|
||||
<UCard>
|
||||
<div class="flex items-center gap-3 text-red-500">
|
||||
<UIcon name="i-lucide-alert-circle" class="text-xl" />
|
||||
<p>{{ decisions.error }}</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Decision detail -->
|
||||
<template v-else-if="decisions.current">
|
||||
<!-- Header with actions -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ decisions.current.title }}
|
||||
</h1>
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<UBadge variant="subtle" color="primary">
|
||||
{{ typeLabel(decisions.current.decision_type) }}
|
||||
</UBadge>
|
||||
<UBadge :color="statusColor(decisions.current.status)" variant="subtle">
|
||||
{{ statusLabel(decisions.current.status) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center gap-2">
|
||||
<UButton
|
||||
v-if="!isTerminal"
|
||||
icon="i-lucide-fast-forward"
|
||||
label="Avancer la decision"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
:loading="advancing"
|
||||
@click="handleAdvance"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-pen-line"
|
||||
label="Modifier"
|
||||
variant="soft"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
@click="openEdit"
|
||||
/>
|
||||
<UButton
|
||||
v-if="isDraft"
|
||||
icon="i-lucide-trash-2"
|
||||
label="Supprimer"
|
||||
variant="soft"
|
||||
color="error"
|
||||
size="sm"
|
||||
@click="showDeleteConfirm = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description & Context -->
|
||||
<UCard v-if="decisions.current.description || decisions.current.context">
|
||||
<div class="space-y-4">
|
||||
<div v-if="decisions.current.description">
|
||||
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">Description</h3>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{{ decisions.current.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="decisions.current.context">
|
||||
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">Contexte</h3>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{{ decisions.current.context }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Metadata -->
|
||||
<UCard>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="text-gray-500">Cree le</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ formatDate(decisions.current.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500">Mis a jour le</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ formatDate(decisions.current.updated_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500">Nombre d'etapes</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ decisions.current.steps.length }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Steps timeline -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Etapes du processus
|
||||
</h2>
|
||||
<UButton
|
||||
v-if="!isTerminal"
|
||||
icon="i-lucide-plus"
|
||||
label="Ajouter une etape"
|
||||
variant="soft"
|
||||
color="primary"
|
||||
size="sm"
|
||||
@click="showAddStep = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DecisionWorkflow
|
||||
:steps="decisions.current.steps"
|
||||
:current-status="decisions.current.status"
|
||||
@create-vote-session="handleCreateVoteSession"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Edit modal -->
|
||||
<UModal v-model:open="showEditModal">
|
||||
<template #content>
|
||||
<div class="p-6 space-y-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Modifier la decision
|
||||
</h3>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Titre</label>
|
||||
<UInput v-model="editData.title" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
|
||||
<UTextarea v-model="editData.description" :rows="4" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Contexte</label>
|
||||
<UTextarea v-model="editData.context" :rows="3" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<UButton
|
||||
label="Annuler"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@click="showEditModal = false"
|
||||
/>
|
||||
<UButton
|
||||
label="Enregistrer"
|
||||
icon="i-lucide-save"
|
||||
color="primary"
|
||||
:loading="saving"
|
||||
:disabled="!editData.title?.trim()"
|
||||
@click="saveEdit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<!-- Delete confirmation modal -->
|
||||
<UModal v-model:open="showDeleteConfirm">
|
||||
<template #content>
|
||||
<div class="p-6 space-y-4">
|
||||
<h3 class="text-lg font-semibold text-red-600">
|
||||
Confirmer la suppression
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Etes-vous sur de vouloir supprimer cette decision ? Cette action est irreversible.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<UButton
|
||||
label="Annuler"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@click="showDeleteConfirm = false"
|
||||
/>
|
||||
<UButton
|
||||
label="Supprimer"
|
||||
icon="i-lucide-trash-2"
|
||||
color="error"
|
||||
:loading="deleting"
|
||||
@click="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<!-- Add step modal -->
|
||||
<UModal v-model:open="showAddStep">
|
||||
<template #content>
|
||||
<div class="p-6 space-y-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Ajouter une etape
|
||||
</h3>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Type d'etape <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<USelect
|
||||
v-model="newStep.step_type"
|
||||
:items="stepTypeOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Titre</label>
|
||||
<UInput v-model="newStep.title" placeholder="Titre de l'etape..." />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
|
||||
<UTextarea v-model="newStep.description" :rows="3" placeholder="Description de l'etape..." />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<UButton
|
||||
label="Annuler"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@click="showAddStep = false"
|
||||
/>
|
||||
<UButton
|
||||
label="Ajouter"
|
||||
icon="i-lucide-plus"
|
||||
color="primary"
|
||||
:loading="addingStep"
|
||||
@click="handleAddStep"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,346 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* /decisions/[id] — la fiche décision, URL permanente de l'objet pivot.
|
||||
* Progressive disclosure par état : timeline + périmètre toujours ;
|
||||
* s'instruire (formulation/vote), fenêtre, formulation, éléments de dossier,
|
||||
* session, vigueur, chaînage. Imprimable en PV A4 ; gravure locale.
|
||||
*/
|
||||
import {
|
||||
ADOPTED_STAMP, BASELINE_ARROW, ENGAGES_LABEL, INERTIA_LABELS,
|
||||
REOPEN_HANDLE, ROUTE_ICONS, ROUTE_LABELS, URGENT_BADGE, WEIGHT_LABELS,
|
||||
} from '~/lexicon'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { displayStatus, displayStatusLabel, dateFr } from '~/components/decisions/decisionUi'
|
||||
|
||||
const route = useRoute()
|
||||
const col = useCollectiveStore()
|
||||
|
||||
const decisionId = computed(() => route.params.id as string)
|
||||
const decision = computed(() => col.decisions.find(d => d.id === decisionId.value))
|
||||
|
||||
const session = computed(() =>
|
||||
col.sessions
|
||||
.filter(s => s.decisionId === decisionId.value)
|
||||
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0])
|
||||
|
||||
const shown = computed(() =>
|
||||
decision.value ? displayStatus(decision.value, session.value) : 'draft')
|
||||
|
||||
// ── Origine : protocole et inertie (poignée « Remettre en question ») ──
|
||||
const originProtocol = computed(() =>
|
||||
decision.value?.protocolId
|
||||
? col.protocols.find(p => p.id === decision.value!.protocolId)?.name
|
||||
: undefined)
|
||||
const originInertia = computed(() => {
|
||||
const clauseId = decision.value?.amendsClauseId
|
||||
if (!clauseId) return undefined
|
||||
const clause = col.clauses.find(c => c.id === clauseId)
|
||||
return clause ? INERTIA_LABELS[clause.inertia] : undefined
|
||||
})
|
||||
const originLine = computed(() => {
|
||||
const parts: string[] = []
|
||||
if (originProtocol.value) parts.push(originProtocol.value)
|
||||
if (originInertia.value) parts.push(originInertia.value)
|
||||
return parts.length > 0
|
||||
? `Le protocole et l'inertie d'origine s'appliquent : ${parts.join(' — ')}.`
|
||||
: 'Le protocole et l\'inertie d\'origine s\'appliquent.'
|
||||
})
|
||||
|
||||
// ── Blocs selon l'état ──
|
||||
const showInstruct = computed(() =>
|
||||
decision.value?.status === 'framing' || decision.value?.status === 'voting')
|
||||
const showWindow = computed(() =>
|
||||
decision.value?.status === 'objection' || decision.value?.status === 'advice')
|
||||
const hasElements = computed(() =>
|
||||
col.decisions.some(d =>
|
||||
d.parentDecisionId === decisionId.value && d.chainKind === 'element'))
|
||||
|
||||
const framingDays = computed(() => col.settings?.triage.framingDays ?? 14)
|
||||
|
||||
// ── Gestes ──
|
||||
async function splitDossier() {
|
||||
await navigateTo(`/decider?parent=${decisionId.value}&chain=element`)
|
||||
}
|
||||
async function reopen() {
|
||||
await navigateTo(`/decider?parent=${decisionId.value}&chain=revision`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<article v-if="decision" class="fiche">
|
||||
<!-- ── En-tête ── -->
|
||||
<header class="fiche__head">
|
||||
<div class="fiche__status-row">
|
||||
<span class="status-pill" :class="`status-${shown}`">
|
||||
{{ displayStatusLabel(shown) }}
|
||||
</span>
|
||||
<span v-if="decision.status === 'adopted'" class="ld-stamp">
|
||||
井 {{ ADOPTED_STAMP }}
|
||||
</span>
|
||||
<span v-if="decision.engraving" class="fiche__well" title="gravée">井</span>
|
||||
</div>
|
||||
|
||||
<h1 class="fiche__title">{{ decision.title }}</h1>
|
||||
|
||||
<p v-if="decision.baselineNote" class="fiche__baseline">
|
||||
<span class="fiche__baseline-arrow">{{ BASELINE_ARROW }}</span>
|
||||
<span>{{ decision.baselineNote }}</span>
|
||||
</p>
|
||||
|
||||
<div class="fiche__meta">
|
||||
<span class="fiche__route" :style="{ color: `var(--route-${decision.route})` }">
|
||||
<UIcon :name="ROUTE_ICONS[decision.route]" />
|
||||
<span>{{ ROUTE_LABELS[decision.route] }}</span>
|
||||
</span>
|
||||
<span class="fiche__chip">{{ WEIGHT_LABELS[decision.weight] }}</span>
|
||||
<span v-if="decision.urgent" class="fiche__urgent">
|
||||
<UIcon name="i-lucide-siren" />
|
||||
<span>{{ URGENT_BADGE }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="decision.decidedHow" class="fiche__how">
|
||||
Comment ça s'est décidé : « {{ decision.decidedHow }} »
|
||||
</p>
|
||||
|
||||
<div v-if="decision.routeOverridden && decision.overrideNote" class="fiche__banner">
|
||||
<UIcon name="i-lucide-feather" />
|
||||
<span><strong>Chemin allégé</strong> — « {{ decision.overrideNote }} »</span>
|
||||
</div>
|
||||
<div v-if="decision.scopeKeptNote" class="fiche__banner">
|
||||
<UIcon name="i-lucide-circle-dot" />
|
||||
<span><strong>Périmètre maintenu</strong> — « {{ decision.scopeKeptNote }} »</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── Actions + fiche de preuve ── -->
|
||||
<DecisionProof :decision="decision" />
|
||||
|
||||
<!-- ── Cycle de vie + périmètre ── -->
|
||||
<div class="fiche__grid">
|
||||
<div class="ld-card fiche__card">
|
||||
<h2 class="fiche__card-title">Cycle de vie</h2>
|
||||
<DecisionTimeline :decision="decision" :session="session" />
|
||||
</div>
|
||||
<div class="ld-card fiche__card">
|
||||
<DecisionPerimeter :decision="decision" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── S'instruire ── -->
|
||||
<div v-if="showInstruct" class="ld-card fiche__card">
|
||||
<DecisionInstruct :decision="decision" />
|
||||
</div>
|
||||
|
||||
<!-- ── Fenêtre ── -->
|
||||
<div v-if="showWindow" class="ld-card fiche__card">
|
||||
<DecisionWindow :decision="decision" />
|
||||
</div>
|
||||
|
||||
<!-- ── Formulation ── -->
|
||||
<div v-if="decision.status === 'framing'" class="ld-card fiche__card">
|
||||
<h2 class="fiche__card-title">Formulation</h2>
|
||||
<p class="fiche__framing-phrase">
|
||||
{{ framingDays }} jours pour s'instruire et formuler des contre-propositions.
|
||||
</p>
|
||||
<LdCountdown v-if="decision.windowEndsAt" :ends-at="decision.windowEndsAt" />
|
||||
<div v-if="decision.weight === 'structural'" class="no-print">
|
||||
<button type="button" class="ld-btn ld-btn--ghost" @click="splitDossier()">
|
||||
<UIcon name="i-lucide-scissors" />
|
||||
<span>Découper en micro-décisions</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Éléments du dossier ── -->
|
||||
<div v-if="hasElements" class="ld-card fiche__card">
|
||||
<DecisionElements :decision="decision" />
|
||||
</div>
|
||||
|
||||
<!-- ── Session ── -->
|
||||
<div v-if="session" class="ld-card fiche__card">
|
||||
<DecisionSession :decision="decision" />
|
||||
</div>
|
||||
|
||||
<!-- ── Vigueur ── -->
|
||||
<div v-if="decision.status === 'adopted'" class="ld-card fiche__card">
|
||||
<DecisionVigor :decision="decision" />
|
||||
</div>
|
||||
|
||||
<!-- ── Remettre en question ── -->
|
||||
<div v-if="decision.status === 'adopted'" class="ld-card fiche__card fiche__reopen no-print">
|
||||
<div>
|
||||
<p class="fiche__reopen-title">{{ REOPEN_HANDLE }}</p>
|
||||
<p class="fiche__reopen-line">{{ originLine }}</p>
|
||||
</div>
|
||||
<button type="button" class="ld-btn ld-btn--ghost" @click="reopen()">
|
||||
<UIcon name="i-lucide-rotate-ccw" />
|
||||
<span>{{ REOPEN_HANDLE }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Chaînage ── -->
|
||||
<div class="fiche__chain-wrap">
|
||||
<DecisionChain :decision="decision" />
|
||||
</div>
|
||||
|
||||
<!-- ── Pied de PV (impression) ── -->
|
||||
<p class="print-only fiche__pv-foot">
|
||||
{{ ENGAGES_LABEL }} : {{ decision.resources?.note ?? '—' }}
|
||||
— fiche imprimée le {{ dateFr(new Date().toISOString()) }} · libreDecision
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<div v-else class="ld-card fiche__missing">
|
||||
<p>Cette décision est introuvable dans ce collectif.</p>
|
||||
<NuxtLink to="/decisions" class="ld-btn ld-btn--ghost">Retour au registre</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fiche {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-width: 52rem;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.fiche__head { display: flex; flex-direction: column; gap: 0.625rem; }
|
||||
.fiche__status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.fiche__well {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-status-vigueur);
|
||||
transform: rotate(-10deg);
|
||||
}
|
||||
.fiche__title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.375rem, 4vw, 1.875rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.02em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.fiche__baseline {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.fiche__baseline-arrow {
|
||||
font-weight: 800;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-accent);
|
||||
background: var(--mood-accent-soft);
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fiche__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.fiche__route {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-weight: 800;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.fiche__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);
|
||||
}
|
||||
.fiche__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);
|
||||
}
|
||||
.fiche__how {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-style: italic;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.fiche__banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--r-input);
|
||||
font-size: 0.9375rem;
|
||||
color: var(--mood-status-fenetre);
|
||||
background: var(--mood-status-fenetre-bg);
|
||||
box-shadow: inset 0 0 0 1.5px var(--mood-status-fenetre);
|
||||
}
|
||||
.fiche__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.fiche__grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
.fiche__card {
|
||||
padding: 1.25rem 1.375rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.fiche__card-title {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.fiche__framing-phrase { margin: 0; font-size: 0.9375rem; font-weight: 600; }
|
||||
.fiche__reopen {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.fiche__reopen-title { margin: 0; font-weight: 800; font-size: 0.9375rem; }
|
||||
.fiche__reopen-line {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.fiche__pv-foot { font-size: 0.75rem; color: #333333; }
|
||||
.fiche__missing {
|
||||
max-width: 52rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
@media print {
|
||||
.fiche__grid { grid-template-columns: 1fr 1fr; }
|
||||
.fiche__card { padding: 0.5rem 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> La salle de vote — le geste démocratique central : instruit,
|
||||
// puis prononcé. Cinq modalités, un seul lieu ; la clôture due est constatée
|
||||
// au chargement, l'adoption s'applique d'elle-même et se fête sobrement.
|
||||
import { wotThreshold } from '~/engine'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { useDecisionsStore } from '~/stores/decisions'
|
||||
import {
|
||||
ADOPTED_STAMP, ADOPTED_TOAST, ELECTION_RULE, SECRET_DISPLAY, STATUS_LABELS,
|
||||
VOTE_PRIVACY, WHO_VOTES, WORKSHOP_MODE,
|
||||
} from '~/lexicon'
|
||||
import type { Id, VoteSession } from '~/types/domain'
|
||||
|
||||
const route = useRoute()
|
||||
const col = useCollectiveStore()
|
||||
const store = useDecisionsStore()
|
||||
|
||||
const decisionId = computed(() => String(route.params.id))
|
||||
const decision = computed(() => col.decisions.find(d => d.id === decisionId.value))
|
||||
const session = computed<VoteSession | undefined>(() =>
|
||||
col.sessions
|
||||
.filter(s => s.decisionId === decisionId.value)
|
||||
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0])
|
||||
const protocol = computed(() => col.protocols.find(p => p.id === session.value?.protocolId))
|
||||
const method = computed(() => protocol.value?.method)
|
||||
const secret = computed(() => protocol.value?.ballot === 'secret')
|
||||
|
||||
// ── Qui vote — la liste arrêtée ──
|
||||
const eligibleIds = computed<Id[]>(() => session.value?.corpusPersonIds ?? [])
|
||||
const voterEntries = computed(() =>
|
||||
col.people.filter(p => eligibleIds.value.includes(p.id)).map(person => ({ person })))
|
||||
const arrestedDate = computed(() => session.value
|
||||
? new Date(session.value.opensAt).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })
|
||||
: '')
|
||||
|
||||
// ── Mode atelier — saisir pour quelqu'un, en présence ──
|
||||
const workshop = ref(false)
|
||||
const asId = ref<Id | ''>('')
|
||||
onMounted(() => { workshop.value = !!localStorage.getItem('ld2-workshop') })
|
||||
const votingAs = computed<Id | undefined>(() =>
|
||||
workshop.value && asId.value && asId.value !== col.me?.id ? asId.value : undefined)
|
||||
const voterId = computed(() => votingAs.value ?? col.me?.id)
|
||||
const inList = computed(() => voterId.value !== undefined && eligibleIds.value.includes(voterId.value))
|
||||
const canAct = computed(() => session.value?.status === 'open' && inList.value)
|
||||
|
||||
const isSteward = computed(() => {
|
||||
const d = decision.value
|
||||
const me = col.me
|
||||
if (!d || !me) return false
|
||||
return d.stewardIds.length > 0 ? d.stewardIds.includes(me.id) : d.authorId === me.id
|
||||
})
|
||||
|
||||
// ── Clôture due, constatée au chargement ──
|
||||
const toast = ref('')
|
||||
function celebrate() {
|
||||
toast.value = ADOPTED_TOAST
|
||||
setTimeout(() => { toast.value = '' }, 5000)
|
||||
}
|
||||
onMounted(() => {
|
||||
const s = session.value
|
||||
if (s && s.status === 'open' && s.closesAt <= col.now()) {
|
||||
const closed = store.closeSession(s)
|
||||
if (!('ok' in closed) && closed.outcome === 'adopted') celebrate()
|
||||
}
|
||||
})
|
||||
|
||||
const displayStatus = computed(() => {
|
||||
if (session.value?.status === 'frozen') return { css: 'status-frozen', label: 'figé' }
|
||||
const s = decision.value?.status ?? 'voting'
|
||||
return { css: `status-${s}`, label: STATUS_LABELS[s] }
|
||||
})
|
||||
|
||||
// ── Affiche de session (impression A4) ──
|
||||
const closesAtLong = computed(() => session.value
|
||||
? new Date(session.value.closesAt).toLocaleString('fr-FR', { dateStyle: 'long', timeStyle: 'short' })
|
||||
: '')
|
||||
const posterRule = computed(() => {
|
||||
const f = protocol.value?.formula
|
||||
if (!f) return ''
|
||||
switch (method.value) {
|
||||
case 'consent': return 'Zéro objection maintenue à l\'échéance — le collectif consent.'
|
||||
case 'nuanced': return `${f.nuancedThresholdPct ?? 80} % de nuances positives (3 à 5) requis.`
|
||||
case 'binary': {
|
||||
const active = session.value ? store.activeVotes(session.value.id) : []
|
||||
const total = active.filter(v => v.value === 'for' || v.value === 'against').length
|
||||
const threshold = wotThreshold(Math.max(session.value?.corpusSize ?? 1, 1), total,
|
||||
f.majorityPct, f.baseExponent, f.gradientExponent, f.constantBase)
|
||||
return `Seuil actuel : ${threshold.toLocaleString('fr-FR')} pour — il descend quand la participation monte.`
|
||||
}
|
||||
case 'parametric': return `Le collectif retient la médiane de chaque curseur, cristallisée par le garant${f.parametricMinParticipants !== undefined ? ` (quorum ${f.parametricMinParticipants})` : ''}.`
|
||||
case 'election': return ELECTION_RULE
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
function printPoster() { window.print() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<div class="vote-room">
|
||||
<template v-if="decision && session && protocol">
|
||||
<div class="no-print vote-room__inner">
|
||||
<!-- En-tête -->
|
||||
<header class="vr__head">
|
||||
<NuxtLink :to="`/decisions/${decision.id}`" class="vr__back">
|
||||
<UIcon name="i-lucide-arrow-left" />
|
||||
<span>Fiche décision</span>
|
||||
</NuxtLink>
|
||||
<div class="vr__title-row">
|
||||
<h1 class="vr__title">{{ decision.title }}</h1>
|
||||
<span v-if="decision.status === 'adopted'" class="ld-stamp">井 {{ ADOPTED_STAMP }}</span>
|
||||
</div>
|
||||
<div class="vr__pills">
|
||||
<span class="status-pill" :class="displayStatus.css">{{ displayStatus.label }}</span>
|
||||
<span class="vr__protocol">{{ protocol.name }}</span>
|
||||
<span v-if="secret" class="vr__secret">
|
||||
<UIcon name="i-lucide-eye-off" />
|
||||
<span>{{ SECRET_DISPLAY }}</span>
|
||||
</span>
|
||||
<LdCountdown v-if="session.status === 'open'" :ends-at="session.closesAt" />
|
||||
<button class="ld-btn ld-btn--quiet vr__print" type="button" @click="printPoster()">
|
||||
<UIcon name="i-lucide-printer" />
|
||||
<span>Imprimer</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="vr__who">
|
||||
<span>{{ WHO_VOTES(eligibleIds.length, arrestedDate) }}</span>
|
||||
<LdAvatarStack :people="voterEntries" :size="28" :max="8" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Mode atelier -->
|
||||
<div v-if="workshop" class="vr__workshop">
|
||||
<UIcon name="i-lucide-users-round" />
|
||||
<span>{{ WORKSHOP_MODE }} — saisir pour</span>
|
||||
<select v-model="asId" class="vr__workshop-select">
|
||||
<option value="">moi-même</option>
|
||||
<option v-for="entry in voterEntries" :key="entry.person.id" :value="entry.person.id">
|
||||
{{ entry.person.displayName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p v-if="session.status === 'open' && !inList" class="vr__consultative">
|
||||
Tu n'es pas dans la liste arrêtée — ta voix est consultative.
|
||||
</p>
|
||||
|
||||
<!-- S'instruire, puis se prononcer -->
|
||||
<VoteInstruct :decision="decision" :secret="secret" />
|
||||
|
||||
<VoteConsent
|
||||
v-if="method === 'consent'"
|
||||
:decision="decision" :session="session"
|
||||
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
|
||||
/>
|
||||
<VoteNuanced
|
||||
v-else-if="method === 'nuanced'"
|
||||
:decision="decision" :session="session" :protocol="protocol"
|
||||
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
|
||||
/>
|
||||
<VoteBinary
|
||||
v-else-if="method === 'binary'"
|
||||
:decision="decision" :session="session" :protocol="protocol"
|
||||
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
|
||||
/>
|
||||
<VoteParametric
|
||||
v-else-if="method === 'parametric' && decision.paramSpec"
|
||||
:decision="decision" :session="session" :protocol="protocol"
|
||||
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
|
||||
:is-steward="isSteward"
|
||||
@adopted="celebrate()"
|
||||
/>
|
||||
<VoteElection
|
||||
v-else-if="method === 'election'"
|
||||
:decision="decision" :session="session" :protocol="protocol"
|
||||
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
|
||||
/>
|
||||
|
||||
<!-- Transversal : re-vote et discrétion -->
|
||||
<p class="vr__privacy">
|
||||
<UIcon name="i-lucide-shield" />
|
||||
<span>{{ VOTE_PRIVACY }}</span>
|
||||
</p>
|
||||
<VoteMyHistory v-if="col.me && voterId === col.me.id" :session="session" :voter-id="col.me.id" />
|
||||
</div>
|
||||
|
||||
<!-- Affiche de session A4 -->
|
||||
<div class="print-only vr__poster">
|
||||
<h1>{{ decision.title }}</h1>
|
||||
<p v-if="decision.body" class="vr__poster-body">{{ decision.body }}</p>
|
||||
<hr>
|
||||
<p><strong>{{ WHO_VOTES(eligibleIds.length, arrestedDate) }}</strong></p>
|
||||
<p v-if="!secret">{{ voterEntries.map(e => e.person.displayName).join(' · ') }}</p>
|
||||
<p>{{ posterRule }}</p>
|
||||
<p><strong>Échéance :</strong> {{ closesAtLong }}</p>
|
||||
<p><strong>Comment participer :</strong> ouvre la salle de vote « {{ decision.title }} » dans libreDecision, ou confie ton geste en présence (mode atelier).</p>
|
||||
<p class="vr__poster-seal">井</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Garde-fous -->
|
||||
<div v-else-if="!decision" class="ld-card vr__empty">
|
||||
<p>Cette décision est introuvable.</p>
|
||||
<NuxtLink to="/decisions" class="ld-btn ld-btn--ghost">Toutes les décisions</NuxtLink>
|
||||
</div>
|
||||
<div v-else class="ld-card vr__empty">
|
||||
<p>Cette décision n'a pas encore de session de vote.</p>
|
||||
<NuxtLink :to="`/decisions/${decisionId}`" class="ld-btn ld-btn--ghost">Fiche décision</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Micro-célébration — aucune fanfare -->
|
||||
<Transition name="vr-toast">
|
||||
<div v-if="toast" class="vr__toast">
|
||||
<span class="ld-stamp">井 {{ ADOPTED_STAMP }}</span>
|
||||
<span>{{ toast }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vote-room { max-width: 46rem; width: 100%; margin: 0 auto; }
|
||||
.vote-room__inner { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.vr__head { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.vr__back {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem; align-self: flex-start;
|
||||
font-size: 0.8125rem; font-weight: 700; color: var(--mood-text-muted); text-decoration: none;
|
||||
}
|
||||
.vr__back:hover { color: var(--mood-accent); }
|
||||
.vr__title-row { display: flex; align-items: flex-start; gap: 0.9rem; flex-wrap: wrap; }
|
||||
.vr__title {
|
||||
margin: 0; font-size: clamp(1.25rem, 4vw, 1.75rem); font-weight: 800;
|
||||
line-height: 1.25; letter-spacing: -0.01em; flex: 1; min-width: 0;
|
||||
}
|
||||
.vr__pills { display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.vr__protocol {
|
||||
font-size: 0.8125rem; font-weight: 700; color: var(--mood-accent);
|
||||
background: var(--mood-accent-soft); padding: 4px 13px; border-radius: var(--r-pill);
|
||||
}
|
||||
.vr__secret {
|
||||
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||
font-size: 0.8125rem; font-weight: 700; color: var(--mood-status-fige);
|
||||
background: var(--mood-status-fige-bg); padding: 4px 13px; border-radius: var(--r-pill);
|
||||
}
|
||||
.vr__print { margin-left: auto; padding: 0.25rem 0.75rem; font-size: 0.8125rem; }
|
||||
.vr__who {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 0.6rem 0.9rem;
|
||||
font-size: 0.9375rem; font-weight: 600; color: var(--mood-text-muted);
|
||||
}
|
||||
.vr__workshop {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem;
|
||||
padding: 0.55rem 0.9rem; border-radius: var(--r-input);
|
||||
background: color-mix(in srgb, var(--mood-secondary) 10%, var(--mood-surface));
|
||||
color: var(--mood-secondary); font-size: 0.875rem; font-weight: 700;
|
||||
}
|
||||
.vr__workshop-select {
|
||||
min-height: 2.25rem; padding: 0.25rem 0.6rem; font-size: 0.875rem; font-weight: 600;
|
||||
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
|
||||
}
|
||||
.vr__consultative {
|
||||
margin: 0; padding: 0.55rem 0.9rem; border-radius: var(--r-input);
|
||||
background: var(--mood-status-fenetre-bg); color: var(--mood-status-fenetre);
|
||||
font-size: 0.875rem; font-weight: 600;
|
||||
}
|
||||
.vr__privacy {
|
||||
display: flex; align-items: baseline; gap: 0.45rem; margin: 0;
|
||||
font-size: 0.8125rem; color: var(--mood-text-muted);
|
||||
}
|
||||
.vr__empty {
|
||||
padding: 2rem; display: flex; flex-direction: column; align-items: center; gap: 1rem;
|
||||
text-align: center; font-weight: 600;
|
||||
}
|
||||
.vr__empty p { margin: 0; }
|
||||
.vr__toast {
|
||||
position: fixed; left: 50%; bottom: 1.5rem; transform: translateX(-50%);
|
||||
z-index: 50; display: flex; align-items: center; gap: 0.75rem;
|
||||
background: var(--mood-surface); color: var(--mood-text);
|
||||
padding: 0.75rem 1.25rem; border-radius: var(--r-card);
|
||||
box-shadow: var(--shadow-raised); font-weight: 600; font-size: 0.9375rem;
|
||||
max-width: min(92vw, 30rem);
|
||||
}
|
||||
.vr-toast-enter-active, .vr-toast-leave-active { transition: opacity 0.12s ease, transform 0.12s ease; }
|
||||
.vr-toast-enter-from, .vr-toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||
|
||||
/* Affiche A4 — noir sur blanc, sans ambiance */
|
||||
.vr__poster { color: #111; font-size: 12pt; line-height: 1.5; }
|
||||
.vr__poster h1 { font-size: 22pt; margin: 0 0 8pt; }
|
||||
.vr__poster hr { border: none; border-top: 1pt solid #111; margin: 8pt 0; }
|
||||
.vr__poster-body { white-space: pre-wrap; }
|
||||
.vr__poster-seal { font-size: 28pt; text-align: right; margin-top: 16pt; }
|
||||
</style>
|
||||
@@ -1,612 +1,320 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Decisions — page index.
|
||||
*
|
||||
* Utilise SectionLayout avec status filters, recherche, tri,
|
||||
* et sidebar "Boîte à outils" affichant les protocoles de vote.
|
||||
* /decisions — le registre v2. Pills d'état cliquables (mapping unique),
|
||||
* pills courtes de route, filtres (cercle/tag/me concerne/poids/gravées/
|
||||
* consignées), recherche locale. Zéro bouton de création : on crée par la
|
||||
* capture — l'état vide le rappelle.
|
||||
*/
|
||||
const decisions = useDecisionsStore()
|
||||
const protocols = useProtocolsStore()
|
||||
const auth = useAuthStore()
|
||||
import type { Decision, DecisionRoute, Weight } from '~/types/domain'
|
||||
import { ROUTE_SHORT, STATUS_LABELS, FROZEN_LABEL, CAPTURE_PLACEHOLDER } from '~/lexicon'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { displayStatus, fold, type DisplayStatus } from '~/components/decisions/decisionUi'
|
||||
|
||||
// Toolbox state
|
||||
const showConsentModal = ref(false)
|
||||
const selectedMethod = ref<string | null>(null)
|
||||
const col = useCollectiveStore()
|
||||
|
||||
const consentSteps = [
|
||||
'Présenter la proposition clairement (2 min)',
|
||||
'Tour de clarification — questions de compréhension uniquement',
|
||||
'Tour de réaction — chacun réagit brièvement',
|
||||
'Porteur amende si nécessaire',
|
||||
'Tour d\'objections — silence = consentement',
|
||||
'Lever les objections valides par amendement',
|
||||
'Adopter ou reporter',
|
||||
// ── Filtres ──
|
||||
const query = ref('')
|
||||
const statusFilter = ref<DisplayStatus | null>(null)
|
||||
const routeFilter = ref<DecisionRoute | null>(null)
|
||||
const filterCircle = ref<string | null>(null)
|
||||
const filterTag = ref<string | null>(null)
|
||||
const filterMine = ref(false)
|
||||
const filterWeight = ref<Weight | null>(null)
|
||||
const filterEngraved = ref(false)
|
||||
const filterRecorded = ref(false)
|
||||
|
||||
const latestSession = (decisionId: string) =>
|
||||
col.sessions
|
||||
.filter(s => s.decisionId === decisionId)
|
||||
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0]
|
||||
|
||||
const shownStatus = (d: Decision): DisplayStatus => displayStatus(d, latestSession(d.id))
|
||||
|
||||
// ── Pills d'état — l'ordre du cycle, comptées, masquées à zéro ──
|
||||
const STATUS_ORDER: DisplayStatus[] = [
|
||||
'draft', 'advice', 'objection', 'framing', 'voting', 'frozen',
|
||||
'adopted', 'closed', 'rejected', 'revoked', 'transmitted',
|
||||
]
|
||||
const statusPills = computed(() =>
|
||||
STATUS_ORDER
|
||||
.map(status => ({
|
||||
status,
|
||||
label: status === 'frozen' ? FROZEN_LABEL : STATUS_LABELS[status],
|
||||
count: col.decisions.filter(d => shownStatus(d) === status).length,
|
||||
}))
|
||||
.filter(pill => pill.count > 0),
|
||||
)
|
||||
|
||||
function handleMethodSelect(method: string) {
|
||||
selectedMethod.value = method
|
||||
if (method.toLowerCase().includes('consentement')) {
|
||||
showConsentModal.value = true
|
||||
}
|
||||
else if (method.toLowerCase().includes('avis')) {
|
||||
// Navigate to advice process guide in mandates toolbox
|
||||
navigateTo('/mandates')
|
||||
}
|
||||
const ROUTES: DecisionRoute[] = ['solo', 'mandate', 'transmit', 'advice', 'collective', 'record']
|
||||
const routePills = computed(() =>
|
||||
ROUTES
|
||||
.map(route => ({ route, count: col.decisions.filter(d => d.route === route).length }))
|
||||
.filter(pill => pill.count > 0),
|
||||
)
|
||||
|
||||
// ── Liste filtrée puis triée : en cours par échéance, terminées ensuite ──
|
||||
const meId = computed(() => col.me?.id ?? null)
|
||||
const ACTIVE: DisplayStatus[] = ['draft', 'advice', 'objection', 'framing', 'voting', 'frozen']
|
||||
|
||||
function concernsMe(d: Decision): boolean {
|
||||
if (!meId.value) return false
|
||||
if (d.authorId === meId.value) return true
|
||||
return col.concerns.some(c => c.decisionId === d.id && c.personId === meId.value)
|
||||
}
|
||||
|
||||
const activeStatus = ref<string | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const sortBy = ref<'date' | 'title' | 'status'>('date')
|
||||
|
||||
const sortOptions = [
|
||||
{ label: 'Date', value: 'date' },
|
||||
{ label: 'Titre', value: 'title' },
|
||||
{ label: 'Statut', value: 'status' },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
decisions.fetchAll(),
|
||||
protocols.fetchProtocols(),
|
||||
])
|
||||
})
|
||||
|
||||
/** Status filter pills with counts. */
|
||||
const statuses = computed(() => [
|
||||
{ id: 'draft', label: 'En prépa', count: decisions.list.filter(d => d.status === 'draft').length },
|
||||
{ id: 'voting', label: 'En vote', count: decisions.list.filter(d => d.status === 'voting' || d.status === 'qualification' || d.status === 'review').length },
|
||||
{ id: 'executed', label: 'En vigueur', count: decisions.list.filter(d => d.status === 'executed').length },
|
||||
{ id: 'closed', label: 'Clos', count: decisions.list.filter(d => d.status === 'closed').length },
|
||||
])
|
||||
|
||||
/** Map for the voting pill — include qualification/review under "En vote". */
|
||||
const statusGroupMap: Record<string, string[]> = {
|
||||
draft: ['draft'],
|
||||
voting: ['qualification', 'review', 'voting'],
|
||||
executed: ['executed'],
|
||||
closed: ['closed'],
|
||||
function deadlineOf(d: Decision): string {
|
||||
return d.windowEndsAt ?? latestSession(d.id)?.closesAt ?? d.createdAt
|
||||
}
|
||||
|
||||
/** Filtered and sorted decisions. */
|
||||
const filteredDecisions = computed(() => {
|
||||
let list = [...decisions.list]
|
||||
|
||||
// Filter by status group
|
||||
if (activeStatus.value && statusGroupMap[activeStatus.value]) {
|
||||
const statuses = statusGroupMap[activeStatus.value]
|
||||
list = list.filter(d => statuses.includes(d.status))
|
||||
}
|
||||
|
||||
// Filter by search query (client-side)
|
||||
if (searchQuery.value.trim()) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
list = list.filter(d => d.title.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
// Sort
|
||||
switch (sortBy.value) {
|
||||
case 'title':
|
||||
list.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
|
||||
break
|
||||
case 'status':
|
||||
list.sort((a, b) => a.status.localeCompare(b.status))
|
||||
break
|
||||
case 'date':
|
||||
default:
|
||||
list.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
||||
break
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
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',
|
||||
const filtered = computed(() => {
|
||||
const needle = fold(query.value.trim())
|
||||
return col.decisions.filter((d) => {
|
||||
if (statusFilter.value && shownStatus(d) !== statusFilter.value) return false
|
||||
if (routeFilter.value && d.route !== routeFilter.value) return false
|
||||
if (filterCircle.value && !d.scope.circleIds.includes(filterCircle.value)) return false
|
||||
if (filterTag.value && !d.tags.includes(filterTag.value)) return false
|
||||
if (filterMine.value && !concernsMe(d)) return false
|
||||
if (filterWeight.value && d.weight !== filterWeight.value) return false
|
||||
if (filterEngraved.value && !d.engraving) return false
|
||||
if (filterRecorded.value && d.route !== 'record') return false
|
||||
if (needle.length > 0 && !fold(d.title).includes(needle)
|
||||
&& !d.tags.some(t => fold(t).includes(needle))) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const sorted = computed(() => {
|
||||
const active = filtered.value
|
||||
.filter(d => ACTIVE.includes(shownStatus(d)))
|
||||
.sort((a, b) => deadlineOf(a).localeCompare(deadlineOf(b)))
|
||||
const settled = filtered.value
|
||||
.filter(d => !ACTIVE.includes(shownStatus(d)))
|
||||
.sort((a, b) => (b.decidedAt ?? b.updatedAt).localeCompare(a.decidedAt ?? a.updatedAt))
|
||||
return [...active, ...settled]
|
||||
})
|
||||
|
||||
const hasAny = computed(() => col.decisions.length > 0)
|
||||
|
||||
function toggleStatus(status: DisplayStatus) {
|
||||
statusFilter.value = statusFilter.value === status ? null : status
|
||||
}
|
||||
function toggleRoute(route: DecisionRoute) {
|
||||
routeFilter.value = routeFilter.value === route ? null : route
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SectionLayout
|
||||
title="Décisions"
|
||||
subtitle="Processus de décision collectifs"
|
||||
:statuses="statuses"
|
||||
:active-status="activeStatus"
|
||||
@update:active-status="activeStatus = $event"
|
||||
>
|
||||
<!-- Search / sort bar -->
|
||||
<template #search>
|
||||
<div class="search-field">
|
||||
<UIcon name="i-lucide-search" class="search-field__icon" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="search-field__input"
|
||||
placeholder="Rechercher une décision..."
|
||||
/>
|
||||
<!-- ld-v2 -->
|
||||
<div class="reg">
|
||||
<header class="reg__header">
|
||||
<div>
|
||||
<h1 class="reg__title">Décisions</h1>
|
||||
<p class="reg__subtitle">le registre — chaque décision a son URL, pour toujours</p>
|
||||
</div>
|
||||
<select v-model="sortBy" class="sort-select">
|
||||
<option v-for="opt in sortOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<NuxtLink
|
||||
v-if="auth.isAuthenticated"
|
||||
to="/decisions/new"
|
||||
class="action-btn"
|
||||
>
|
||||
<UIcon name="i-lucide-plus" class="text-xs" />
|
||||
<span>Nouvelle</span>
|
||||
<NuxtLink to="/decisions/observatoire" class="reg__observatory">
|
||||
<UIcon name="i-lucide-telescope" />
|
||||
<span>L'Observatoire</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</header>
|
||||
|
||||
<!-- Main content: decision list -->
|
||||
<template #default>
|
||||
<!-- Error state -->
|
||||
<div v-if="decisions.error" class="flex items-center gap-3 p-4 rounded-lg" style="background: var(--mood-surface); border: 1px solid var(--mood-border);">
|
||||
<UIcon name="i-lucide-alert-circle" class="text-xl" style="color: var(--mood-error);" />
|
||||
<p style="color: var(--mood-text);">{{ decisions.error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-else-if="decisions.loading" class="space-y-3">
|
||||
<LoadingSkeleton v-for="i in 5" :key="i" :lines="2" card />
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-else-if="filteredDecisions.length === 0"
|
||||
class="text-center py-12"
|
||||
style="color: var(--mood-text-muted);"
|
||||
<div v-if="hasAny" class="reg__pills" role="group" aria-label="Filtrer par état">
|
||||
<button
|
||||
v-for="pill in statusPills"
|
||||
:key="pill.status"
|
||||
type="button"
|
||||
class="status-pill is-clickable"
|
||||
:class="[`status-${pill.status}`, { active: statusFilter === pill.status }]"
|
||||
@click="toggleStatus(pill.status)"
|
||||
>
|
||||
<UIcon name="i-lucide-scale" class="text-4xl mb-3 block mx-auto" />
|
||||
<p>Aucune décision trouvée</p>
|
||||
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
|
||||
Essayez de modifier vos filtres
|
||||
</p>
|
||||
</div>
|
||||
<span>{{ pill.label }}</span>
|
||||
<span class="reg__count">{{ pill.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Decision cards -->
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="decision in filteredDecisions"
|
||||
:key="decision.id"
|
||||
class="decision-card"
|
||||
@click="navigateTo(`/decisions/${decision.id}`)"
|
||||
<div v-if="hasAny" class="reg__pills" role="group" aria-label="Filtrer par chemin">
|
||||
<button
|
||||
v-for="pill in routePills"
|
||||
:key="pill.route"
|
||||
type="button"
|
||||
class="reg__route-pill"
|
||||
:class="{ 'reg__route-pill--on': routeFilter === pill.route }"
|
||||
@click="toggleRoute(pill.route)"
|
||||
>
|
||||
{{ ROUTE_SHORT[pill.route] }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="hasAny" class="reg__tools">
|
||||
<div class="reg__search">
|
||||
<UIcon name="i-lucide-search" class="reg__search-icon" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Chercher une décision…"
|
||||
aria-label="Chercher une décision"
|
||||
>
|
||||
<div class="decision-card__header">
|
||||
<div class="decision-card__title-block">
|
||||
<h3 class="decision-card__title">
|
||||
{{ decision.title }}
|
||||
</h3>
|
||||
<p v-if="decision.description" class="decision-card__description">
|
||||
{{ decision.description }}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge :status="decision.status" type="decision" />
|
||||
</div>
|
||||
|
||||
<div class="decision-card__meta">
|
||||
<span class="decision-card__type-badge">
|
||||
{{ typeLabel(decision.decision_type) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="decision.decision_type === 'runtime_upgrade'"
|
||||
class="decision-card__onchain-badge"
|
||||
>
|
||||
<UIcon name="i-lucide-link" class="text-xs" />
|
||||
on-chain
|
||||
</span>
|
||||
<span class="decision-card__steps">
|
||||
<UIcon name="i-lucide-layers" class="text-xs" />
|
||||
{{ decision.steps.length }} étape{{ decision.steps.length !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
<span class="decision-card__date">
|
||||
<UIcon name="i-lucide-clock" class="text-xs" />
|
||||
{{ formatDate(decision.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Protocol link for runtime_upgrade -->
|
||||
<NuxtLink
|
||||
v-if="decision.decision_type === 'runtime_upgrade'"
|
||||
to="/protocols"
|
||||
class="decision-card__protocol-link"
|
||||
@click.stop
|
||||
>
|
||||
<UIcon name="i-lucide-git-branch" class="text-xs" />
|
||||
<span>Protocole : Soumission Runtime Upgrade</span>
|
||||
<UIcon name="i-lucide-arrow-right" class="text-xs" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Toolbox sidebar -->
|
||||
<template #toolbox>
|
||||
<!-- Context mapper -->
|
||||
<ToolboxSection title="Quelle méthode ?" icon="i-lucide-compass">
|
||||
<ContextMapper @use="handleMethodSelect" />
|
||||
</ToolboxSection>
|
||||
|
||||
<!-- Vote inertiel WoT -->
|
||||
<ToolboxVignette
|
||||
title="Vote inertiel WoT"
|
||||
:bullets="[
|
||||
'Seuil adaptatif à la participation',
|
||||
'Faible participation → quasi-unanimité',
|
||||
'Formule g1vote — tracé on-chain',
|
||||
]"
|
||||
:actions="[
|
||||
{ label: 'Simuler', icon: 'i-lucide-calculator', to: '/protocols/formulas', primary: true },
|
||||
{ label: 'Protocoles', icon: 'i-lucide-settings', to: '/protocols' },
|
||||
]"
|
||||
<DecisionRegistryFilters
|
||||
v-model:circle-id="filterCircle"
|
||||
v-model:tag="filterTag"
|
||||
v-model:mine="filterMine"
|
||||
v-model:weight="filterWeight"
|
||||
v-model:engraved="filterEngraved"
|
||||
v-model:recorded="filterRecorded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Consentement sociocratique -->
|
||||
<ToolboxVignette
|
||||
title="Consentement sociocratique"
|
||||
:bullets="[
|
||||
'Aucune objection grave = adopté',
|
||||
'Rapide pour petits groupes',
|
||||
'Distingue préférence et objection',
|
||||
]"
|
||||
:actions="[
|
||||
{ label: 'Guide', icon: 'i-lucide-book-open', emit: 'consent', primary: true },
|
||||
]"
|
||||
/>
|
||||
<div v-if="sorted.length > 0" class="reg__list">
|
||||
<DecisionRegistryCard v-for="d in sorted" :key="d.id" :decision="d" />
|
||||
</div>
|
||||
|
||||
<!-- Advice process -->
|
||||
<ToolboxVignette
|
||||
title="Processus d'avis (Laloux)"
|
||||
:bullets="[
|
||||
'Décisions urgentes : < 2h',
|
||||
'Consultant experts + impactés',
|
||||
'Responsabilise le porteur',
|
||||
]"
|
||||
:actions="[
|
||||
{ label: 'Guide', icon: 'i-lucide-message-circle', emit: 'advice', primary: true },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</SectionLayout>
|
||||
|
||||
<!-- Modal consent guide -->
|
||||
<UModal v-model:open="showConsentModal">
|
||||
<template #content>
|
||||
<div class="decision-modal">
|
||||
<h3 class="decision-modal__title">Consentement sociocratique</h3>
|
||||
<p class="decision-modal__text">
|
||||
Une décision est adoptée par consentement quand aucun membre ne soulève d'objection grave.
|
||||
Une objection grave est une raison pour laquelle la proposition nuit à la mission commune —
|
||||
pas une simple préférence.
|
||||
<div v-else class="ld-card reg__empty">
|
||||
<template v-if="!hasAny">
|
||||
<span class="reg__empty-well">井</span>
|
||||
<p class="reg__empty-title">Aucune décision pour l'instant.</p>
|
||||
<p class="reg__empty-line">
|
||||
Rien ne se crée ici : tout part de la capture —
|
||||
« {{ CAPTURE_PLACEHOLDER }} » sur Aujourd'hui.
|
||||
</p>
|
||||
<div class="decision-modal__steps">
|
||||
<div v-for="(step, i) in consentSteps" :key="i" class="decision-modal__step">
|
||||
<div class="decision-modal__step-num">{{ i + 1 }}</div>
|
||||
<div class="decision-modal__step-text">{{ step }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="decision-modal__ref">Référence : "La Sociocracie" — Gerard Endenburg, Brian Robertson (Holacracy)</p>
|
||||
<button class="decision-modal__close" @click="showConsentModal = false">Fermer</button>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
<NuxtLink to="/" class="ld-btn ld-btn--ghost">
|
||||
<UIcon name="i-lucide-sun-medium" />
|
||||
<span>Aller à Aujourd'hui</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="reg__empty-title">Rien ne correspond à ces filtres.</p>
|
||||
<p class="reg__empty-line">Élargis la recherche — ou décide par la capture, sur Aujourd'hui.</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.decision-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--mood-surface);
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.decision-card {
|
||||
gap: 0.625rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.decision-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 24px var(--mood-shadow);
|
||||
}
|
||||
|
||||
.decision-card:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.decision-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.decision-card__title-block {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.decision-card__title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.decision-card__title {
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
}
|
||||
|
||||
.decision-card__description {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.decision-card__description {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.decision-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.decision-card__meta {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.decision-card__steps {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.decision-card__date {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
margin-left: auto;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.decision-card__date {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.decision-card__type-badge {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
|
||||
.decision-card__onchain-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 3px 8px;
|
||||
border-radius: 20px;
|
||||
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
|
||||
color: var(--mood-success);
|
||||
}
|
||||
|
||||
.decision-card__protocol-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border-radius: 20px;
|
||||
text-decoration: none;
|
||||
background: color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 10%, transparent);
|
||||
color: var(--mood-tertiary, var(--mood-accent));
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.decision-card__protocol-link:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px var(--mood-shadow);
|
||||
}
|
||||
|
||||
/* --- Modern search / sort / action --- */
|
||||
.search-field {
|
||||
flex: 1;
|
||||
min-width: 10rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 12px;
|
||||
transition: box-shadow 0.15s ease;
|
||||
}
|
||||
.search-field:focus-within {
|
||||
box-shadow: 0 0 0 2.5px var(--mood-accent-soft);
|
||||
}
|
||||
.search-field__icon {
|
||||
color: var(--mood-text-muted);
|
||||
opacity: 0.5;
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.search-field__input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--mood-text);
|
||||
min-width: 0;
|
||||
}
|
||||
.search-field__input::placeholder {
|
||||
color: var(--mood-text-muted);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sort-select {
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text);
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
min-width: 5.5rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-accent-text);
|
||||
background: var(--mood-accent);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.action-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px var(--mood-shadow);
|
||||
}
|
||||
.action-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Decision modal */
|
||||
.decision-modal {
|
||||
padding: 1.25rem;
|
||||
.reg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-width: 52rem;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.decision-modal { padding: 2rem; gap: 1.25rem; }
|
||||
.reg__header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.decision-modal__title {
|
||||
font-size: 1.125rem;
|
||||
.reg__title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.5rem, 4vw, 2rem);
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
margin: 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.decision-modal__text {
|
||||
font-size: 0.875rem;
|
||||
.reg__subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
.reg__observatory {
|
||||
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;
|
||||
text-decoration: none;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
.reg__observatory:hover { transform: translateY(-1px); }
|
||||
|
||||
.reg__pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.reg__count {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.reg__route-pill {
|
||||
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__route-pill:hover { transform: translateY(-1px); color: var(--mood-text); }
|
||||
.reg__route-pill--on {
|
||||
background: var(--mood-accent);
|
||||
color: var(--mood-accent-text);
|
||||
}
|
||||
|
||||
.decision-modal__steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.decision-modal__step {
|
||||
.reg__tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.decision-modal__step-num {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--mood-accent);
|
||||
color: var(--mood-accent-text);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 800;
|
||||
.reg__search {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 14rem;
|
||||
}
|
||||
|
||||
.decision-modal__step-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text);
|
||||
padding-top: 0.125rem;
|
||||
line-height: 1.5;
|
||||
.reg__search input {
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.5rem 1rem 0.5rem 2.5rem;
|
||||
font-size: 0.9375rem;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.decision-modal__ref {
|
||||
font-size: 0.75rem;
|
||||
.reg__search-icon {
|
||||
position: absolute;
|
||||
left: 0.875rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--mood-text-muted);
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.decision-modal__close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.625rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-accent-text);
|
||||
background: var(--mood-accent);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
align-self: flex-end;
|
||||
transition: transform 0.1s ease;
|
||||
.reg__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.reg__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2.5rem 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.reg__empty-well {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-accent);
|
||||
opacity: 0.5;
|
||||
transform: rotate(-10deg);
|
||||
}
|
||||
.reg__empty-title {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.reg__empty-line {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--mood-text-muted);
|
||||
max-width: 28rem;
|
||||
}
|
||||
.decision-modal__close:hover { transform: translateY(-1px); }
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,385 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> /decisions/observatoire — L'Observatoire (Δ12) : comment nous
|
||||
// décidons, dans les faits. Tout est dérivé des stores en computed purs ; des
|
||||
// faits comptés, des barres sobres aux couleurs du mood — jamais un score.
|
||||
import type { DecisionRoute, Id } from '~/types/domain'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { useDecisionsStore } from '~/stores/decisions'
|
||||
import {
|
||||
MATURATION_CARD, OBSERVATORY_SUBTITLE, OBSERVATORY_TITLE,
|
||||
REVIEW_TITLE, REVIEW_VERDICTS, REVOKED_SECTION, ROUTE_SHORT,
|
||||
} from '~/lexicon'
|
||||
import { formatDay } from '~/components/mandates/mandateUi'
|
||||
|
||||
const col = useCollectiveStore()
|
||||
const decisionsStore = useDecisionsStore()
|
||||
const reviewLabels = REVIEW_VERDICTS
|
||||
|
||||
// ── Filtres : personne / cercle / tag / période ──
|
||||
const fPerson = ref(''); const fCircle = ref(''); const fTag = ref(''); const fPeriod = ref('')
|
||||
const tagOptions = computed(() => [...new Set(col.decisions.flatMap(d => d.tags))].sort())
|
||||
const since = computed(() =>
|
||||
fPeriod.value === '' ? '' : new Date(Date.now() - Number(fPeriod.value) * 86_400_000).toISOString(),
|
||||
)
|
||||
const filtered = computed(() => col.decisions.filter(d =>
|
||||
(fPerson.value === '' || d.authorId === fPerson.value)
|
||||
&& (fCircle.value === '' || d.scope.circleIds.includes(fCircle.value))
|
||||
&& (fTag.value === '' || d.tags.includes(fTag.value))
|
||||
&& (since.value === '' || (d.decidedAt ?? d.createdAt) >= since.value),
|
||||
))
|
||||
const engaged = computed(() => filtered.value.filter(d => d.status !== 'draft'))
|
||||
|
||||
// ── Autonomie : ratio par route, maturité, temps médians, participation ──
|
||||
const ROUTES: DecisionRoute[] = ['solo', 'mandate', 'advice', 'collective', 'record']
|
||||
const routeBars = computed(() => {
|
||||
const total = Math.max(1, engaged.value.length)
|
||||
return ROUTES.map((r) => {
|
||||
const n = engaged.value.filter(d => d.route === r).length
|
||||
return { label: ROUTE_SHORT[r], value: n, display: `${n} · ${Math.round((n / total) * 100)} %` }
|
||||
})
|
||||
})
|
||||
const recordedCount = computed(() => engaged.value.filter(d => d.route === 'record').length)
|
||||
const tooledCount = computed(() => engaged.value.length - recordedCount.value)
|
||||
|
||||
function median(xs: number[]): number {
|
||||
if (xs.length === 0) return 0
|
||||
const s = [...xs].sort((a, b) => a - b)
|
||||
const mid = Math.floor(s.length / 2)
|
||||
return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2
|
||||
}
|
||||
const medianBars = computed(() => ROUTES.map((r) => {
|
||||
const days = filtered.value
|
||||
.filter(d => d.route === r && d.decidedAt !== undefined)
|
||||
.map(d => (new Date(d.decidedAt!).getTime() - new Date(d.createdAt).getTime()) / 86_400_000)
|
||||
const m = median(days)
|
||||
return { label: ROUTE_SHORT[r], value: m, display: m === 0 ? '—' : m < 1 ? `${Math.max(1, Math.round(m * 24))} h` : `${Math.round(m)} j` }
|
||||
}))
|
||||
|
||||
const participation = computed(() => {
|
||||
const ids = new Set(filtered.value.map(d => d.id))
|
||||
const closed = col.sessions.filter(s => ids.has(s.decisionId) && s.status !== 'open' && s.corpusSize > 0)
|
||||
if (closed.length === 0) return { value: '—', hint: 'aucune session close' }
|
||||
const avg = closed.reduce((sum, s) => sum + decisionsStore.activeVotes(s.id).length / s.corpusSize, 0) / closed.length
|
||||
return { value: `${Math.round(avg * 100)} %`, hint: `sur ${closed.length} session${closed.length > 1 ? 's' : ''} close${closed.length > 1 ? 's' : ''}` }
|
||||
})
|
||||
|
||||
const heavier = computed(() => engaged.value.filter(d => d.routeOverridden && d.overrideNote === undefined).length)
|
||||
const lighter = computed(() => engaged.value.filter(d => d.overrideNote !== undefined).length)
|
||||
const scopeKept = computed(() => engaged.value.filter(d => d.scopeKeptNote !== undefined).length)
|
||||
|
||||
// ── Ressources PAR UNITÉ — l'huile et l'eau, jamais additionnées ──
|
||||
const resourceLines = computed(() => {
|
||||
const byUnit = new Map<string, { total: number; count: number }>()
|
||||
for (const d of engaged.value) {
|
||||
if (!d.resources?.amount) continue
|
||||
const unit = d.resources.unit ?? 'sans unité'
|
||||
const line = byUnit.get(unit) ?? { total: 0, count: 0 }
|
||||
line.total += d.resources.amount
|
||||
line.count += 1
|
||||
byUnit.set(unit, line)
|
||||
}
|
||||
return [...byUnit.entries()].map(([unit, l]) => ({
|
||||
unit, total: l.total.toLocaleString('fr-FR'), count: l.count,
|
||||
}))
|
||||
})
|
||||
|
||||
// ── Épreuves du réel ──
|
||||
const now = new Date().toISOString()
|
||||
const reviewsDue = computed(() => filtered.value
|
||||
.filter(d => d.review !== undefined && d.review.verdict === undefined && d.review.dueAt <= now)
|
||||
.map(d => ({ id: d.id, title: d.title, dueAt: d.review!.dueAt })))
|
||||
const reviewCount = (v: 'confirmed' | 'revise' | 'revoke') =>
|
||||
filtered.value.filter(d => d.review?.verdict === v).length
|
||||
|
||||
// ── Révoquées, consignations, maturation ──
|
||||
const revoked = computed(() => filtered.value
|
||||
.filter(d => d.status === 'revoked')
|
||||
.map(d => ({
|
||||
decision: d,
|
||||
revocation: col.decisions.find(c => c.parentDecisionId === d.id && c.chainKind === 'revocation'),
|
||||
})))
|
||||
const records = computed(() => filtered.value
|
||||
.filter(d => d.route === 'record')
|
||||
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)))
|
||||
|
||||
const maturations = computed(() => {
|
||||
const pairs = new Map<string, { tags: [string, string]; ids: Set<Id> }>()
|
||||
for (const r of records.value) {
|
||||
const tags = [...new Set(r.tags)].sort()
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
for (let j = i + 1; j < tags.length; j++) {
|
||||
const key = `${tags[i]}|${tags[j]}`
|
||||
const entry = pairs.get(key) ?? { tags: [tags[i]!, tags[j]!], ids: new Set<Id>() }
|
||||
entry.ids.add(r.id)
|
||||
pairs.set(key, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
const ripe = [...pairs.values()].filter(p => p.ids.size >= 3)
|
||||
.sort((a, b) => b.ids.size - a.ids.size)
|
||||
const seen = new Set<string>()
|
||||
return ripe.filter((p) => {
|
||||
const sig = [...p.ids].sort().join(',')
|
||||
if (seen.has(sig)) return false
|
||||
seen.add(sig)
|
||||
return true
|
||||
}).slice(0, 4)
|
||||
})
|
||||
|
||||
// ── Élagage & décisions jamais revues ──
|
||||
const sixMonthsAgo = new Date(Date.now() - 180 * 86_400_000).toISOString()
|
||||
const unusedProtocols = computed(() => col.protocols.filter(p =>
|
||||
!col.decisions.some(d => d.protocolId === p.id) && !col.sessions.some(s => s.protocolId === p.id)))
|
||||
const idleMandates = computed(() => col.mandates.filter(m =>
|
||||
m.status === 'active' && m.startsAt <= sixMonthsAgo
|
||||
&& !col.decisions.some(d => d.underMandateId === m.id && d.createdAt >= sixMonthsAgo)))
|
||||
const reviewDelay = computed(() => col.settings?.triage.reviewDelayDays ?? 90)
|
||||
const neverReviewed = computed(() => {
|
||||
const limit = new Date(Date.now() - reviewDelay.value * 86_400_000).toISOString()
|
||||
return filtered.value.filter(d =>
|
||||
d.status === 'adopted' && d.route !== 'record' && d.review === undefined
|
||||
&& d.decidedAt !== undefined && d.decidedAt <= limit)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<div class="obs">
|
||||
<header class="obs__header">
|
||||
<h1 class="obs__title">{{ OBSERVATORY_TITLE }}</h1>
|
||||
<p class="obs__sub">{{ OBSERVATORY_SUBTITLE }}</p>
|
||||
</header>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="obs__filters">
|
||||
<select v-model="fPerson" class="obs__select" aria-label="Filtrer par personne">
|
||||
<option value="">Toutes les personnes</option>
|
||||
<option v-for="p in col.people" :key="p.id" :value="p.id">{{ p.displayName }}</option>
|
||||
</select>
|
||||
<select v-model="fCircle" class="obs__select" aria-label="Filtrer par cercle">
|
||||
<option value="">Tous les cercles</option>
|
||||
<option v-for="c in col.circles" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
<select v-model="fTag" class="obs__select" aria-label="Filtrer par tag">
|
||||
<option value="">Tous les tags</option>
|
||||
<option v-for="t in tagOptions" :key="t" :value="t">#{{ t }}</option>
|
||||
</select>
|
||||
<select v-model="fPeriod" class="obs__select" aria-label="Filtrer par période">
|
||||
<option value="">Depuis toujours</option>
|
||||
<option value="30">30 derniers jours</option>
|
||||
<option value="90">90 derniers jours</option>
|
||||
<option value="365">Cette année</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Autonomie -->
|
||||
<ObservatorySection title="Stats d'autonomie" icon="i-lucide-compass" sub="qui décide comment — le miroir, pas le juge">
|
||||
<ObservatoryBars :items="routeBars" />
|
||||
<div class="obs__tiles">
|
||||
<ObservatoryStat
|
||||
label="Maturité"
|
||||
:value="`${recordedCount} / ${tooledCount}`"
|
||||
hint="consignées / outillées — le fil de l'eau se protocolise quand il est mûr"
|
||||
/>
|
||||
<ObservatoryStat label="Participation moyenne" :value="participation.value" :hint="participation.hint" />
|
||||
</div>
|
||||
<h3 class="obs__h3">Temps médians par route</h3>
|
||||
<ObservatoryBars :items="medianBars" />
|
||||
<h3 class="obs__h3">Dérogations au chemin</h3>
|
||||
<div class="obs__tiles">
|
||||
<ObservatoryStat label="Alourdies" :value="String(heavier)" hint="un chemin plus exigeant que suggéré" />
|
||||
<ObservatoryStat label="Allégées" :value="String(lighter)" hint="toujours motivées publiquement" />
|
||||
<ObservatoryStat label="Périmètres maintenus" :value="String(scopeKept)" hint="affluence atteinte, maintien motivé" />
|
||||
</div>
|
||||
</ObservatorySection>
|
||||
|
||||
<!-- Ressources par unité -->
|
||||
<ObservatorySection title="Ce que ça engage" icon="i-lucide-droplets" sub="par unité, lignes séparées — l'huile et l'eau ne s'additionnent pas">
|
||||
<ul v-if="resourceLines.length" class="obs__list">
|
||||
<li v-for="line in resourceLines" :key="line.unit" class="obs__resource">
|
||||
<span class="obs__resource-total">{{ line.total }} {{ line.unit }}</span>
|
||||
<span class="obs__muted">sur {{ line.count }} décision{{ line.count > 1 ? 's' : '' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="obs__muted">Aucune ressource engagée sur la période.</p>
|
||||
</ObservatorySection>
|
||||
|
||||
<!-- Épreuves du réel -->
|
||||
<ObservatorySection :title="REVIEW_TITLE" icon="i-lucide-telescope" sub="le réel a-t-il suivi ?">
|
||||
<div class="obs__tiles">
|
||||
<ObservatoryStat label="Dues" :value="String(reviewsDue.length)" />
|
||||
<ObservatoryStat :label="reviewLabels.confirmed" :value="String(reviewCount('confirmed'))" />
|
||||
<ObservatoryStat :label="reviewLabels.revise" :value="String(reviewCount('revise'))" />
|
||||
<ObservatoryStat :label="reviewLabels.revoke" :value="String(reviewCount('revoke'))" />
|
||||
</div>
|
||||
<ul v-if="reviewsDue.length" class="obs__list">
|
||||
<li v-for="r in reviewsDue" :key="r.id">
|
||||
<NuxtLink :to="`/decisions/${r.id}`" class="obs__row">
|
||||
<span class="obs__row-title">{{ r.title }}</span>
|
||||
<span class="obs__due">due le {{ formatDay(r.dueAt) }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</ObservatorySection>
|
||||
|
||||
<!-- Révoquées — ce qu'on en a appris -->
|
||||
<ObservatorySection :title="REVOKED_SECTION" icon="i-lucide-flask-conical" sub="le droit à l'erreur, regardé avec la curiosité du chercheur">
|
||||
<ul v-if="revoked.length" class="obs__list">
|
||||
<li v-for="entry in revoked" :key="entry.decision.id" class="obs__revoked">
|
||||
<NuxtLink :to="`/decisions/${entry.decision.id}`" class="obs__row">
|
||||
<span class="obs__row-title">{{ entry.decision.title }}</span>
|
||||
<span class="obs__muted">{{ formatDay(entry.decision.decidedAt ?? entry.decision.createdAt) }}</span>
|
||||
</NuxtLink>
|
||||
<p v-if="entry.decision.review?.note" class="obs__learned">{{ entry.decision.review.note }}</p>
|
||||
<NuxtLink v-if="entry.revocation" :to="`/decisions/${entry.revocation.id}`" class="obs__chain">
|
||||
<UIcon name="i-lucide-link" />
|
||||
la décision qui l'a révoquée
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="obs__muted">Rien à apprendre ici pour l'instant — aucune révoquée sur la période.</p>
|
||||
</ObservatorySection>
|
||||
|
||||
<!-- Consignations -->
|
||||
<ObservatorySection title="Consignations" icon="i-lucide-notebook-pen" sub="déjà tranché ailleurs — consigné ici, tel quel">
|
||||
<ul v-if="records.length" class="obs__list">
|
||||
<li v-for="d in records" :key="d.id">
|
||||
<NuxtLink :to="`/decisions/${d.id}`" class="obs__row obs__row--col">
|
||||
<span class="obs__row-title">{{ d.title }}</span>
|
||||
<span v-if="d.decidedHow" class="obs__how">{{ d.decidedHow }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="obs__muted">Aucune consignation sur la période.</p>
|
||||
|
||||
<div v-for="m in maturations" :key="m.tags.join('|')" class="obs__card obs__card--ripe">
|
||||
<p class="obs__card-title">
|
||||
<UIcon name="i-lucide-sprout" />
|
||||
{{ MATURATION_CARD }}
|
||||
</p>
|
||||
<p class="obs__muted">
|
||||
{{ m.ids.size }} consignations partagent {{ m.tags.map(t => `#${t}`).join(' et ') }}.
|
||||
</p>
|
||||
<NuxtLink :to="`/decider?clause-nouvelle&tags=${m.tags.join(',')}`" class="ld-btn ld-btn--ghost obs__card-btn">
|
||||
Protocoliser
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</ObservatorySection>
|
||||
|
||||
<!-- Élagage & relances -->
|
||||
<ObservatorySection title="Élagage" icon="i-lucide-scissors" sub="ce qui ne sert plus mérite une revue — pas un enterrement silencieux">
|
||||
<div v-for="p in unusedProtocols" :key="p.id" class="obs__card">
|
||||
<p class="obs__card-title">Le protocole « {{ p.name }} » n'a jamais servi.</p>
|
||||
<p class="obs__muted">Le relire, l'amender — ou l'élaguer par une décision.</p>
|
||||
</div>
|
||||
<div v-for="m in idleMandates" :key="m.id" class="obs__card">
|
||||
<p class="obs__card-title">Le mandat « {{ m.title }} » est sans trace depuis 6 mois.</p>
|
||||
<NuxtLink :to="`/mandats/${m.id}`" class="obs__chain">
|
||||
<UIcon name="i-lucide-key-round" />
|
||||
voir la fiche — une revue s'impose peut-être
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<template v-if="neverReviewed.length">
|
||||
<h3 class="obs__h3">Jamais revues depuis {{ reviewDelay }} jours</h3>
|
||||
<ul class="obs__list">
|
||||
<li v-for="d in neverReviewed" :key="d.id">
|
||||
<NuxtLink :to="`/decisions/${d.id}`" class="obs__row">
|
||||
<span class="obs__row-title">{{ d.title }}</span>
|
||||
<span class="obs__muted">décidée le {{ formatDay(d.decidedAt) }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<p v-if="!unusedProtocols.length && !idleMandates.length && !neverReviewed.length" class="obs__muted">
|
||||
Rien à élaguer — tout ce qui existe sert encore.
|
||||
</p>
|
||||
</ObservatorySection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.obs { max-width: 52rem; margin: 0 auto; width: 100%; display: flex; flex-direction: column; gap: 1.25rem; }
|
||||
.obs__title { margin: 0; font-size: clamp(1.375rem, 3.5vw, 1.75rem); font-weight: 800; letter-spacing: -0.01em; }
|
||||
.obs__sub { margin: 0.25rem 0 0; font-size: 0.9375rem; font-style: italic; color: var(--mood-text-muted); }
|
||||
.obs__filters { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.obs__select {
|
||||
flex: 1 1 10rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text);
|
||||
background: var(--mood-input-bg);
|
||||
border: none;
|
||||
border-radius: var(--r-input);
|
||||
box-shadow: inset 0 0 0 1px var(--mood-input-border);
|
||||
}
|
||||
.obs__select:focus { outline: none; box-shadow: inset 0 0 0 2px var(--mood-input-focus); }
|
||||
.obs__h3 {
|
||||
margin: 0.375rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.obs__tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.625rem; }
|
||||
.obs__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.375rem; }
|
||||
.obs__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--mood-accent-soft);
|
||||
text-decoration: none;
|
||||
color: var(--mood-text);
|
||||
transition: transform 0.1s ease;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.obs__row:hover { transform: translateY(-1px); }
|
||||
.obs__row--col { flex-direction: column; align-items: flex-start; gap: 0.125rem; }
|
||||
.obs__row-title { font-size: 0.875rem; font-weight: 600; min-width: 0; }
|
||||
.obs__due { font-size: 0.75rem; font-weight: 700; color: var(--mood-status-fenetre); white-space: nowrap; }
|
||||
.obs__muted { margin: 0; font-size: 0.8125rem; color: var(--mood-text-muted); }
|
||||
.obs__how { font-size: 0.8125rem; font-style: italic; color: var(--mood-text-muted); }
|
||||
.obs__resource { display: flex; align-items: baseline; gap: 0.625rem; flex-wrap: wrap; }
|
||||
.obs__resource-total { font-size: 1.125rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.obs__revoked { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.obs__learned {
|
||||
margin: 0;
|
||||
padding-left: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-style: italic;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.obs__chain {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-accent);
|
||||
text-decoration: none;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
.obs__chain:hover { text-decoration: underline; }
|
||||
.obs__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: var(--r-icon);
|
||||
background: var(--mood-accent-soft);
|
||||
}
|
||||
.obs__card--ripe { background: var(--mood-status-vigueur-bg); }
|
||||
.obs__card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.obs__card-btn { align-self: flex-start; margin-top: 0.375rem; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user