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,82 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Card component for displaying a mandate in a list.
|
||||
*
|
||||
* Shows title, type badge, status badge, mandatee, date range.
|
||||
* Navigates to the mandate detail page on click.
|
||||
*/
|
||||
import type { Mandate } from '~/stores/mandates'
|
||||
// <!-- ld-v2 --> Carte mandat — titulaire, domaine, échéance, prochain rapport,
|
||||
// COMPTEURS BRUTS (Δ7 : aucune jauge, aucun agrégat — des faits comptés).
|
||||
import type { Mandate } from '~/types/domain'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import {
|
||||
MANDATE_STATUS_LABELS,
|
||||
MANDATE_STATUS_PILL,
|
||||
formatDay,
|
||||
mandateFacts,
|
||||
} from './mandateUi'
|
||||
|
||||
const props = defineProps<{
|
||||
mandate: Mandate
|
||||
}>()
|
||||
const props = defineProps<{ mandate: Mandate }>()
|
||||
|
||||
const typeLabel = (mandateType: string) => {
|
||||
switch (mandateType) {
|
||||
case 'techcomm': return 'Comite technique'
|
||||
case 'smith': return 'Forgeron'
|
||||
case 'custom': return 'Personnalise'
|
||||
default: return mandateType
|
||||
}
|
||||
}
|
||||
const col = useCollectiveStore()
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function navigate() {
|
||||
navigateTo(`/mandates/${props.mandate.id}`)
|
||||
}
|
||||
const holder = computed(() => col.people.find(p => p.id === props.mandate.holderId))
|
||||
const domainCircles = computed(() =>
|
||||
props.mandate.domain.circleIds
|
||||
.map(id => col.circles.find(c => c.id === id))
|
||||
.filter(c => c !== undefined),
|
||||
)
|
||||
const facts = computed(() => mandateFacts(props.mandate, col.decisions, col.objections))
|
||||
</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-user-check" class="text-gray-400" />
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ mandate.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<StatusBadge :status="mandate.status" type="mandate" />
|
||||
</div>
|
||||
|
||||
<p v-if="mandate.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{{ mandate.description }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<UBadge variant="subtle" color="primary" size="xs">
|
||||
{{ typeLabel(mandate.mandate_type) }}
|
||||
</UBadge>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ mandate.steps.length }} etape(s)
|
||||
</span>
|
||||
<span v-if="mandate.mandatee_id" class="text-xs text-gray-500 flex items-center gap-1">
|
||||
<UIcon name="i-lucide-user" class="text-xs" />
|
||||
{{ mandate.mandatee_id.slice(0, 8) }}...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-xs text-gray-500">
|
||||
<div>
|
||||
<span class="block font-medium">Debut</span>
|
||||
{{ formatDate(mandate.starts_at) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="block font-medium">Fin</span>
|
||||
{{ formatDate(mandate.ends_at) }}
|
||||
</div>
|
||||
<!-- ld-v2 -->
|
||||
<NuxtLink :to="`/mandats/${mandate.id}`" class="mcard ld-card ld-card--hover">
|
||||
<div class="mcard__head">
|
||||
<LdAvatarStack v-if="holder" :people="[{ person: holder }]" :size="34" />
|
||||
<div class="mcard__who">
|
||||
<p class="mcard__title">{{ mandate.title }}</p>
|
||||
<p v-if="holder" class="mcard__holder">{{ holder.displayName }}</p>
|
||||
</div>
|
||||
<span class="status-pill" :class="MANDATE_STATUS_PILL[mandate.status]">
|
||||
{{ MANDATE_STATUS_LABELS[mandate.status] }}
|
||||
</span>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<div class="mcard__domain">
|
||||
<span v-for="c in domainCircles" :key="c.id" class="mcard__chip">
|
||||
<UIcon name="i-lucide-circle-dashed" />
|
||||
{{ c.name }}
|
||||
</span>
|
||||
<span v-for="tag in mandate.domain.tags" :key="tag" class="mcard__tag">#{{ tag }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mcard__meta">
|
||||
<span class="mcard__meta-item">
|
||||
<UIcon name="i-lucide-calendar-range" />
|
||||
jusqu'au {{ formatDay(mandate.endsAt) }}
|
||||
</span>
|
||||
<span v-if="facts.nextReportDueAt" class="mcard__meta-item mcard__meta-item--due">
|
||||
<UIcon name="i-lucide-file-clock" />
|
||||
rapport dû le {{ formatDay(facts.nextReportDueAt) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="mcard__facts">{{ facts.line }}</p>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mcard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1.125rem 1.25rem;
|
||||
text-decoration: none;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
.mcard__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.mcard__who { flex: 1; min-width: 0; }
|
||||
.mcard__title {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.mcard__holder {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.mcard__domain {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
.mcard__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-accent);
|
||||
background: var(--mood-accent-soft);
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.mcard__tag {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.mcard__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem 1rem;
|
||||
}
|
||||
.mcard__meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.mcard__meta-item--due { color: var(--mood-status-fenetre); font-weight: 600; }
|
||||
.mcard__facts {
|
||||
margin: 0;
|
||||
padding-top: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--mood-text);
|
||||
box-shadow: 0 -1px 0 color-mix(in srgb, var(--mood-accent) 10%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> « Exercice du mandat » (Δ7) — des FAITS COMPTÉS, jamais
|
||||
// synthétisés : décisions tracées, objections listées, rapports rendus/dus.
|
||||
// Le titulaire rend son rapport ici quand il est dû — contenu dépliable.
|
||||
import type { Mandate } from '~/types/domain'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { MANDATE_EXERCISE, STATUS_LABELS } from '~/lexicon'
|
||||
import {
|
||||
OBJECTION_STATUS_LABELS,
|
||||
formatDay,
|
||||
mandateFacts,
|
||||
reportIntervalMs,
|
||||
} from './mandateUi'
|
||||
|
||||
const props = defineProps<{ mandate: Mandate }>()
|
||||
|
||||
const col = useCollectiveStore()
|
||||
|
||||
const facts = computed(() => mandateFacts(props.mandate, col.decisions, col.objections))
|
||||
|
||||
const objectionRows = computed(() =>
|
||||
facts.value.objections.map(o => ({
|
||||
id: o.id,
|
||||
decisionId: o.decisionId,
|
||||
decisionTitle: col.decisions.find(d => d.id === o.decisionId)?.title ?? '—',
|
||||
argument: o.argument,
|
||||
statusLabel: OBJECTION_STATUS_LABELS[o.status],
|
||||
open: o.status === 'open',
|
||||
})),
|
||||
)
|
||||
|
||||
const sortedReports = computed(() =>
|
||||
[...props.mandate.reports].sort((a, b) => (a.dueAt < b.dueAt ? -1 : 1)),
|
||||
)
|
||||
|
||||
/** Le rapport dû maintenant, à rendre par le titulaire (moi). */
|
||||
const dueReport = computed(() => {
|
||||
if (col.me?.id !== props.mandate.holderId) return undefined
|
||||
if (props.mandate.status !== 'active') return undefined
|
||||
return sortedReports.value.find(r => r.deliveredAt === undefined && r.dueAt <= col.now())
|
||||
})
|
||||
|
||||
const reportText = ref('')
|
||||
|
||||
function deliverReport(): void {
|
||||
const target = dueReport.value
|
||||
if (!target || reportText.value.trim().length === 0) return
|
||||
target.deliveredAt = col.now()
|
||||
target.content = reportText.value.trim()
|
||||
// Cadence : le prochain rapport garde l'espacement existant, borné à l'échéance.
|
||||
const interval = reportIntervalMs(props.mandate)
|
||||
if (interval !== undefined) {
|
||||
const nextDue = new Date(new Date(target.dueAt).getTime() + interval).toISOString()
|
||||
if (nextDue < props.mandate.endsAt) props.mandate.reports.push({ dueAt: nextDue })
|
||||
}
|
||||
col.stamp(props.mandate)
|
||||
col.persist()
|
||||
reportText.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<section class="mex ld-card">
|
||||
<h2 class="mex__title">
|
||||
<UIcon name="i-lucide-list-checks" />
|
||||
{{ MANDATE_EXERCISE }}
|
||||
</h2>
|
||||
<p class="mex__facts">{{ facts.line }}</p>
|
||||
|
||||
<!-- Décisions tracées -->
|
||||
<div v-if="facts.traced.length" class="mex__block">
|
||||
<h3 class="mex__block-title">Décisions tracées</h3>
|
||||
<ul class="mex__list">
|
||||
<li v-for="d in facts.traced" :key="d.id">
|
||||
<NuxtLink :to="`/decisions/${d.id}`" class="mex__row">
|
||||
<span class="mex__row-title">{{ d.title }}</span>
|
||||
<span class="status-pill" :class="`status-${d.status}`">
|
||||
{{ STATUS_LABELS[d.status] }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Objections — liste cliquable -->
|
||||
<div v-if="objectionRows.length" class="mex__block">
|
||||
<h3 class="mex__block-title">Objections</h3>
|
||||
<ul class="mex__list">
|
||||
<li v-for="o in objectionRows" :key="o.id">
|
||||
<NuxtLink :to="`/decisions/${o.decisionId}`" class="mex__row">
|
||||
<span class="mex__row-title">{{ o.argument }}</span>
|
||||
<span class="mex__obj-status" :class="{ 'mex__obj-status--open': o.open }">
|
||||
{{ o.statusLabel }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Rapports — rendus dépliables, saisie quand dû -->
|
||||
<div v-if="sortedReports.length || dueReport" class="mex__block">
|
||||
<h3 class="mex__block-title">
|
||||
Rapports — {{ facts.reportsDelivered }}/{{ facts.reportsTotal }} rendus
|
||||
</h3>
|
||||
<ul class="mex__list">
|
||||
<li v-for="r in sortedReports" :key="r.dueAt">
|
||||
<details v-if="r.deliveredAt" class="mex__report">
|
||||
<summary class="mex__report-summary">
|
||||
<UIcon name="i-lucide-file-check-2" />
|
||||
<span>Rapport rendu le {{ formatDay(r.deliveredAt) }}</span>
|
||||
<span class="mex__report-due">dû le {{ formatDay(r.dueAt) }}</span>
|
||||
</summary>
|
||||
<p class="mex__report-content">{{ r.content }}</p>
|
||||
</details>
|
||||
<div v-else class="mex__report mex__report--pending">
|
||||
<UIcon name="i-lucide-file-clock" />
|
||||
<span>Rapport dû le {{ formatDay(r.dueAt) }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form v-if="dueReport" class="mex__deliver" @submit.prevent="deliverReport">
|
||||
<label class="mex__deliver-label" for="mex-report">
|
||||
Ton rapport est dû — quelques phrases suffisent.
|
||||
</label>
|
||||
<textarea
|
||||
id="mex-report"
|
||||
v-model="reportText"
|
||||
rows="4"
|
||||
lang="fr"
|
||||
spellcheck="true"
|
||||
placeholder="Ce qui a été fait, ce qui engage la suite…"
|
||||
/>
|
||||
<button type="submit" class="ld-btn" :disabled="reportText.trim().length === 0">
|
||||
<UIcon name="i-lucide-send" />
|
||||
Rendre le rapport
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mex { padding: 1.375rem 1.5rem; display: flex; flex-direction: column; gap: 1rem; }
|
||||
.mex__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.mex__facts {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.mex__block { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.mex__block-title {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.mex__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.375rem; }
|
||||
.mex__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;
|
||||
}
|
||||
.mex__row:hover { transform: translateY(-1px); }
|
||||
.mex__row-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mex__obj-status {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.mex__obj-status--open { color: var(--mood-status-fenetre); }
|
||||
.mex__report {
|
||||
border-radius: var(--r-input);
|
||||
background: var(--mood-accent-soft);
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.mex__report-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.mex__report-summary::-webkit-details-marker { display: none; }
|
||||
.mex__report-due { margin-left: auto; font-size: 0.75rem; color: var(--mood-text-muted); }
|
||||
.mex__report-content {
|
||||
margin: 0.625rem 0 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: var(--mood-text);
|
||||
white-space: pre-line;
|
||||
}
|
||||
.mex__report--pending {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-status-fenetre);
|
||||
background: var(--mood-status-fenetre-bg);
|
||||
}
|
||||
.mex__deliver { display: flex; flex-direction: column; gap: 0.625rem; margin-top: 0.375rem; }
|
||||
.mex__deliver-label { font-size: 0.875rem; font-weight: 600; }
|
||||
.mex__deliver textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
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);
|
||||
resize: vertical;
|
||||
}
|
||||
.mex__deliver textarea:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--mood-input-focus);
|
||||
}
|
||||
.mex__deliver .ld-btn { align-self: flex-start; }
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Étape 2 du wizard mandat — les 6 modalités de nomination,
|
||||
// avec leurs pédagogies courtes (pro / limite). Pour l'élection sans candidat,
|
||||
// la règle de clôture est affichée : vous départagez, jamais l'outil.
|
||||
import type { NominationMethod } from '~/types/domain'
|
||||
import { NOMINATION_LABELS } from '~/lexicon'
|
||||
import { NOMINATION_PEDAGOGY } from './mandateUi'
|
||||
|
||||
const props = defineProps<{ modelValue: NominationMethod | null }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: NominationMethod): void }>()
|
||||
|
||||
const METHODS = Object.keys(NOMINATION_LABELS) as NominationMethod[]
|
||||
|
||||
function select(method: NominationMethod): void {
|
||||
emit('update:modelValue', method)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<div class="mnom">
|
||||
<button
|
||||
v-for="method in METHODS"
|
||||
:key="method"
|
||||
type="button"
|
||||
class="mnom__card"
|
||||
:class="{ 'mnom__card--active': props.modelValue === method }"
|
||||
@click="select(method)"
|
||||
>
|
||||
<div class="mnom__head">
|
||||
<span class="mnom__icon">
|
||||
<UIcon :name="NOMINATION_PEDAGOGY[method].icon" />
|
||||
</span>
|
||||
<span class="mnom__name">{{ NOMINATION_LABELS[method] }}</span>
|
||||
<UIcon
|
||||
:name="props.modelValue === method ? 'i-lucide-circle-check' : 'i-lucide-circle'"
|
||||
class="mnom__check"
|
||||
/>
|
||||
</div>
|
||||
<p class="mnom__desc">{{ NOMINATION_PEDAGOGY[method].desc }}</p>
|
||||
<p class="mnom__pro">
|
||||
<UIcon name="i-lucide-plus" />
|
||||
{{ NOMINATION_PEDAGOGY[method].pro }}
|
||||
</p>
|
||||
<p class="mnom__con">
|
||||
<UIcon name="i-lucide-minus" />
|
||||
{{ NOMINATION_PEDAGOGY[method].con }}
|
||||
</p>
|
||||
<p v-if="NOMINATION_PEDAGOGY[method].rule" class="mnom__rule">
|
||||
<UIcon name="i-lucide-scale" />
|
||||
{{ NOMINATION_PEDAGOGY[method].rule }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mnom {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.mnom { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
.mnom__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 1rem 1.125rem;
|
||||
text-align: left;
|
||||
background: var(--mood-surface);
|
||||
border-radius: var(--r-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
cursor: pointer;
|
||||
color: var(--mood-text);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.mnom__card:hover { transform: translateY(-3px); box-shadow: var(--shadow-card-hover); }
|
||||
.mnom__card:active { transform: translateY(0); }
|
||||
.mnom__card--active {
|
||||
box-shadow: 0 0 0 2px var(--mood-accent), var(--shadow-card);
|
||||
background: var(--mood-accent-soft);
|
||||
}
|
||||
.mnom__head { display: flex; align-items: center; gap: 0.625rem; }
|
||||
.mnom__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.125rem;
|
||||
height: 2.125rem;
|
||||
border-radius: var(--r-icon);
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-accent);
|
||||
font-size: 1.0625rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mnom__name { flex: 1; font-size: 0.9375rem; font-weight: 700; }
|
||||
.mnom__check { color: var(--mood-accent); flex-shrink: 0; }
|
||||
.mnom__desc { margin: 0; font-size: 0.8438rem; line-height: 1.5; color: var(--mood-text-muted); }
|
||||
.mnom__pro,
|
||||
.mnom__con,
|
||||
.mnom__rule {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.mnom__pro { color: var(--mood-success); }
|
||||
.mnom__con { color: var(--mood-text-muted); }
|
||||
.mnom__rule {
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--mood-status-vote-bg);
|
||||
color: var(--mood-status-vote);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Période du mandat — barre de temps simple : début, fin,
|
||||
// marqueur aujourd'hui. Un fait daté, pas une jauge de quoi que ce soit.
|
||||
import { formatDay } from './mandateUi'
|
||||
|
||||
const props = defineProps<{ startsAt: string; endsAt: string }>()
|
||||
|
||||
const pct = computed(() => {
|
||||
const start = new Date(props.startsAt).getTime()
|
||||
const end = new Date(props.endsAt).getTime()
|
||||
if (end <= start) return 100
|
||||
return Math.min(100, Math.max(0, ((Date.now() - start) / (end - start)) * 100))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<div class="mperiod">
|
||||
<div class="mperiod__track">
|
||||
<div class="mperiod__fill" :style="{ width: `${pct}%` }" />
|
||||
<div class="mperiod__today" :style="{ left: `${pct}%` }" title="aujourd'hui" />
|
||||
</div>
|
||||
<div class="mperiod__dates">
|
||||
<span>{{ formatDay(startsAt) }}</span>
|
||||
<span>{{ formatDay(endsAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mperiod { display: flex; flex-direction: column; gap: 0.375rem; }
|
||||
.mperiod__track {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--mood-accent-soft);
|
||||
}
|
||||
.mperiod__fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
border-radius: 4px 0 0 4px;
|
||||
background: var(--mood-accent);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.mperiod__today {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--mood-accent);
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 0 0 2px var(--mood-surface);
|
||||
}
|
||||
.mperiod__dates {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> « Feux de la rampe » (Δ7) — le flux public des décisions
|
||||
// prises sous ce mandat, chacune avec sa fenêtre : contestables, donc visibles.
|
||||
import type { Mandate } from '~/types/domain'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { MANDATE_SPOTLIGHT, MANDATE_SPOTLIGHT_SUB, STATUS_LABELS } from '~/lexicon'
|
||||
import { formatDay } from './mandateUi'
|
||||
|
||||
const props = defineProps<{ mandate: Mandate }>()
|
||||
|
||||
const col = useCollectiveStore()
|
||||
|
||||
const traced = computed(() =>
|
||||
col.decisions
|
||||
.filter(d => d.underMandateId === props.mandate.id)
|
||||
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<section class="mspot ld-card">
|
||||
<h2 class="mspot__title">
|
||||
<UIcon name="i-lucide-lamp" />
|
||||
{{ MANDATE_SPOTLIGHT }}
|
||||
</h2>
|
||||
<p class="mspot__sub">{{ MANDATE_SPOTLIGHT_SUB }}</p>
|
||||
|
||||
<ul v-if="traced.length" class="mspot__list">
|
||||
<li v-for="d in traced" :key="d.id" class="mspot__item">
|
||||
<NuxtLink :to="`/decisions/${d.id}`" class="mspot__link">
|
||||
<div class="mspot__main">
|
||||
<span class="mspot__decision-title">{{ d.title }}</span>
|
||||
<span class="mspot__date">{{ formatDay(d.decidedAt ?? d.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="mspot__side">
|
||||
<LdCountdown
|
||||
v-if="d.windowEndsAt && !d.decidedAt"
|
||||
:ends-at="d.windowEndsAt"
|
||||
:suspended-at="d.windowSuspendedAt"
|
||||
/>
|
||||
<span class="status-pill" :class="`status-${d.status}`">
|
||||
{{ STATUS_LABELS[d.status] }}
|
||||
</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="mspot__empty">
|
||||
Aucune décision sous ce mandat pour l'instant — les feux s'allumeront à la première trace.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mspot { padding: 1.375rem 1.5rem; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.mspot__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.mspot__sub {
|
||||
margin: -0.375rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.mspot__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.375rem; }
|
||||
.mspot__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 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;
|
||||
}
|
||||
.mspot__link:hover { transform: translateY(-1px); }
|
||||
.mspot__main { display: flex; flex-direction: column; gap: 0.125rem; min-width: 0; flex: 1; }
|
||||
.mspot__decision-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mspot__date { font-size: 0.75rem; color: var(--mood-text-muted); }
|
||||
.mspot__side { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
|
||||
.mspot__empty { margin: 0; font-size: 0.875rem; color: var(--mood-text-muted); font-style: italic; }
|
||||
</style>
|
||||
@@ -1,138 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Visual timeline for mandate lifecycle steps.
|
||||
*
|
||||
* Displays each step with its type, status, and visual indicators.
|
||||
* Similar pattern to DecisionWorkflow but with mandate-specific step types.
|
||||
*/
|
||||
import type { MandateStep } from '~/stores/mandates'
|
||||
|
||||
const props = defineProps<{
|
||||
steps: MandateStep[]
|
||||
currentStatus: string
|
||||
}>()
|
||||
|
||||
const sortedSteps = computed(() => {
|
||||
return [...props.steps].sort((a, b) => a.step_order - b.step_order)
|
||||
})
|
||||
|
||||
const stepTypeLabel = (stepType: string) => {
|
||||
switch (stepType) {
|
||||
case 'candidacy': return 'Candidature'
|
||||
case 'voting': return 'Vote'
|
||||
case 'active': return 'Actif'
|
||||
case 'reporting': return 'Rapport'
|
||||
case 'completed': return 'Termine'
|
||||
default: return stepType
|
||||
}
|
||||
}
|
||||
|
||||
const stepTypeIcon = (stepType: string) => {
|
||||
switch (stepType) {
|
||||
case 'candidacy': return 'i-lucide-user-plus'
|
||||
case 'voting': return 'i-lucide-vote'
|
||||
case 'active': return 'i-lucide-shield-check'
|
||||
case 'reporting': return 'i-lucide-file-text'
|
||||
case 'completed': return 'i-lucide-check-circle'
|
||||
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 ce mandat</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-red-500 border-red-500': step.status === 'revoked',
|
||||
'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"
|
||||
/>
|
||||
<UIcon
|
||||
v-else-if="step.status === 'revoked'"
|
||||
name="i-lucide-x"
|
||||
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="mandate" />
|
||||
</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>
|
||||
|
||||
<div v-if="step.vote_session_id" class="mt-2">
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="soft"
|
||||
color="primary"
|
||||
icon="i-lucide-vote"
|
||||
label="Voir la session de vote"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Barres horizontales sobres de l'Observatoire — une mesure,
|
||||
// une teinte (l'accent du mood, jamais codée en dur) ; l'identité est portée
|
||||
// par le libellé, la grandeur par la longueur. Extrémité arrondie côté donnée,
|
||||
// base carrée sur la ligne de base (specs dataviz).
|
||||
export interface BarItem {
|
||||
label: string
|
||||
value: number
|
||||
display?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{ items: BarItem[] }>()
|
||||
|
||||
const max = computed(() => Math.max(1, ...props.items.map(i => i.value)))
|
||||
|
||||
function width(value: number): string {
|
||||
if (value <= 0) return '0'
|
||||
return `${Math.max(2, (value / max.value) * 100)}%`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<div class="obars" role="img">
|
||||
<div v-for="item in items" :key="item.label" class="obars__row">
|
||||
<span class="obars__label">{{ item.label }}</span>
|
||||
<span class="obars__plot">
|
||||
<span class="obars__fill" :style="{ width: width(item.value) }" />
|
||||
</span>
|
||||
<span class="obars__value">{{ item.display ?? item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.obars { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.obars__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(5.5rem, 8rem) 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.obars__label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.obars__plot {
|
||||
position: relative;
|
||||
height: 14px;
|
||||
/* ligne de base : filet discret, une teinte du mood */
|
||||
box-shadow: inset 1px 0 0 color-mix(in srgb, var(--mood-text-muted) 35%, transparent);
|
||||
}
|
||||
.obars__fill {
|
||||
position: absolute;
|
||||
inset: 1px auto 1px 0;
|
||||
background: var(--mood-accent);
|
||||
border-radius: 0 4px 4px 0; /* extrémité donnée arrondie, base carrée */
|
||||
}
|
||||
.obars__value {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Section de l'Observatoire — carte titrée, ton curieux.
|
||||
defineProps<{ title: string; icon: string; sub?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<section class="osec ld-card">
|
||||
<h2 class="osec__title">
|
||||
<UIcon :name="icon" />
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="sub" class="osec__sub">{{ sub }}</p>
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.osec {
|
||||
padding: 1.25rem 1.375rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
.osec__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.osec__title :deep(svg),
|
||||
.osec__title :deep(span[class^='i-']) { color: var(--mood-accent); }
|
||||
.osec__sub {
|
||||
margin: -0.5rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
font-style: italic;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Tuile de stat de l'Observatoire — libellé, valeur, note.
|
||||
// Chiffres proportionnels (pas de tabular à cette taille), encre = tokens texte.
|
||||
defineProps<{ label: string; value: string; hint?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<div class="ostat">
|
||||
<p class="ostat__label">{{ label }}</p>
|
||||
<p class="ostat__value">{{ value }}</p>
|
||||
<p v-if="hint" class="ostat__hint">{{ hint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ostat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: var(--r-icon);
|
||||
background: var(--mood-accent-soft);
|
||||
min-width: 0;
|
||||
}
|
||||
.ostat__label {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.ostat__value {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
line-height: 1.15;
|
||||
}
|
||||
.ostat__hint { margin: 0; font-size: 0.75rem; color: var(--mood-text-muted); }
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Shared UI helpers of the mandate screens (carte, fiche, wizard) — v2.
|
||||
* INVARIANT Δ7 : no gauge, no score, no aggregate on a mandate — COUNTED
|
||||
* FACTS only, never synthesized. The lexicon stays the single source for
|
||||
* everything it already names (MANDATE_EXERCISE, NOMINATION_LABELS…).
|
||||
*/
|
||||
|
||||
import type {
|
||||
Decision,
|
||||
Id,
|
||||
Mandate,
|
||||
MandateReport,
|
||||
NominationMethod,
|
||||
Objection,
|
||||
} from '~/types/domain'
|
||||
|
||||
/** Mandate statuses in plain French — proposé / actif / expiré / révoqué. */
|
||||
export const MANDATE_STATUS_LABELS: Record<Mandate['status'], string> = {
|
||||
proposed: 'proposé',
|
||||
active: 'actif',
|
||||
expired: 'expiré',
|
||||
revoked: 'révoqué',
|
||||
}
|
||||
|
||||
/** Global status-pill classes reused for mandates (moods.css). */
|
||||
export const MANDATE_STATUS_PILL: Record<Mandate['status'], string> = {
|
||||
proposed: 'status-draft',
|
||||
active: 'status-adopted',
|
||||
expired: 'status-closed',
|
||||
revoked: 'status-revoked',
|
||||
}
|
||||
|
||||
/** Objection statuses in plain French (fiche mandat — listes cliquables). */
|
||||
export const OBJECTION_STATUS_LABELS: Record<Objection['status'], string> = {
|
||||
open: 'ouverte',
|
||||
withdrawn: 'retirée',
|
||||
integrated: 'intégrée',
|
||||
escalated: 'escaladée',
|
||||
}
|
||||
|
||||
/** Counted facts of a mandate — raw numbers, never a synthesis. */
|
||||
export interface MandateFacts {
|
||||
traced: Decision[]
|
||||
objections: Objection[]
|
||||
reportsDelivered: number
|
||||
reportsTotal: number
|
||||
nextReportDueAt?: string
|
||||
line: string
|
||||
}
|
||||
|
||||
export function mandateFacts(
|
||||
mandate: Mandate,
|
||||
decisions: Decision[],
|
||||
objections: Objection[],
|
||||
): MandateFacts {
|
||||
const traced = decisions
|
||||
.filter(d => d.underMandateId === mandate.id)
|
||||
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
|
||||
const tracedIds = new Set<Id>(traced.map(d => d.id))
|
||||
const own = objections.filter(o => tracedIds.has(o.decisionId))
|
||||
const delivered = mandate.reports.filter(r => r.deliveredAt !== undefined).length
|
||||
const total = mandate.reports.length
|
||||
const next = mandate.reports
|
||||
.filter(r => r.deliveredAt === undefined)
|
||||
.sort((a, b) => (a.dueAt < b.dueAt ? -1 : 1))[0]
|
||||
const parts = [
|
||||
`${traced.length} décision${traced.length > 1 ? 's' : ''} tracée${traced.length > 1 ? 's' : ''}`,
|
||||
`${own.length} objection${own.length > 1 ? 's' : ''}`,
|
||||
]
|
||||
if (total > 0) parts.push(`${delivered}/${total} rapport${total > 1 ? 's' : ''}`)
|
||||
const facts: MandateFacts = {
|
||||
traced,
|
||||
objections: own,
|
||||
reportsDelivered: delivered,
|
||||
reportsTotal: total,
|
||||
line: parts.join(' · '),
|
||||
}
|
||||
if (next) facts.nextReportDueAt = next.dueAt
|
||||
return facts
|
||||
}
|
||||
|
||||
/** Next report cadence, inferred from the existing dueAt spacing. */
|
||||
export function reportIntervalMs(mandate: Mandate): number | undefined {
|
||||
const dues = mandate.reports.map((r: MandateReport) => r.dueAt).sort()
|
||||
const last = dues[dues.length - 1]
|
||||
if (last === undefined) return undefined
|
||||
const previous = dues.length >= 2 ? dues[dues.length - 2]! : mandate.startsAt
|
||||
const gap = new Date(last).getTime() - new Date(previous).getTime()
|
||||
return gap > 0 ? gap : undefined
|
||||
}
|
||||
|
||||
/** Short pedagogy of the 6 nomination modalities (wizard step 2). */
|
||||
export interface NominationPedagogy {
|
||||
icon: string
|
||||
desc: string
|
||||
pro: string
|
||||
con: string
|
||||
rule?: string
|
||||
}
|
||||
|
||||
export const ELECTION_CLOSE_RULE
|
||||
= 'Pluralité, blanc possible — en cas d\'égalité, vous départagez, jamais l\'outil.'
|
||||
|
||||
export const NOMINATION_PEDAGOGY: Record<NominationMethod, NominationPedagogy> = {
|
||||
'ratified-self': {
|
||||
icon: 'i-lucide-user-check',
|
||||
desc: 'Tu te proposes toi-même ; le cercle ratifie par consentement.',
|
||||
pro: 'le plus rapide — l\'élan vient de la personne',
|
||||
con: 'suppose un vrai espace d\'objection',
|
||||
},
|
||||
'election-no-candidate': {
|
||||
icon: 'i-lucide-vote',
|
||||
desc: 'Chacun désigne la personne qu\'il juge la plus indiquée — personne ne se déclare.',
|
||||
pro: 'révèle la légitimité réelle, sans campagne',
|
||||
con: 'demande un cercle qui se connaît',
|
||||
rule: ELECTION_CLOSE_RULE,
|
||||
},
|
||||
'nuanced-vote': {
|
||||
icon: 'i-lucide-list-ordered',
|
||||
desc: 'Chaque nom proposé reçoit une nuance, de « Pas du tout » à « Tout à fait ».',
|
||||
pro: 'mesure l\'adhésion, pas seulement la préférence',
|
||||
con: 'plus long à dépouiller qu\'un consentement',
|
||||
},
|
||||
'consent': {
|
||||
icon: 'i-lucide-handshake',
|
||||
desc: 'La nomination passe si aucune objection argumentée ne tient.',
|
||||
pro: 'sobre et robuste — l\'objection nourrit la décision',
|
||||
con: 'le silence doit pouvoir être un vrai oui',
|
||||
},
|
||||
'draw': {
|
||||
icon: 'i-lucide-dices',
|
||||
desc: 'Le sort désigne parmi les volontaires du cercle électeur.',
|
||||
pro: 'égalité parfaite — casse les notabilités',
|
||||
con: 'demande d\'accompagner la personne tirée',
|
||||
},
|
||||
'rotation': {
|
||||
icon: 'i-lucide-rotate-ccw',
|
||||
desc: 'Le mandat tourne à échéance fixe entre les membres du cercle.',
|
||||
pro: 'le pouvoir circule par construction',
|
||||
con: 'transmission à soigner à chaque tour',
|
||||
},
|
||||
}
|
||||
|
||||
/** jj mois aaaa — the one date format of the mandate screens. */
|
||||
export function formatDay(iso: string | undefined): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric', month: 'short', year: 'numeric',
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user