forked from yvv/decision
Fusion feat/v2-refonte : libreDecision v2 — démocratie d'exercice
- 8 moteurs purs (342 tests verts), 14 écrans, 5 modalités, 7 gabarits - local-first : IndexedDB + export/import Bundle, seeds Atelier + Ğ1 - correctif QA : désignation « c'est moi » dans un collectif importé - documentation v2 réécrite, orphelins v1 supprimés Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,3 +41,4 @@ htmlcov/
|
|||||||
|
|
||||||
# IPFS
|
# IPFS
|
||||||
ipfs-data/
|
ipfs-data/
|
||||||
|
docs/*.pdf
|
||||||
|
|||||||
@@ -1,75 +1,73 @@
|
|||||||
# libreDecision
|
# libreDecision
|
||||||
|
|
||||||
Boîte à outils de gouvernance collective pour la communauté Duniter/G1.
|
Boîte à outils de la première démocratie — décider ensemble, du trio à la population.
|
||||||
Documents modulaires sous vote permanent + protocoles de vote + mandats.
|
v2 local-first : 6 routes de décision, 5 modalités, documents sous vote permanent, mandats,
|
||||||
Architecture marque blanche — vocation à être intégré dans sweethomeCloud et librodrome.
|
observatoire. Marque blanche par construction (seeds Ğ1 + Atelier du Canal).
|
||||||
|
**Référence produit : `docs/dev/BLUEPRINT-V2.md`** — toute décision de comportement s'y adosse.
|
||||||
|
|
||||||
## Protocole de début de session
|
## Protocole de début de session
|
||||||
|
|
||||||
1. `git pull --rebase origin main`
|
1. `git pull --rebase origin main`
|
||||||
2. Si des migrations DB sont attendues : `cd backend && alembic upgrade head`
|
2. Si l'objectif de la session n'est pas précisé, le demander
|
||||||
3. Si l'objectif de la session n'est pas précisé, le demander
|
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Frontend** : Nuxt 4 (Vue 3, TypeScript) + Nuxt UI v3 + Pinia + UnoCSS ; package manager : npm
|
- **Frontend** : Nuxt 4 (Vue 3, TypeScript, SPA — SSR off) + Nuxt UI v3 + Pinia + UnoCSS ;
|
||||||
- **Backend** : Python FastAPI + SQLAlchemy 2.0 async + PostgreSQL asyncpg ; migrations Alembic
|
package manager : npm ; port **3002** strict
|
||||||
- **Auth** : Duniter V2 Ed25519 challenge-response (substrate-interface — stub en dev)
|
- **Local-first** : IndexedDB (idb-keyval) via `app/data/persistence.ts` — une clé
|
||||||
- **Sanctuaire** : IPFS kubo + hash on-chain (system.remark) — TODO sprint 2
|
`ld2:<collectiveId>` par collectif, export/import de bundles JSON, seeds par le même chemin
|
||||||
- Déploiement : Docker multi-stage + Traefik (postgres + backend + frontend + ipfs) ; CI Woodpecker
|
- **Backend v1** (FastAPI + SQLAlchemy async, port 8002) : conservé pour la **future synchro**
|
||||||
|
— non requis pour le dev frontend ; `runtimeConfig.public.apiBase` gardé dans nuxt.config
|
||||||
|
en prévision
|
||||||
|
- Déploiement : Docker multi-stage + Traefik ; CI Woodpecker
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/
|
frontend/
|
||||||
app/
|
app/
|
||||||
components/ # composants Vue
|
engine/ # moteurs PURS (zéro dépendance Nuxt) : threshold (+electionResult),
|
||||||
layouts/ # layouts Nuxt
|
# nuanced, modeParams, parametric (+crystallize), settings, state (canTransition),
|
||||||
pages/ # routing file-based (9 pages sprint 1)
|
# triage, impact — import UNIQUEMENT via le barrel ~/engine
|
||||||
composables/ # (1 composable sprint 1)
|
data/
|
||||||
stores/ # 5 Pinia stores (auth, ...)
|
persistence.ts # IndexedDB local-first
|
||||||
assets/css/
|
templates.ts # 7 gabarits de collectif (invariant : protocole Consentement partout)
|
||||||
moods.css # système de palettes (.mood-* sur <html>)
|
seeds/ # duniter-g1.bundle.json (324 Ko) + atelier-du-canal.bundle.json
|
||||||
utils/ # (2 utils sprint 1)
|
stores/
|
||||||
nuxt.config.ts # port 3002, host 0.0.0.0, apiBase via NUXT_PUBLIC_API_BASE
|
collective.ts # le tenant : collectif actif, état complet, cycle de vie des bundles
|
||||||
backend/
|
decisions.ts # façade d'actions sans état propre (LWW updatedAt, persist debounced)
|
||||||
app/
|
composables/ # useMood (useLibreMood), useFeed (le Fil = sélecteur pur), useSearch
|
||||||
routers/ # 8 routers : auth, communes, documents, protocols, votes, ...
|
components/ # par domaine : chemin/ decisions/ votes/ texts/ mandates/ feed/
|
||||||
services/ # 6 services
|
# onboarding/ common/ (primitives Ld*)
|
||||||
engine/ # 5 modules : formule inertie, critères Smith/TechComm, médiane
|
pages/ # 14 routes : / · decider · decisions (+[id], vote, observatoire) ·
|
||||||
models/ # 14 tables SQLAlchemy
|
# textes (+[slug], formules) · mandats (+[id], nouveau) ·
|
||||||
alembic/versions/ # migrations
|
# creer (layout bare) · donnees
|
||||||
tests/ # 186 tests (63 intégration TDD sprint 1)
|
lexicon.ts # SOURCE UNIQUE des libellés FR + FORBIDDEN_UI_TERMS
|
||||||
seed.py # Engagement Certification (33 items) + Forgeron (51 items) + Runtime Upgrade
|
types/domain.ts # le noyau du modèle
|
||||||
docker/
|
tests/ # 342 tests vitest : engine/, stores/, seeds/ (non-perte), lexicon.spec.ts
|
||||||
docker-compose.yml # postgres + backend + frontend + ipfs
|
backend/ # v1 FastAPI conservé pour la synchro future — ne pas développer dessus
|
||||||
backend.Dockerfile
|
scripts/export_seed_bundle.py # extrait seed.py → duniter-g1.bundle.json (source vivante = bundle)
|
||||||
frontend.Dockerfile
|
docker/ # compose + Dockerfiles
|
||||||
docs/content/ # 7 docs dev + 8 docs user
|
docs/dev/BLUEPRINT-V2.md # la référence produit v2
|
||||||
public/
|
public/hexagram-tsing*.svg # sceau 井 (#48 Tsing)
|
||||||
hexagram-tsing.svg # sceau 井 (embossed)
|
|
||||||
hexagram-tsing-flat.svg
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Données runtime
|
## Données runtime
|
||||||
|
|
||||||
- **postgres-data** : volume Docker PostgreSQL — jamais écrasé par les builds
|
- **postgres-data** / **ipfs-data** : volumes Docker du backend v1 — jamais écrasés
|
||||||
- **ipfs-data** : volume Docker IPFS kubo
|
- `.env` racine : secrets backend — jamais commité
|
||||||
- `.env` à la racine : `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `SECRET_KEY`, `DOMAIN`, `DUNITER_RPC_URL`
|
|
||||||
|
|
||||||
## Commandes
|
## Commandes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
# Frontend (tout le dev v2)
|
||||||
cd backend && . venv/bin/activate
|
|
||||||
uvicorn app.main:app --port 8002 --host 0.0.0.0 --reload
|
|
||||||
pytest tests/ -v
|
|
||||||
alembic upgrade head
|
|
||||||
python seed.py # reseed Engagement Certification + Forgeron + Runtime Upgrade
|
|
||||||
|
|
||||||
# Frontend
|
|
||||||
cd frontend && npm run dev # :3002
|
cd frontend && npm run dev # :3002
|
||||||
npm run build
|
npm test # vitest — 342 tests, référence à maintenir
|
||||||
|
npm run build # zéro erreur exigé avant commit
|
||||||
|
|
||||||
|
# Seeds : le bundle est la source vivante ; seed.py (backend) est remplacé par
|
||||||
|
cd backend && .venv/bin/python scripts/export_seed_bundle.py # régénère duniter-g1.bundle.json
|
||||||
|
# → test de non-perte bloquant : tests/seeds/duniter-g1.spec.ts
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker compose -f docker/docker-compose.yml up
|
docker compose -f docker/docker-compose.yml up
|
||||||
@@ -78,11 +76,25 @@ docker compose -f docker/docker-compose.yml up
|
|||||||
## Conventions / pièges
|
## Conventions / pièges
|
||||||
|
|
||||||
- **UI français, code anglais** (variables, commentaires, docstrings)
|
- **UI français, code anglais** (variables, commentaires, docstrings)
|
||||||
- **API** : préfixe `/api/v1/`, Pydantic v2 pour tous les schémas, async partout (SQLAlchemy + FastAPI)
|
- **lexicon.ts d'abord** : aucun libellé UI en dur si le lexicon peut le porter ; ton = tutoyer
|
||||||
- **Auth** : `get_current_admin` (24h), `get_current_citizen` (4h), `require_super_admin`
|
la personne, sujet grammatical = la personne ou le collectif, jamais la formule ni le système
|
||||||
- **Formule inertie** : `Result = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)` — voir `backend/app/engine/`
|
- **Anti-lexique** (`tests/lexicon.spec.ts`) : `FORBIDDEN_UI_TERMS` scanné sur lexicon +
|
||||||
- **Mood system** : `useMood.ts` synchronise `colorMode.preference` avec la palette — **jamais** de `:global()` dans `<style scoped>` pour les styles mood-dépendants (causa le bug dark mode veil)
|
templates `.vue` (insensible casse/accents) — « triage », « verdict », « déléguer »… restent
|
||||||
- **Sceau** `井` (#48 Tsing) : `.app-seal` dans `app.vue`, right-aligned ; SVGs dans `public/`
|
des identifiants de code, jamais des textes affichés
|
||||||
- **CSS drop-shadow()** safe pour effets emboss ; `<filter>` SVG inline cause des artefacts de rendu
|
- **Invariants doctrinaux** : jamais de score sur une personne (attributs jamais affichés à
|
||||||
|
autrui, re-votes visibles du seul auteur) ; jamais de contenu adopté sans geste humain
|
||||||
|
(cristallisation, clôture de dossier, départage = gestes datés) ; repli universel → consent ;
|
||||||
|
dérogation asymétrique (alourdir libre, alléger motivé)
|
||||||
|
- **Budget 400 lignes max par page** (`app/pages/`)
|
||||||
|
- **Moteurs purs** : engine/ sans Nuxt/Pinia/DOM, import via `~/engine` seulement ; stores en
|
||||||
|
imports explicites (testables sous vitest nu) ; toute mutation d'état passe par
|
||||||
|
`canTransition(decision, to, ctx)`
|
||||||
|
- **Formule inertie** : `R = C + B^W + (M + (1−M)(1−(T/W)^G))·max(0, T−C)` —
|
||||||
|
`frontend/app/engine/threshold.ts` ; l'Atelier (`/textes/formules`) est la seule maison des
|
||||||
|
lettres W/T/M/B/G/C et de KaTeX
|
||||||
|
- **Mood system** : `useLibreMood()` (`composables/useMood.ts`) — **jamais** de `:global()`
|
||||||
|
dans `<style scoped>` pour les styles mood-dépendants (causa le bug dark mode veil)
|
||||||
|
- **Sceau** `井` (#48 Tsing) : `LdSeal` dans les layouts ; SVGs dans `public/`
|
||||||
|
- **CSS `drop-shadow()`** safe pour l'emboss ; `<filter>` SVG inline cause des artefacts
|
||||||
|
- **Composants** : `pathPrefix: false` — noms de fichiers uniques, auto-import
|
||||||
- **Domaine** : decision.librodrome.org (Woodpecker CI ; ancien dossier : Glibredecision)
|
- **Domaine** : decision.librodrome.org (Woodpecker CI ; ancien dossier : Glibredecision)
|
||||||
- **Ed25519 verification** : stub en dev (substrate-interface), autoritaire en prod — ne pas bypasser sans test
|
|
||||||
|
|||||||
+68
-29
@@ -1,52 +1,91 @@
|
|||||||
# Contribuer à libreDecision
|
# Contribuer à libreDecision
|
||||||
|
|
||||||
|
La référence produit est `docs/dev/BLUEPRINT-V2.md` — toute contribution qui touche au
|
||||||
|
comportement doit s'y conformer (ou proposer un amendement du blueprint, argumenté).
|
||||||
|
|
||||||
## Environnement
|
## Environnement
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend (Python 3.11+)
|
# Frontend — suffit pour tout le développement v2 (local-first, aucun serveur requis)
|
||||||
cd backend
|
|
||||||
python -m venv .venv && source .venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
alembic upgrade head
|
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload
|
|
||||||
|
|
||||||
# Frontend (Node 20+)
|
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev # http://localhost:3002 — port strict, jamais de fallback
|
||||||
|
npm test # vitest — 342 tests
|
||||||
|
npm run build # doit passer sans erreur avant tout commit
|
||||||
|
|
||||||
|
# Backend v1 (optionnel — conservé pour la future synchro)
|
||||||
|
cd backend
|
||||||
|
python -m venv venv && source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **UI** : français — **Code** : anglais (variables, commentaires, docstrings)
|
- **UI en français, code en anglais** (variables, commentaires, docstrings).
|
||||||
- **CSS** : scoped, sans bordures (`border: none`), profondeur via `box-shadow`
|
- **`app/lexicon.ts` est la source unique des libellés français.** Aucun libellé UI en dur
|
||||||
- **Composants** : `pathPrefix: false` — noms courts, auto-import
|
dans un composant s'il existe (ou devrait exister) dans le lexicon. Ton : tutoyer la
|
||||||
- **API** : versionnée `/api/v1/`, Pydantic v2, async partout
|
personne dans ses gestes ; actes collectifs à l'infinitif ; le sujet grammatical est
|
||||||
- **Ports stricts** : frontend=3002, backend=8002 — jamais de fallback
|
toujours la personne ou le collectif — jamais la formule ni le système.
|
||||||
|
- **Test anti-lexique** (`tests/lexicon.spec.ts`) : la liste `FORBIDDEN_UI_TERMS` interdit
|
||||||
|
certains mots dans toute chaîne visible (lexicon + templates `.vue`), insensible à la casse
|
||||||
|
et aux accents. « Triage », « verdict », « déléguer »… peuvent exister comme identifiants de
|
||||||
|
code, jamais à l'écran.
|
||||||
|
- **CSS** : borderless (profondeur via background + box-shadow), palettes par classes
|
||||||
|
`.mood-*` sur `<html>`, composants natifs (`<input>`, `<select>`, `<button>` custom).
|
||||||
|
- **Composants** : `pathPrefix: false` — noms de fichiers uniques et parlants, auto-import.
|
||||||
|
|
||||||
## Architecture toolbox
|
## Invariants doctrinaux (non négociables)
|
||||||
|
|
||||||
Chaque section expose une `<SectionLayout>` avec :
|
- **Jamais de score sur une personne.** Pas de note, pas de réputation, pas d'agrégat
|
||||||
- Contenu principal (slot `#default`)
|
individuel. `Person.attributes` ne sont jamais affichés à autrui ni agrégés ; l'historique
|
||||||
- Boîte à outils sticky (slot `#toolbox`) — 30rem, flottante, zéro scroll
|
de re-vote n'est visible que de son auteur.
|
||||||
|
- **Jamais de contenu adopté sans geste humain.** Le moteur constate des seuils sur des
|
||||||
|
contenus formulés par des humains ; la cristallisation paramétrique, la clôture d'un
|
||||||
|
dossier découpé, le départage d'une égalité sont des gestes datés — jamais des automatismes.
|
||||||
|
- **Le repli universel est le consentement** : tout collectif a un protocole Consentement
|
||||||
|
(`protocolByRange.consent`), tout repli de résolution y mène.
|
||||||
|
- **Dérogation asymétrique** : alourdir libre (1 tap journalisé) ; alléger = note obligatoire
|
||||||
|
+ fenêtre d'objection.
|
||||||
|
- **Budget 400 lignes maximum par page** (`app/pages/`). Au-delà, extraire des composants.
|
||||||
|
- **Moteurs purs** : `app/engine/` ne dépend de rien (ni Nuxt, ni Pinia, ni DOM), s'importe
|
||||||
|
uniquement via le barrel `~/engine`, et chaque module a ses tests vitest. Les stores
|
||||||
|
s'écrivent avec des imports explicites (pas d'auto-import Nuxt) pour rester testables sous
|
||||||
|
vitest pur.
|
||||||
|
|
||||||
Composants toolbox :
|
## Ajouter une modalité de vote
|
||||||
- `ToolboxSection` : accordéon collapsible générique
|
|
||||||
- `ToolboxVignette` : carte compacte avec bullets toggleables
|
1. `app/types/domain.ts` — étendre `VoteMethod` et les types de session/vote nécessaires.
|
||||||
- `toolbox/ContextMapper` : recommandeur de méthode (4 questions → méthode optimale)
|
2. `app/engine/` — le calcul dans un module pur + ses tests (`tests/engine/`). Exporter via
|
||||||
- `toolbox/SocioElection` : guide élection sociocratique + advice process
|
`engine/index.ts`.
|
||||||
- `toolbox/WorkflowMilestones` : jalons de protocole (Ostrom)
|
3. `app/engine/state.ts` — la règle de clôture (automatique à `closesAt`, ou geste humain).
|
||||||
|
4. `app/lexicon.ts` — les libellés (et vérifier l'anti-lexique).
|
||||||
|
5. `app/components/votes/VoteX.vue` — l'écran de la modalité, branché dans
|
||||||
|
`pages/decisions/[id]/vote.vue`.
|
||||||
|
6. Seeds et gabarits — un protocole nommé qui la référence (le modifier = amender le Pacte).
|
||||||
|
|
||||||
|
## Ajouter un gabarit de collectif
|
||||||
|
|
||||||
|
Dans `app/data/templates.ts` : une entrée `TEMPLATE_CARDS` + le montage dans
|
||||||
|
`buildTemplateBundle()`. Invariants : protocole Consentement au minimum, clause A1
|
||||||
|
(« Notre finalité »), préambule Autonomie–Équilibre–Liaison proposé et modifiable, cercles
|
||||||
|
typés `kind`. Un bundle seed passe par le même `importBundle()` que l'import utilisateur.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend && pytest tests/ -v
|
cd frontend && npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
186 tests, zéro dette technique acceptée depuis le sprint 1.
|
- `tests/engine/` — les moteurs purs (seuils, triage, états, médiane, impact…).
|
||||||
|
- `tests/stores/` — la façade decisions sous Pinia nu.
|
||||||
|
- `tests/seeds/` — tests de non-perte : le bundle Ğ1 doit conserver 33+59 clauses, décomptes
|
||||||
|
et provenance exacts ; l'Atelier du Canal doit couvrir tous les états.
|
||||||
|
- `tests/lexicon.spec.ts` — l'anti-lexique.
|
||||||
|
|
||||||
## Formule de vote inertiel
|
Le compteur de référence est **342 tests verts**. Toute PR qui en casse un est refusée ;
|
||||||
|
toute fonctionnalité de moteur arrive avec les siens.
|
||||||
|
|
||||||
`R = C + B^W + (M + (1-M)·(1-(T/W)^G))·max(0, T-C)`
|
## Build
|
||||||
|
|
||||||
Voir `docs/content/dev/` pour la documentation complète.
|
`npm run build` doit rester vert (zéro erreur, zéro warning nouveau) avant chaque commit.
|
||||||
|
Vérifier mobile + desktop (zéro régression CSS).
|
||||||
|
|||||||
@@ -1,32 +1,113 @@
|
|||||||
# libreDecision
|
# libreDecision
|
||||||
|
|
||||||
Plateforme de décisions collectives pour la communauté Duniter/G1.
|
Boîte à outils de la première démocratie — décider ensemble, du trio à la population.
|
||||||
Boîte à outils gouvernance multi-collectifs, architecture white-label.
|
|
||||||
|
libreDecision fait de la décision un geste quotidien : chaque question tient en une phrase,
|
||||||
|
l'outil propose le chemin le plus court — jamais plus lourd que l'enjeu — et tout ce qui est
|
||||||
|
décidé reste révisable, mesuré, consigné. Le même outil sert un trio d'amis, une association,
|
||||||
|
une coopérative ou la toile de confiance Ğ1 (marque blanche par construction).
|
||||||
|
|
||||||
|
## Manifeste
|
||||||
|
|
||||||
|
> Personne n'a jamais vécu en démocratie — alors nous la rêvons de jour, et nous la commençons
|
||||||
|
> à notre échelle. libreDecision attrape chaque décision en une phrase et propose le chemin le
|
||||||
|
> plus court : décide seul et trace-le, décide sous mandat, écoute puis décide, ou décidez
|
||||||
|
> ensemble — jamais plus lourd que l'enjeu. Ici on ne délègue pas, on mandate, et on reprend ;
|
||||||
|
> on n'est pas consulté, on s'instruit, puis on se prononce ; on ne s'affronte pas en deux
|
||||||
|
> camps, on règle des curseurs — et la position de chacun reste visible dans le faisceau, la
|
||||||
|
> médiane n'écrase personne, la dernière main est toujours humaine. Les concernés en premier
|
||||||
|
> lieu ont voix par construction, chacun voit pourquoi ; lever la main « ça me concerne » est
|
||||||
|
> un droit, pas une demande. Chaque décision dit ce qu'elle engage, ce qu'elle cherche à
|
||||||
|
> changer, et repasse l'épreuve du réel : rien n'est définitif, toute décision en vigueur garde
|
||||||
|
> sa poignée, se tromper est une donnée qu'on consigne. Les règles du jeu elles-mêmes — le
|
||||||
|
> Pacte — s'écrivent au fil de l'eau, par ceux qu'elles gouvernent, sous les boussoles
|
||||||
|
> Autonomie, Équilibre, Liaison. Du trio d'amis à la toile Ğ1 : le même geste, dix secondes,
|
||||||
|
> et l'observatoire qui apprend de nous. La ville change, le puits demeure.
|
||||||
|
|
||||||
|
## Les 6 routes de décision
|
||||||
|
|
||||||
|
Le triage (moteur pur, toujours expliqué en une phrase, toujours contournable) oriente chaque
|
||||||
|
décision vers l'une de six routes :
|
||||||
|
|
||||||
|
| Route | Geste |
|
||||||
|
|---|---|
|
||||||
|
| **solo** | « Personne d'autre n'est concerné — décide. » 2 gestes, moins de 10 secondes, trace facultative |
|
||||||
|
| **record** | « Déjà tranché — je le consigne. » L'observatoire en un geste, adopté immédiatement |
|
||||||
|
| **mandate** | « Ton mandat couvre — décide, c'est tracé. » Trace obligatoire + fenêtre d'objection 48 h |
|
||||||
|
| **transmit** | « Le mandat d'un autre couvre — transmets-lui. » (ou conteste le mandat) |
|
||||||
|
| **advice** | « Réversible et petit cercle — demande leur avis, puis décide. » 72 h |
|
||||||
|
| **collective** | « Nous décidons. » Formulation, liste des concernés arrêtée, session de vote |
|
||||||
|
|
||||||
|
Dérogation asymétrique : alourdir est libre (1 tap journalisé) ; alléger exige une note motivée
|
||||||
|
et une fenêtre d'objection.
|
||||||
|
|
||||||
|
## Les 5 modalités
|
||||||
|
|
||||||
|
Chaque protocole est nommé et référencé par une clause du Pacte — le modifier, c'est amender
|
||||||
|
le Pacte.
|
||||||
|
|
||||||
|
1. **Consentement** — « Ça me va / J'objecte » : zéro objection maintenue = adopté. Le repli
|
||||||
|
universel : tout collectif en a un (invariant).
|
||||||
|
2. **Nuancé 6 niveaux** — de « Pas du tout » à « Tout à fait », jamais « contre » ;
|
||||||
|
histogramme visible, commentaire obligatoire sur les niveaux bas.
|
||||||
|
3. **Paramétrique** — décider au curseur : faisceau des positions, médiane vivante, carte
|
||||||
|
« Pour moi » ; la cristallisation finale est un geste humain daté du garant, jamais un
|
||||||
|
automatisme.
|
||||||
|
4. **Élection** — réservée aux mandats et aux problématiques d'échelle (6 méthodes de
|
||||||
|
nomination, vote blanc, jamais de départage par l'outil).
|
||||||
|
5. **Binaire inertiel** — le seuil `C + B^W + (M + (1−M)(1−(T/W)^G))·max(0, T−C)`, héritage
|
||||||
|
Ğ1 remis à sa place : l'outil de dernier recours des très grands corps. L'Atelier des
|
||||||
|
formules (`/textes/formules`) reste son école.
|
||||||
|
|
||||||
|
## Local-first
|
||||||
|
|
||||||
|
- **Aucun serveur requis** : tout l'état vit dans le navigateur (IndexedDB via idb-keyval,
|
||||||
|
une clé par collectif).
|
||||||
|
- **Export / import** de bundles JSON — la fédération, c'est l'échange de bundles, jamais une
|
||||||
|
table.
|
||||||
|
- **Seeds** livrés et importés par le même chemin que l'import utilisateur :
|
||||||
|
- `duniter-g1.bundle.json` — la toile Ğ1 : 33 clauses Certification + 59 clauses Forgeron
|
||||||
|
(textes votés intacts, provenance et décomptes conservés), protocoles, 3 mandats ;
|
||||||
|
- `atelier-du-canal.bundle.json` — association fictive de 12 membres, le test permanent de
|
||||||
|
marque blanche : décisions à tous les états, paramétrique vivant, épreuve du réel due.
|
||||||
|
- **7 gabarits** de collectif à la création (`/creer`) : page blanche, informel, association,
|
||||||
|
coopérative, commune, monnaie libre, institution symétrique.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Frontend** : Nuxt 4 + Vue 3 + Pinia + UnoCSS (port 3002)
|
- **Frontend** : Nuxt 4 (SPA, SSR off) + Vue 3 + TypeScript + Nuxt UI v3 + Pinia + UnoCSS ;
|
||||||
- **Backend** : Python FastAPI + SQLAlchemy async + SQLite (port 8002)
|
npm ; port **3002**
|
||||||
- **Auth** : Duniter V2 Ed25519 challenge-response
|
- **Moteurs purs** : `frontend/app/engine/` — triage, machine à états, seuils, médiane
|
||||||
- **Sanctuaire** : IPFS (kubo) + hash on-chain (system.remark)
|
paramétrique, nuancé, périmètres — zéro dépendance Nuxt, testés vitest (**342 tests**)
|
||||||
|
- **Backend v1** (FastAPI + SQLAlchemy async) : conservé dans `backend/` pour la future
|
||||||
|
synchronisation multi-appareils — non requis pour utiliser ou développer le frontend
|
||||||
|
|
||||||
## Démarrage
|
## Démarrage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
cd frontend
|
||||||
cd backend && .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload
|
npm install
|
||||||
|
npm run dev # http://localhost:3002
|
||||||
# Frontend
|
npm test # vitest — 342 tests
|
||||||
cd frontend && npm run dev
|
npm run build # build de production
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sections
|
## Structure v2
|
||||||
|
|
||||||
- **Décisions** : processus de vote collectif avec boîte à outils (ContextMapper, consentement, advice process)
|
```
|
||||||
- **Mandats** : élection sociocratique, cycle de mandat, révocation
|
frontend/app/
|
||||||
- **Documents** : documents de référence sous vote permanent, niveaux d'inertie, sanctuaire IPFS
|
engine/ # moteurs purs (barrel index.ts — seul point d'import)
|
||||||
- **Protocoles** : protocoles opérationnels, jalons de workflow, formules de vote
|
data/ # persistence.ts (IndexedDB), templates.ts (7 gabarits), seeds/*.bundle.json
|
||||||
|
stores/ # collective.ts (le tenant) + decisions.ts (façade d'actions)
|
||||||
|
composables/ # useMood, useFeed, useSearch
|
||||||
|
components/ # par domaine : chemin/ decisions/ votes/ texts/ mandates/ feed/ onboarding/ common/
|
||||||
|
pages/ # 14 routes : / decider decisions(+id, vote, observatoire)
|
||||||
|
# textes(+slug, formules) mandats(+id, nouveau) creer donnees
|
||||||
|
lexicon.ts # source unique des libellés FR + test anti-lexique
|
||||||
|
types/domain.ts # le noyau du modèle
|
||||||
|
docs/dev/BLUEPRINT-V2.md # la référence produit
|
||||||
|
```
|
||||||
|
|
||||||
## Architecture
|
## Licence
|
||||||
|
|
||||||
Voir `CLAUDE.md` pour les conventions et `docs/content/dev/` pour la documentation technique.
|
Logiciel libre. Le fichier LICENSE sera posé avant la première publication.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+10
-729
@@ -1,740 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const auth = useAuthStore()
|
// Shell v2 — app.vue MINCE : ambiance + hydratation du collectif, rien d'autre.
|
||||||
const orgsStore = useOrganizationsStore()
|
// Toute la structure (header, sidebar, drawer, sceau) vit dans layouts/default.vue.
|
||||||
const documentsStore = useDocumentsStore()
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
const decisionsStore = useDecisionsStore()
|
|
||||||
const protocolsStore = useProtocolsStore()
|
|
||||||
const mandatesStore = useMandatesStore()
|
|
||||||
const route = useRoute()
|
|
||||||
const { initMood } = useLibreMood()
|
const { initMood } = useLibreMood()
|
||||||
|
|
||||||
const navigationItems = [
|
onMounted(() => {
|
||||||
{
|
|
||||||
label: 'Décisions',
|
|
||||||
icon: 'i-lucide-scale',
|
|
||||||
to: '/decisions',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Documents',
|
|
||||||
icon: 'i-lucide-book-open',
|
|
||||||
to: '/documents',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Mandats',
|
|
||||||
icon: 'i-lucide-user-check',
|
|
||||||
to: '/mandates',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Protocoles',
|
|
||||||
icon: 'i-lucide-settings',
|
|
||||||
to: '/protocols',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Outils',
|
|
||||||
icon: 'i-lucide-wrench',
|
|
||||||
to: '/tools',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Sanctuaire',
|
|
||||||
icon: 'i-lucide-archive',
|
|
||||||
to: '/sanctuary',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
/** Mobile drawer state. */
|
|
||||||
const mobileMenuOpen = ref(false)
|
|
||||||
|
|
||||||
/** Sidebar collapse state (icons-only mode). */
|
|
||||||
const sidebarCollapsed = ref(false)
|
|
||||||
|
|
||||||
/** Close mobile menu on route change. */
|
|
||||||
watch(() => route.path, () => {
|
|
||||||
mobileMenuOpen.value = false
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Refetch all content stores when the active workspace changes. */
|
|
||||||
watch(() => orgsStore.activeSlug, (newSlug, oldSlug) => {
|
|
||||||
if (oldSlug !== null && newSlug !== null && newSlug !== oldSlug) {
|
|
||||||
documentsStore.fetchAll()
|
|
||||||
decisionsStore.fetchAll()
|
|
||||||
protocolsStore.fetchProtocols()
|
|
||||||
mandatesStore.fetchAll()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/** WebSocket connection and notifications. */
|
|
||||||
const ws = useWebSocket()
|
|
||||||
const { setupWsNotifications } = useNotifications()
|
|
||||||
|
|
||||||
watch(sidebarCollapsed, (val) => {
|
|
||||||
localStorage.setItem('libred-sidebar-collapsed', String(val))
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
initMood()
|
initMood()
|
||||||
const savedCollapsed = localStorage.getItem('libred-sidebar-collapsed')
|
useCollectiveStore().init?.()
|
||||||
if (savedCollapsed !== null) sidebarCollapsed.value = savedCollapsed === 'true'
|
|
||||||
auth.hydrateFromStorage()
|
|
||||||
if (auth.token) {
|
|
||||||
try {
|
|
||||||
await auth.fetchMe()
|
|
||||||
} catch (err: any) {
|
|
||||||
// Déconnexion seulement sur session réellement invalide (401/403)
|
|
||||||
// Erreur réseau ou backend temporairement indisponible → conserver la session
|
|
||||||
if (err?.status === 401 || err?.status === 403) {
|
|
||||||
auth.logout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ws.connect()
|
|
||||||
setupWsNotifications(ws)
|
|
||||||
// Load organizations in parallel — non-blocking, no auth required
|
|
||||||
orgsStore.fetchOrganizations()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
ws.disconnect()
|
|
||||||
})
|
|
||||||
|
|
||||||
function isActive(to: string) {
|
|
||||||
return route.path === to || route.path.startsWith(to + '/')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
<UApp>
|
<UApp>
|
||||||
<OfflineBanner />
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
<div
|
</NuxtLayout>
|
||||||
class="app-shell"
|
|
||||||
:style="{
|
|
||||||
backgroundColor: 'var(--mood-bg)',
|
|
||||||
color: 'var(--mood-text)',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<!-- Header -->
|
|
||||||
<header class="app-header">
|
|
||||||
<div class="app-header__inner">
|
|
||||||
<!-- Left: Hamburger (mobile) + Logo -->
|
|
||||||
<div class="app-header__left">
|
|
||||||
<button
|
|
||||||
class="app-header__menu-btn"
|
|
||||||
aria-label="Ouvrir le menu"
|
|
||||||
@click="mobileMenuOpen = true"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-menu" class="text-xl" />
|
|
||||||
</button>
|
|
||||||
<NuxtLink to="/" class="app-header__logo">
|
|
||||||
<span class="app-header__logo-stamp">
|
|
||||||
<UIcon name="i-lucide-gavel" class="app-header__logo-icon" />
|
|
||||||
</span>
|
|
||||||
<span class="app-header__logo-text">
|
|
||||||
<span class="app-header__logo-libre">libre</span><span class="app-header__logo-decision">Decision</span>
|
|
||||||
</span>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Center: Workspace selector + Mood switcher (desktop) -->
|
|
||||||
<div class="app-header__center">
|
|
||||||
<WorkspaceSelector class="hidden sm:flex" />
|
|
||||||
<MoodSwitcher class="hidden sm:flex" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: Auth -->
|
|
||||||
<div class="app-header__right">
|
|
||||||
<template v-if="auth.isAuthenticated">
|
|
||||||
<span class="app-header__identity">
|
|
||||||
{{ auth.identity?.display_name || auth.identity?.address?.slice(0, 10) + '...' }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="auth.identity?.is_smith"
|
|
||||||
class="app-header__role app-header__role--smith"
|
|
||||||
>
|
|
||||||
Forgeron
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="auth.identity?.is_techcomm"
|
|
||||||
class="app-header__role app-header__role--tech"
|
|
||||||
>
|
|
||||||
CoTec
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
class="app-header__icon-btn"
|
|
||||||
aria-label="Se deconnecter"
|
|
||||||
@click="auth.logout()"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-log-out" />
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<NuxtLink to="/login" class="app-header__connect-btn">
|
|
||||||
<UIcon name="i-lucide-log-in" />
|
|
||||||
<span>Connexion</span>
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Mobile navigation drawer -->
|
|
||||||
<USlideover
|
|
||||||
v-model:open="mobileMenuOpen"
|
|
||||||
side="left"
|
|
||||||
title="Navigation"
|
|
||||||
:ui="{ width: 'max-w-xs' }"
|
|
||||||
>
|
|
||||||
<template #body>
|
|
||||||
<nav class="app-mobile-nav">
|
|
||||||
<NuxtLink
|
|
||||||
v-for="item in navigationItems"
|
|
||||||
:key="item.to"
|
|
||||||
:to="item.to"
|
|
||||||
class="app-mobile-nav__link"
|
|
||||||
:class="{ 'app-mobile-nav__link--active': isActive(item.to) }"
|
|
||||||
@click="mobileMenuOpen = false"
|
|
||||||
>
|
|
||||||
<UIcon :name="item.icon" class="text-lg" />
|
|
||||||
<span>{{ item.label }}</span>
|
|
||||||
</NuxtLink>
|
|
||||||
</nav>
|
|
||||||
<!-- Workspace + Mood in mobile drawer -->
|
|
||||||
<div class="app-mobile-mood">
|
|
||||||
<span class="app-mobile-mood__label">Espace</span>
|
|
||||||
<WorkspaceSelector />
|
|
||||||
</div>
|
|
||||||
<div class="app-mobile-mood">
|
|
||||||
<span class="app-mobile-mood__label">Ambiance</span>
|
|
||||||
<MoodSwitcher />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</USlideover>
|
|
||||||
|
|
||||||
<!-- Main content with sidebar -->
|
|
||||||
<div class="app-body">
|
|
||||||
<!-- Desktop sidebar -->
|
|
||||||
<aside class="app-sidebar" :class="{ 'app-sidebar--collapsed': sidebarCollapsed }">
|
|
||||||
<nav class="app-sidebar__nav">
|
|
||||||
<NuxtLink
|
|
||||||
v-for="item in navigationItems"
|
|
||||||
:key="item.to"
|
|
||||||
:to="item.to"
|
|
||||||
class="app-sidebar__link"
|
|
||||||
:class="{ 'app-sidebar__link--active': isActive(item.to) }"
|
|
||||||
>
|
|
||||||
<UIcon :name="item.icon" class="text-lg flex-shrink-0" />
|
|
||||||
<span class="app-sidebar__link-label">{{ item.label }}</span>
|
|
||||||
</NuxtLink>
|
|
||||||
<div class="app-sidebar__divider" />
|
|
||||||
<button
|
|
||||||
class="app-sidebar__toggle"
|
|
||||||
:title="sidebarCollapsed ? 'Déplier le menu' : 'Replier le menu'"
|
|
||||||
@click="sidebarCollapsed = !sidebarCollapsed"
|
|
||||||
>
|
|
||||||
<UIcon
|
|
||||||
:name="sidebarCollapsed ? 'i-lucide-panel-left-open' : 'i-lucide-panel-left-close'"
|
|
||||||
class="text-base flex-shrink-0"
|
|
||||||
/>
|
|
||||||
<span class="app-sidebar__link-label">Replier</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Page content -->
|
|
||||||
<main class="app-main">
|
|
||||||
<ErrorBoundary>
|
|
||||||
<NuxtPage />
|
|
||||||
</ErrorBoundary>
|
|
||||||
|
|
||||||
<!-- 井 Tsing — sceau (traits épais, coins nets, ~carré) -->
|
|
||||||
<svg class="app-seal" viewBox="0 0 112 105" fill="currentColor" aria-hidden="true">
|
|
||||||
<!-- Line 6 (top) — yin -->
|
|
||||||
<rect x="6" y="6" width="42" height="8"/>
|
|
||||||
<rect x="64" y="6" width="42" height="8"/>
|
|
||||||
<!-- Line 5 — yang -->
|
|
||||||
<rect x="6" y="23" width="100" height="8"/>
|
|
||||||
<!-- Line 4 — yin -->
|
|
||||||
<rect x="6" y="40" width="42" height="8"/>
|
|
||||||
<rect x="64" y="40" width="42" height="8"/>
|
|
||||||
<!-- Line 3 — yang -->
|
|
||||||
<rect x="6" y="57" width="100" height="8"/>
|
|
||||||
<!-- Line 2 — yang -->
|
|
||||||
<rect x="6" y="74" width="100" height="8"/>
|
|
||||||
<!-- Line 1 (bottom) — yin -->
|
|
||||||
<rect x="6" y="91" width="42" height="8"/>
|
|
||||||
<rect x="64" y="91" width="42" height="8"/>
|
|
||||||
</svg>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- WebSocket error banner -->
|
|
||||||
<Transition name="slide-up">
|
|
||||||
<div
|
|
||||||
v-if="ws.error.value"
|
|
||||||
class="app-ws-banner"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-plug-zap" class="text-lg" />
|
|
||||||
<span>{{ ws.error.value }}</span>
|
|
||||||
<button class="app-ws-banner__btn" @click="ws.disconnect(); ws.connect()">
|
|
||||||
Reconnecter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<footer class="app-footer">
|
|
||||||
<span>libreDecision v0.1.0</span>
|
|
||||||
<span class="app-footer__sep">·</span>
|
|
||||||
<span>Licence libre</span>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</UApp>
|
</UApp>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* === Shell === */
|
|
||||||
.app-shell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Header === */
|
|
||||||
.app-header {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 30;
|
|
||||||
background: var(--mood-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__inner {
|
|
||||||
max-width: 80rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
height: 3.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__center {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
flex: 1;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__menu-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2.25rem;
|
|
||||||
height: 2.25rem;
|
|
||||||
background: none;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
.app-header__menu-btn:hover {
|
|
||||||
color: var(--mood-text);
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.app-header__menu-btn {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo {
|
|
||||||
text-decoration: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo-stamp {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
background: var(--mood-accent);
|
|
||||||
color: var(--mood-accent-text);
|
|
||||||
border-radius: 8px;
|
|
||||||
transform: rotate(-10deg);
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo:hover .app-header__logo-stamp {
|
|
||||||
transform: rotate(-16deg) scale(1.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo-icon {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
transform: scaleX(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo-text {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo-libre {
|
|
||||||
font-size: 1.0625rem;
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo-decision {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__identity {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text);
|
|
||||||
max-width: 6rem;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.app-header__identity {
|
|
||||||
max-width: 12rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__role {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 20px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.app-header__role {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__role--smith {
|
|
||||||
background: rgba(24, 132, 59, 0.15);
|
|
||||||
color: var(--mood-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__role--tech {
|
|
||||||
background: rgba(24, 86, 168, 0.15);
|
|
||||||
color: var(--mood-status-vote, #1856a8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__icon-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2.25rem;
|
|
||||||
height: 2.25rem;
|
|
||||||
background: none;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
.app-header__icon-btn:hover {
|
|
||||||
color: var(--mood-text);
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__connect-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.5rem 0.875rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-accent-text);
|
|
||||||
background: var(--mood-accent);
|
|
||||||
border-radius: 24px;
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.app-header__connect-btn {
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 1.25rem;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.app-header__connect-btn:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px var(--mood-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Sidebar === */
|
|
||||||
.app-body {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar {
|
|
||||||
width: 14rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--mood-surface);
|
|
||||||
display: none;
|
|
||||||
transition: width 0.22s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar--collapsed {
|
|
||||||
width: 3.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.app-sidebar {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__nav {
|
|
||||||
position: sticky;
|
|
||||||
top: 3.5rem;
|
|
||||||
padding: 1rem 0.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
padding: 0.625rem 0.75rem;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
transition: all 0.12s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__link:hover {
|
|
||||||
color: var(--mood-text);
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__link--active {
|
|
||||||
color: var(--mood-accent);
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__link-label {
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: opacity 0.18s ease, max-width 0.22s ease;
|
|
||||||
max-width: 10rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar--collapsed .app-sidebar__link-label {
|
|
||||||
opacity: 0;
|
|
||||||
max-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar--collapsed .app-sidebar__link {
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__divider {
|
|
||||||
height: 1px;
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
|
|
||||||
margin: 0.375rem 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 12px;
|
|
||||||
width: 100%;
|
|
||||||
transition: all 0.12s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__toggle:hover {
|
|
||||||
color: var(--mood-text);
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar--collapsed .app-sidebar__toggle {
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Mobile nav === */
|
|
||||||
.app-mobile-nav {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-mobile-nav__link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.875rem;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
font-size: 1.0625rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 14px;
|
|
||||||
min-height: 3rem;
|
|
||||||
}
|
|
||||||
.app-mobile-nav__link:hover,
|
|
||||||
.app-mobile-nav__link:active {
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
color: var(--mood-text);
|
|
||||||
}
|
|
||||||
.app-mobile-nav__link--active {
|
|
||||||
color: var(--mood-accent);
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-mobile-mood {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
border-top: 1px solid var(--mood-accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-mobile-mood__label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Main === */
|
|
||||||
.app-main {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 1.5rem 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.app-main {
|
|
||||||
padding: 2rem 1.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.app-main {
|
|
||||||
padding: 2rem 2.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === WS Banner === */
|
|
||||||
.app-ws-banner {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 40;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
padding: 0.625rem 1rem;
|
|
||||||
background: var(--mood-error);
|
|
||||||
color: white;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-ws-banner__btn {
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
background: rgba(255,255,255,0.2);
|
|
||||||
border-radius: 20px;
|
|
||||||
color: white;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.app-ws-banner__btn:hover {
|
|
||||||
background: rgba(255,255,255,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Footer === */
|
|
||||||
.app-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-footer__sep {
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Seal — 井 Tsing === */
|
|
||||||
.app-seal {
|
|
||||||
display: block;
|
|
||||||
width: 44px;
|
|
||||||
margin: 1.5rem 0 0.5rem auto;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
opacity: 0.28;
|
|
||||||
filter: drop-shadow(1px 1px 0.5px rgba(0,0,0,0.25))
|
|
||||||
drop-shadow(-0.5px -0.5px 0.5px rgba(255,255,255,0.15));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Transitions === */
|
|
||||||
.slide-up-enter-active,
|
|
||||||
.slide-up-leave-active {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
.slide-up-enter-from,
|
|
||||||
.slide-up-leave-to {
|
|
||||||
transform: translateY(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
+284
-181
@@ -1,158 +1,196 @@
|
|||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
libreDecision — Mood / Ambiance System
|
libreDecision v2 — Ambiances (champ lexical du puits 井)
|
||||||
Palettes harmoniques variees, colores en lite, lumineux en dark.
|
Source (light, eau claire) · Margelle (light, pierre chaude)
|
||||||
|
Nappe (dark, eau profonde) · Minuit (dark, encre & lanterne)
|
||||||
|
Borderless : profondeur par background + box-shadow, jamais de bordures.
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
Peps — Chaud, colore, tonique (Light)
|
Source — l'eau vive au grand jour (Light)
|
||||||
Palette: corail vif / ocre / indigo / vert franc
|
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
.mood-peps {
|
.mood-source {
|
||||||
--mood-bg: #faf8f5;
|
--mood-bg: #f0f5f6;
|
||||||
--mood-surface: #ffffff;
|
--mood-surface: #fcfefe;
|
||||||
--mood-surface-hover: #fdf2ec;
|
--mood-surface-hover: #e6eff2;
|
||||||
--mood-text: #1e1410;
|
--mood-text: #102126;
|
||||||
--mood-text-muted: #7a5e48;
|
--mood-text-muted: #52707a;
|
||||||
--mood-accent: #d44a10;
|
--mood-accent: #0f7fa8;
|
||||||
--mood-accent-soft: rgba(212, 74, 16, 0.08);
|
--mood-accent-soft: rgba(15, 127, 168, 0.09);
|
||||||
--mood-accent-text: #ffffff;
|
--mood-accent-text: #ffffff;
|
||||||
--mood-border: #e8d8c8;
|
--mood-secondary: #a8742c; /* laiton — le mandat, le pouvoir confié */
|
||||||
--mood-secondary: #4a5ec0;
|
--mood-tertiary: #2e8a72; /* eau végétale — l'avis, le dialogue */
|
||||||
--mood-tertiary: #c07820;
|
--mood-success: #1d8a4a;
|
||||||
--mood-success: #1a8c3e;
|
--mood-warning: #b07c14;
|
||||||
--mood-warning: #c47d0a;
|
--mood-error: #bc3d33;
|
||||||
--mood-error: #c23028;
|
--mood-gradient: linear-gradient(150deg, #eaf3f5 0%, #fcfefe 45%, #eef2ee 100%);
|
||||||
--mood-gradient: linear-gradient(145deg, #fdf8f0 0%, #ffffff 40%, #f0f4fb 100%);
|
--mood-shadow: rgba(20, 90, 120, 0.08);
|
||||||
--mood-shadow: rgba(180, 80, 20, 0.07);
|
--mood-input-bg: #fcfefe;
|
||||||
--mood-input-bg: #ffffff;
|
--mood-input-border: #b8ccd2;
|
||||||
--mood-input-border: #d4c0aa;
|
--mood-input-focus: #0f7fa8;
|
||||||
--mood-input-focus: #d44a10;
|
|
||||||
|
|
||||||
--mood-status-prepa: #b8600e;
|
--mood-status-prepa: #8a7040;
|
||||||
--mood-status-prepa-bg: rgba(184, 96, 14, 0.12);
|
--mood-status-prepa-bg: rgba(138, 112, 64, 0.10);
|
||||||
--mood-status-vote: #4a5ec0;
|
--mood-status-fenetre: #b07c14;
|
||||||
--mood-status-vote-bg: rgba(74, 94, 192, 0.10);
|
--mood-status-fenetre-bg: rgba(176, 124, 20, 0.11);
|
||||||
--mood-status-vigueur: #1a8c3e;
|
--mood-status-vote: #2661b8;
|
||||||
--mood-status-vigueur-bg: rgba(26, 140, 62, 0.10);
|
--mood-status-vote-bg: rgba(38, 97, 184, 0.10);
|
||||||
--mood-status-clos: #6e5844;
|
--mood-status-fige: #58708c;
|
||||||
--mood-status-clos-bg: rgba(110, 88, 68, 0.08);
|
--mood-status-fige-bg: rgba(88, 112, 140, 0.12);
|
||||||
|
--mood-status-vigueur: #1d8a4a;
|
||||||
|
--mood-status-vigueur-bg: rgba(29, 138, 74, 0.10);
|
||||||
|
--mood-status-revoque: #a4553a;
|
||||||
|
--mood-status-revoque-bg: rgba(164, 85, 58, 0.10);
|
||||||
|
--mood-status-clos: #5f6f74;
|
||||||
|
--mood-status-clos-bg: rgba(95, 111, 116, 0.09);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
Zen — Vegetal lumineux, nature vivante (Light)
|
Margelle — la pierre du puits, calcaire et bronze (Light)
|
||||||
Palette: vert franc / terre sienne / bleu canard / bois chaud
|
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
.mood-zen {
|
.mood-margelle {
|
||||||
--mood-bg: #f4f6f0;
|
--mood-bg: #f6f3ec;
|
||||||
--mood-surface: #fbfcf8;
|
--mood-surface: #fdfbf6;
|
||||||
--mood-surface-hover: #eaf0e4;
|
--mood-surface-hover: #efe9dc;
|
||||||
--mood-text: #1a2418;
|
--mood-text: #221c10;
|
||||||
--mood-text-muted: #5a7050;
|
--mood-text-muted: #77684c;
|
||||||
--mood-accent: #2e8b48;
|
--mood-accent: #96682a;
|
||||||
--mood-accent-soft: rgba(46, 139, 72, 0.09);
|
--mood-accent-soft: rgba(150, 104, 42, 0.10);
|
||||||
--mood-accent-text: #ffffff;
|
--mood-accent-text: #ffffff;
|
||||||
--mood-border: #b8d0a8;
|
--mood-secondary: #285e88; /* le ciel dans l'eau du seau */
|
||||||
--mood-secondary: #8a6030;
|
--mood-tertiary: #4f7a3c; /* mousse entre les pierres */
|
||||||
--mood-tertiary: #2a7888;
|
--mood-success: #3d7e34;
|
||||||
--mood-success: #268040;
|
--mood-warning: #a8720e;
|
||||||
--mood-warning: #a87818;
|
--mood-error: #b23f30;
|
||||||
--mood-error: #b83838;
|
--mood-gradient: linear-gradient(150deg, #f3eee2 0%, #fdfbf6 45%, #f0eee6 100%);
|
||||||
--mood-gradient: linear-gradient(145deg, #f0f5ea 0%, #fbfcf8 40%, #f4efe6 100%);
|
--mood-shadow: rgba(120, 90, 40, 0.09);
|
||||||
--mood-shadow: rgba(40, 100, 50, 0.07);
|
--mood-input-bg: #fdfbf6;
|
||||||
--mood-input-bg: #fbfcf8;
|
--mood-input-border: #cfc2a6;
|
||||||
--mood-input-border: #a8c898;
|
--mood-input-focus: #96682a;
|
||||||
--mood-input-focus: #2e8b48;
|
|
||||||
|
|
||||||
--mood-status-prepa: #a87818;
|
--mood-status-prepa: #7c6a48;
|
||||||
--mood-status-prepa-bg: rgba(168, 120, 24, 0.11);
|
--mood-status-prepa-bg: rgba(124, 106, 72, 0.10);
|
||||||
--mood-status-vote: #2a7888;
|
--mood-status-fenetre: #a8720e;
|
||||||
--mood-status-vote-bg: rgba(42, 120, 136, 0.10);
|
--mood-status-fenetre-bg: rgba(168, 114, 14, 0.11);
|
||||||
--mood-status-vigueur: #268040;
|
--mood-status-vote: #285e88;
|
||||||
--mood-status-vigueur-bg: rgba(38, 128, 64, 0.10);
|
--mood-status-vote-bg: rgba(40, 94, 136, 0.10);
|
||||||
--mood-status-clos: #607858;
|
--mood-status-fige: #6e6a58;
|
||||||
--mood-status-clos-bg: rgba(96, 120, 88, 0.08);
|
--mood-status-fige-bg: rgba(110, 106, 88, 0.12);
|
||||||
|
--mood-status-vigueur: #3d7e34;
|
||||||
|
--mood-status-vigueur-bg: rgba(61, 126, 52, 0.10);
|
||||||
|
--mood-status-revoque: #9c5236;
|
||||||
|
--mood-status-revoque-bg: rgba(156, 82, 54, 0.10);
|
||||||
|
--mood-status-clos: #6f6a5c;
|
||||||
|
--mood-status-clos-bg: rgba(111, 106, 92, 0.09);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
Chagrine — Nuit profonde, reflets varies (Dark)
|
Nappe — l'eau profonde sous la ville (Dark)
|
||||||
Palette: bleu lavande / ambre chaud / rose ancien / vert d'eau
|
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
.mood-chagrine {
|
.mood-nappe {
|
||||||
--mood-bg: #0e1018;
|
--mood-bg: #0a141b;
|
||||||
--mood-surface: #151a28;
|
--mood-surface: #101f2a;
|
||||||
--mood-surface-hover: #1c2640;
|
--mood-surface-hover: #16293a;
|
||||||
--mood-text: #dce0ec;
|
--mood-text: #d6e3ea;
|
||||||
--mood-text-muted: #8898b8;
|
--mood-text-muted: #7e98a8;
|
||||||
--mood-accent: #6488d8;
|
--mood-accent: #3fa9cc;
|
||||||
--mood-accent-soft: rgba(100, 136, 216, 0.12);
|
--mood-accent-soft: rgba(63, 169, 204, 0.13);
|
||||||
--mood-accent-text: #ffffff;
|
--mood-accent-text: #06141c;
|
||||||
--mood-border: #222e48;
|
--mood-secondary: #cfa14a; /* laiton à la lueur de la lampe */
|
||||||
--mood-secondary: #d0a040;
|
--mood-tertiary: #52b894;
|
||||||
--mood-tertiary: #c87090;
|
--mood-success: #46bf7c;
|
||||||
--mood-success: #48c87a;
|
--mood-warning: #d2a13c;
|
||||||
--mood-warning: #d8a838;
|
--mood-error: #d86458;
|
||||||
--mood-error: #d86060;
|
--mood-gradient: linear-gradient(150deg, #0a141b 0%, #101f2a 45%, #0d1a24 100%);
|
||||||
--mood-gradient: linear-gradient(145deg, #0e1018 0%, #151a28 40%, #101420 100%);
|
--mood-shadow: rgba(30, 120, 160, 0.14);
|
||||||
--mood-shadow: rgba(60, 100, 180, 0.12);
|
--mood-input-bg: #101f2a;
|
||||||
--mood-input-bg: #151a28;
|
--mood-input-border: #24425a;
|
||||||
--mood-input-border: #2a3858;
|
--mood-input-focus: #3fa9cc;
|
||||||
--mood-input-focus: #6488d8;
|
|
||||||
|
|
||||||
--mood-status-prepa: #d8a838;
|
--mood-status-prepa: #a5987c;
|
||||||
--mood-status-prepa-bg: rgba(216, 168, 56, 0.14);
|
--mood-status-prepa-bg: rgba(165, 152, 124, 0.12);
|
||||||
--mood-status-vote: #6488d8;
|
--mood-status-fenetre: #d2a13c;
|
||||||
--mood-status-vote-bg: rgba(100, 136, 216, 0.14);
|
--mood-status-fenetre-bg: rgba(210, 161, 60, 0.14);
|
||||||
--mood-status-vigueur: #48c87a;
|
--mood-status-vote: #5f97e0;
|
||||||
--mood-status-vigueur-bg: rgba(72, 200, 122, 0.14);
|
--mood-status-vote-bg: rgba(95, 151, 224, 0.14);
|
||||||
--mood-status-clos: #7888a8;
|
--mood-status-fige: #7d97b5;
|
||||||
--mood-status-clos-bg: rgba(120, 136, 168, 0.10);
|
--mood-status-fige-bg: rgba(125, 151, 181, 0.14);
|
||||||
|
--mood-status-vigueur: #46bf7c;
|
||||||
|
--mood-status-vigueur-bg: rgba(70, 191, 124, 0.13);
|
||||||
|
--mood-status-revoque: #cd7a56;
|
||||||
|
--mood-status-revoque-bg: rgba(205, 122, 86, 0.13);
|
||||||
|
--mood-status-clos: #7d8fa0;
|
||||||
|
--mood-status-clos-bg: rgba(125, 143, 160, 0.11);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
Grave — Ambre mineral, ocre lumineux (Dark)
|
Minuit — l'encre, et la lanterne posée sur la margelle (Dark)
|
||||||
Palette: ambre dore / ocre rouge / bleu ardoise / vert mousse
|
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
.mood-grave {
|
.mood-minuit {
|
||||||
--mood-bg: #131210;
|
--mood-bg: #101014;
|
||||||
--mood-surface: #1c1a16;
|
--mood-surface: #17171e;
|
||||||
--mood-surface-hover: #28241c;
|
--mood-surface-hover: #202029;
|
||||||
--mood-text: #ece4d4;
|
--mood-text: #e2e2e8;
|
||||||
--mood-text-muted: #a89870;
|
--mood-text-muted: #8e8e9e;
|
||||||
--mood-accent: #d8a030;
|
--mood-accent: #cf9c3e;
|
||||||
--mood-accent-soft: rgba(216, 160, 48, 0.11);
|
--mood-accent-soft: rgba(207, 156, 62, 0.12);
|
||||||
--mood-accent-text: #131210;
|
--mood-accent-text: #131309;
|
||||||
--mood-border: #342e24;
|
--mood-secondary: #6c94d8;
|
||||||
--mood-secondary: #6898b8;
|
--mood-tertiary: #58ae8e;
|
||||||
--mood-tertiary: #c07040;
|
--mood-success: #52bd7e;
|
||||||
--mood-success: #58c870;
|
--mood-warning: #cfa03a;
|
||||||
--mood-warning: #d0a030;
|
--mood-error: #d25f55;
|
||||||
--mood-error: #d05050;
|
--mood-gradient: linear-gradient(150deg, #101014 0%, #17171e 45%, #131318 100%);
|
||||||
--mood-gradient: linear-gradient(145deg, #131210 0%, #1c1a16 40%, #16181c 100%);
|
--mood-shadow: rgba(190, 140, 50, 0.11);
|
||||||
--mood-shadow: rgba(180, 130, 40, 0.10);
|
--mood-input-bg: #17171e;
|
||||||
--mood-input-bg: #1c1a16;
|
--mood-input-border: #33333f;
|
||||||
--mood-input-border: #3c3428;
|
--mood-input-focus: #cf9c3e;
|
||||||
--mood-input-focus: #d8a030;
|
|
||||||
|
|
||||||
--mood-status-prepa: #c07040;
|
--mood-status-prepa: #9e9584;
|
||||||
--mood-status-prepa-bg: rgba(192, 112, 64, 0.15);
|
--mood-status-prepa-bg: rgba(158, 149, 132, 0.12);
|
||||||
--mood-status-vote: #6898b8;
|
--mood-status-fenetre: #cfa03a;
|
||||||
--mood-status-vote-bg: rgba(104, 152, 184, 0.14);
|
--mood-status-fenetre-bg: rgba(207, 160, 58, 0.14);
|
||||||
--mood-status-vigueur: #58c870;
|
--mood-status-vote: #6c94d8;
|
||||||
--mood-status-vigueur-bg: rgba(88, 200, 112, 0.14);
|
--mood-status-vote-bg: rgba(108, 148, 216, 0.14);
|
||||||
--mood-status-clos: #807860;
|
--mood-status-fige: #8b8ba0;
|
||||||
--mood-status-clos-bg: rgba(128, 120, 96, 0.12);
|
--mood-status-fige-bg: rgba(139, 139, 160, 0.14);
|
||||||
|
--mood-status-vigueur: #52bd7e;
|
||||||
|
--mood-status-vigueur-bg: rgba(82, 189, 126, 0.13);
|
||||||
|
--mood-status-revoque: #c97a5c;
|
||||||
|
--mood-status-revoque-bg: rgba(201, 122, 92, 0.13);
|
||||||
|
--mood-status-clos: #85859a;
|
||||||
|
--mood-status-clos-bg: rgba(133, 133, 154, 0.11);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
Global design tokens — Plus Jakarta Sans, rounded, borderless
|
Tokens dérivés — routes, tailles, ombres (identiques dans toutes les ambiances)
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
.mood-source, .mood-margelle, .mood-nappe, .mood-minuit {
|
||||||
|
/* Routes du chemin — vocabulaire visuel fixe */
|
||||||
|
--route-solo: var(--mood-accent);
|
||||||
|
--route-mandate: var(--mood-secondary);
|
||||||
|
--route-transmit: var(--mood-text-muted);
|
||||||
|
--route-advice: var(--mood-tertiary);
|
||||||
|
--route-collective: var(--mood-status-vote);
|
||||||
|
--route-record: var(--mood-status-prepa);
|
||||||
|
--route-urgent: var(--mood-error);
|
||||||
|
|
||||||
*,
|
/* Rayons (consignes) */
|
||||||
*::before,
|
--r-card: 16px;
|
||||||
*::after {
|
--r-input: 12px;
|
||||||
border-color: transparent;
|
--r-pill: 20px;
|
||||||
|
--r-icon: 14px;
|
||||||
|
|
||||||
|
/* Ombres borderless — deux couches, jamais de bordure */
|
||||||
|
--shadow-card: 0 1px 2px var(--mood-shadow), 0 5px 18px var(--mood-shadow);
|
||||||
|
--shadow-card-hover: 0 2px 4px var(--mood-shadow), 0 10px 28px var(--mood-shadow);
|
||||||
|
--shadow-raised: 0 1px 3px var(--mood-shadow), 0 8px 24px var(--mood-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Socle global — Plus Jakarta Sans, borderless, gestes
|
||||||
|
========================================================================== */
|
||||||
|
*, *::before, *::after { border-color: transparent; }
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
|
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -160,89 +198,154 @@ html {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
|
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--mood-bg);
|
||||||
|
color: var(--mood-text);
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- No borders anywhere --- */
|
|
||||||
button, input, select, textarea {
|
button, input, select, textarea {
|
||||||
border: none !important;
|
border: none !important;
|
||||||
outline: none;
|
outline: none;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus, select:focus, textarea:focus {
|
input, select, textarea {
|
||||||
|
background: var(--mood-input-bg);
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus-visible, select:focus-visible, textarea:focus-visible,
|
||||||
|
button:focus-visible, a:focus-visible, [tabindex]:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 2.5px var(--mood-accent-soft);
|
box-shadow: 0 0 0 2.5px var(--mood-accent-soft), 0 0 0 1.5px var(--mood-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Status pills --- */
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Cartes --- */
|
||||||
|
.ld-card {
|
||||||
|
background: var(--mood-surface);
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
.ld-card--hover:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: var(--shadow-card-hover);
|
||||||
|
}
|
||||||
|
.ld-card--hover:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
/* --- Boutons pill --- */
|
||||||
|
.ld-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--mood-accent);
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
transition: transform 0.1s ease, box-shadow 0.1s ease, filter 0.1s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.ld-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px var(--mood-shadow); }
|
||||||
|
.ld-btn:active { transform: translateY(0); }
|
||||||
|
.ld-btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||||
|
.ld-btn--ghost {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
}
|
||||||
|
.ld-btn--quiet {
|
||||||
|
background: none;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.ld-btn--quiet:hover { background: var(--mood-accent-soft); color: var(--mood-text); box-shadow: none; }
|
||||||
|
|
||||||
|
/* --- Pills d'état (mapping unique état→couleur) --- */
|
||||||
.status-pill {
|
.status-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 5px 14px;
|
gap: 0.3rem;
|
||||||
border-radius: 20px;
|
padding: 4px 13px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
border: none !important;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
.status-pill:hover {
|
.status-pill.is-clickable { cursor: pointer; }
|
||||||
filter: brightness(1.08);
|
.status-pill.is-clickable:hover { filter: brightness(1.08); transform: translateY(-1px); }
|
||||||
transform: translateY(-1px);
|
.status-pill.active { box-shadow: 0 0 0 2.5px var(--mood-accent); }
|
||||||
}
|
|
||||||
.status-pill.active {
|
.status-draft { background: var(--mood-status-prepa-bg); color: var(--mood-status-prepa); }
|
||||||
box-shadow: 0 0 0 2.5px var(--mood-accent);
|
.status-advice,
|
||||||
|
.status-objection { background: var(--mood-status-fenetre-bg); color: var(--mood-status-fenetre); }
|
||||||
|
.status-framing { background: var(--mood-status-prepa-bg); color: var(--mood-status-prepa); }
|
||||||
|
.status-voting { background: var(--mood-status-vote-bg); color: var(--mood-status-vote); }
|
||||||
|
.status-frozen { background: var(--mood-status-fige-bg); color: var(--mood-status-fige); }
|
||||||
|
.status-adopted { background: var(--mood-status-vigueur-bg); color: var(--mood-status-vigueur); }
|
||||||
|
.status-rejected { background: var(--mood-status-clos-bg); color: var(--mood-status-clos); text-decoration: line-through; }
|
||||||
|
.status-revoked { background: var(--mood-status-revoque-bg); color: var(--mood-status-revoque); }
|
||||||
|
.status-transmitted { background: var(--mood-status-fige-bg); color: var(--mood-status-fige); }
|
||||||
|
.status-closed { background: var(--mood-status-clos-bg); color: var(--mood-status-clos); }
|
||||||
|
|
||||||
|
/* Suspension (frontière contestée) : fond strié */
|
||||||
|
.status-suspended {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
-45deg,
|
||||||
|
var(--mood-status-fenetre-bg),
|
||||||
|
var(--mood-status-fenetre-bg) 6px,
|
||||||
|
transparent 6px,
|
||||||
|
transparent 10px
|
||||||
|
);
|
||||||
|
color: var(--mood-status-fenetre);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-prepa {
|
/* --- Le tampon 井 (signature) --- */
|
||||||
background: var(--mood-status-prepa-bg);
|
.ld-stamp {
|
||||||
color: var(--mood-status-prepa);
|
display: inline-flex;
|
||||||
}
|
align-items: center;
|
||||||
.status-vote {
|
gap: 0.4rem;
|
||||||
background: var(--mood-status-vote-bg);
|
padding: 0.35rem 0.85rem;
|
||||||
color: var(--mood-status-vote);
|
border-radius: 10px;
|
||||||
}
|
transform: rotate(-10deg);
|
||||||
.status-vigueur {
|
font-weight: 800;
|
||||||
background: var(--mood-status-vigueur-bg);
|
font-size: 0.8125rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
color: var(--mood-status-vigueur);
|
color: var(--mood-status-vigueur);
|
||||||
}
|
box-shadow: inset 0 0 0 2px var(--mood-status-vigueur);
|
||||||
.status-clos {
|
background: var(--mood-status-vigueur-bg);
|
||||||
background: var(--mood-status-clos-bg);
|
user-select: none;
|
||||||
color: var(--mood-status-clos);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================================================
|
/* --- Sceau hexagramme #48 Tsing --- */
|
||||||
Nuxt UI overrides — borderless everything
|
.app-seal {
|
||||||
========================================================================== */
|
display: block;
|
||||||
|
width: 44px;
|
||||||
:root .mood-peps,
|
color: var(--mood-accent);
|
||||||
:root .mood-zen,
|
opacity: 0.28;
|
||||||
:root .mood-chagrine,
|
filter: drop-shadow(1px 1px 0.5px rgba(0, 0, 0, 0.25))
|
||||||
:root .mood-grave {
|
drop-shadow(-0.5px -0.5px 0.5px rgba(255, 255, 255, 0.15));
|
||||||
--ui-border: transparent;
|
|
||||||
--ui-bg: var(--mood-input-bg);
|
|
||||||
--ui-text-highlighted: var(--mood-accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root button,
|
/* --- Impression A4 (PV, affiches de session) --- */
|
||||||
:root [class*="UButton"],
|
@media print {
|
||||||
:root [class*="u-button"],
|
body { background: #ffffff !important; color: #111111 !important; }
|
||||||
:root [data-variant] {
|
.app-header, .app-sidebar, .app-fab, .app-seal, .no-print { display: none !important; }
|
||||||
border: none !important;
|
.ld-card { box-shadow: none !important; }
|
||||||
font-family: 'Plus Jakarta Sans', system-ui, sans-serif !important;
|
.print-only { display: block !important; }
|
||||||
}
|
|
||||||
|
|
||||||
:root input,
|
|
||||||
:root textarea,
|
|
||||||
:root select,
|
|
||||||
:root [class*="UInput"],
|
|
||||||
:root [class*="USelect"],
|
|
||||||
:root [class*="UTextarea"] {
|
|
||||||
border: none !important;
|
|
||||||
font-family: 'Plus Jakarta Sans', system-ui, sans-serif !important;
|
|
||||||
}
|
}
|
||||||
|
.print-only { display: none; }
|
||||||
|
|||||||
@@ -1,373 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* SectionLayout — Mise en page pour sections.
|
|
||||||
*
|
|
||||||
* Desktop (≥1024px) : 2 colonnes, toolbox sticky à droite, toujours visible.
|
|
||||||
* Mobile/tablette : toolbox en USlideover droit, bouton flottant.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface StatusFilter {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
count: number
|
|
||||||
cssClass?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToolboxItem {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
actions: Array<{
|
|
||||||
label: string
|
|
||||||
to?: string
|
|
||||||
onClick?: () => void
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
title: string
|
|
||||||
subtitle?: string
|
|
||||||
statuses: StatusFilter[]
|
|
||||||
toolboxItems?: ToolboxItem[]
|
|
||||||
activeStatus?: string | null
|
|
||||||
toolboxTitle?: string
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
subtitle: undefined,
|
|
||||||
toolboxItems: undefined,
|
|
||||||
activeStatus: null,
|
|
||||||
toolboxTitle: 'Boîte à outils',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:activeStatus': [status: string | null]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const toolboxOpen = ref(false)
|
|
||||||
|
|
||||||
const statusCssMap: Record<string, string> = {
|
|
||||||
draft: 'status-prepa',
|
|
||||||
qualification: 'status-prepa',
|
|
||||||
candidacy: 'status-prepa',
|
|
||||||
voting: 'status-vote',
|
|
||||||
review: 'status-vote',
|
|
||||||
active: 'status-vigueur',
|
|
||||||
executed: 'status-vigueur',
|
|
||||||
completed: 'status-vigueur',
|
|
||||||
closed: 'status-clos',
|
|
||||||
archived: 'status-clos',
|
|
||||||
revoked: 'status-clos',
|
|
||||||
reporting: 'status-vote',
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusClass(status: StatusFilter): string {
|
|
||||||
return status.cssClass || statusCssMap[status.id] || 'status-prepa'
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleStatus(statusId: string) {
|
|
||||||
if (props.activeStatus === statusId) {
|
|
||||||
emit('update:activeStatus', null)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
emit('update:activeStatus', statusId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="section">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="section__header">
|
|
||||||
<div class="section__header-left">
|
|
||||||
<h1 class="section__title">{{ title }}</h1>
|
|
||||||
<p v-if="subtitle" class="section__subtitle">{{ subtitle }}</p>
|
|
||||||
</div>
|
|
||||||
<!-- Mobile toolbox trigger -->
|
|
||||||
<button
|
|
||||||
class="section__toolbox-fab lg:hidden"
|
|
||||||
:class="{ 'section__toolbox-fab--active': toolboxOpen }"
|
|
||||||
@click="toolboxOpen = true"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-wrench" />
|
|
||||||
<span>Outils</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Body: content + toolbox -->
|
|
||||||
<div class="section__body">
|
|
||||||
<div class="section__main">
|
|
||||||
<!-- Status pills -->
|
|
||||||
<div v-if="statuses.length > 0" class="section__pills">
|
|
||||||
<button
|
|
||||||
v-for="status in statuses"
|
|
||||||
:key="status.id"
|
|
||||||
type="button"
|
|
||||||
class="status-pill"
|
|
||||||
:class="[getStatusClass(status), { active: activeStatus === status.id }]"
|
|
||||||
@click="toggleStatus(status.id)"
|
|
||||||
>
|
|
||||||
{{ status.label }}
|
|
||||||
<span v-if="status.count > 0" class="section__pill-count">{{ status.count }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="$slots.search" class="section__search">
|
|
||||||
<slot name="search" />
|
|
||||||
</div>
|
|
||||||
<div class="section__content">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Desktop toolbox sidebar (≥1024px) -->
|
|
||||||
<aside class="section__toolbox">
|
|
||||||
<div class="section__toolbox-head">
|
|
||||||
<UIcon name="i-lucide-wrench" class="section__toolbox-head-icon" />
|
|
||||||
<span>{{ toolboxTitle }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="section__toolbox-body">
|
|
||||||
<div v-if="$slots.toolbox">
|
|
||||||
<slot name="toolbox" />
|
|
||||||
</div>
|
|
||||||
<div v-else-if="toolboxItems && toolboxItems.length > 0">
|
|
||||||
<ToolboxVignette
|
|
||||||
v-for="(item, idx) in toolboxItems"
|
|
||||||
:key="idx"
|
|
||||||
:title="item.title"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else class="section__toolbox-empty">
|
|
||||||
Aucun outil disponible
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile toolbox: USlideover from right -->
|
|
||||||
<USlideover
|
|
||||||
v-model:open="toolboxOpen"
|
|
||||||
side="right"
|
|
||||||
:title="toolboxTitle"
|
|
||||||
:ui="{
|
|
||||||
width: 'max-w-sm',
|
|
||||||
header: { padding: 'p-4' },
|
|
||||||
body: { padding: 'p-4' },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #body>
|
|
||||||
<div class="section__toolbox-slideover">
|
|
||||||
<div v-if="$slots.toolbox">
|
|
||||||
<slot name="toolbox" />
|
|
||||||
</div>
|
|
||||||
<div v-else-if="toolboxItems && toolboxItems.length > 0">
|
|
||||||
<ToolboxVignette
|
|
||||||
v-for="(item, idx) in toolboxItems"
|
|
||||||
:key="idx"
|
|
||||||
:title="item.title"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else class="section__toolbox-empty">
|
|
||||||
Aucun outil disponible
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</USlideover>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.section { gap: 1.5rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.section__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__header-left {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__title {
|
|
||||||
font-size: 1.375rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--mood-text);
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.section__title { font-size: 1.75rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__subtitle {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
font-weight: 500;
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.section__subtitle { font-size: 1rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile toolbox trigger */
|
|
||||||
.section__toolbox-fab {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.5rem 0.875rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
border-radius: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__toolbox-fab:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 3px 10px var(--mood-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__toolbox-fab--active {
|
|
||||||
background: var(--mood-accent);
|
|
||||||
color: var(--mood-accent-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Body layout */
|
|
||||||
.section__body {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 1.5rem;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.section__body {
|
|
||||||
grid-template-columns: 1fr 30rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status pills */
|
|
||||||
.section__pills {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
overflow-x: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
scrollbar-width: none;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__pills::-webkit-scrollbar { display: none; }
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.section__pills {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
overflow-x: visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__pill-count {
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 800;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__search {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 639px) {
|
|
||||||
.section__search { flex-direction: column; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__content { min-height: 12rem; }
|
|
||||||
|
|
||||||
/* Desktop toolbox sidebar */
|
|
||||||
.section__toolbox {
|
|
||||||
display: none;
|
|
||||||
position: sticky;
|
|
||||||
top: 4.5rem;
|
|
||||||
align-self: start;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--mood-surface);
|
|
||||||
border-radius: 16px;
|
|
||||||
max-height: calc(100vh - 5.5rem);
|
|
||||||
box-shadow: 0 4px 24px var(--mood-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.section__toolbox { display: flex; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__toolbox-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.875rem 1rem 0.625rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__toolbox-head-icon {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__toolbox-body {
|
|
||||||
padding: 0 0.75rem 0.875rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__toolbox-empty {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
text-align: center;
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Slideover content */
|
|
||||||
.section__toolbox-slideover {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const orgsStore = useOrganizationsStore()
|
|
||||||
|
|
||||||
const isOpen = ref(false)
|
|
||||||
const containerRef = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
const active = computed(() => orgsStore.active)
|
|
||||||
const organizations = computed(() => orgsStore.organizations)
|
|
||||||
|
|
||||||
function selectOrg(slug: string) {
|
|
||||||
orgsStore.setActive(slug)
|
|
||||||
isOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close on outside click
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
|
|
||||||
isOpen.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div ref="containerRef" class="ws">
|
|
||||||
<button
|
|
||||||
class="ws__trigger"
|
|
||||||
:class="{ 'ws__trigger--open': isOpen }"
|
|
||||||
:disabled="orgsStore.loading || !active"
|
|
||||||
@click="isOpen = !isOpen"
|
|
||||||
>
|
|
||||||
<div v-if="orgsStore.loading" class="ws__icon ws__icon--muted">
|
|
||||||
<UIcon name="i-lucide-loader-2" class="animate-spin" />
|
|
||||||
</div>
|
|
||||||
<div v-else-if="active" class="ws__icon" :style="{ background: active.color ? active.color + '22' : undefined, color: active.color || undefined }">
|
|
||||||
<UIcon :name="active.icon || 'i-lucide-building'" />
|
|
||||||
</div>
|
|
||||||
<span class="ws__name">{{ active?.name ?? '…' }}</span>
|
|
||||||
<UIcon name="i-lucide-chevrons-up-down" class="ws__caret" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Transition name="dropdown">
|
|
||||||
<div v-if="isOpen && organizations.length" class="ws__dropdown">
|
|
||||||
<div class="ws__dropdown-header">
|
|
||||||
Espace de travail
|
|
||||||
</div>
|
|
||||||
<div class="ws__items">
|
|
||||||
<button
|
|
||||||
v-for="org in organizations"
|
|
||||||
:key="org.id"
|
|
||||||
class="ws__item"
|
|
||||||
:class="{ 'ws__item--active': org.slug === orgsStore.activeSlug }"
|
|
||||||
@click="selectOrg(org.slug)"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="ws__item-icon"
|
|
||||||
:style="{ background: org.color ? org.color + '22' : undefined, color: org.color || undefined }"
|
|
||||||
>
|
|
||||||
<UIcon :name="org.icon || 'i-lucide-building'" />
|
|
||||||
</div>
|
|
||||||
<div class="ws__item-info">
|
|
||||||
<span class="ws__item-name">{{ org.name }}</span>
|
|
||||||
<span class="ws__item-role">{{ org.is_transparent ? 'Public' : 'Membres' }}</span>
|
|
||||||
</div>
|
|
||||||
<UIcon v-if="org.slug === orgsStore.activeSlug" name="i-lucide-check" class="ws__item-check" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="ws__dropdown-footer">
|
|
||||||
<button class="ws__new-btn" disabled>
|
|
||||||
<UIcon name="i-lucide-plus" />
|
|
||||||
Nouveau collectif
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.ws {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws__trigger {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.375rem 0.625rem;
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.12s ease;
|
|
||||||
min-height: 2rem;
|
|
||||||
max-width: 11rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws__trigger:hover:not(:disabled) {
|
|
||||||
background: color-mix(in srgb, var(--mood-accent-soft) 80%, var(--mood-accent) 20%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws__trigger--open {
|
|
||||||
background: color-mix(in srgb, var(--mood-accent-soft) 60%, var(--mood-accent) 40%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws__trigger:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws__icon {
|
|
||||||
width: 1.375rem;
|
|
||||||
height: 1.375rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws__icon--muted {
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws__name {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-text);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws__caret {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dropdown */
|
|
||||||
.ws__dropdown {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 0.375rem);
|
|
||||||
left: 0;
|
|
||||||
min-width: 13rem;
|
|
||||||
background: var(--mood-surface);
|
|
||||||
border-radius: 14px;
|
|
||||||
box-shadow: 0 8px 32px var(--mood-shadow);
|
|
||||||
z-index: 100;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws__dropdown-header {
|
|
||||||
padding: 0.625rem 0.875rem 0.375rem;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws__items {
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws__item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
padding: 0.625rem 0.625rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.1s ease;
|
|
||||||
text-align: left;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws__item:hover { background: var(--mood-accent-soft); }
|
|
||||||
.ws__item--active { background: var(--mood-accent-soft); }
|
|
||||||
|
|
||||||
.ws__item-icon {
|
|
||||||
width: 1.75rem;
|
|
||||||
height: 1.75rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws__item-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws__item-name {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws__item-role {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws__item-check {
|
|
||||||
color: var(--mood-accent);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws__dropdown-footer {
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-top: 1px solid var(--mood-accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws__new-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 0.625rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
background: none;
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Transition */
|
|
||||||
.dropdown-enter-active, .dropdown-leave-active {
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
transform-origin: top left;
|
|
||||||
}
|
|
||||||
.dropdown-enter-from, .dropdown-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.95) translateY(-4px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// « Je choisis autrement » — the permanent alternatives, each with its cost.
|
||||||
|
// Weighting up is one tap; lightening REQUIRES a note and imposes a window.
|
||||||
|
import type { ParamSpec, Verdict } from '~/types/domain'
|
||||||
|
import { CHOOSE_OTHERWISE, PARAMETRIC_ALT, RECORD_HOW } from '~/lexicon'
|
||||||
|
|
||||||
|
type AltKey = 'parametric' | 'record' | 'binary'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
alternatives: Verdict['alternatives']
|
||||||
|
parametricHint: boolean
|
||||||
|
chosen: AltKey | null
|
||||||
|
lightening: boolean
|
||||||
|
decidedHow: string
|
||||||
|
overrideNote: string
|
||||||
|
spec?: ParamSpec
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:chosen', value: AltKey | null): void
|
||||||
|
(e: 'update:decidedHow' | 'update:overrideNote', value: string): void
|
||||||
|
(e: 'update:spec', value: ParamSpec): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
|
||||||
|
const keyed = computed(() =>
|
||||||
|
props.alternatives.map((alt) => ({
|
||||||
|
...alt,
|
||||||
|
key: (alt.route === 'record'
|
||||||
|
? 'record'
|
||||||
|
: alt.label === PARAMETRIC_ALT ? 'parametric' : 'binary') as AltKey,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
function pick(key: AltKey) {
|
||||||
|
emit('update:chosen', props.chosen === key ? null : key)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<section class="alt">
|
||||||
|
<button class="alt__toggle" type="button" @click="open = !open">
|
||||||
|
<UIcon :name="open ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'" />
|
||||||
|
<span>{{ CHOOSE_OTHERWISE }}</span>
|
||||||
|
<span v-if="chosen && !open" class="alt__chosen-hint">1 choix actif</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="open" class="alt__body">
|
||||||
|
<div class="alt__list">
|
||||||
|
<button
|
||||||
|
v-for="alt in keyed"
|
||||||
|
:key="alt.key"
|
||||||
|
class="alt__item"
|
||||||
|
:class="{
|
||||||
|
'alt__item--on': chosen === alt.key,
|
||||||
|
'alt__item--hint': alt.key === 'parametric' && parametricHint,
|
||||||
|
}"
|
||||||
|
type="button"
|
||||||
|
@click="pick(alt.key)"
|
||||||
|
>
|
||||||
|
<span class="alt__label">{{ alt.label }}</span>
|
||||||
|
<span class="alt__cost">{{ alt.cost }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="chosen === 'parametric'" class="alt__detail">
|
||||||
|
<CheminParamEditor
|
||||||
|
:model-value="spec"
|
||||||
|
@update:model-value="emit('update:spec', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="chosen === 'record'" class="alt__detail">
|
||||||
|
<label class="alt__field">
|
||||||
|
<span>{{ RECORD_HOW }}</span>
|
||||||
|
<input
|
||||||
|
:value="decidedHow"
|
||||||
|
type="text"
|
||||||
|
placeholder="Décidé au café, comme d'habitude…"
|
||||||
|
@input="emit('update:decidedHow', ($event.target as HTMLInputElement).value)"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="lightening" class="alt__lighten">
|
||||||
|
<p class="alt__lighten-mention">
|
||||||
|
<UIcon name="i-lucide-scale" />
|
||||||
|
Tu prends un chemin plus léger que celui du collectif : une fenêtre d'objection
|
||||||
|
de 48 h est imposée, et ta raison s'affiche.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
:value="overrideNote"
|
||||||
|
class="alt__note"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Pourquoi alléger ? (obligatoire)"
|
||||||
|
@input="emit('update:overrideNote', ($event.target as HTMLTextAreaElement).value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.alt { display: flex; flex-direction: column; gap: 0.625rem; }
|
||||||
|
.alt__toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: none;
|
||||||
|
padding: 0.375rem 0.25rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: flex-start;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
}
|
||||||
|
.alt__toggle:hover { color: var(--mood-text); }
|
||||||
|
.alt__chosen-hint { font-size: 0.75rem; font-weight: 600; color: var(--mood-accent); }
|
||||||
|
.alt__body { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.alt__list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.alt__item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border-radius: var(--r-icon);
|
||||||
|
background: var(--mood-input-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
.alt__item:hover { transform: translateY(-1px); }
|
||||||
|
.alt__item--hint { box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--mood-accent) 55%, transparent); }
|
||||||
|
.alt__item--on {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
box-shadow: 0 0 0 2px var(--mood-accent);
|
||||||
|
}
|
||||||
|
.alt__label { font-size: 0.9063rem; font-weight: 700; color: var(--mood-text); }
|
||||||
|
.alt__cost { font-size: 0.8125rem; font-style: italic; color: var(--mood-text-muted); }
|
||||||
|
.alt__detail { padding-left: 0.25rem; }
|
||||||
|
.alt__field { display: flex; flex-direction: column; gap: 0.35rem; font-size: 0.875rem; font-weight: 700; }
|
||||||
|
.alt__field input { padding: 0.625rem 0.75rem; font-size: 0.9375rem; width: 100%; }
|
||||||
|
.alt__lighten {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: var(--mood-status-fenetre-bg);
|
||||||
|
}
|
||||||
|
.alt__lighten-mention {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-status-fenetre);
|
||||||
|
}
|
||||||
|
.alt__note { width: 100%; padding: 0.625rem 0.75rem; font-size: 0.9375rem; resize: vertical; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// The three tunnel chips — Concernés / Réversible ? / Ça engage quoi ? —
|
||||||
|
// one unfolded at a time (mobile-first), plus the discreet urgency toggle,
|
||||||
|
// the never-blocking « Ce que ça engage » suggestion line and the
|
||||||
|
// « Aujourd'hui : … » status-quo line.
|
||||||
|
import type { Decision, Person, Reversibility, Weight } from '~/types/domain'
|
||||||
|
import {
|
||||||
|
BASELINE_PREFIX,
|
||||||
|
ENGAGES_LABEL,
|
||||||
|
REVERSIBILITY_LABELS,
|
||||||
|
URGENT_TOGGLE,
|
||||||
|
WEIGHT_LABELS,
|
||||||
|
} from '~/lexicon'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
stack: { person: Person; origin?: 'computed' | 'declared'; reason?: string }[]
|
||||||
|
engagesOpen: boolean
|
||||||
|
baselineSuggested: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const scope = defineModel<Decision['scope']>('scope', { required: true })
|
||||||
|
const reversibility = defineModel<Reversibility>('reversibility', { required: true })
|
||||||
|
const weight = defineModel<Weight>('weight', { required: true })
|
||||||
|
const urgent = defineModel<boolean>('urgent', { required: true })
|
||||||
|
const resourceNote = defineModel<string>('resourceNote', { required: true })
|
||||||
|
const resourceAmount = defineModel<number | null>('resourceAmount', { required: true })
|
||||||
|
const resourceUnit = defineModel<string>('resourceUnit', { required: true })
|
||||||
|
const baselineNote = defineModel<string>('baselineNote', { required: true })
|
||||||
|
|
||||||
|
const openChip = ref<'scope' | 'rev' | 'weight' | null>(null)
|
||||||
|
const REV_OPTIONS = Object.keys(REVERSIBILITY_LABELS) as Reversibility[]
|
||||||
|
const WEIGHT_OPTIONS = Object.keys(WEIGHT_LABELS) as Weight[]
|
||||||
|
|
||||||
|
function toggle(chip: 'scope' | 'rev' | 'weight') {
|
||||||
|
openChip.value = openChip.value === chip ? null : chip
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<div class="chips">
|
||||||
|
<div class="chips__row">
|
||||||
|
<button
|
||||||
|
class="chips__chip"
|
||||||
|
:class="{ 'chips__chip--open': openChip === 'scope' }"
|
||||||
|
type="button"
|
||||||
|
@click="toggle('scope')"
|
||||||
|
>
|
||||||
|
<span class="chips__name">Concernés</span>
|
||||||
|
<span class="chips__value">
|
||||||
|
{{ scope.selfOnly ? 'moi seul' : `${stack.length} personne${stack.length > 1 ? 's' : ''}` }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="chips__chip"
|
||||||
|
:class="{ 'chips__chip--open': openChip === 'rev' }"
|
||||||
|
type="button"
|
||||||
|
@click="toggle('rev')"
|
||||||
|
>
|
||||||
|
<span class="chips__name">Réversible ?</span>
|
||||||
|
<span class="chips__value">{{ REVERSIBILITY_LABELS[reversibility] }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="chips__chip"
|
||||||
|
:class="{ 'chips__chip--open': openChip === 'weight' }"
|
||||||
|
type="button"
|
||||||
|
@click="toggle('weight')"
|
||||||
|
>
|
||||||
|
<span class="chips__name">Ça engage quoi ?</span>
|
||||||
|
<span class="chips__value">{{ WEIGHT_LABELS[weight] }}</span>
|
||||||
|
</button>
|
||||||
|
<label class="chips__urgent" :class="{ 'chips__urgent--on': urgent }">
|
||||||
|
<input v-model="urgent" type="checkbox">
|
||||||
|
<UIcon name="i-lucide-siren" />
|
||||||
|
<span>{{ URGENT_TOGGLE }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="openChip === 'scope'" class="ld-card chips__panel">
|
||||||
|
<CheminScope v-model:scope="scope" :stack="stack" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="openChip === 'rev'" class="ld-card chips__panel">
|
||||||
|
<div class="chips__options">
|
||||||
|
<button
|
||||||
|
v-for="opt in REV_OPTIONS"
|
||||||
|
:key="opt"
|
||||||
|
class="chips__option"
|
||||||
|
:class="{ 'chips__option--on': reversibility === opt }"
|
||||||
|
type="button"
|
||||||
|
@click="reversibility = opt"
|
||||||
|
>
|
||||||
|
{{ REVERSIBILITY_LABELS[opt] }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="openChip === 'weight'" class="ld-card chips__panel">
|
||||||
|
<div class="chips__options">
|
||||||
|
<button
|
||||||
|
v-for="opt in WEIGHT_OPTIONS"
|
||||||
|
:key="opt"
|
||||||
|
class="chips__option"
|
||||||
|
:class="{ 'chips__option--on': weight === opt }"
|
||||||
|
type="button"
|
||||||
|
@click="weight = opt"
|
||||||
|
>
|
||||||
|
{{ WEIGHT_LABELS[opt] }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- « Ce que ça engage » — suggestion dépliée, JAMAIS bloquante -->
|
||||||
|
<div v-if="engagesOpen" class="chips__engages">
|
||||||
|
<span class="chips__engages-label">{{ ENGAGES_LABEL }}</span>
|
||||||
|
<input
|
||||||
|
v-model="resourceNote"
|
||||||
|
class="chips__engages-note"
|
||||||
|
type="text"
|
||||||
|
placeholder="En une phrase — suggéré, jamais exigé ici"
|
||||||
|
>
|
||||||
|
<div class="chips__engages-amount">
|
||||||
|
<input
|
||||||
|
v-model.number="resourceAmount"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="Montant"
|
||||||
|
>
|
||||||
|
<select v-model="resourceUnit">
|
||||||
|
<option value="heures">heures</option>
|
||||||
|
<option value="€">€</option>
|
||||||
|
<option value="DU">DU</option>
|
||||||
|
<option value="jours">jours</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- « Aujourd'hui : … » — le statu quo, suggéré sur avis/collectif -->
|
||||||
|
<label v-if="baselineSuggested" class="chips__baseline">
|
||||||
|
<span>{{ BASELINE_PREFIX }}</span>
|
||||||
|
<input
|
||||||
|
v-model="baselineNote"
|
||||||
|
type="text"
|
||||||
|
placeholder="ce qui se passe si rien ne change"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chips { display: flex; flex-direction: column; gap: 0.625rem; }
|
||||||
|
.chips__row { display: flex; flex-wrap: wrap; align-items: stretch; gap: 0.5rem; }
|
||||||
|
.chips__chip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.125rem;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
border-radius: var(--r-icon);
|
||||||
|
background: var(--mood-surface);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
.chips__chip:hover { transform: translateY(-1px); }
|
||||||
|
.chips__chip--open { box-shadow: 0 0 0 2px var(--mood-accent), var(--shadow-card); }
|
||||||
|
.chips__name {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.chips__value { font-size: 0.875rem; font-weight: 700; color: var(--mood-text); }
|
||||||
|
.chips__urgent {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.4rem 0.875rem;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.chips__urgent input { position: absolute; opacity: 0; width: 1px; height: 1px; }
|
||||||
|
.chips__urgent--on {
|
||||||
|
color: var(--route-urgent);
|
||||||
|
background: color-mix(in srgb, var(--route-urgent) 12%, transparent);
|
||||||
|
}
|
||||||
|
.chips__panel { padding: 1rem; }
|
||||||
|
.chips__options { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||||
|
.chips__option {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--mood-input-bg);
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
}
|
||||||
|
.chips__option--on {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
box-shadow: 0 0 0 1.5px var(--mood-accent);
|
||||||
|
}
|
||||||
|
.chips__engages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: color-mix(in srgb, var(--mood-accent) 6%, transparent);
|
||||||
|
}
|
||||||
|
.chips__engages-label { font-size: 0.8125rem; font-weight: 700; color: var(--mood-accent); }
|
||||||
|
.chips__engages-note { width: 100%; padding: 0.5rem 0.75rem; font-size: 0.9375rem; }
|
||||||
|
.chips__engages-amount { display: flex; gap: 0.5rem; }
|
||||||
|
.chips__engages-amount input { width: 7rem; padding: 0.5rem 0.75rem; font-size: 0.9375rem; }
|
||||||
|
.chips__engages-amount select { padding: 0.5rem 0.75rem; font-size: 0.9375rem; }
|
||||||
|
.chips__baseline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.chips__baseline input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Mini-editor of a « Réglage collectif »: up to 7 named sliders
|
||||||
|
// {label, min, max, step, unit, baseline}, plus the 100 % split option with
|
||||||
|
// its ONE named derived share (« calculé » — resolved, never voted).
|
||||||
|
import type { ParamDef, ParamSpec } from '~/types/domain'
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue?: ParamSpec }>()
|
||||||
|
const emit = defineEmits<{ (e: 'update:modelValue', spec: ParamSpec): void }>()
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
label: string
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
step: number
|
||||||
|
unit: string
|
||||||
|
baseline: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const blankRow = (): Row => ({ label: '', min: 0, max: 100, step: 1, unit: '', baseline: null })
|
||||||
|
|
||||||
|
const rows = ref<Row[]>(
|
||||||
|
props.modelValue
|
||||||
|
? props.modelValue.params
|
||||||
|
.filter(p => !p.derived)
|
||||||
|
.map(p => ({
|
||||||
|
label: p.label,
|
||||||
|
min: p.min,
|
||||||
|
max: p.max,
|
||||||
|
step: p.step,
|
||||||
|
unit: p.unit ?? '',
|
||||||
|
baseline: p.baseline ?? null,
|
||||||
|
}))
|
||||||
|
: [blankRow()],
|
||||||
|
)
|
||||||
|
const sum100 = ref(props.modelValue?.constraint === 'sum100')
|
||||||
|
const derivedName = ref(
|
||||||
|
props.modelValue?.params.find(p => p.derived)?.label ?? 'Réserve (calculé)',
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxRows = computed(() => (sum100.value ? 6 : 7))
|
||||||
|
const derivedBaseline = computed(() =>
|
||||||
|
Math.max(0, 100 - rows.value.reduce((sum, r) => sum + (r.baseline ?? 0), 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
function build(): ParamSpec {
|
||||||
|
const params: ParamDef[] = rows.value.map((row, i) => ({
|
||||||
|
key: `p${i + 1}`,
|
||||||
|
label: row.label.trim() || `Curseur ${i + 1}`,
|
||||||
|
kind: sum100.value ? 'share' : 'slider',
|
||||||
|
min: sum100.value ? 0 : row.min,
|
||||||
|
max: sum100.value ? 100 : row.max,
|
||||||
|
step: row.step > 0 ? row.step : 1,
|
||||||
|
...(sum100.value ? { unit: '%' } : row.unit.trim() ? { unit: row.unit.trim() } : {}),
|
||||||
|
...(row.baseline !== null ? { baseline: row.baseline } : {}),
|
||||||
|
}))
|
||||||
|
if (sum100.value) {
|
||||||
|
params.push({
|
||||||
|
key: 'derived',
|
||||||
|
label: derivedName.value.trim() || 'Réserve (calculé)',
|
||||||
|
kind: 'share',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
unit: '%',
|
||||||
|
baseline: derivedBaseline.value,
|
||||||
|
derived: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { params, constraint: sum100.value ? 'sum100' : 'none' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
emit('update:modelValue', build())
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
if (rows.value.length >= maxRows.value) return
|
||||||
|
rows.value.push(blankRow())
|
||||||
|
sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(index: number) {
|
||||||
|
rows.value.splice(index, 1)
|
||||||
|
if (rows.value.length === 0) rows.value.push(blankRow())
|
||||||
|
sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(sync)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<div class="pe">
|
||||||
|
<div v-for="(row, i) in rows" :key="i" class="pe__row">
|
||||||
|
<input
|
||||||
|
v-model="row.label"
|
||||||
|
class="pe__label"
|
||||||
|
type="text"
|
||||||
|
:placeholder="`Curseur ${i + 1} — libellé métier`"
|
||||||
|
@input="sync"
|
||||||
|
>
|
||||||
|
<div class="pe__nums">
|
||||||
|
<template v-if="!sum100">
|
||||||
|
<label class="pe__num"><span>min</span>
|
||||||
|
<input v-model.number="row.min" type="number" @input="sync">
|
||||||
|
</label>
|
||||||
|
<label class="pe__num"><span>max</span>
|
||||||
|
<input v-model.number="row.max" type="number" @input="sync">
|
||||||
|
</label>
|
||||||
|
<label class="pe__num"><span>pas</span>
|
||||||
|
<input v-model.number="row.step" type="number" min="0" @input="sync">
|
||||||
|
</label>
|
||||||
|
<label class="pe__num"><span>unité</span>
|
||||||
|
<input v-model="row.unit" type="text" placeholder="€, h…" @input="sync">
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
<label class="pe__num"><span>aujourd'hui</span>
|
||||||
|
<input v-model.number="row.baseline" type="number" @input="sync">
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
v-if="rows.length > 1"
|
||||||
|
class="pe__remove"
|
||||||
|
type="button"
|
||||||
|
aria-label="Retirer ce curseur"
|
||||||
|
@click="removeRow(i)"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-x" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="rows.length < maxRows" class="ld-btn ld-btn--quiet pe__add" type="button" @click="addRow">
|
||||||
|
<UIcon name="i-lucide-plus" /> Ajouter un curseur
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label class="pe__sum">
|
||||||
|
<input v-model="sum100" type="checkbox" @change="sync">
|
||||||
|
<span>Répartition 100 % — une part est calculée, jamais votée</span>
|
||||||
|
</label>
|
||||||
|
<div v-if="sum100" class="pe__derived">
|
||||||
|
<input v-model="derivedName" class="pe__label" type="text" @input="sync">
|
||||||
|
<span class="pe__derived-value">calculé — {{ derivedBaseline }} % aujourd'hui</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pe { display: flex; flex-direction: column; gap: 0.625rem; }
|
||||||
|
.pe__row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.625rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: color-mix(in srgb, var(--mood-accent) 5%, transparent);
|
||||||
|
}
|
||||||
|
.pe__label { padding: 0.5rem 0.75rem; font-size: 0.9375rem; font-weight: 600; width: 100%; }
|
||||||
|
.pe__nums { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.5rem; }
|
||||||
|
.pe__num { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.pe__num span {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.pe__num input { width: 5.25rem; padding: 0.375rem 0.5rem; font-size: 0.875rem; }
|
||||||
|
.pe__remove {
|
||||||
|
background: none;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.375rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
min-height: 2.25rem;
|
||||||
|
}
|
||||||
|
.pe__remove:hover { color: var(--mood-error); background: var(--mood-accent-soft); }
|
||||||
|
.pe__add { align-self: flex-start; font-size: 0.875rem; }
|
||||||
|
.pe__sum {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pe__sum input { accent-color: var(--mood-accent); width: 1rem; height: 1rem; }
|
||||||
|
.pe__derived { display: flex; align-items: center; gap: 0.625rem; flex-wrap: wrap; }
|
||||||
|
.pe__derived .pe__label { max-width: 16rem; }
|
||||||
|
.pe__derived-value {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// « Le chemin » — the live card. Big route icon, first-person route name,
|
||||||
|
// the ONE French explanation sentence (never a rule code), the perimeter /
|
||||||
|
// duration / threshold strip, the pre-checked follow-ups, ONE main button.
|
||||||
|
import type { DecisionRoute, Person, Verdict } from '~/types/domain'
|
||||||
|
import { PATH_CARD_TITLE, ROUTE_ICONS, ROUTE_LABELS, URGENT_BADGE } from '~/lexicon'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
path: Verdict
|
||||||
|
routeShown: DecisionRoute
|
||||||
|
overridden: boolean
|
||||||
|
overrideLabel?: string
|
||||||
|
stack: { person: Person; origin?: 'computed' | 'declared'; reason?: string }[]
|
||||||
|
selfOnly: boolean
|
||||||
|
durationLabel: string
|
||||||
|
thresholdLabel: string
|
||||||
|
mainLabel: string
|
||||||
|
reviewChecked: boolean
|
||||||
|
engraveChecked: boolean
|
||||||
|
reviewLocked: boolean
|
||||||
|
disabled: boolean
|
||||||
|
blockReason: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'validate'): void
|
||||||
|
(e: 'update:reviewChecked' | 'update:engraveChecked', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const tint = computed(() => `var(--route-${props.routeShown})`)
|
||||||
|
const showUrgentBadge = computed(
|
||||||
|
() => props.path.conservatoryChain === true,
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<section class="ld-card pc" :style="{ '--pc-tint': tint }">
|
||||||
|
<h2 class="pc__caption">{{ PATH_CARD_TITLE }}</h2>
|
||||||
|
|
||||||
|
<div class="pc__head">
|
||||||
|
<span class="pc__icon"><UIcon :name="ROUTE_ICONS[routeShown]" /></span>
|
||||||
|
<div class="pc__names">
|
||||||
|
<span class="pc__route">{{ ROUTE_LABELS[routeShown] }}</span>
|
||||||
|
<span v-if="overridden" class="pc__override">
|
||||||
|
Tu choisis autrement{{ overrideLabel ? ` — ${overrideLabel}` : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="pc__explanation">{{ path.explanation }}</p>
|
||||||
|
<span v-if="showUrgentBadge" class="status-pill status-advice pc__urgent">
|
||||||
|
<UIcon name="i-lucide-siren" /> {{ URGENT_BADGE }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="pc__strip">
|
||||||
|
<div class="pc__cell">
|
||||||
|
<span class="pc__cell-label">Périmètre</span>
|
||||||
|
<span v-if="selfOnly" class="pc__cell-value">moi seul</span>
|
||||||
|
<LdAvatarStack v-else-if="stack.length > 0" :people="stack" :max="5" :size="24" />
|
||||||
|
<span v-else class="pc__cell-value">à préciser</span>
|
||||||
|
</div>
|
||||||
|
<div class="pc__cell">
|
||||||
|
<span class="pc__cell-label">Durée</span>
|
||||||
|
<span class="pc__cell-value">{{ durationLabel || 'aucune attente' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="thresholdLabel" class="pc__cell">
|
||||||
|
<span class="pc__cell-label">Seuil</span>
|
||||||
|
<span class="pc__cell-value">{{ thresholdLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="path.reviewRequired || path.engravingSuggested" class="pc__checks">
|
||||||
|
<label v-if="path.reviewRequired" class="pc__check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="reviewChecked"
|
||||||
|
:disabled="reviewLocked"
|
||||||
|
@change="emit('update:reviewChecked', ($event.target as HTMLInputElement).checked)"
|
||||||
|
>
|
||||||
|
<span>Revoyure — l'épreuve du réel sera posée</span>
|
||||||
|
</label>
|
||||||
|
<label v-if="path.engravingSuggested" class="pc__check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="engraveChecked"
|
||||||
|
@change="emit('update:engraveChecked', ($event.target as HTMLInputElement).checked)"
|
||||||
|
>
|
||||||
|
<span>Gravure — empreinte locale à l'adoption</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="blockReason" class="pc__block">{{ blockReason }}</p>
|
||||||
|
|
||||||
|
<button class="ld-btn pc__main" type="button" :disabled="disabled" @click="emit('validate')">
|
||||||
|
{{ mainLabel }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pc {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.875rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.pc__caption {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.pc__head { display: flex; align-items: center; gap: 0.875rem; }
|
||||||
|
.pc__icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3.25rem;
|
||||||
|
height: 3.25rem;
|
||||||
|
border-radius: var(--r-icon);
|
||||||
|
font-size: 1.625rem;
|
||||||
|
color: var(--pc-tint);
|
||||||
|
background: color-mix(in srgb, var(--pc-tint) 14%, transparent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pc__names { display: flex; flex-direction: column; gap: 0.125rem; min-width: 0; }
|
||||||
|
.pc__route { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.01em; }
|
||||||
|
.pc__override { font-size: 0.8125rem; font-weight: 600; color: var(--mood-accent); }
|
||||||
|
.pc__explanation { margin: 0; font-size: 1rem; line-height: 1.5; }
|
||||||
|
.pc__urgent { align-self: flex-start; }
|
||||||
|
.pc__strip {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: color-mix(in srgb, var(--pc-tint) 6%, transparent);
|
||||||
|
}
|
||||||
|
.pc__cell { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||||
|
.pc__cell-label {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.pc__cell-value { font-size: 0.875rem; font-weight: 600; }
|
||||||
|
.pc__checks { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.pc__check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pc__check input { accent-color: var(--mood-accent); width: 1rem; height: 1rem; }
|
||||||
|
.pc__block { margin: 0; font-size: 0.8125rem; font-weight: 600; color: var(--mood-error); }
|
||||||
|
.pc__main { align-self: stretch; font-size: 1.0625rem; min-height: 2.75rem; }
|
||||||
|
@media (min-width: 480px) {
|
||||||
|
.pc__main { align-self: flex-start; padding: 0.5rem 2rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Q0 « déjà décidé ? » — inline results while typing, from the ONE search
|
||||||
|
// index (same as Cmd+K). Max 3: standing clauses first, then close decisions.
|
||||||
|
import type { Id } from '~/types/domain'
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
import { useSearch } from '~/composables/useSearch'
|
||||||
|
|
||||||
|
const props = defineProps<{ title: string; linkedClauseId?: Id }>()
|
||||||
|
const emit = defineEmits<{ (e: 'contest', clauseId: Id): void }>()
|
||||||
|
|
||||||
|
const col = useCollectiveStore()
|
||||||
|
const { search } = useSearch()
|
||||||
|
|
||||||
|
interface Q0Hit {
|
||||||
|
kind: 'clause' | 'decision'
|
||||||
|
id: Id
|
||||||
|
label: string
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = computed<Q0Hit[]>(() => {
|
||||||
|
const needle = props.title.trim()
|
||||||
|
if (needle.length < 3) return []
|
||||||
|
const out: Q0Hit[] = []
|
||||||
|
for (const hit of search(needle)) {
|
||||||
|
if (out.length >= 3) break
|
||||||
|
if (hit.kind === 'clause') {
|
||||||
|
const clause = col.clauses.find(c => c.id === hit.id)
|
||||||
|
const doc = col.docs.find(d => d.id === clause?.docId)
|
||||||
|
out.push({
|
||||||
|
kind: 'clause',
|
||||||
|
id: hit.id,
|
||||||
|
label: hit.label,
|
||||||
|
to: doc ? `/textes/${doc.slug}` : '/textes',
|
||||||
|
})
|
||||||
|
} else if (hit.kind === 'decision') {
|
||||||
|
out.push({ kind: 'decision', id: hit.id, label: hit.label, to: `/decisions/${hit.id}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<div v-if="hits.length > 0" class="q0">
|
||||||
|
<div v-for="hit in hits" :key="hit.id" class="q0__row">
|
||||||
|
<template v-if="hit.kind === 'clause'">
|
||||||
|
<UIcon name="i-lucide-book-open-check" class="q0__icon" />
|
||||||
|
<NuxtLink :to="hit.to" class="q0__link">
|
||||||
|
C'est déjà décidé ({{ hit.label }}) — agis
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
class="q0__contest"
|
||||||
|
type="button"
|
||||||
|
:class="{ 'q0__contest--active': linkedClauseId === hit.id }"
|
||||||
|
@click="emit('contest', hit.id)"
|
||||||
|
>
|
||||||
|
Conteste la règle
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<UIcon name="i-lucide-history" class="q0__icon q0__icon--decision" />
|
||||||
|
<NuxtLink :to="hit.to" class="q0__link">
|
||||||
|
Décision proche : {{ hit.label }}
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.q0 {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem 0.25rem 0;
|
||||||
|
}
|
||||||
|
.q0__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.q0__icon { color: var(--mood-accent); flex-shrink: 0; }
|
||||||
|
.q0__icon--decision { color: var(--mood-text-muted); }
|
||||||
|
.q0__link {
|
||||||
|
color: var(--mood-text);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: color-mix(in srgb, var(--mood-accent) 45%, transparent);
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
.q0__link:hover { color: var(--mood-accent); }
|
||||||
|
.q0__contest {
|
||||||
|
background: none;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.q0__contest--active { box-shadow: 0 0 0 2px var(--mood-accent); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// « Concernés » — perimeter editor of the tunnel. The computed first-rank
|
||||||
|
// people are a FLOOR: no individual removal, ever. Widening is free.
|
||||||
|
import type { Decision, Person } from '~/types/domain'
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
scope: Decision['scope']
|
||||||
|
stack: { person: Person; origin?: 'computed' | 'declared'; reason?: string }[]
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ (e: 'update:scope', scope: Decision['scope']): void }>()
|
||||||
|
|
||||||
|
const col = useCollectiveStore()
|
||||||
|
const tappedReason = ref('')
|
||||||
|
|
||||||
|
const others = computed(() => col.people.filter(p => !p.isMe))
|
||||||
|
|
||||||
|
function patch(partial: Partial<Decision['scope']>) {
|
||||||
|
emit('update:scope', {
|
||||||
|
selfOnly: props.scope.selfOnly,
|
||||||
|
circleIds: [...props.scope.circleIds],
|
||||||
|
personIds: [...props.scope.personIds],
|
||||||
|
...partial,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelfOnly(value: boolean) {
|
||||||
|
patch(value ? { selfOnly: true, circleIds: [], personIds: [] } : { selfOnly: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCircle(id: string) {
|
||||||
|
const ids = props.scope.circleIds.includes(id)
|
||||||
|
? props.scope.circleIds.filter(c => c !== id)
|
||||||
|
: [...props.scope.circleIds, id]
|
||||||
|
patch({ selfOnly: false, circleIds: ids })
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePerson(id: string) {
|
||||||
|
const ids = props.scope.personIds.includes(id)
|
||||||
|
? props.scope.personIds.filter(p => p !== id)
|
||||||
|
: [...props.scope.personIds, id]
|
||||||
|
patch({ selfOnly: false, personIds: ids })
|
||||||
|
}
|
||||||
|
|
||||||
|
function showReason(entry: { person: Person; reason?: string }) {
|
||||||
|
tappedReason.value = entry.reason
|
||||||
|
? `${entry.person.displayName} — ${entry.reason}`
|
||||||
|
: entry.person.displayName
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<div class="cs">
|
||||||
|
<div class="cs__modes">
|
||||||
|
<button
|
||||||
|
class="cs__mode"
|
||||||
|
:class="{ 'cs__mode--active': scope.selfOnly }"
|
||||||
|
type="button"
|
||||||
|
@click="setSelfOnly(true)"
|
||||||
|
>
|
||||||
|
Moi seul
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="cs__mode"
|
||||||
|
:class="{ 'cs__mode--active': !scope.selfOnly }"
|
||||||
|
type="button"
|
||||||
|
@click="setSelfOnly(false)"
|
||||||
|
>
|
||||||
|
D'autres que moi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="!scope.selfOnly">
|
||||||
|
<div v-if="stack.length > 0" class="cs__stack">
|
||||||
|
<LdAvatarStack :people="stack" :max="8" :size="30" @tap="showReason" />
|
||||||
|
<span class="cs__count">{{ stack.length }} concerné·e·s</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="tappedReason" class="cs__reason">{{ tappedReason }}</p>
|
||||||
|
|
||||||
|
<div v-if="col.circles.length > 0" class="cs__group">
|
||||||
|
<span class="cs__label">Cercles</span>
|
||||||
|
<div class="cs__chips">
|
||||||
|
<button
|
||||||
|
v-for="circle in col.circles"
|
||||||
|
:key="circle.id"
|
||||||
|
class="cs__chip"
|
||||||
|
:class="{ 'cs__chip--on': scope.circleIds.includes(circle.id) }"
|
||||||
|
type="button"
|
||||||
|
@click="toggleCircle(circle.id)"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
:name="circle.kind === 'place' ? 'i-lucide-map-pin'
|
||||||
|
: circle.kind === 'theme' ? 'i-lucide-tag' : 'i-lucide-users'"
|
||||||
|
/>
|
||||||
|
<span>{{ circle.name }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="others.length > 0" class="cs__group">
|
||||||
|
<span class="cs__label">Personnes nommées</span>
|
||||||
|
<div class="cs__chips">
|
||||||
|
<button
|
||||||
|
v-for="person in others"
|
||||||
|
:key="person.id"
|
||||||
|
class="cs__chip"
|
||||||
|
:class="{ 'cs__chip--on': scope.personIds.includes(person.id) }"
|
||||||
|
type="button"
|
||||||
|
@click="togglePerson(person.id)"
|
||||||
|
>
|
||||||
|
{{ person.displayName }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="cs__floor">
|
||||||
|
On ne retire pas un·e concerné·e en premier lieu — le périmètre s'élargit librement,
|
||||||
|
jamais en dessous du calcul.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cs { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.cs__modes { display: flex; gap: 0.5rem; }
|
||||||
|
.cs__mode {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--mood-input-bg);
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cs__mode--active { background: var(--mood-accent); color: var(--mood-accent-text); }
|
||||||
|
.cs__stack { display: flex; align-items: center; gap: 0.625rem; }
|
||||||
|
.cs__count { font-size: 0.8125rem; font-weight: 600; color: var(--mood-text-muted); }
|
||||||
|
.cs__reason { margin: 0; font-size: 0.8125rem; color: var(--mood-accent); }
|
||||||
|
.cs__group { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.cs__label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.cs__chips { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||||
|
.cs__chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--mood-input-bg);
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
}
|
||||||
|
.cs__chip--on {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
box-shadow: 0 0 0 1.5px var(--mood-accent);
|
||||||
|
}
|
||||||
|
.cs__floor {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// R6 / maturation overlay under the path card — a discreet suggestion,
|
||||||
|
// never blocking, never re-routing.
|
||||||
|
import type { Verdict } from '~/types/domain'
|
||||||
|
import { MATURATION_CARD } from '~/lexicon'
|
||||||
|
|
||||||
|
defineProps<{ suggestion: NonNullable<Verdict['suggestion']> }>()
|
||||||
|
const mandateAttached = defineModel<boolean>('mandateAttached', { required: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<div class="ld-card sug">
|
||||||
|
<template v-if="suggestion.kind === 'claim-mandate'">
|
||||||
|
<UIcon name="i-lucide-repeat" class="sug__icon" />
|
||||||
|
<span>Encore cette décision — réclame un mandat pour ne plus y revenir.</span>
|
||||||
|
<label class="sug__check">
|
||||||
|
<input v-model="mandateAttached" type="checkbox">
|
||||||
|
<span>Joindre la demande de mandat</span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="suggestion.kind === 'protocolize'">
|
||||||
|
<UIcon name="i-lucide-sprout" class="sug__icon" />
|
||||||
|
<NuxtLink to="/textes" class="sug__link">{{ MATURATION_CARD }}</NuxtLink>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<UIcon name="i-lucide-book-open-check" class="sug__icon" />
|
||||||
|
<NuxtLink to="/textes" class="sug__link">
|
||||||
|
Cette pratique revient — crée une règle en quelques phrases.
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sug {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.sug__icon { font-size: 1.125rem; color: var(--mood-accent); flex-shrink: 0; }
|
||||||
|
.sug__check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
}
|
||||||
|
.sug__check input { accent-color: var(--mood-accent); }
|
||||||
|
.sug__link { color: var(--mood-text); }
|
||||||
|
.sug__link:hover { color: var(--mood-accent); }
|
||||||
|
</style>
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps<{
|
|
||||||
diff: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
interface DiffLine {
|
|
||||||
text: string
|
|
||||||
type: 'added' | 'removed' | 'header' | 'context'
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedLines = computed((): DiffLine[] => {
|
|
||||||
if (!props.diff) return []
|
|
||||||
|
|
||||||
return props.diff.split('\n').map((line) => {
|
|
||||||
if (line.startsWith('@@')) {
|
|
||||||
return { text: line, type: 'header' as const }
|
|
||||||
}
|
|
||||||
if (line.startsWith('+')) {
|
|
||||||
return { text: line, type: 'added' as const }
|
|
||||||
}
|
|
||||||
if (line.startsWith('-')) {
|
|
||||||
return { text: line, type: 'removed' as const }
|
|
||||||
}
|
|
||||||
return { text: line, type: 'context' as const }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
function lineClass(type: DiffLine['type']): string {
|
|
||||||
switch (type) {
|
|
||||||
case 'added':
|
|
||||||
return 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
|
||||||
case 'removed':
|
|
||||||
return 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
|
||||||
case 'header':
|
|
||||||
return 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 font-semibold'
|
|
||||||
default:
|
|
||||||
return 'text-gray-700 dark:text-gray-300'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
||||||
<pre class="text-xs font-mono leading-relaxed overflow-x-auto"><template
|
|
||||||
v-for="(line, index) in parsedLines"
|
|
||||||
:key="index"
|
|
||||||
><div
|
|
||||||
:class="['px-4 py-0.5', lineClass(line.type)]"
|
|
||||||
>{{ line.text }}</div></template></pre>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Error boundary component.
|
|
||||||
*
|
|
||||||
* Wraps slot content with NuxtErrorBoundary and displays a user-friendly
|
|
||||||
* error message in French when child components crash.
|
|
||||||
* Logs error details to console and emits error event for monitoring.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
error: [error: any]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const hasError = ref(false)
|
|
||||||
const errorDetails = ref<string | null>(null)
|
|
||||||
|
|
||||||
function handleError(error: any) {
|
|
||||||
hasError.value = true
|
|
||||||
errorDetails.value = error?.message || error?.toString() || 'Erreur inconnue'
|
|
||||||
|
|
||||||
// Log to console for debugging
|
|
||||||
console.error('[ErrorBoundary] Erreur capturee:', error)
|
|
||||||
|
|
||||||
// Emit for external monitoring
|
|
||||||
emit('error', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
function retry() {
|
|
||||||
hasError.value = false
|
|
||||||
errorDetails.value = null
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NuxtErrorBoundary @error="handleError">
|
|
||||||
<template v-if="!hasError">
|
|
||||||
<slot />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
|
||||||
<div class="max-w-md space-y-4">
|
|
||||||
<UIcon
|
|
||||||
name="i-lucide-alert-triangle"
|
|
||||||
class="text-5xl text-warning mx-auto"
|
|
||||||
/>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
Une erreur est survenue
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Un probleme inattendu s'est produit lors du chargement de ce contenu.
|
|
||||||
Vous pouvez essayer de recharger cette section.
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-if="errorDetails"
|
|
||||||
class="text-xs text-gray-400 dark:text-gray-500 font-mono bg-gray-100 dark:bg-gray-800 p-2 rounded"
|
|
||||||
>
|
|
||||||
{{ errorDetails }}
|
|
||||||
</p>
|
|
||||||
<UButton
|
|
||||||
icon="i-lucide-refresh-cw"
|
|
||||||
label="Reessayer"
|
|
||||||
color="primary"
|
|
||||||
variant="soft"
|
|
||||||
@click="retry"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NuxtErrorBoundary>
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> Stacked initials avatars — deterministic tint from id, -8px overlap.
|
||||||
|
// origin glyphs: 'computed' = full dot (concerné·e en premier lieu),
|
||||||
|
// 'declared' = raised hand (en second lieu). Tap → parent shows inclusion reason.
|
||||||
|
import type { Person } from '~/types/domain'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
people: { person: Person; origin?: 'computed' | 'declared'; reason?: string }[]
|
||||||
|
max?: number
|
||||||
|
size?: number
|
||||||
|
}>(), { max: 6, size: 30 })
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'tap', entry: { person: Person; origin?: string; reason?: string }): void }>()
|
||||||
|
|
||||||
|
const visible = computed(() => props.people.slice(0, props.max))
|
||||||
|
const overflow = computed(() => Math.max(0, props.people.length - props.max))
|
||||||
|
|
||||||
|
function initials(name: string): string {
|
||||||
|
return name.split(/[\s-]+/).map(w => w[0] ?? '').join('').slice(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
function tint(id: string): string {
|
||||||
|
let h = 0
|
||||||
|
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) % 360
|
||||||
|
return `oklch(0.72 0.09 ${h})`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<div class="ld-avatars" :style="{ '--av-size': size + 'px' }">
|
||||||
|
<button
|
||||||
|
v-for="entry in visible"
|
||||||
|
:key="entry.person.id"
|
||||||
|
class="ld-avatars__item"
|
||||||
|
:style="{ background: tint(entry.person.id) }"
|
||||||
|
:title="entry.reason ? `${entry.person.displayName} — ${entry.reason}` : entry.person.displayName"
|
||||||
|
type="button"
|
||||||
|
@click="emit('tap', entry)"
|
||||||
|
>
|
||||||
|
<span>{{ initials(entry.person.displayName) }}</span>
|
||||||
|
<span v-if="entry.origin === 'declared'" class="ld-avatars__glyph">
|
||||||
|
<UIcon name="i-lucide-hand" />
|
||||||
|
</span>
|
||||||
|
<span v-else-if="entry.origin === 'computed'" class="ld-avatars__dot" />
|
||||||
|
</button>
|
||||||
|
<span v-if="overflow > 0" class="ld-avatars__more">+{{ overflow }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ld-avatars { display: inline-flex; align-items: center; }
|
||||||
|
.ld-avatars__item {
|
||||||
|
position: relative;
|
||||||
|
width: var(--av-size);
|
||||||
|
height: var(--av-size);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: calc(var(--av-size) * 0.36);
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
margin-left: -8px;
|
||||||
|
box-shadow: 0 0 0 2px var(--mood-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
.ld-avatars__item:first-child { margin-left: 0; }
|
||||||
|
.ld-avatars__item:hover { transform: translateY(-1px); z-index: 1; }
|
||||||
|
.ld-avatars__glyph {
|
||||||
|
position: absolute;
|
||||||
|
right: -3px;
|
||||||
|
bottom: -3px;
|
||||||
|
width: calc(var(--av-size) * 0.48);
|
||||||
|
height: calc(var(--av-size) * 0.48);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mood-surface);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: calc(var(--av-size) * 0.3);
|
||||||
|
}
|
||||||
|
.ld-avatars__dot {
|
||||||
|
position: absolute;
|
||||||
|
right: -1px;
|
||||||
|
bottom: -1px;
|
||||||
|
width: calc(var(--av-size) * 0.28);
|
||||||
|
height: calc(var(--av-size) * 0.28);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mood-accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--mood-surface);
|
||||||
|
}
|
||||||
|
.ld-avatars__more {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> Window countdown chip. Suspended (boundary objection) shows the
|
||||||
|
// pause state — the most important state to make visible.
|
||||||
|
import { BOUNDARY_SUSPENDED } from '~/lexicon'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
endsAt?: string
|
||||||
|
suspendedAt?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const label = computed(() => {
|
||||||
|
if (props.suspendedAt) return BOUNDARY_SUSPENDED
|
||||||
|
if (!props.endsAt) return ''
|
||||||
|
const ms = new Date(props.endsAt).getTime() - Date.now()
|
||||||
|
if (ms <= 0) return 'échéance passée'
|
||||||
|
const h = Math.floor(ms / 3_600_000)
|
||||||
|
if (h < 1) return `${Math.max(1, Math.floor(ms / 60_000))} min restantes`
|
||||||
|
if (h < 48) return `${h} h restantes`
|
||||||
|
return `${Math.floor(h / 24)} jours restants`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<span class="ld-countdown" :class="{ 'ld-countdown--paused': suspendedAt }">
|
||||||
|
<UIcon :name="suspendedAt ? 'i-lucide-pause' : 'i-lucide-timer'" />
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ld-countdown {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-status-fenetre);
|
||||||
|
background: var(--mood-status-fenetre-bg);
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
}
|
||||||
|
.ld-countdown--paused {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
-45deg,
|
||||||
|
var(--mood-status-fenetre-bg),
|
||||||
|
var(--mood-status-fenetre-bg) 6px,
|
||||||
|
transparent 6px,
|
||||||
|
transparent 10px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/** Quatre pastilles rondes — une par ambiance. Ring accent sur l'actif. */
|
||||||
|
const { moods, currentMood, setMood } = useLibreMood()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<div class="mood-switcher" role="group" aria-label="Ambiance">
|
||||||
|
<UTooltip v-for="m in moods" :key="m.id" :text="m.label">
|
||||||
|
<button
|
||||||
|
class="mood-dot"
|
||||||
|
:class="{ 'mood-dot--active': currentMood === m.id }"
|
||||||
|
:style="{ background: m.color }"
|
||||||
|
:aria-label="m.label"
|
||||||
|
:aria-pressed="currentMood === m.id"
|
||||||
|
@click="setMood(m.id)"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mood-switcher {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood-dot {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, 0.35), 0 1px 3px var(--mood-shadow);
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
.mood-dot:hover {
|
||||||
|
transform: translateY(-1px) scale(1.12);
|
||||||
|
}
|
||||||
|
.mood-dot:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.mood-dot--active {
|
||||||
|
box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, 0.35),
|
||||||
|
0 0 0 2px var(--mood-surface),
|
||||||
|
0 0 0 4px var(--mood-accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Sceau hexagramme #48 Tsing 井 — géométrie SVG canonique.
|
||||||
|
* viewBox 112×105, 6 traits (trait 1 en bas = dernier rect), coins vifs.
|
||||||
|
* Réutilisable : stamp du logo (miniature), sceau de page (.app-seal), badges.
|
||||||
|
*/
|
||||||
|
const props = withDefaults(defineProps<{ size?: number }>(), { size: 44 })
|
||||||
|
|
||||||
|
const height = computed(() => Math.round((props.size * 105) / 112))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<svg
|
||||||
|
:width="size"
|
||||||
|
:height="height"
|
||||||
|
viewBox="0 0 112 105"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<!-- Trait 6 (haut) — yin -->
|
||||||
|
<rect x="6" y="6" width="42" height="8" />
|
||||||
|
<rect x="64" y="6" width="42" height="8" />
|
||||||
|
<!-- Trait 5 — yang -->
|
||||||
|
<rect x="6" y="23" width="100" height="8" />
|
||||||
|
<!-- Trait 4 — yin -->
|
||||||
|
<rect x="6" y="40" width="42" height="8" />
|
||||||
|
<rect x="64" y="40" width="42" height="8" />
|
||||||
|
<!-- Trait 3 — yang -->
|
||||||
|
<rect x="6" y="57" width="100" height="8" />
|
||||||
|
<!-- Trait 2 — yang -->
|
||||||
|
<rect x="6" y="74" width="100" height="8" />
|
||||||
|
<!-- Trait 1 (bas) — yin -->
|
||||||
|
<rect x="6" y="91" width="42" height="8" />
|
||||||
|
<rect x="64" y="91" width="42" height="8" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Sélecteur de collectif — pastille couleur + nom + finalité (clause A1 du Pacte)
|
||||||
|
* en une ligne. Menu léger : collectifs locaux, « Créer un collectif », « Données locales ».
|
||||||
|
* Accès défensifs (?.) : le store peut ne pas être hydraté.
|
||||||
|
*/
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
|
||||||
|
const store = useCollectiveStore()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const rootEl = ref<HTMLElement | null>(null)
|
||||||
|
onClickOutside(rootEl, () => { open.value = false })
|
||||||
|
watch(() => route.path, () => { open.value = false })
|
||||||
|
|
||||||
|
const current = computed(() => store.current?.collective ?? null)
|
||||||
|
const index = computed(() => store.index ?? [])
|
||||||
|
|
||||||
|
/** Finalité du collectif = contenu de la version courante de la clause A1 du Pacte. */
|
||||||
|
const purpose = computed(() => {
|
||||||
|
const state = store.current
|
||||||
|
if (!state) return ''
|
||||||
|
const clause = state.clauses?.find(c => c.code === 'A1')
|
||||||
|
if (!clause?.currentVersionId) return ''
|
||||||
|
return state.versions?.find(v => v.id === clause.currentVersionId)?.content ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function pick(id: string) {
|
||||||
|
open.value = false
|
||||||
|
if (id !== store.activeId) await store.switchTo?.(id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<div ref="rootEl" class="ws">
|
||||||
|
<!-- Aucun collectif : inviter à créer -->
|
||||||
|
<NuxtLink v-if="!current" to="/creer" class="ld-btn ld-btn--ghost ws__create">
|
||||||
|
<UIcon name="i-lucide-sparkles" />
|
||||||
|
<span>Créer</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
class="ws__trigger"
|
||||||
|
:aria-expanded="open"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
@click="open = !open"
|
||||||
|
>
|
||||||
|
<span class="ws__dot" :style="{ background: current.color }" />
|
||||||
|
<span class="ws__text">
|
||||||
|
<span class="ws__name">{{ current.name }}</span>
|
||||||
|
<span v-if="purpose" class="ws__purpose">{{ purpose }}</span>
|
||||||
|
</span>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-chevron-down"
|
||||||
|
class="ws__chevron"
|
||||||
|
:class="{ 'ws__chevron--open': open }"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Transition name="ws-pop">
|
||||||
|
<div v-if="open" class="ws__menu ld-card" role="menu">
|
||||||
|
<button
|
||||||
|
v-for="entry in index"
|
||||||
|
:key="entry.id"
|
||||||
|
class="ws__item"
|
||||||
|
:class="{ 'ws__item--active': entry.id === store.activeId }"
|
||||||
|
role="menuitem"
|
||||||
|
@click="pick(entry.id)"
|
||||||
|
>
|
||||||
|
<span class="ws__dot" :style="{ background: entry.color }" />
|
||||||
|
<span class="ws__item-name">{{ entry.name }}</span>
|
||||||
|
<UIcon
|
||||||
|
v-if="entry.id === store.activeId"
|
||||||
|
name="i-lucide-check"
|
||||||
|
class="ws__item-check"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="ws__sep" />
|
||||||
|
|
||||||
|
<NuxtLink to="/creer" class="ws__item" role="menuitem">
|
||||||
|
<UIcon name="i-lucide-sparkles" class="ws__item-icon" />
|
||||||
|
<span class="ws__item-name">Créer un collectif</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/donnees" class="ws__item" role="menuitem">
|
||||||
|
<UIcon name="i-lucide-hard-drive" class="ws__item-icon" />
|
||||||
|
<span class="ws__item-name">Données locales</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ws {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__create {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
max-width: 20rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: none;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
.ws__trigger:hover {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__dot {
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 1px 3px var(--mood-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__name {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__purpose {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__chevron {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.ws__chevron--open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Menu --- */
|
||||||
|
.ws__menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.5rem);
|
||||||
|
left: 0;
|
||||||
|
z-index: 40;
|
||||||
|
min-width: 15rem;
|
||||||
|
max-width: 20rem;
|
||||||
|
padding: 0.375rem;
|
||||||
|
box-shadow: var(--shadow-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: none;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
.ws__item:hover {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
}
|
||||||
|
.ws__item--active {
|
||||||
|
color: var(--mood-accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__item-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__item-check {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__item-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__sep {
|
||||||
|
height: 1px;
|
||||||
|
margin: 0.375rem 0.5rem;
|
||||||
|
background: color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Transition --- */
|
||||||
|
.ws-pop-enter-active,
|
||||||
|
.ws-pop-leave-active {
|
||||||
|
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||||
|
}
|
||||||
|
.ws-pop-enter-from,
|
||||||
|
.ws-pop-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Reusable skeleton loader component.
|
|
||||||
*
|
|
||||||
* Provides multiple skeleton variants for loading states:
|
|
||||||
* - Card: card layout with title and content lines
|
|
||||||
* - List: multiple rows with optional avatar
|
|
||||||
* - Detail: detailed view with mixed content
|
|
||||||
*
|
|
||||||
* Uses Nuxt UI USkeleton components.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
/** Number of skeleton lines to display (default: 3). */
|
|
||||||
lines?: number
|
|
||||||
/** Show an avatar circle placeholder. */
|
|
||||||
avatar?: boolean
|
|
||||||
/** Render as a card skeleton with border and padding. */
|
|
||||||
card?: boolean
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
lines: 3,
|
|
||||||
avatar: false,
|
|
||||||
card: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Generate varying line widths for a natural appearance. */
|
|
||||||
const lineWidths = computed(() => {
|
|
||||||
const widths = ['w-full', 'w-3/4', 'w-5/6', 'w-2/3', 'w-4/5']
|
|
||||||
return Array.from({ length: props.lines }, (_, i) => widths[i % widths.length])
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- Card variant -->
|
|
||||||
<UCard v-if="card" class="animate-pulse">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- Optional avatar row -->
|
|
||||||
<div v-if="avatar" class="flex items-center gap-3">
|
|
||||||
<USkeleton class="h-10 w-10 rounded-full" />
|
|
||||||
<div class="flex-1 space-y-2">
|
|
||||||
<USkeleton class="h-4 w-1/3" />
|
|
||||||
<USkeleton class="h-3 w-1/4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Title line -->
|
|
||||||
<USkeleton class="h-5 w-2/3" />
|
|
||||||
|
|
||||||
<!-- Content lines -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<USkeleton
|
|
||||||
v-for="(width, i) in lineWidths"
|
|
||||||
:key="i"
|
|
||||||
class="h-3"
|
|
||||||
:class="width"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<!-- List / default variant -->
|
|
||||||
<div v-else class="space-y-3 animate-pulse">
|
|
||||||
<div
|
|
||||||
v-for="(width, i) in lineWidths"
|
|
||||||
:key="i"
|
|
||||||
class="flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<!-- Optional avatar per line -->
|
|
||||||
<USkeleton v-if="avatar" class="h-8 w-8 rounded-full flex-shrink-0" />
|
|
||||||
|
|
||||||
<!-- Line content -->
|
|
||||||
<div class="flex-1 space-y-1">
|
|
||||||
<USkeleton class="h-3" :class="width" />
|
|
||||||
<USkeleton v-if="i === 0" class="h-2 w-1/4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const { currentMood, moods, setMood } = useLibreMood()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="mood-switcher" role="radiogroup" aria-label="Ambiance visuelle">
|
|
||||||
<UTooltip
|
|
||||||
v-for="mood in moods"
|
|
||||||
:key="mood.id"
|
|
||||||
:text="mood.label"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
:aria-checked="currentMood === mood.id"
|
|
||||||
:aria-label="`Ambiance ${mood.label}`"
|
|
||||||
class="mood-dot"
|
|
||||||
:class="{ 'mood-dot--active': currentMood === mood.id }"
|
|
||||||
:style="{ '--dot-color': mood.color }"
|
|
||||||
@click="setMood(mood.id)"
|
|
||||||
/>
|
|
||||||
</UTooltip>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.mood-switcher {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mood-dot {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
background: var(--dot-color, var(--mood-text-muted));
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mood-dot:hover {
|
|
||||||
transform: scale(1.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mood-dot--active {
|
|
||||||
border-color: var(--mood-text);
|
|
||||||
box-shadow: 0 0 0 2px var(--mood-bg), 0 0 0 3px var(--mood-text-muted);
|
|
||||||
transform: scale(1.15);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Offline detection banner.
|
|
||||||
*
|
|
||||||
* Uses navigator.onLine and online/offline events to detect
|
|
||||||
* network connectivity changes. Shows a warning banner when
|
|
||||||
* offline and a brief success message when back online.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const isOnline = ref(true)
|
|
||||||
const showReconnected = ref(false)
|
|
||||||
|
|
||||||
let reconnectedTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
function handleOffline() {
|
|
||||||
isOnline.value = false
|
|
||||||
showReconnected.value = false
|
|
||||||
if (reconnectedTimer) {
|
|
||||||
clearTimeout(reconnectedTimer)
|
|
||||||
reconnectedTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOnline() {
|
|
||||||
isOnline.value = true
|
|
||||||
showReconnected.value = true
|
|
||||||
|
|
||||||
// Show "reconnected" message briefly then hide
|
|
||||||
reconnectedTimer = setTimeout(() => {
|
|
||||||
showReconnected.value = false
|
|
||||||
reconnectedTimer = null
|
|
||||||
}, 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
isOnline.value = navigator.onLine
|
|
||||||
window.addEventListener('offline', handleOffline)
|
|
||||||
window.addEventListener('online', handleOnline)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener('offline', handleOffline)
|
|
||||||
window.removeEventListener('online', handleOnline)
|
|
||||||
if (reconnectedTimer) {
|
|
||||||
clearTimeout(reconnectedTimer)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Transition name="slide-down">
|
|
||||||
<div
|
|
||||||
v-if="!isOnline"
|
|
||||||
class="bg-warning-100 dark:bg-warning-900/50 border-b border-warning-300 dark:border-warning-700 px-4 py-2 text-center"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-center gap-2 text-sm text-warning-800 dark:text-warning-200">
|
|
||||||
<UIcon name="i-lucide-wifi-off" class="text-lg flex-shrink-0" />
|
|
||||||
<span>Vous etes hors ligne. Certaines fonctionnalites sont indisponibles.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<Transition name="slide-down">
|
|
||||||
<div
|
|
||||||
v-if="showReconnected && isOnline"
|
|
||||||
class="bg-success-100 dark:bg-success-900/50 border-b border-success-300 dark:border-success-700 px-4 py-2 text-center"
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-center gap-2 text-sm text-success-800 dark:text-success-200">
|
|
||||||
<UIcon name="i-lucide-wifi" class="text-lg flex-shrink-0" />
|
|
||||||
<span>Connexion retablie</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.slide-down-enter-active,
|
|
||||||
.slide-down-leave-active {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-down-enter-from,
|
|
||||||
.slide-down-leave-to {
|
|
||||||
transform: translateY(-100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-down-enter-to,
|
|
||||||
.slide-down-leave-from {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
status: string
|
|
||||||
type?: 'document' | 'decision' | 'mandate' | 'vote' | 'version'
|
|
||||||
clickable?: boolean
|
|
||||||
active?: boolean
|
|
||||||
}>(), {
|
|
||||||
clickable: true,
|
|
||||||
active: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
click: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, { label: string; cssClass: string }> = {
|
|
||||||
// Universal statuses
|
|
||||||
draft: { label: 'En prepa', cssClass: 'status-prepa' },
|
|
||||||
active: { label: 'En vigueur', cssClass: 'status-vigueur' },
|
|
||||||
closed: { label: 'Clos', cssClass: 'status-clos' },
|
|
||||||
|
|
||||||
// Decision/vote specific
|
|
||||||
qualification: { label: 'En prepa', cssClass: 'status-prepa' },
|
|
||||||
review: { label: 'En prepa', cssClass: 'status-prepa' },
|
|
||||||
voting: { label: 'En vote', cssClass: 'status-vote' },
|
|
||||||
open: { label: 'En vote', cssClass: 'status-vote' },
|
|
||||||
executed: { label: 'En vigueur', cssClass: 'status-vigueur' },
|
|
||||||
|
|
||||||
// Version specific
|
|
||||||
pending: { label: 'En prepa', cssClass: 'status-prepa' },
|
|
||||||
accepted: { label: 'En vigueur', cssClass: 'status-vigueur' },
|
|
||||||
rejected: { label: 'Clos', cssClass: 'status-clos' },
|
|
||||||
|
|
||||||
// Mandate specific
|
|
||||||
formulation: { label: 'En prepa', cssClass: 'status-prepa' },
|
|
||||||
candidature: { label: 'En prepa', cssClass: 'status-prepa' },
|
|
||||||
candidacy: { label: 'En prepa', cssClass: 'status-prepa' },
|
|
||||||
investiture: { label: 'En vote', cssClass: 'status-vote' },
|
|
||||||
revoked: { label: 'Clos', cssClass: 'status-clos' },
|
|
||||||
completed: { label: 'Clos', cssClass: 'status-clos' },
|
|
||||||
archived: { label: 'Clos', cssClass: 'status-clos' },
|
|
||||||
reporting: { label: 'En vote', cssClass: 'status-vote' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = computed(() => {
|
|
||||||
return STATUS_MAP[props.status] ?? { label: props.status, cssClass: 'status-prepa' }
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleClick() {
|
|
||||||
if (props.clickable) {
|
|
||||||
emit('click')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<button
|
|
||||||
v-if="clickable"
|
|
||||||
type="button"
|
|
||||||
class="status-pill"
|
|
||||||
:class="[resolved.cssClass, { active: active }]"
|
|
||||||
@click="handleClick"
|
|
||||||
>
|
|
||||||
{{ resolved.label }}
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="status-pill"
|
|
||||||
:class="[resolved.cssClass]"
|
|
||||||
>
|
|
||||||
{{ resolved.label }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* ToolboxVignette — Carte compacte, collapsible, bullet points + actions.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ToolboxAction {
|
|
||||||
label: string
|
|
||||||
icon?: string
|
|
||||||
to?: string
|
|
||||||
emit?: string
|
|
||||||
primary?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
title: string
|
|
||||||
bullets?: string[]
|
|
||||||
actions?: ToolboxAction[]
|
|
||||||
defaultOpen?: boolean
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
bullets: undefined,
|
|
||||||
actions: undefined,
|
|
||||||
defaultOpen: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
action: [actionEmit: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const open = ref(props.defaultOpen)
|
|
||||||
|
|
||||||
const defaultActions: ToolboxAction[] = [
|
|
||||||
{ label: 'Tutos', icon: 'i-lucide-graduation-cap', emit: 'tutos' },
|
|
||||||
{ label: 'Formules', icon: 'i-lucide-calculator', emit: 'formules' },
|
|
||||||
{ label: 'Demarrer', icon: 'i-lucide-play', emit: 'demarrer', primary: true },
|
|
||||||
]
|
|
||||||
|
|
||||||
const resolvedActions = computed(() => props.actions ?? defaultActions)
|
|
||||||
|
|
||||||
function handleAction(action: ToolboxAction) {
|
|
||||||
if (action.to) {
|
|
||||||
navigateTo(action.to)
|
|
||||||
}
|
|
||||||
else if (action.emit) {
|
|
||||||
emit('action', action.emit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="vignette" :class="{ 'vignette--open': open }">
|
|
||||||
<button class="vignette__header" @click="open = !open">
|
|
||||||
<h4 class="vignette__title">{{ title }}</h4>
|
|
||||||
<UIcon name="i-lucide-chevron-down" class="vignette__chevron" />
|
|
||||||
</button>
|
|
||||||
<div v-show="open" class="vignette__content">
|
|
||||||
<ul v-if="bullets && bullets.length > 0" class="vignette__bullets">
|
|
||||||
<li v-for="(b, i) in bullets" :key="i">{{ b }}</li>
|
|
||||||
</ul>
|
|
||||||
<div class="vignette__actions">
|
|
||||||
<button
|
|
||||||
v-for="action in resolvedActions"
|
|
||||||
:key="action.label"
|
|
||||||
class="vignette__btn"
|
|
||||||
:class="{ 'vignette__btn--primary': action.primary }"
|
|
||||||
@click="handleAction(action)"
|
|
||||||
>
|
|
||||||
<UIcon v-if="action.icon" :name="action.icon" />
|
|
||||||
<span>{{ action.label }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.vignette {
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.625rem 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
gap: 0.375rem;
|
|
||||||
transition: background 0.12s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette__header:hover {
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 8%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette__title {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-text);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette__chevron {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
opacity: 0.5;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette--open .vignette__chevron {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
opacity: 1;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette__content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0 0.75rem 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette__bullets {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0 0 0 1rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
line-height: 1.5;
|
|
||||||
list-style-type: disc;
|
|
||||||
}
|
|
||||||
.vignette__bullets li::marker {
|
|
||||||
color: var(--mood-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette__actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.375rem;
|
|
||||||
margin-top: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette__btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.5rem 0.875rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
background: var(--mood-surface);
|
|
||||||
border-radius: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
|
||||||
min-height: 2.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.vignette__btn {
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
min-height: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette__btn:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 2px 8px var(--mood-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette__btn:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette__btn--primary {
|
|
||||||
color: var(--mood-accent-text);
|
|
||||||
background: var(--mood-accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Cadrage form component for creating or editing a decision.
|
|
||||||
*
|
|
||||||
* Provides all fields needed for the initial decision setup:
|
|
||||||
* title, description, context, decision type, and voting protocol.
|
|
||||||
*/
|
|
||||||
import type { DecisionCreate } from '~/stores/decisions'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: DecisionCreate
|
|
||||||
submitting?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: DecisionCreate]
|
|
||||||
'submit': []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const decisionTypeOptions = [
|
|
||||||
{ label: 'Runtime upgrade', value: 'runtime_upgrade' },
|
|
||||||
{ label: 'Modification de document', value: 'document_change' },
|
|
||||||
{ label: 'Vote de mandat', value: 'mandate_vote' },
|
|
||||||
{ label: 'Changement de parametre', value: 'parameter_change' },
|
|
||||||
{ label: 'Autre', value: 'other' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function updateField<K extends keyof DecisionCreate>(field: K, value: DecisionCreate[K]) {
|
|
||||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = computed(() => {
|
|
||||||
return props.modelValue.title?.trim() && props.modelValue.decision_type
|
|
||||||
})
|
|
||||||
|
|
||||||
function onSubmit() {
|
|
||||||
if (isValid.value) {
|
|
||||||
emit('submit')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<form class="space-y-6" @submit.prevent="onSubmit">
|
|
||||||
<!-- Titre -->
|
|
||||||
<div class="space-y-1">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Titre <span class="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<UInput
|
|
||||||
:model-value="modelValue.title"
|
|
||||||
placeholder="Titre de la decision..."
|
|
||||||
required
|
|
||||||
@update:model-value="updateField('title', $event as string)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<div class="space-y-1">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Description <span class="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<UTextarea
|
|
||||||
:model-value="modelValue.description ?? ''"
|
|
||||||
placeholder="Decrivez l'objet de cette decision..."
|
|
||||||
:rows="4"
|
|
||||||
@update:model-value="updateField('description', $event as string)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contexte -->
|
|
||||||
<div class="space-y-1">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Contexte
|
|
||||||
</label>
|
|
||||||
<UTextarea
|
|
||||||
:model-value="modelValue.context ?? ''"
|
|
||||||
placeholder="Contexte, motivations, liens utiles..."
|
|
||||||
:rows="3"
|
|
||||||
@update:model-value="updateField('context', $event as string)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Type de decision -->
|
|
||||||
<div class="space-y-1">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Type de decision <span class="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<USelect
|
|
||||||
:model-value="modelValue.decision_type"
|
|
||||||
:items="decisionTypeOptions"
|
|
||||||
placeholder="Selectionnez un type..."
|
|
||||||
@update:model-value="updateField('decision_type', $event as string)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Protocole de vote -->
|
|
||||||
<div class="space-y-1">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Protocole de vote
|
|
||||||
</label>
|
|
||||||
<ProtocolPicker
|
|
||||||
:model-value="modelValue.voting_protocol_id ?? null"
|
|
||||||
@update:model-value="updateField('voting_protocol_id', $event)"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Optionnel. Peut etre defini ulterieurement pour chaque etape.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submit -->
|
|
||||||
<div class="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<UButton
|
|
||||||
type="submit"
|
|
||||||
label="Creer la decision"
|
|
||||||
icon="i-lucide-plus"
|
|
||||||
color="primary"
|
|
||||||
:loading="submitting"
|
|
||||||
:disabled="!isValid"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Card component for displaying a decision in a list.
|
|
||||||
*
|
|
||||||
* Shows title, type badge, status badge, step count, and creation date.
|
|
||||||
* Navigates to the decision detail page on click.
|
|
||||||
*/
|
|
||||||
import type { Decision } from '~/stores/decisions'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
decision: Decision
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const typeLabel = (decisionType: string) => {
|
|
||||||
switch (decisionType) {
|
|
||||||
case 'runtime_upgrade': return 'Runtime upgrade'
|
|
||||||
case 'document_change': return 'Modif. document'
|
|
||||||
case 'mandate_vote': return 'Vote de mandat'
|
|
||||||
case 'parameter_change': return 'Param. change'
|
|
||||||
case 'other': return 'Autre'
|
|
||||||
default: return decisionType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigate() {
|
|
||||||
navigateTo(`/decisions/${props.decision.id}`)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UCard
|
|
||||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 hover:shadow-md transition-all"
|
|
||||||
@click="navigate"
|
|
||||||
>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon name="i-lucide-scale" class="text-gray-400" />
|
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
|
||||||
{{ decision.title }}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<StatusBadge :status="decision.status" type="decision" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="decision.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
|
||||||
{{ decision.description }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
|
||||||
<UBadge variant="subtle" color="primary" size="xs">
|
|
||||||
{{ typeLabel(decision.decision_type) }}
|
|
||||||
</UBadge>
|
|
||||||
<span class="text-xs text-gray-500">
|
|
||||||
{{ decision.steps.length }} etape(s)
|
|
||||||
</span>
|
|
||||||
<span class="text-xs text-gray-500">
|
|
||||||
{{ formatDate(decision.created_at) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> Chain block: clickable parent and children with their chain
|
||||||
|
// kind in plain French (ratification / révision / révocation / élément).
|
||||||
|
import type { Decision } from '~/types/domain'
|
||||||
|
import { STATUS_LABELS } from '~/lexicon'
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
import { CHAIN_LABELS } from './decisionUi'
|
||||||
|
|
||||||
|
const props = defineProps<{ decision: Decision }>()
|
||||||
|
|
||||||
|
const col = useCollectiveStore()
|
||||||
|
|
||||||
|
const parent = computed(() =>
|
||||||
|
props.decision.parentDecisionId
|
||||||
|
? col.decisions.find(d => d.id === props.decision.parentDecisionId)
|
||||||
|
: undefined)
|
||||||
|
|
||||||
|
const parentLabel = computed(() =>
|
||||||
|
props.decision.chainKind
|
||||||
|
? `${CHAIN_LABELS[props.decision.chainKind]} de`
|
||||||
|
: 'liée à')
|
||||||
|
|
||||||
|
const children = computed(() =>
|
||||||
|
col.decisions
|
||||||
|
.filter(d => d.parentDecisionId === props.decision.id)
|
||||||
|
.map(d => ({
|
||||||
|
...d,
|
||||||
|
kindLabel: d.chainKind ? CHAIN_LABELS[d.chainKind] : 'liée',
|
||||||
|
})))
|
||||||
|
|
||||||
|
const hasChain = computed(() => parent.value !== undefined || children.value.length > 0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<section v-if="hasChain" class="chain">
|
||||||
|
<h2 class="chain__title">Chaînage</h2>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
v-if="parent"
|
||||||
|
:to="`/decisions/${parent.id}`"
|
||||||
|
class="chain__link"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-corner-left-up" />
|
||||||
|
<span class="chain__kind">{{ parentLabel }}</span>
|
||||||
|
<span class="chain__name">{{ parent.title }}</span>
|
||||||
|
<span class="status-pill" :class="`status-${parent.status}`">
|
||||||
|
{{ STATUS_LABELS[parent.status] }}
|
||||||
|
</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
v-for="child in children"
|
||||||
|
:key="child.id"
|
||||||
|
:to="`/decisions/${child.id}`"
|
||||||
|
class="chain__link"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-corner-down-right" />
|
||||||
|
<span class="chain__kind">{{ child.kindLabel }}</span>
|
||||||
|
<span class="chain__name">{{ child.title }}</span>
|
||||||
|
<span class="status-pill" :class="`status-${child.status}`">
|
||||||
|
{{ STATUS_LABELS[child.status] }}
|
||||||
|
</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chain { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.chain__title {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.chain__link {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-text);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
.chain__link:hover { transform: translateY(-1px); }
|
||||||
|
.chain__kind {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
.chain__name {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 10rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> ÉLÉMENTS tab of a split dossier: element children with states
|
||||||
|
// and deadlines, 0-3 stake weighting by the concerned (Concern.priority — feeds
|
||||||
|
// the closing cartography, never the voting right), auto-generated closing
|
||||||
|
// cartography, and the steward's « dossier complet » closing gesture.
|
||||||
|
import type { Decision } from '~/types/domain'
|
||||||
|
import { DOSSIER_COMPLETE_CARD, STATUS_LABELS } from '~/lexicon'
|
||||||
|
import { TERMINAL_STATUSES } from '~/engine'
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
import { useDecisionsStore } from '~/stores/decisions'
|
||||||
|
import { dateFr } from './decisionUi'
|
||||||
|
|
||||||
|
const props = defineProps<{ decision: Decision }>()
|
||||||
|
|
||||||
|
const col = useCollectiveStore()
|
||||||
|
const store = useDecisionsStore()
|
||||||
|
|
||||||
|
const elements = computed(() =>
|
||||||
|
col.decisions.filter(d =>
|
||||||
|
d.parentDecisionId === props.decision.id && d.chainKind === 'element'))
|
||||||
|
|
||||||
|
const meId = computed(() => col.me?.id ?? null)
|
||||||
|
|
||||||
|
function priorityStats(elementId: string) {
|
||||||
|
const priorities = col.concerns
|
||||||
|
.filter(c => c.decisionId === elementId && c.priority !== undefined)
|
||||||
|
.map(c => c.priority as number)
|
||||||
|
if (priorities.length === 0) return { avg: null as number | null, count: 0 }
|
||||||
|
const avg = priorities.reduce((a, b) => a + b, 0) / priorities.length
|
||||||
|
return { avg: Math.round(avg * 10) / 10, count: priorities.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
function myPriority(elementId: string): number | undefined {
|
||||||
|
if (!meId.value) return undefined
|
||||||
|
return col.concerns.find(
|
||||||
|
c => c.decisionId === elementId && c.personId === meId.value,
|
||||||
|
)?.priority
|
||||||
|
}
|
||||||
|
|
||||||
|
/** « Pondère tes enjeux » — set my priority on this element (0-3). */
|
||||||
|
function setPriority(elementId: string, priority: 0 | 1 | 2 | 3) {
|
||||||
|
if (!meId.value || !col.current) return
|
||||||
|
let mine = col.concerns.find(
|
||||||
|
c => c.decisionId === elementId && c.personId === meId.value,
|
||||||
|
)
|
||||||
|
if (!mine) {
|
||||||
|
const created = store.declareConcern(elementId, meId.value)
|
||||||
|
if ('ok' in created) return
|
||||||
|
mine = created
|
||||||
|
}
|
||||||
|
mine.priority = priority
|
||||||
|
col.stamp(mine)
|
||||||
|
col.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTerminal = computed(() =>
|
||||||
|
elements.value.length > 0
|
||||||
|
&& elements.value.every(e => TERMINAL_STATUSES.includes(e.status)))
|
||||||
|
|
||||||
|
const isSteward = computed(() => {
|
||||||
|
if (!meId.value) return false
|
||||||
|
if (props.decision.stewardIds.length > 0) {
|
||||||
|
return props.decision.stewardIds.includes(meId.value)
|
||||||
|
}
|
||||||
|
return props.decision.authorId === meId.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const closeError = ref('')
|
||||||
|
function closeDossier() {
|
||||||
|
const result = store.transition(props.decision.id, 'closed')
|
||||||
|
closeError.value = result.ok ? '' : result.reason
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITIES = [0, 1, 2, 3] as const
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<section class="els">
|
||||||
|
<h2 class="els__title">Éléments du dossier</h2>
|
||||||
|
<p class="els__hint">
|
||||||
|
Chaque élément suit son propre chemin — pondère tes enjeux de 0 à 3 :
|
||||||
|
la pondération nourrit la cartographie, jamais le droit de vote.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="els__list">
|
||||||
|
<li v-for="el in elements" :key="el.id" class="els__item">
|
||||||
|
<div class="els__item-main">
|
||||||
|
<NuxtLink :to="`/decisions/${el.id}`" class="els__item-title">
|
||||||
|
{{ el.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span class="status-pill" :class="`status-${el.status}`">
|
||||||
|
{{ STATUS_LABELS[el.status] }}
|
||||||
|
</span>
|
||||||
|
<LdCountdown
|
||||||
|
v-if="el.windowEndsAt"
|
||||||
|
:ends-at="el.windowEndsAt"
|
||||||
|
:suspended-at="el.windowSuspendedAt"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="els__weighting no-print">
|
||||||
|
<span class="els__weighting-label">Mes enjeux</span>
|
||||||
|
<button
|
||||||
|
v-for="p in PRIORITIES"
|
||||||
|
:key="p"
|
||||||
|
type="button"
|
||||||
|
class="els__prio"
|
||||||
|
:class="{ 'els__prio--on': myPriority(el.id) === p }"
|
||||||
|
@click="setPriority(el.id, p)"
|
||||||
|
>
|
||||||
|
{{ p }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Cartographie de clôture — auto-générée -->
|
||||||
|
<div class="els__map">
|
||||||
|
<h3 class="els__map-title">Cartographie de clôture</h3>
|
||||||
|
<table class="els__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Élément</th>
|
||||||
|
<th>État</th>
|
||||||
|
<th>Enjeu moyen</th>
|
||||||
|
<th>Échéance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="el in elements" :key="el.id">
|
||||||
|
<td>{{ el.title }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-pill" :class="`status-${el.status}`">
|
||||||
|
{{ STATUS_LABELS[el.status] }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<template v-if="priorityStats(el.id).avg !== null">
|
||||||
|
{{ priorityStats(el.id).avg }} / 3
|
||||||
|
<span class="els__map-count">({{ priorityStats(el.id).count }})</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>—</template>
|
||||||
|
</td>
|
||||||
|
<td>{{ el.windowEndsAt ? dateFr(el.windowEndsAt) : (el.decidedAt ? dateFr(el.decidedAt) : '—') }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dossier complet — le geste du garant, jamais automatique -->
|
||||||
|
<div v-if="allTerminal" class="els__complete">
|
||||||
|
<p class="els__complete-text">
|
||||||
|
<UIcon name="i-lucide-flag" />
|
||||||
|
<span>{{ DOSSIER_COMPLETE_CARD }}</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
v-if="isSteward"
|
||||||
|
type="button"
|
||||||
|
class="ld-btn no-print"
|
||||||
|
@click="closeDossier()"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-stamp" />
|
||||||
|
<span>Clore le dossier</span>
|
||||||
|
</button>
|
||||||
|
<p v-else class="els__hint">Le geste appartient au garant du dossier.</p>
|
||||||
|
<p v-if="closeError" class="els__error">{{ closeError }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.els { display: flex; flex-direction: column; gap: 0.875rem; }
|
||||||
|
.els__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.els__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.els__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
.els__item {
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.els__item-main {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.els__item-title {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text);
|
||||||
|
text-decoration: none;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 10rem;
|
||||||
|
}
|
||||||
|
.els__item-title:hover { color: var(--mood-accent); }
|
||||||
|
.els__weighting {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
.els__weighting-label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
.els__prio {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mood-surface);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s ease;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
.els__prio:hover { transform: translateY(-1px); }
|
||||||
|
.els__prio--on {
|
||||||
|
background: var(--mood-accent);
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
}
|
||||||
|
.els__map-title {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.els__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.els__table th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
}
|
||||||
|
.els__table td {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
}
|
||||||
|
.els__table tbody tr td:first-child { border-radius: var(--r-input) 0 0 var(--r-input); }
|
||||||
|
.els__table tbody tr td:last-child { border-radius: 0 var(--r-input) var(--r-input) 0; }
|
||||||
|
.els__map-count { color: var(--mood-text-muted); font-size: 0.8125rem; }
|
||||||
|
.els__complete {
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: var(--mood-status-vigueur-bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
.els__complete-text {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-status-vigueur);
|
||||||
|
}
|
||||||
|
.els__error {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-error);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> « S'instruire » block (framing/voting): compact summary always
|
||||||
|
// visible (title, sought effects, « Ce que ça engage ») + internal disclosures
|
||||||
|
// (body, 2-column versions with folded author, deposited advices/objections,
|
||||||
|
// provenance). Auto-collapsed on later visits (local preference per decision).
|
||||||
|
import type { Decision } from '~/types/domain'
|
||||||
|
import { ENGAGES_LABEL, INSTRUCT_BLOCK } from '~/lexicon'
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
import { ADVICE_LABELS } from './decisionUi'
|
||||||
|
|
||||||
|
const props = defineProps<{ decision: Decision }>()
|
||||||
|
|
||||||
|
const col = useCollectiveStore()
|
||||||
|
|
||||||
|
// ── Repli automatique aux visites suivantes ──
|
||||||
|
const storageKey = `ld2-instruct-${props.decision.id}`
|
||||||
|
const collapsed = ref(false)
|
||||||
|
onMounted(() => {
|
||||||
|
collapsed.value = localStorage.getItem(storageKey) === '1'
|
||||||
|
localStorage.setItem(storageKey, '1')
|
||||||
|
})
|
||||||
|
|
||||||
|
const effects = computed(() => props.decision.brief?.effects ?? [])
|
||||||
|
const resources = computed(() => props.decision.resources)
|
||||||
|
|
||||||
|
const advices = computed(() =>
|
||||||
|
col.advices
|
||||||
|
.filter(a => a.decisionId === props.decision.id)
|
||||||
|
.map(a => ({
|
||||||
|
...a,
|
||||||
|
who: col.people.find(p => p.id === a.personId)?.displayName ?? '—',
|
||||||
|
label: ADVICE_LABELS[a.position],
|
||||||
|
})))
|
||||||
|
|
||||||
|
const objections = computed(() =>
|
||||||
|
col.objections
|
||||||
|
.filter(o => o.decisionId === props.decision.id)
|
||||||
|
.map(o => ({
|
||||||
|
...o,
|
||||||
|
who: col.people.find(p => p.id === o.personId)?.displayName ?? '—',
|
||||||
|
})))
|
||||||
|
|
||||||
|
// ── Versions (clause visée) : diff simple 2 colonnes, auteur replié ──
|
||||||
|
const clause = computed(() =>
|
||||||
|
props.decision.amendsClauseId
|
||||||
|
? col.clauses.find(c => c.id === props.decision.amendsClauseId)
|
||||||
|
: undefined)
|
||||||
|
|
||||||
|
const currentVersion = computed(() =>
|
||||||
|
clause.value
|
||||||
|
? col.versions.find(v => v.clauseId === clause.value!.id && v.status === 'current')
|
||||||
|
: undefined)
|
||||||
|
|
||||||
|
const proposedVersions = computed(() =>
|
||||||
|
clause.value
|
||||||
|
? col.versions
|
||||||
|
.filter(v => v.clauseId === clause.value!.id && v.status === 'proposed')
|
||||||
|
.map((v) => {
|
||||||
|
const origin = col.decisions.find(d => d.id === v.decisionId)
|
||||||
|
const author = origin
|
||||||
|
? col.people.find(p => p.id === origin.authorId)?.displayName
|
||||||
|
: undefined
|
||||||
|
return { ...v, author: author ?? '—' }
|
||||||
|
})
|
||||||
|
: [])
|
||||||
|
|
||||||
|
// ── Provenance (voteRecord du document de la clause visée) ──
|
||||||
|
const record = computed(() => {
|
||||||
|
if (!clause.value) return undefined
|
||||||
|
const doc = col.docs.find(d => d.id === clause.value!.docId)
|
||||||
|
return doc?.provenance?.voteRecord
|
||||||
|
})
|
||||||
|
const recordLine = computed(() => {
|
||||||
|
const r = record.value
|
||||||
|
if (!r) return ''
|
||||||
|
const res = r.result
|
||||||
|
return `${res.for} oui · ${res.against} non`
|
||||||
|
+ `${res.invalid !== undefined ? ` · ${res.invalid} nuls` : ''}`
|
||||||
|
+ ` — seuil ${res.thresholdRequired}, ${res.wotSize} inscrits (${r.period})`
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasDetails = computed(() =>
|
||||||
|
!!props.decision.body || proposedVersions.value.length > 0
|
||||||
|
|| advices.value.length > 0 || objections.value.length > 0 || !!record.value)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<section class="ins">
|
||||||
|
<button type="button" class="ins__head no-print" @click="collapsed = !collapsed">
|
||||||
|
<UIcon name="i-lucide-book-open" />
|
||||||
|
<span class="ins__head-title">{{ INSTRUCT_BLOCK }}</span>
|
||||||
|
<UIcon :name="collapsed ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-show="!collapsed" class="ins__body">
|
||||||
|
<!-- Résumé compact — toujours visible quand le bloc est ouvert -->
|
||||||
|
<p class="ins__title">{{ decision.title }}</p>
|
||||||
|
|
||||||
|
<div v-if="effects.length > 0" class="ins__effects">
|
||||||
|
<p class="ins__label">Effets recherchés</p>
|
||||||
|
<ul class="ins__effect-list">
|
||||||
|
<li v-for="(effect, i) in effects" :key="i">
|
||||||
|
<span>{{ effect.label }}</span>
|
||||||
|
<span v-if="effect.target" class="ins__target">cible : {{ effect.target }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="resources" class="ins__engages">
|
||||||
|
<p class="ins__label">{{ ENGAGES_LABEL }}</p>
|
||||||
|
<p class="ins__engages-note">
|
||||||
|
{{ resources.note }}
|
||||||
|
<strong v-if="resources.amount !== undefined">
|
||||||
|
— {{ resources.amount.toLocaleString('fr-FR') }} {{ resources.unit ?? '' }}
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dépliables internes -->
|
||||||
|
<template v-if="hasDetails">
|
||||||
|
<details v-if="decision.body" class="ins__fold">
|
||||||
|
<summary>Le texte</summary>
|
||||||
|
<p class="ins__prose">{{ decision.body }}</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details v-if="proposedVersions.length > 0" class="ins__fold">
|
||||||
|
<summary>Versions proposées</summary>
|
||||||
|
<div v-for="v in proposedVersions" :key="v.id" class="ins__diff">
|
||||||
|
<div class="ins__diff-col">
|
||||||
|
<p class="ins__diff-head">Aujourd'hui</p>
|
||||||
|
<p class="ins__prose">{{ currentVersion?.content ?? '— (nouvelle clause)' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="ins__diff-col ins__diff-col--proposed">
|
||||||
|
<p class="ins__diff-head">Proposé ({{ v.versionLabel }})</p>
|
||||||
|
<p class="ins__prose">{{ v.content }}</p>
|
||||||
|
<details class="ins__author">
|
||||||
|
<summary>Qui propose ?</summary>
|
||||||
|
<p>{{ v.author }}</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details v-if="advices.length > 0" class="ins__fold">
|
||||||
|
<summary>Avis déposés ({{ advices.length }})</summary>
|
||||||
|
<ul class="ins__voice-list">
|
||||||
|
<li v-for="a in advices" :key="a.id">
|
||||||
|
<strong>{{ a.who }}</strong> — {{ a.label }}
|
||||||
|
<span v-if="a.note" class="ins__voice-note">« {{ a.note }} »</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details v-if="objections.length > 0" class="ins__fold">
|
||||||
|
<summary>Objections ({{ objections.length }})</summary>
|
||||||
|
<ul class="ins__voice-list">
|
||||||
|
<li v-for="o in objections" :key="o.id">
|
||||||
|
<strong>{{ o.who }}</strong>
|
||||||
|
<span class="ins__obj-status">({{ o.status === 'open' ? 'ouverte' : o.status === 'withdrawn' ? 'retirée' : o.status === 'integrated' ? 'intégrée' : 'escaladée' }})</span>
|
||||||
|
— « {{ o.argument }} »
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details v-if="record" class="ins__fold">
|
||||||
|
<summary>Provenance</summary>
|
||||||
|
<p class="ins__prose">{{ recordLine }}</p>
|
||||||
|
<p v-if="record.url" class="ins__prose">
|
||||||
|
<a :href="record.url" target="_blank" rel="noopener">source du vote</a>
|
||||||
|
— {{ record.modeParams }}
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-show="collapsed" class="ins__collapsed-hint">
|
||||||
|
{{ decision.title }} — déplié à ta première visite, replié depuis.
|
||||||
|
{{ effects.length > 0 ? `${effects.length} effet${effects.length > 1 ? 's' : ''} recherché${effects.length > 1 ? 's' : ''}.` : '' }}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ins { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.ins__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.ins__head-title { flex: 1; }
|
||||||
|
.ins__body { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.ins__title { margin: 0; font-size: 1rem; font-weight: 700; }
|
||||||
|
.ins__label {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.ins__effect-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.125rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
.ins__target {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-status-vote);
|
||||||
|
background: var(--mood-status-vote-bg);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
}
|
||||||
|
.ins__engages-note { margin: 0; font-size: 0.9375rem; }
|
||||||
|
.ins__fold summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.ins__prose {
|
||||||
|
margin: 0.375rem 0 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.ins__diff {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.ins__diff { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
.ins__diff-col {
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
}
|
||||||
|
.ins__diff-col--proposed {
|
||||||
|
background: var(--mood-status-vote-bg);
|
||||||
|
}
|
||||||
|
.ins__diff-head {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.ins__author { margin-top: 0.5rem; }
|
||||||
|
.ins__author summary {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ins__author p { margin: 0.25rem 0 0; font-size: 0.875rem; font-weight: 700; }
|
||||||
|
.ins__voice-list {
|
||||||
|
margin: 0.375rem 0 0;
|
||||||
|
padding-left: 1.125rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
.ins__voice-note { font-style: italic; color: var(--mood-text-muted); }
|
||||||
|
.ins__obj-status { font-size: 0.8125rem; color: var(--mood-text-muted); }
|
||||||
|
.ins__collapsed-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> Perimeter panel: first-place concerned (computed, full dot,
|
||||||
|
// tap → inclusion reason), second-place (declared, raised hand, public note),
|
||||||
|
// « Ça me concerne », arrested-list countdown, NON-IGNORABLE influx banner
|
||||||
|
// (widen one notch / keep with a public note), boundary objection.
|
||||||
|
import type { Concern, Decision, Id, Person } from '~/types/domain'
|
||||||
|
import {
|
||||||
|
CONCERN_FIRST, CONCERN_ME, CONCERN_SECOND, PERIMETER_OVERFLOW,
|
||||||
|
SCOPE_KEEP, SCOPE_WIDEN,
|
||||||
|
} from '~/lexicon'
|
||||||
|
import { computeConcerned } from '~/engine'
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
import { useDecisionsStore } from '~/stores/decisions'
|
||||||
|
|
||||||
|
const props = defineProps<{ decision: Decision }>()
|
||||||
|
|
||||||
|
const col = useCollectiveStore()
|
||||||
|
const store = useDecisionsStore()
|
||||||
|
|
||||||
|
const concerns = computed(() =>
|
||||||
|
col.concerns.filter(c => c.decisionId === props.decision.id))
|
||||||
|
|
||||||
|
function entriesOf(origin: Concern['origin']) {
|
||||||
|
return concerns.value
|
||||||
|
.filter(c => c.origin === origin)
|
||||||
|
.map((c) => {
|
||||||
|
const person = col.people.find(p => p.id === c.personId)
|
||||||
|
return person ? { person, origin, reason: c.reason, concern: c } : null
|
||||||
|
})
|
||||||
|
.filter((e): e is { person: Person; origin: Concern['origin']; reason: string; concern: Concern } => e !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstPlace = computed(() => entriesOf('computed'))
|
||||||
|
const secondPlace = computed(() => entriesOf('declared'))
|
||||||
|
|
||||||
|
// ── Popover : raison d'inclusion en un tap ──
|
||||||
|
const selected = ref<{ name: string; title: string; reason: string; note?: string; consultative: boolean } | null>(null)
|
||||||
|
|
||||||
|
function onTap(entry: { person: Person; origin?: string; reason?: string }) {
|
||||||
|
const concern = concerns.value.find(c => c.personId === entry.person.id)
|
||||||
|
const parts: string[] = []
|
||||||
|
if (concern?.reason) parts.push(concern.reason)
|
||||||
|
selected.value = {
|
||||||
|
name: entry.person.displayName,
|
||||||
|
title: concern?.origin === 'declared' ? CONCERN_SECOND : CONCERN_FIRST,
|
||||||
|
reason: parts.join(' — '),
|
||||||
|
...(concern?.declaredNote ? { note: concern.declaredNote } : {}),
|
||||||
|
consultative: concern?.origin === 'declared' && concern.beforeSnapshot === false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── « Ça me concerne » ──
|
||||||
|
const meId = computed(() => col.me?.id ?? null)
|
||||||
|
const alreadyConcerned = computed(() =>
|
||||||
|
meId.value !== null
|
||||||
|
&& (props.decision.authorId === meId.value
|
||||||
|
|| concerns.value.some(c => c.personId === meId.value)))
|
||||||
|
|
||||||
|
function declareMe() {
|
||||||
|
if (meId.value) store.declareConcern(props.decision.id, meId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Compte à rebours de la liste arrêtée (session à venir) ──
|
||||||
|
const arrestAt = computed(() =>
|
||||||
|
props.decision.status === 'framing' ? props.decision.windowEndsAt : undefined)
|
||||||
|
|
||||||
|
// ── Bannière d'affluence — non-ignorable ──
|
||||||
|
const ACTIVE = ['draft', 'advice', 'objection', 'framing', 'voting']
|
||||||
|
const overflow = computed(() => {
|
||||||
|
if (!ACTIVE.includes(props.decision.status) || props.decision.scopeKeptNote) return false
|
||||||
|
const computedCount = firstPlace.value.length
|
||||||
|
const declaredCount = secondPlace.value.length
|
||||||
|
const ratio = col.settings?.triage.concernEscalateRatio ?? 0.5
|
||||||
|
return computedCount > 0 && declaredCount >= ratio * computedCount
|
||||||
|
})
|
||||||
|
|
||||||
|
function widen() {
|
||||||
|
const d = props.decision
|
||||||
|
if (!col.current) return
|
||||||
|
const next = new Set<Id>()
|
||||||
|
for (const id of d.scope.circleIds) {
|
||||||
|
const circle = col.circles.find(c => c.id === id)
|
||||||
|
next.add(circle?.parentCircleId ?? id)
|
||||||
|
}
|
||||||
|
if (next.size === 0) next.add(col.current.collective.rootCircleId)
|
||||||
|
d.scope.circleIds = [...next]
|
||||||
|
// Re-persist the widened computation — every inclusion keeps its reason.
|
||||||
|
const activeMandates = col.mandates.filter(m => m.status === 'active')
|
||||||
|
const known = new Set(concerns.value.map(c => c.personId))
|
||||||
|
const now = col.now()
|
||||||
|
for (const entry of computeConcerned(d.scope, col.circles, activeMandates, d.authorId)) {
|
||||||
|
if (known.has(entry.personId)) continue
|
||||||
|
col.current.concerns.push({
|
||||||
|
id: col.newId(),
|
||||||
|
collectiveId: d.collectiveId,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
decisionId: d.id,
|
||||||
|
personId: entry.personId,
|
||||||
|
origin: 'computed',
|
||||||
|
reason: entry.reason,
|
||||||
|
beforeSnapshot: !col.sessions.some(s => s.decisionId === d.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
col.stamp(d)
|
||||||
|
col.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepOpen = ref(false)
|
||||||
|
const keepNote = ref('')
|
||||||
|
function keepScope() {
|
||||||
|
const note = keepNote.value.trim()
|
||||||
|
if (note.length === 0) return
|
||||||
|
props.decision.scopeKeptNote = note
|
||||||
|
col.stamp(props.decision)
|
||||||
|
col.persist()
|
||||||
|
keepOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Objection de frontière ──
|
||||||
|
const boundaryOpen = ref(false)
|
||||||
|
const boundaryArg = ref('')
|
||||||
|
const boundaryError = ref('')
|
||||||
|
function sendBoundary() {
|
||||||
|
const result = store.objectTo(props.decision.id, 'boundary', boundaryArg.value)
|
||||||
|
if ('ok' in result) { boundaryError.value = result.reason; return }
|
||||||
|
boundaryOpen.value = false
|
||||||
|
boundaryArg.value = ''
|
||||||
|
boundaryError.value = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<section class="peri">
|
||||||
|
<h2 class="peri__title">Périmètre</h2>
|
||||||
|
|
||||||
|
<div v-if="overflow" class="peri__overflow no-print">
|
||||||
|
<p class="peri__overflow-text">
|
||||||
|
<UIcon name="i-lucide-waves" />
|
||||||
|
<span>{{ PERIMETER_OVERFLOW }}</span>
|
||||||
|
</p>
|
||||||
|
<div class="peri__overflow-actions">
|
||||||
|
<button type="button" class="ld-btn" @click="widen()">{{ SCOPE_WIDEN }}</button>
|
||||||
|
<button type="button" class="ld-btn ld-btn--ghost" @click="keepOpen = !keepOpen">
|
||||||
|
{{ SCOPE_KEEP }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="keepOpen" class="peri__keep">
|
||||||
|
<textarea
|
||||||
|
v-model="keepNote"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Pourquoi le périmètre reste-t-il ainsi ? Cette note sera publique."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ld-btn"
|
||||||
|
:disabled="keepNote.trim().length === 0"
|
||||||
|
@click="keepScope()"
|
||||||
|
>
|
||||||
|
Publier la note et maintenir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="peri__group">
|
||||||
|
<p class="peri__label">{{ CONCERN_FIRST }}</p>
|
||||||
|
<LdAvatarStack
|
||||||
|
v-if="firstPlace.length > 0"
|
||||||
|
:people="firstPlace"
|
||||||
|
:max="8"
|
||||||
|
@tap="onTap"
|
||||||
|
/>
|
||||||
|
<p v-else class="peri__none">Personne d'autre — décision sur soi.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="peri__group">
|
||||||
|
<p class="peri__label">{{ CONCERN_SECOND }}</p>
|
||||||
|
<LdAvatarStack
|
||||||
|
v-if="secondPlace.length > 0"
|
||||||
|
:people="secondPlace"
|
||||||
|
:max="8"
|
||||||
|
@tap="onTap"
|
||||||
|
/>
|
||||||
|
<p v-else class="peri__none">Personne ne s'est déclaré·e pour l'instant.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selected" class="peri__popover" @click="selected = null">
|
||||||
|
<p class="peri__popover-name">{{ selected.name }}</p>
|
||||||
|
<p class="peri__popover-origin">{{ selected.title }}</p>
|
||||||
|
<p v-if="selected.reason" class="peri__popover-reason">{{ selected.reason }}</p>
|
||||||
|
<p v-if="selected.note" class="peri__popover-note">« {{ selected.note }} »</p>
|
||||||
|
<p v-if="selected.consultative" class="peri__popover-consult">
|
||||||
|
Déclaré·e après l'arrêt de la liste — voix consultative.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="arrestAt" class="peri__arrest">
|
||||||
|
<LdCountdown :ends-at="arrestAt" />
|
||||||
|
<p class="peri__arrest-rule">
|
||||||
|
Déclaré·e avant l'arrêt de la liste : tu votes. Après : voix consultative.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="peri__actions no-print">
|
||||||
|
<button
|
||||||
|
v-if="!alreadyConcerned"
|
||||||
|
type="button"
|
||||||
|
class="ld-btn ld-btn--ghost"
|
||||||
|
@click="declareMe()"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-hand" />
|
||||||
|
<span>{{ CONCERN_ME }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ld-btn ld-btn--quiet"
|
||||||
|
@click="boundaryOpen = !boundaryOpen"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-user-plus" />
|
||||||
|
<span>J'ai été oublié·e / il manque quelqu'un</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="boundaryOpen" class="peri__keep no-print">
|
||||||
|
<textarea
|
||||||
|
v-model="boundaryArg"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Qui manque, et pourquoi cette personne est concernée ?"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ld-btn"
|
||||||
|
:disabled="boundaryArg.trim().length === 0"
|
||||||
|
@click="sendBoundary()"
|
||||||
|
>
|
||||||
|
Contester la frontière
|
||||||
|
</button>
|
||||||
|
<p v-if="boundaryError" class="peri__error">{{ boundaryError }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.peri {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
.peri__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.peri__group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.peri__label {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.peri__none {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.peri__popover {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.peri__popover-name { margin: 0; font-weight: 800; font-size: 0.9375rem; }
|
||||||
|
.peri__popover-origin {
|
||||||
|
margin: 0.125rem 0 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
}
|
||||||
|
.peri__popover-reason { margin: 0.25rem 0 0; font-size: 0.875rem; }
|
||||||
|
.peri__popover-note {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.peri__popover-consult {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-status-fenetre);
|
||||||
|
}
|
||||||
|
.peri__arrest {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.peri__arrest-rule {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.peri__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.peri__overflow {
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: color-mix(in srgb, var(--mood-status-fenetre) 14%, transparent);
|
||||||
|
box-shadow: inset 0 0 0 2px var(--mood-status-fenetre);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.peri__overflow-text {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-status-fenetre);
|
||||||
|
}
|
||||||
|
.peri__overflow-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.peri__keep {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.peri__keep textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.peri__error {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-error);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> Sheet actions + proof card: « Imprimer » (A4 PV — the print
|
||||||
|
// stylesheet lives in moods.css), « Graver » → truncated copyable sha256 mono
|
||||||
|
// fingerprint + « empreinte locale — démo ».
|
||||||
|
import type { Decision } from '~/types/domain'
|
||||||
|
import { PROOF_LOCAL } from '~/lexicon'
|
||||||
|
import { useDecisionsStore } from '~/stores/decisions'
|
||||||
|
import { dateFr } from './decisionUi'
|
||||||
|
|
||||||
|
const props = defineProps<{ decision: Decision }>()
|
||||||
|
|
||||||
|
const store = useDecisionsStore()
|
||||||
|
|
||||||
|
const engraving = computed(() => props.decision.engraving)
|
||||||
|
const shortHash = computed(() =>
|
||||||
|
engraving.value ? `${engraving.value.sha256.slice(0, 20)}…` : '')
|
||||||
|
|
||||||
|
const busy = ref(false)
|
||||||
|
async function engraveNow() {
|
||||||
|
busy.value = true
|
||||||
|
try { await store.engrave(props.decision.id) }
|
||||||
|
finally { busy.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const copied = ref(false)
|
||||||
|
async function copyHash() {
|
||||||
|
if (!engraving.value) return
|
||||||
|
await navigator.clipboard.writeText(engraving.value.sha256)
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => { copied.value = false }, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSheet() { window.print() }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<div class="proof">
|
||||||
|
<div class="proof__actions no-print">
|
||||||
|
<button type="button" class="ld-btn ld-btn--ghost" @click="printSheet()">
|
||||||
|
<UIcon name="i-lucide-printer" />
|
||||||
|
<span>Imprimer</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!engraving"
|
||||||
|
type="button"
|
||||||
|
class="ld-btn ld-btn--ghost"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="engraveNow()"
|
||||||
|
>
|
||||||
|
<span>井 Graver</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="engraving" class="ld-card proof__card">
|
||||||
|
<p class="proof__title">Fiche de preuve</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="proof__hash no-print"
|
||||||
|
title="Copier l'empreinte complète"
|
||||||
|
@click="copyHash()"
|
||||||
|
>
|
||||||
|
<code>{{ shortHash }}</code>
|
||||||
|
<UIcon :name="copied ? 'i-lucide-check' : 'i-lucide-copy'" />
|
||||||
|
</button>
|
||||||
|
<code class="print-only proof__hash-full">{{ engraving.sha256 }}</code>
|
||||||
|
<p class="proof__meta">
|
||||||
|
{{ PROOF_LOCAL }} — gravée le {{ dateFr(engraving.engravedAt) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.proof { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
.proof__actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||||
|
.proof__card {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.proof__title { margin: 0; font-weight: 800; font-size: 0.9375rem; }
|
||||||
|
.proof__hash {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
.proof__hash code,
|
||||||
|
.proof__hash-full {
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.proof__meta {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> Registry card: title, tinted route icon, weight, deadline,
|
||||||
|
// mini vote gauge, 井 badge when engraved, urgency badge. Links to the fiche.
|
||||||
|
import type { Decision } from '~/types/domain'
|
||||||
|
import { ROUTE_ICONS, ROUTE_SHORT, URGENT_BADGE } from '~/lexicon'
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
import { useDecisionsStore } from '~/stores/decisions'
|
||||||
|
import { WEIGHT_SHORT, displayStatus, displayStatusLabel } from './decisionUi'
|
||||||
|
|
||||||
|
const props = defineProps<{ decision: Decision }>()
|
||||||
|
|
||||||
|
const col = useCollectiveStore()
|
||||||
|
const store = useDecisionsStore()
|
||||||
|
|
||||||
|
const session = computed(() =>
|
||||||
|
col.sessions
|
||||||
|
.filter(s => s.decisionId === props.decision.id)
|
||||||
|
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
const shown = computed(() => displayStatus(props.decision, session.value))
|
||||||
|
|
||||||
|
/** Mini gauge — cast voices over the arrested list, voting only. */
|
||||||
|
const gauge = computed(() => {
|
||||||
|
const s = session.value
|
||||||
|
if (!s || props.decision.status !== 'voting') return null
|
||||||
|
const size = Math.max(s.corpusSize, 1)
|
||||||
|
const cast = store.activeVotes(s.id).length
|
||||||
|
return { cast, size, pct: Math.min(100, Math.round((cast / size) * 100)) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const gaugeTitle = computed(() =>
|
||||||
|
gauge.value ? `${gauge.value.cast} voix sur ${gauge.value.size}` : '')
|
||||||
|
|
||||||
|
const deadline = computed(() => {
|
||||||
|
if (props.decision.windowEndsAt) return props.decision.windowEndsAt
|
||||||
|
if (session.value?.status === 'open') return session.value.closesAt
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<NuxtLink :to="`/decisions/${decision.id}`" class="ld-card ld-card--hover reg-card">
|
||||||
|
<span class="reg-card__icon" :style="{ color: `var(--route-${decision.route})` }">
|
||||||
|
<UIcon :name="ROUTE_ICONS[decision.route]" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="reg-card__body">
|
||||||
|
<span class="reg-card__title">{{ decision.title }}</span>
|
||||||
|
|
||||||
|
<span class="reg-card__meta">
|
||||||
|
<span class="status-pill" :class="`status-${shown}`">{{ displayStatusLabel(shown) }}</span>
|
||||||
|
<span class="reg-card__chip">{{ ROUTE_SHORT[decision.route] }}</span>
|
||||||
|
<span class="reg-card__chip">{{ WEIGHT_SHORT[decision.weight] }}</span>
|
||||||
|
<LdCountdown
|
||||||
|
v-if="deadline"
|
||||||
|
:ends-at="deadline"
|
||||||
|
:suspended-at="decision.windowSuspendedAt"
|
||||||
|
/>
|
||||||
|
<span v-if="decision.urgent" class="reg-card__urgent">
|
||||||
|
<UIcon name="i-lucide-siren" />
|
||||||
|
<span>{{ URGENT_BADGE }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="decision.engraving" class="reg-card__well" title="gravée">井</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span v-if="gauge" class="reg-card__gauge" :title="gaugeTitle">
|
||||||
|
<span class="reg-card__gauge-fill" :style="{ width: `${gauge.pct}%` }" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.reg-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.875rem;
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
.reg-card__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: var(--r-icon);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||||
|
}
|
||||||
|
.reg-card__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.reg-card__title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.reg-card__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.reg-card__chip {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
}
|
||||||
|
.reg-card__urgent {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--route-urgent);
|
||||||
|
background: color-mix(in srgb, var(--route-urgent) 11%, transparent);
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
}
|
||||||
|
.reg-card__well {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-status-vigueur);
|
||||||
|
transform: rotate(-10deg);
|
||||||
|
}
|
||||||
|
.reg-card__gauge {
|
||||||
|
display: block;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--mood-status-vote-bg);
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 16rem;
|
||||||
|
}
|
||||||
|
.reg-card__gauge-fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--mood-status-vote);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> Registry filters: circles grouped by kind (lieu/thème/équipe
|
||||||
|
// icons), tags, « me concerne », weight, engraved 井, recorded entries.
|
||||||
|
import type { CircleKind, Weight } from '~/types/domain'
|
||||||
|
import { CONCERN_ME, ROUTE_SHORT } from '~/lexicon'
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
import { KIND_ICONS, KIND_LABELS, WEIGHT_SHORT } from './decisionUi'
|
||||||
|
|
||||||
|
const circleId = defineModel<string | null>('circleId', { default: null })
|
||||||
|
const tag = defineModel<string | null>('tag', { default: null })
|
||||||
|
const mine = defineModel<boolean>('mine', { default: false })
|
||||||
|
const weight = defineModel<Weight | null>('weight', { default: null })
|
||||||
|
const engraved = defineModel<boolean>('engraved', { default: false })
|
||||||
|
const recorded = defineModel<boolean>('recorded', { default: false })
|
||||||
|
|
||||||
|
const col = useCollectiveStore()
|
||||||
|
|
||||||
|
const KINDS: CircleKind[] = ['place', 'theme', 'team']
|
||||||
|
|
||||||
|
/** Circles grouped by kind — untyped circles last, under « Cercles ». */
|
||||||
|
const circleGroups = computed(() => {
|
||||||
|
const groups = KINDS
|
||||||
|
.map(kind => ({
|
||||||
|
kind,
|
||||||
|
icon: KIND_ICONS[kind],
|
||||||
|
label: KIND_LABELS[kind],
|
||||||
|
circles: col.circles.filter(c => c.kind === kind),
|
||||||
|
}))
|
||||||
|
.filter(g => g.circles.length > 0)
|
||||||
|
const untyped = col.circles.filter(c => c.kind === undefined)
|
||||||
|
if (untyped.length > 0) {
|
||||||
|
groups.push({ kind: 'team', icon: 'i-lucide-circle-dashed', label: 'Cercles', circles: untyped })
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
const allTags = computed(() =>
|
||||||
|
[...new Set(col.decisions.flatMap(d => d.tags))].sort((a, b) => a.localeCompare(b, 'fr')))
|
||||||
|
|
||||||
|
const WEIGHTS: Weight[] = ['light', 'binding', 'structural']
|
||||||
|
|
||||||
|
function toggleCircle(id: string) { circleId.value = circleId.value === id ? null : id }
|
||||||
|
function toggleTag(t: string) { tag.value = tag.value === t ? null : t }
|
||||||
|
function toggleWeight(w: Weight) { weight.value = weight.value === w ? null : w }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<details class="reg-filters">
|
||||||
|
<summary class="reg-filters__summary">
|
||||||
|
<UIcon name="i-lucide-sliders-horizontal" />
|
||||||
|
<span>Filtres</span>
|
||||||
|
<span
|
||||||
|
v-if="circleId || tag || mine || weight || engraved || recorded"
|
||||||
|
class="reg-filters__dot"
|
||||||
|
/>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="reg-filters__body">
|
||||||
|
<div v-for="group in circleGroups" :key="group.label" class="reg-filters__row">
|
||||||
|
<span class="reg-filters__label">
|
||||||
|
<UIcon :name="group.icon" />
|
||||||
|
<span>{{ group.label }}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-for="circle in group.circles"
|
||||||
|
:key="circle.id"
|
||||||
|
type="button"
|
||||||
|
class="reg-filters__chip"
|
||||||
|
:class="{ 'reg-filters__chip--on': circleId === circle.id }"
|
||||||
|
@click="toggleCircle(circle.id)"
|
||||||
|
>
|
||||||
|
{{ circle.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="allTags.length > 0" class="reg-filters__row">
|
||||||
|
<span class="reg-filters__label">
|
||||||
|
<UIcon name="i-lucide-hash" />
|
||||||
|
<span>Tags</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-for="t in allTags"
|
||||||
|
:key="t"
|
||||||
|
type="button"
|
||||||
|
class="reg-filters__chip"
|
||||||
|
:class="{ 'reg-filters__chip--on': tag === t }"
|
||||||
|
@click="toggleTag(t)"
|
||||||
|
>
|
||||||
|
#{{ t }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reg-filters__row">
|
||||||
|
<span class="reg-filters__label">
|
||||||
|
<UIcon name="i-lucide-anchor" />
|
||||||
|
<span>Poids</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-for="w in WEIGHTS"
|
||||||
|
:key="w"
|
||||||
|
type="button"
|
||||||
|
class="reg-filters__chip"
|
||||||
|
:class="{ 'reg-filters__chip--on': weight === w }"
|
||||||
|
@click="toggleWeight(w)"
|
||||||
|
>
|
||||||
|
{{ WEIGHT_SHORT[w] }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reg-filters__row">
|
||||||
|
<span class="reg-filters__label">
|
||||||
|
<UIcon name="i-lucide-filter" />
|
||||||
|
<span>Et aussi</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="reg-filters__chip"
|
||||||
|
:class="{ 'reg-filters__chip--on': mine }"
|
||||||
|
@click="mine = !mine"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-hand" />
|
||||||
|
<span>{{ CONCERN_ME }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="reg-filters__chip"
|
||||||
|
:class="{ 'reg-filters__chip--on': engraved }"
|
||||||
|
@click="engraved = !engraved"
|
||||||
|
>
|
||||||
|
<span>井 gravées</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="reg-filters__chip"
|
||||||
|
:class="{ 'reg-filters__chip--on': recorded }"
|
||||||
|
@click="recorded = !recorded"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-notebook-pen" />
|
||||||
|
<span>{{ ROUTE_SHORT.record }}es</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.reg-filters__summary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0.375rem 1rem;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.reg-filters__summary::-webkit-details-marker { display: none; }
|
||||||
|
.reg-filters__dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mood-accent);
|
||||||
|
}
|
||||||
|
.reg-filters__body {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
background: var(--mood-surface);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.reg-filters__row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.reg-filters__label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
min-width: 6rem;
|
||||||
|
}
|
||||||
|
.reg-filters__chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0.25rem 0.875rem;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s ease;
|
||||||
|
}
|
||||||
|
.reg-filters__chip:hover { transform: translateY(-1px); color: var(--mood-text); }
|
||||||
|
.reg-filters__chip:active { transform: translateY(0); }
|
||||||
|
.reg-filters__chip--on {
|
||||||
|
background: var(--mood-accent);
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> Session block: link to the vote room, arrested-list sentence
|
||||||
|
// (« Qui vote : N personnes — liste arrêtée le … »), frozen banner while the
|
||||||
|
// steward's crystallization gesture is awaited, outcome once closed.
|
||||||
|
import type { Decision } from '~/types/domain'
|
||||||
|
import { FROZEN_BANNER, WHO_VOTES } from '~/lexicon'
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
import { dateFr } from './decisionUi'
|
||||||
|
|
||||||
|
const props = defineProps<{ decision: Decision }>()
|
||||||
|
|
||||||
|
const col = useCollectiveStore()
|
||||||
|
|
||||||
|
const session = computed(() =>
|
||||||
|
col.sessions
|
||||||
|
.filter(s => s.decisionId === props.decision.id)
|
||||||
|
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0])
|
||||||
|
|
||||||
|
const protocol = computed(() =>
|
||||||
|
session.value
|
||||||
|
? col.protocols.find(p => p.id === session.value!.protocolId)
|
||||||
|
: undefined)
|
||||||
|
|
||||||
|
const whoVotes = computed(() =>
|
||||||
|
session.value
|
||||||
|
? WHO_VOTES(session.value.corpusSize, dateFr(session.value.opensAt))
|
||||||
|
: '')
|
||||||
|
|
||||||
|
const crystallizedBy = computed(() => {
|
||||||
|
const s = session.value
|
||||||
|
if (!s?.crystallizedById) return ''
|
||||||
|
const who = col.people.find(p => p.id === s.crystallizedById)?.displayName ?? '—'
|
||||||
|
return `Cristallisée par ${who} le ${dateFr(s.crystallizedAt)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const outcomeLabel = computed(() => {
|
||||||
|
switch (session.value?.outcome) {
|
||||||
|
case 'adopted': return 'adoptée'
|
||||||
|
case 'rejected': return 'rejetée'
|
||||||
|
case 'tie': return 'égalité — vous départagez, jamais l\'outil'
|
||||||
|
default: return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<section v-if="session" class="ses">
|
||||||
|
<div class="ses__head">
|
||||||
|
<h2 class="ses__title">Session de vote</h2>
|
||||||
|
<span v-if="protocol" class="ses__protocol">{{ protocol.name }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="ses__who">{{ whoVotes }}</p>
|
||||||
|
|
||||||
|
<p v-if="session.status === 'frozen'" class="ses__frozen">
|
||||||
|
<UIcon name="i-lucide-stamp" />
|
||||||
|
<span>{{ FROZEN_BANNER }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-if="session.status === 'closed'">
|
||||||
|
<p class="ses__result">
|
||||||
|
Résultat : <strong>{{ outcomeLabel }}</strong>
|
||||||
|
<span class="ses__result-date">— clôture le {{ dateFr(session.closesAt) }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="crystallizedBy" class="ses__crystal">{{ crystallizedBy }}</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="ses__actions no-print">
|
||||||
|
<NuxtLink :to="`/decisions/${decision.id}/vote`" class="ld-btn">
|
||||||
|
<UIcon name="i-lucide-vote" />
|
||||||
|
<span>{{ session.status === 'open' ? 'Entrer dans la salle de vote' : 'Voir la salle de vote' }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<LdCountdown v-if="session.status === 'open'" :ends-at="session.closesAt" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ses { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.ses__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ses__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.ses__protocol {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-status-vote);
|
||||||
|
background: var(--mood-status-vote-bg);
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
}
|
||||||
|
.ses__who {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ses__frozen {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-status-fige);
|
||||||
|
background: var(--mood-status-fige-bg);
|
||||||
|
}
|
||||||
|
.ses__result { margin: 0; font-size: 0.9375rem; }
|
||||||
|
.ses__result-date { color: var(--mood-text-muted); font-size: 0.875rem; }
|
||||||
|
.ses__crystal {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.ses__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> Vertical lifecycle timeline: full dots linked by a background
|
||||||
|
// line, traversed states with timestamps, ONE state→color mapping. The paused
|
||||||
|
// dot is striped when the boundary is contested; an extended non-easy window
|
||||||
|
// shows deep amber + « il manque un accord explicite ».
|
||||||
|
import type { Decision, DecisionStatus, VoteSession } from '~/types/domain'
|
||||||
|
import {
|
||||||
|
ASSENT_MISSING, BOUNDARY_SUSPENDED, FROZEN_LABEL, STATUS_LABELS,
|
||||||
|
} from '~/lexicon'
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
import { STATUS_TOKEN, dateTimeFr, type DisplayStatus } from './decisionUi'
|
||||||
|
|
||||||
|
const props = defineProps<{ decision: Decision; session?: VoteSession }>()
|
||||||
|
|
||||||
|
const col = useCollectiveStore()
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
key: DisplayStatus
|
||||||
|
label: string
|
||||||
|
at?: string
|
||||||
|
state: 'done' | 'current' | 'ahead'
|
||||||
|
token: string
|
||||||
|
suspended?: boolean
|
||||||
|
extended?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasThirdPartyAssent = computed(() =>
|
||||||
|
col.assents.some(a =>
|
||||||
|
a.decisionId === props.decision.id && a.personId !== props.decision.authorId))
|
||||||
|
|
||||||
|
/** Extended window: non-easy deadline passed without an explicit agreement. */
|
||||||
|
const isExtended = computed(() => {
|
||||||
|
const d = props.decision
|
||||||
|
return d.status === 'objection'
|
||||||
|
&& d.reversibility !== 'easy'
|
||||||
|
&& !d.windowSuspendedAt
|
||||||
|
&& d.windowEndsAt !== undefined
|
||||||
|
&& new Date(d.windowEndsAt).getTime() < Date.now()
|
||||||
|
&& !hasThirdPartyAssent.value
|
||||||
|
})
|
||||||
|
|
||||||
|
/** The path of this decision — traversed states first, expected end last. */
|
||||||
|
const steps = computed<Step[]>(() => {
|
||||||
|
const d = props.decision
|
||||||
|
const s = props.session
|
||||||
|
const path: { key: DisplayStatus; at?: string }[] = [{ key: 'draft', at: d.createdAt }]
|
||||||
|
|
||||||
|
if (d.route === 'mandate') path.push({ key: 'objection', at: d.createdAt })
|
||||||
|
else if (d.route === 'advice') path.push({ key: 'advice', at: d.createdAt })
|
||||||
|
else if (d.route === 'collective') {
|
||||||
|
const framed = d.status === 'framing'
|
||||||
|
|| (d.status === 'closed' && !d.decidedAt)
|
||||||
|
|| col.decisions.some(c => c.parentDecisionId === d.id && c.chainKind === 'element')
|
||||||
|
if (framed) path.push({ key: 'framing', at: d.createdAt })
|
||||||
|
if (d.status !== 'framing' || s) path.push({ key: 'voting', at: s?.opensAt })
|
||||||
|
if (s?.status === 'frozen') path.push({ key: 'frozen', at: s.closesAt })
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminals: DecisionStatus[] = ['adopted', 'rejected', 'revoked', 'closed', 'transmitted']
|
||||||
|
if (d.status === 'revoked') {
|
||||||
|
path.push({ key: 'adopted', at: d.decidedAt }, { key: 'revoked', at: d.updatedAt })
|
||||||
|
} else if (d.status === 'closed' && d.decidedAt) {
|
||||||
|
path.push({ key: 'adopted', at: d.decidedAt }, { key: 'closed', at: d.updatedAt })
|
||||||
|
} else if (terminals.includes(d.status)) {
|
||||||
|
path.push({ key: d.status, at: d.decidedAt ?? d.updatedAt })
|
||||||
|
} else {
|
||||||
|
// Expected landing of the route, shown ahead.
|
||||||
|
path.push({ key: d.route === 'transmit' ? 'transmitted' : 'adopted' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const shownKey: DisplayStatus = d.status === 'voting' && s?.status === 'frozen'
|
||||||
|
? 'frozen'
|
||||||
|
: d.status
|
||||||
|
const currentIndex = path.findIndex(p => p.key === shownKey)
|
||||||
|
|
||||||
|
return path.map((p, i) => ({
|
||||||
|
key: p.key,
|
||||||
|
label: p.key === 'frozen' ? FROZEN_LABEL : STATUS_LABELS[p.key as DecisionStatus],
|
||||||
|
...(p.at !== undefined ? { at: p.at } : {}),
|
||||||
|
state: currentIndex === -1 || i < currentIndex
|
||||||
|
? 'done'
|
||||||
|
: i === currentIndex ? 'current' : 'ahead',
|
||||||
|
token: STATUS_TOKEN[p.key],
|
||||||
|
suspended: i === currentIndex && !!d.windowSuspendedAt
|
||||||
|
&& (p.key === 'objection' || p.key === 'advice'),
|
||||||
|
extended: i === currentIndex && p.key === 'objection' && isExtended.value,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<ol class="tl" aria-label="Cycle de vie">
|
||||||
|
<li
|
||||||
|
v-for="step in steps"
|
||||||
|
:key="step.key"
|
||||||
|
class="tl__step"
|
||||||
|
:class="[`tl__step--${step.state}`, { 'tl__step--extended': step.extended }]"
|
||||||
|
:style="{
|
||||||
|
'--step-color': `var(--mood-status-${step.token})`,
|
||||||
|
'--step-bg': `var(--mood-status-${step.token}-bg)`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span class="tl__dot" :class="{ 'tl__dot--paused': step.suspended }">
|
||||||
|
<UIcon v-if="step.suspended" name="i-lucide-pause" class="tl__pause" />
|
||||||
|
</span>
|
||||||
|
<span class="tl__body">
|
||||||
|
<span class="tl__label">{{ step.label }}</span>
|
||||||
|
<span v-if="step.at && step.state !== 'ahead'" class="tl__date">
|
||||||
|
{{ dateTimeFr(step.at) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="step.suspended" class="tl__note tl__note--paused">
|
||||||
|
{{ BOUNDARY_SUSPENDED }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="step.extended" class="tl__note tl__note--extended">
|
||||||
|
{{ ASSENT_MISSING }} — la fenêtre se prolonge
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tl {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.tl__step {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0 0 1.125rem 0;
|
||||||
|
}
|
||||||
|
.tl__step:last-child { padding-bottom: 0; }
|
||||||
|
/* Trait de fond reliant les pastilles */
|
||||||
|
.tl__step:not(:last-child)::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 18px;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
}
|
||||||
|
.tl__dot {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--step-color);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
.tl__step--ahead .tl__dot {
|
||||||
|
background: var(--step-bg);
|
||||||
|
box-shadow: inset 0 0 0 2px var(--step-color);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.tl__dot--paused {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
-45deg,
|
||||||
|
var(--mood-status-fenetre),
|
||||||
|
var(--mood-status-fenetre) 3px,
|
||||||
|
var(--mood-status-fenetre-bg) 3px,
|
||||||
|
var(--mood-status-fenetre-bg) 6px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.tl__pause {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: var(--mood-surface);
|
||||||
|
}
|
||||||
|
.tl__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.tl__label {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--step-color);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.tl__step--ahead .tl__label { opacity: 0.5; }
|
||||||
|
.tl__step--current .tl__label { font-size: 1rem; }
|
||||||
|
.tl__date {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.tl__note {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.tl__note--paused {
|
||||||
|
color: var(--mood-status-fenetre);
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
-45deg,
|
||||||
|
var(--mood-status-fenetre-bg),
|
||||||
|
var(--mood-status-fenetre-bg) 6px,
|
||||||
|
transparent 6px,
|
||||||
|
transparent 10px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.tl__note--extended {
|
||||||
|
color: var(--mood-surface);
|
||||||
|
background: var(--mood-status-fenetre);
|
||||||
|
}
|
||||||
|
.tl__step--extended .tl__dot { box-shadow: 0 0 0 3px var(--mood-status-fenetre-bg); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> « En vigueur » block: stewards, measurers, « Ce que ça
|
||||||
|
// engage », sunset, and the reality check when due — the sought/target/
|
||||||
|
// observed table ABOVE the three buttons. Measurers record observations here.
|
||||||
|
import type { Decision } from '~/types/domain'
|
||||||
|
import {
|
||||||
|
ENGAGES_LABEL, REVIEW_QUESTION, REVIEW_TITLE, REVIEW_VERDICTS,
|
||||||
|
} from '~/lexicon'
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
import { useDecisionsStore } from '~/stores/decisions'
|
||||||
|
import { dateFr } from './decisionUi'
|
||||||
|
|
||||||
|
const props = defineProps<{ decision: Decision }>()
|
||||||
|
|
||||||
|
const col = useCollectiveStore()
|
||||||
|
const store = useDecisionsStore()
|
||||||
|
|
||||||
|
const reviewLabels = REVIEW_VERDICTS
|
||||||
|
|
||||||
|
const names = (ids: string[]) =>
|
||||||
|
ids.map(id => col.people.find(p => p.id === id)?.displayName ?? '—').join(', ')
|
||||||
|
|
||||||
|
const stewards = computed(() => names(props.decision.stewardIds))
|
||||||
|
const measurers = computed(() => names(props.decision.measurerIds))
|
||||||
|
|
||||||
|
const meId = computed(() => col.me?.id ?? null)
|
||||||
|
const iMeasure = computed(() =>
|
||||||
|
meId.value !== null && props.decision.measurerIds.includes(meId.value))
|
||||||
|
|
||||||
|
const effects = computed(() => props.decision.brief?.effects ?? [])
|
||||||
|
|
||||||
|
const review = computed(() => props.decision.review)
|
||||||
|
const reviewDue = computed(() =>
|
||||||
|
review.value !== undefined
|
||||||
|
&& review.value.verdict === undefined
|
||||||
|
&& new Date(review.value.dueAt).getTime() <= Date.now())
|
||||||
|
|
||||||
|
const reviewDoneLabel = computed(() => {
|
||||||
|
const r = review.value
|
||||||
|
if (!r?.verdict) return ''
|
||||||
|
return `${reviewLabels[r.verdict]} — le ${dateFr(r.decidedAt)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
/** One line for the facts list — done label, or the planned date. */
|
||||||
|
const reviewLine = computed(() => {
|
||||||
|
const r = review.value
|
||||||
|
if (!r) return ''
|
||||||
|
return r.verdict ? reviewDoneLabel.value : `prévue le ${dateFr(r.dueAt)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Le geste d'épreuve ──
|
||||||
|
const reviewNote = ref('')
|
||||||
|
const feedback = ref('')
|
||||||
|
async function giveReview(kind: 'confirmed' | 'revise' | 'revoke') {
|
||||||
|
const note = reviewNote.value.trim()
|
||||||
|
const result = store.reviewVerdict(props.decision.id, kind, note.length > 0 ? note : undefined)
|
||||||
|
if ('ok' in result) { feedback.value = result.reason; return }
|
||||||
|
feedback.value = ''
|
||||||
|
// revise/revoke chain a child decision under the ORIGINAL protocol — go there.
|
||||||
|
if (result.id !== props.decision.id) await navigateTo(`/decisions/${result.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Saisie de mesure (measurers) ──
|
||||||
|
const measureDrafts = ref<Record<number, string>>({})
|
||||||
|
function saveMeasure(index: number) {
|
||||||
|
const note = (measureDrafts.value[index] ?? '').trim()
|
||||||
|
if (note.length === 0 || !meId.value) return
|
||||||
|
const effect = props.decision.brief?.effects[index]
|
||||||
|
if (!effect) return
|
||||||
|
effect.measured = { note, at: col.now(), byId: meId.value }
|
||||||
|
col.stamp(props.decision)
|
||||||
|
col.persist()
|
||||||
|
measureDrafts.value[index] = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<section class="vig">
|
||||||
|
<h2 class="vig__title">En vigueur</h2>
|
||||||
|
|
||||||
|
<dl class="vig__facts">
|
||||||
|
<template v-if="stewards">
|
||||||
|
<dt>Garant·e·s</dt>
|
||||||
|
<dd>{{ stewards }}</dd>
|
||||||
|
</template>
|
||||||
|
<template v-if="measurers">
|
||||||
|
<dt>Mesureur·e·s</dt>
|
||||||
|
<dd>{{ measurers }}</dd>
|
||||||
|
</template>
|
||||||
|
<template v-if="decision.resources">
|
||||||
|
<dt>{{ ENGAGES_LABEL }}</dt>
|
||||||
|
<dd>
|
||||||
|
{{ decision.resources.note }}
|
||||||
|
<strong v-if="decision.resources.amount !== undefined">
|
||||||
|
— {{ decision.resources.amount.toLocaleString('fr-FR') }} {{ decision.resources.unit ?? '' }}
|
||||||
|
</strong>
|
||||||
|
</dd>
|
||||||
|
</template>
|
||||||
|
<template v-if="decision.sunsetAt">
|
||||||
|
<dt>Échéance de fin</dt>
|
||||||
|
<dd>{{ dateFr(decision.sunsetAt) }}</dd>
|
||||||
|
</template>
|
||||||
|
<template v-if="review">
|
||||||
|
<dt>{{ REVIEW_TITLE }}</dt>
|
||||||
|
<dd>{{ reviewLine }}</dd>
|
||||||
|
</template>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p v-if="review?.note" class="vig__review-note">« {{ review.note }} »</p>
|
||||||
|
|
||||||
|
<!-- L'épreuve du réel — due -->
|
||||||
|
<div v-if="reviewDue" class="vig__review">
|
||||||
|
<h3 class="vig__review-title">{{ REVIEW_TITLE }}</h3>
|
||||||
|
<p class="vig__review-question">{{ REVIEW_QUESTION }}</p>
|
||||||
|
|
||||||
|
<table v-if="effects.length > 0" class="vig__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Effet recherché</th>
|
||||||
|
<th>Cible</th>
|
||||||
|
<th>Constaté</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(effect, i) in effects" :key="i">
|
||||||
|
<td>{{ effect.label }}</td>
|
||||||
|
<td>{{ effect.target ?? '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<template v-if="effect.measured">
|
||||||
|
{{ effect.measured.note }}
|
||||||
|
<span class="vig__measured-at">({{ dateFr(effect.measured.at) }})</span>
|
||||||
|
</template>
|
||||||
|
<div v-else-if="iMeasure" class="vig__measure no-print">
|
||||||
|
<input
|
||||||
|
v-model="measureDrafts[i]"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ce que tu constates…"
|
||||||
|
>
|
||||||
|
<button type="button" class="ld-btn ld-btn--ghost" @click="saveMeasure(i)">
|
||||||
|
Consigner
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<template v-else>—</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-else class="vig__hint">
|
||||||
|
Aucun effet recherché n'avait été formulé — l'épreuve se joue sur la mémoire du collectif.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
v-model="reviewNote"
|
||||||
|
rows="2"
|
||||||
|
class="no-print"
|
||||||
|
placeholder="Un mot sur ce que le réel a montré (optionnel)…"
|
||||||
|
/>
|
||||||
|
<div class="vig__actions no-print">
|
||||||
|
<button type="button" class="ld-btn" @click="giveReview('confirmed')">
|
||||||
|
{{ reviewLabels.confirmed }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ld-btn ld-btn--ghost" @click="giveReview('revise')">
|
||||||
|
{{ reviewLabels.revise }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ld-btn ld-btn--ghost" @click="giveReview('revoke')">
|
||||||
|
{{ reviewLabels.revoke }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="feedback" class="vig__error">{{ feedback }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.vig { display: flex; flex-direction: column; gap: 0.875rem; }
|
||||||
|
.vig__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.vig__facts {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.375rem 1rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
.vig__facts dt {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.vig__facts dd { margin: 0; }
|
||||||
|
.vig__review-note {
|
||||||
|
margin: 0;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
.vig__review {
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: color-mix(in srgb, var(--mood-tertiary) 9%, transparent);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.vig__review-title { margin: 0; font-size: 1rem; font-weight: 800; }
|
||||||
|
.vig__review-question {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.vig__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.vig__table th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
}
|
||||||
|
.vig__table td {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
background: var(--mood-surface);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.vig__measured-at { color: var(--mood-text-muted); font-size: 0.8125rem; }
|
||||||
|
.vig__measure {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.vig__measure input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 8rem;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.vig__review textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.vig__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.vig__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.vig__error {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-error);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> Window block. Objection: « Ça me va / J'objecte » inline —
|
||||||
|
// silence counts as agreement ONLY when easily reversible, otherwise the
|
||||||
|
// explicit-agreement counter + « il manque un accord explicite ». Advice:
|
||||||
|
// three inline positions, the author then decides.
|
||||||
|
import type { Advice, Decision } from '~/types/domain'
|
||||||
|
import { ASSENT_MISSING, WINDOW_OBJECT, WINDOW_OK } from '~/lexicon'
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
import { useDecisionsStore } from '~/stores/decisions'
|
||||||
|
import { ADVICE_LABELS } from './decisionUi'
|
||||||
|
|
||||||
|
const props = defineProps<{ decision: Decision }>()
|
||||||
|
|
||||||
|
const col = useCollectiveStore()
|
||||||
|
const store = useDecisionsStore()
|
||||||
|
|
||||||
|
const meId = computed(() => col.me?.id ?? null)
|
||||||
|
const isAuthor = computed(() => meId.value === props.decision.authorId)
|
||||||
|
|
||||||
|
// ── Assents (« Ça me va » stocké) ──
|
||||||
|
const assents = computed(() =>
|
||||||
|
col.assents.filter(a => a.decisionId === props.decision.id))
|
||||||
|
const thirdPartyCount = computed(() =>
|
||||||
|
assents.value.filter(a => a.personId !== props.decision.authorId).length)
|
||||||
|
const iAssented = computed(() =>
|
||||||
|
meId.value !== null && assents.value.some(a => a.personId === meId.value))
|
||||||
|
|
||||||
|
function assent() { store.assentTo(props.decision.id) }
|
||||||
|
|
||||||
|
// ── Objection de fond ──
|
||||||
|
const objectOpen = ref(false)
|
||||||
|
const objectArg = ref('')
|
||||||
|
const feedback = ref('')
|
||||||
|
function object() {
|
||||||
|
const result = store.objectTo(props.decision.id, 'content', objectArg.value)
|
||||||
|
if ('ok' in result) { feedback.value = result.reason; return }
|
||||||
|
objectOpen.value = false
|
||||||
|
objectArg.value = ''
|
||||||
|
feedback.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Avis ──
|
||||||
|
const myAdvice = computed(() =>
|
||||||
|
col.advices.find(a => a.decisionId === props.decision.id && a.personId === meId.value))
|
||||||
|
const adviceNote = ref('')
|
||||||
|
function advise(position: Advice['position']) {
|
||||||
|
const note = adviceNote.value.trim()
|
||||||
|
store.adviseOn(props.decision.id, position, note.length > 0 ? note : undefined)
|
||||||
|
adviceNote.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The author decides once instructed — the ONE state gate answers. */
|
||||||
|
function authorAdopts() {
|
||||||
|
const result = store.transition(props.decision.id, 'adopted')
|
||||||
|
feedback.value = result.ok ? '' : result.reason
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<section class="win">
|
||||||
|
<div class="win__head">
|
||||||
|
<h2 class="win__title">
|
||||||
|
{{ decision.status === 'advice' ? 'Fenêtre d\'avis' : 'Fenêtre d\'objection' }}
|
||||||
|
</h2>
|
||||||
|
<LdCountdown
|
||||||
|
v-if="decision.windowEndsAt"
|
||||||
|
:ends-at="decision.windowEndsAt"
|
||||||
|
:suspended-at="decision.windowSuspendedAt"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="decision.status === 'objection'">
|
||||||
|
<p v-if="decision.reversibility === 'easy'" class="win__rule">
|
||||||
|
À l'échéance sans objection, c'est adopté — le silence vaut accord,
|
||||||
|
parce que c'est facilement réversible.
|
||||||
|
</p>
|
||||||
|
<template v-else>
|
||||||
|
<p class="win__rule">
|
||||||
|
{{ thirdPartyCount }} accord{{ thirdPartyCount > 1 ? 's' : '' }} explicite{{ thirdPartyCount > 1 ? 's' : '' }}
|
||||||
|
— hors du réversible, l'accord est un geste, pas une absence.
|
||||||
|
</p>
|
||||||
|
<p v-if="thirdPartyCount === 0" class="win__missing">{{ ASSENT_MISSING }}</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="win__actions no-print">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ld-btn"
|
||||||
|
:disabled="iAssented"
|
||||||
|
@click="assent()"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-check" />
|
||||||
|
<span>{{ iAssented ? `${WINDOW_OK} — déposé` : WINDOW_OK }}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ld-btn ld-btn--ghost" @click="objectOpen = !objectOpen">
|
||||||
|
{{ WINDOW_OBJECT }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="objectOpen" class="win__form no-print">
|
||||||
|
<textarea
|
||||||
|
v-model="objectArg"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Une objection s'argumente — écris pourquoi."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ld-btn"
|
||||||
|
:disabled="objectArg.trim().length === 0"
|
||||||
|
@click="object()"
|
||||||
|
>
|
||||||
|
Déposer l'objection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="decision.status === 'advice'">
|
||||||
|
<p class="win__rule">
|
||||||
|
L'auteur·e écoute, puis décide — les avis restent annexés à la décision.
|
||||||
|
</p>
|
||||||
|
<div v-if="!myAdvice" class="win__form no-print">
|
||||||
|
<textarea
|
||||||
|
v-model="adviceNote"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Un mot pour éclairer (optionnel)…"
|
||||||
|
/>
|
||||||
|
<div class="win__actions">
|
||||||
|
<button type="button" class="ld-btn" @click="advise('favorable')">
|
||||||
|
{{ ADVICE_LABELS.favorable }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ld-btn ld-btn--ghost" @click="advise('reserved')">
|
||||||
|
{{ ADVICE_LABELS.reserved }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ld-btn ld-btn--ghost" @click="advise('unfavorable')">
|
||||||
|
{{ ADVICE_LABELS.unfavorable }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="win__rule">Ton avis est déposé — {{ ADVICE_LABELS[myAdvice.position] }}.</p>
|
||||||
|
|
||||||
|
<div v-if="isAuthor" class="win__actions no-print">
|
||||||
|
<button type="button" class="ld-btn" @click="authorAdopts()">
|
||||||
|
<UIcon name="i-lucide-check-check" />
|
||||||
|
<span>Je décide — adopter</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-if="feedback" class="win__feedback">{{ feedback }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.win { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.win__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.win__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.win__rule {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.win__missing {
|
||||||
|
margin: 0;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-surface);
|
||||||
|
background: var(--mood-status-fenetre);
|
||||||
|
}
|
||||||
|
.win__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.win__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.win__form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.win__feedback {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-status-fenetre);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Visual stepper/timeline showing the decision workflow.
|
|
||||||
*
|
|
||||||
* Displays each step with its type icon, status badge, and dates.
|
|
||||||
* The active step is highlighted, completed steps show a checkmark.
|
|
||||||
*/
|
|
||||||
import type { DecisionStep } from '~/stores/decisions'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
steps: DecisionStep[]
|
|
||||||
currentStatus: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'create-vote-session': [step: DecisionStep]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const sortedSteps = computed(() => {
|
|
||||||
return [...props.steps].sort((a, b) => a.step_order - b.step_order)
|
|
||||||
})
|
|
||||||
|
|
||||||
const stepTypeLabel = (stepType: string) => {
|
|
||||||
switch (stepType) {
|
|
||||||
case 'qualification': return 'Qualification'
|
|
||||||
case 'review': return 'Revue'
|
|
||||||
case 'vote': return 'Vote'
|
|
||||||
case 'execution': return 'Execution'
|
|
||||||
case 'reporting': return 'Compte rendu'
|
|
||||||
default: return stepType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stepTypeIcon = (stepType: string) => {
|
|
||||||
switch (stepType) {
|
|
||||||
case 'qualification': return 'i-lucide-check-square'
|
|
||||||
case 'review': return 'i-lucide-eye'
|
|
||||||
case 'vote': return 'i-lucide-vote'
|
|
||||||
case 'execution': return 'i-lucide-play'
|
|
||||||
case 'reporting': return 'i-lucide-file-text'
|
|
||||||
default: return 'i-lucide-circle'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div v-if="sortedSteps.length === 0" class="text-center py-8">
|
|
||||||
<UIcon name="i-lucide-list-checks" class="text-4xl text-gray-400 mb-3" />
|
|
||||||
<p class="text-gray-500">Aucune etape definie pour cette decision</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="relative">
|
|
||||||
<!-- Timeline line -->
|
|
||||||
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
|
|
||||||
|
|
||||||
<!-- Steps -->
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="step in sortedSteps"
|
|
||||||
:key="step.id"
|
|
||||||
class="relative pl-12"
|
|
||||||
>
|
|
||||||
<!-- Timeline dot -->
|
|
||||||
<div
|
|
||||||
class="absolute left-2 w-5 h-5 rounded-full border-2 flex items-center justify-center"
|
|
||||||
:class="{
|
|
||||||
'bg-green-500 border-green-500': step.status === 'completed',
|
|
||||||
'bg-primary border-primary': step.status === 'active' || step.status === 'in_progress',
|
|
||||||
'bg-yellow-400 border-yellow-400': step.status === 'pending',
|
|
||||||
'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600': step.status === 'draft',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<UIcon
|
|
||||||
v-if="step.status === 'completed'"
|
|
||||||
name="i-lucide-check"
|
|
||||||
class="text-white text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UCard>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon :name="stepTypeIcon(step.step_type)" class="text-gray-500" />
|
|
||||||
<span class="text-sm font-mono text-gray-400">Etape {{ step.step_order }}</span>
|
|
||||||
<UBadge variant="subtle" color="neutral" size="xs">
|
|
||||||
{{ stepTypeLabel(step.step_type) }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
<StatusBadge :status="step.status" type="decision" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 v-if="step.title" class="font-medium text-gray-900 dark:text-white">
|
|
||||||
{{ step.title }}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p v-if="step.description" class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{{ step.description }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="text-xs text-gray-500">
|
|
||||||
Cree le {{ formatDate(step.created_at) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="step.outcome" class="flex items-center gap-2 mt-2">
|
|
||||||
<UIcon name="i-lucide-flag" class="text-gray-400" />
|
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Resultat : {{ step.outcome }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Vote session actions -->
|
|
||||||
<div class="flex items-center gap-2 mt-2">
|
|
||||||
<UButton
|
|
||||||
v-if="step.vote_session_id"
|
|
||||||
size="xs"
|
|
||||||
variant="soft"
|
|
||||||
color="primary"
|
|
||||||
icon="i-lucide-vote"
|
|
||||||
label="Voir la session de vote"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
v-else-if="step.step_type === 'vote' && (step.status === 'active' || step.status === 'pending')"
|
|
||||||
size="xs"
|
|
||||||
variant="soft"
|
|
||||||
color="primary"
|
|
||||||
icon="i-lucide-plus"
|
|
||||||
label="Creer une session de vote"
|
|
||||||
@click.stop="emit('create-vote-session', step)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Shared UI helpers of the decisions screens (registry + fiche) — v2.
|
||||||
|
* Labels that complement app/lexicon.ts locally (short weight forms, chain
|
||||||
|
* kinds, circle-kind iconography) live here; the lexicon stays the single
|
||||||
|
* source for everything it already names.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ChainKind,
|
||||||
|
CircleKind,
|
||||||
|
Decision,
|
||||||
|
DecisionStatus,
|
||||||
|
VoteSession,
|
||||||
|
Weight,
|
||||||
|
} from '~/types/domain'
|
||||||
|
import { FROZEN_LABEL, STATUS_LABELS } from '~/lexicon'
|
||||||
|
|
||||||
|
/** Short weight forms — registry pills and card metadata. */
|
||||||
|
export const WEIGHT_SHORT: Record<Weight, string> = {
|
||||||
|
light: 'léger',
|
||||||
|
binding: 'engageant',
|
||||||
|
structural: 'structurant',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chain kinds in plain French (fiche chain block). */
|
||||||
|
export const CHAIN_LABELS: Record<ChainKind, string> = {
|
||||||
|
ratification: 'ratification',
|
||||||
|
revision: 'révision',
|
||||||
|
revocation: 'révocation',
|
||||||
|
element: 'élément',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Circle kind iconography — lieu / thème / équipe (never a right). */
|
||||||
|
export const KIND_ICONS: Record<CircleKind, string> = {
|
||||||
|
place: 'i-lucide-map-pin',
|
||||||
|
theme: 'i-lucide-tag',
|
||||||
|
team: 'i-lucide-users-round',
|
||||||
|
}
|
||||||
|
export const KIND_LABELS: Record<CircleKind, string> = {
|
||||||
|
place: 'Lieux',
|
||||||
|
theme: 'Thèmes',
|
||||||
|
team: 'Équipes',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advice positions in plain French. */
|
||||||
|
export const ADVICE_LABELS = {
|
||||||
|
favorable: 'favorable',
|
||||||
|
reserved: 'réservé',
|
||||||
|
unfavorable: 'défavorable',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** Display status: 'frozen' overlays 'voting' when the session is frozen. */
|
||||||
|
export type DisplayStatus = DecisionStatus | 'frozen'
|
||||||
|
|
||||||
|
export function displayStatus(decision: Decision, session?: VoteSession): DisplayStatus {
|
||||||
|
if (decision.status === 'voting' && session?.status === 'frozen') return 'frozen'
|
||||||
|
return decision.status
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayStatusLabel(status: DisplayStatus): string {
|
||||||
|
return status === 'frozen' ? FROZEN_LABEL : STATUS_LABELS[status as DecisionStatus]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Timeline color mapping — the ONE state→color mapping (moods.css tokens). */
|
||||||
|
export const STATUS_TOKEN: Record<DisplayStatus, string> = {
|
||||||
|
draft: 'prepa',
|
||||||
|
advice: 'fenetre',
|
||||||
|
objection: 'fenetre',
|
||||||
|
framing: 'prepa',
|
||||||
|
voting: 'vote',
|
||||||
|
frozen: 'fige',
|
||||||
|
adopted: 'vigueur',
|
||||||
|
rejected: 'clos',
|
||||||
|
revoked: 'revoque',
|
||||||
|
transmitted: 'fige',
|
||||||
|
closed: 'clos',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case/accent-insensitive folding — same logic as useSearch. */
|
||||||
|
export function fold(text: string): string {
|
||||||
|
return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short French date (« 12 août 2026 »). */
|
||||||
|
export function dateFr(iso?: string): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
return new Date(iso).toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric', month: 'short', year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** French date + time for timeline milestones. */
|
||||||
|
export function dateTimeFr(iso?: string): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
return new Date(iso).toLocaleString('fr-FR', {
|
||||||
|
day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { Document } from '~/stores/documents'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
documents: Document[]
|
|
||||||
loading?: boolean
|
|
||||||
}>(), {
|
|
||||||
loading: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const typeLabel = (docType: string): string => {
|
|
||||||
switch (docType) {
|
|
||||||
case 'licence': return 'Licence'
|
|
||||||
case 'engagement': return 'Engagement'
|
|
||||||
case 'reglement': return 'Reglement'
|
|
||||||
case 'constitution': return 'Constitution'
|
|
||||||
default: return docType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- Loading state -->
|
|
||||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<USkeleton v-for="i in 6" :key="i" class="h-48 w-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty state -->
|
|
||||||
<UCard v-else-if="documents.length === 0">
|
|
||||||
<div class="text-center py-8">
|
|
||||||
<UIcon name="i-lucide-book-open" class="text-4xl text-gray-400 mb-3" />
|
|
||||||
<p class="text-gray-500">Aucun document de reference pour le moment</p>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<!-- Document grid -->
|
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<UCard
|
|
||||||
v-for="doc in documents"
|
|
||||||
:key="doc.id"
|
|
||||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
|
|
||||||
@click="navigateTo(`/documents/${doc.slug}`)"
|
|
||||||
>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white text-sm leading-tight">
|
|
||||||
{{ doc.title }}
|
|
||||||
</h3>
|
|
||||||
<StatusBadge :status="doc.status" type="document" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Type + Version -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UBadge variant="subtle" color="primary" size="xs">
|
|
||||||
{{ typeLabel(doc.doc_type) }}
|
|
||||||
</UBadge>
|
|
||||||
<span class="text-xs text-gray-500 font-mono">v{{ doc.version }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<p
|
|
||||||
v-if="doc.description"
|
|
||||||
class="text-xs text-gray-600 dark:text-gray-400 line-clamp-2"
|
|
||||||
>
|
|
||||||
{{ doc.description }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<div class="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
|
|
||||||
<div class="flex items-center gap-1 text-xs text-gray-500">
|
|
||||||
<UIcon name="i-lucide-list" class="text-sm" />
|
|
||||||
<span>{{ doc.items_count }} items</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-gray-400">{{ formatDate(doc.updated_at) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* DocumentPreview — clean "PDF-like" viewer for a reference document.
|
|
||||||
*
|
|
||||||
* Two modes:
|
|
||||||
* - current: shows current_text for every item (document en vigueur)
|
|
||||||
* - projected: applies latest "vote" or "proposed" version per item,
|
|
||||||
* highlighting changed clauses (document tel qu'il serait si les
|
|
||||||
* votes en cours passaient)
|
|
||||||
*/
|
|
||||||
import type { Document, DocumentItem, ItemVersion } from '~/stores/documents'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
document: Document
|
|
||||||
items: DocumentItem[]
|
|
||||||
mode: 'current' | 'projected'
|
|
||||||
versionMap: Record<string, ItemVersion | null>
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const sortedItems = computed(() =>
|
|
||||||
[...props.items].sort((a, b) => a.sort_order - b.sort_order),
|
|
||||||
)
|
|
||||||
|
|
||||||
const changedCount = computed(() =>
|
|
||||||
props.mode === 'projected'
|
|
||||||
? Object.values(props.versionMap).filter(Boolean).length
|
|
||||||
: 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
function getDisplayText(item: DocumentItem): string {
|
|
||||||
if (props.mode === 'projected' && props.versionMap[item.id]) {
|
|
||||||
return props.versionMap[item.id]!.proposed_text
|
|
||||||
}
|
|
||||||
return item.current_text
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChanged(item: DocumentItem): boolean {
|
|
||||||
return props.mode === 'projected' && !!props.versionMap[item.id]
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemTypeLabel(type: string): string {
|
|
||||||
switch (type) {
|
|
||||||
case 'clause': return 'Engagement'
|
|
||||||
case 'rule': return 'Variable'
|
|
||||||
case 'verification': return 'Application'
|
|
||||||
case 'preamble': return 'Préambule'
|
|
||||||
default: return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const today = new Date().toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="doc-preview">
|
|
||||||
<!-- ══ Document header ══ -->
|
|
||||||
<div class="doc-preview__header">
|
|
||||||
<div class="doc-preview__watermark" aria-hidden="true">
|
|
||||||
{{ mode === 'projected' ? 'PROJECTION' : 'EN VIGUEUR' }}
|
|
||||||
</div>
|
|
||||||
<h1 class="doc-preview__title">{{ document.title }}</h1>
|
|
||||||
<div class="doc-preview__meta">
|
|
||||||
<span>Version {{ document.version }}</span>
|
|
||||||
<span class="doc-preview__sep">·</span>
|
|
||||||
<span>{{ document.items_count }} items</span>
|
|
||||||
<span v-if="mode === 'projected'" class="doc-preview__proj-badge">
|
|
||||||
<UIcon name="i-lucide-flask-conical" class="text-xs" />
|
|
||||||
{{ changedCount }} modification{{ changedCount > 1 ? 's' : '' }} projetée{{ changedCount > 1 ? 's' : '' }}
|
|
||||||
</span>
|
|
||||||
<span v-else class="doc-preview__current-badge">
|
|
||||||
<UIcon name="i-lucide-circle-check" class="text-xs" />
|
|
||||||
Texte officiel
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ══ Items ══ -->
|
|
||||||
<div class="doc-preview__body">
|
|
||||||
<template v-for="item in sortedItems" :key="item.id">
|
|
||||||
<!-- Section heading -->
|
|
||||||
<div v-if="item.item_type === 'section'" class="doc-preview__section">
|
|
||||||
<h2 class="doc-preview__section-title">
|
|
||||||
<UIcon name="i-lucide-bookmark" class="text-sm" style="color: var(--mood-accent)" />
|
|
||||||
{{ item.title || item.current_text }}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Preamble -->
|
|
||||||
<div v-else-if="item.item_type === 'preamble'" class="doc-preview__preamble">
|
|
||||||
<MarkdownRenderer :content="getDisplayText(item)" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Regular clause / rule / verification -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="doc-preview__item"
|
|
||||||
:class="{ 'doc-preview__item--changed': isChanged(item) }"
|
|
||||||
>
|
|
||||||
<div class="doc-preview__item-head">
|
|
||||||
<span class="doc-preview__item-pos">{{ item.position }}</span>
|
|
||||||
<span v-if="item.title" class="doc-preview__item-title">{{ item.title }}</span>
|
|
||||||
<span v-if="itemTypeLabel(item.item_type)" class="doc-preview__item-type">
|
|
||||||
{{ itemTypeLabel(item.item_type) }}
|
|
||||||
</span>
|
|
||||||
<span v-if="isChanged(item)" class="doc-preview__change-chip">
|
|
||||||
<UIcon name="i-lucide-git-branch" class="text-xs" />
|
|
||||||
Vote en cours
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="doc-preview__item-text">
|
|
||||||
<MarkdownRenderer :content="getDisplayText(item)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ══ Footer ══ -->
|
|
||||||
<div class="doc-preview__footer">
|
|
||||||
<div class="doc-preview__footer-main">
|
|
||||||
<span>libreDecision · {{ document.title }} · v{{ document.version }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="mode === 'projected'" class="doc-preview__footer-note">
|
|
||||||
Projection non officielle — texte simulé selon {{ changedCount }} vote{{ changedCount > 1 ? 's' : '' }} en cours au {{ today }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.doc-preview {
|
|
||||||
position: relative;
|
|
||||||
background: var(--mood-surface);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: clamp(1.5rem, 4vw, 3rem);
|
|
||||||
box-shadow: 0 4px 32px var(--mood-shadow);
|
|
||||||
line-height: 1.75;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Watermark ── */
|
|
||||||
.doc-preview__watermark {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%) rotate(-35deg);
|
|
||||||
font-size: clamp(2rem, 8vw, 5rem);
|
|
||||||
font-weight: 900;
|
|
||||||
letter-spacing: 0.15em;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
opacity: 0.03;
|
|
||||||
pointer-events: none;
|
|
||||||
user-select: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Header ── */
|
|
||||||
.doc-preview__header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
padding-bottom: 1.5rem;
|
|
||||||
border-bottom: 2px solid color-mix(in srgb, var(--mood-accent) 20%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-preview__title {
|
|
||||||
font-size: clamp(1.25rem, 3vw, 1.875rem);
|
|
||||||
font-weight: 900;
|
|
||||||
color: var(--mood-text);
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
margin-bottom: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-preview__meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-preview__sep { opacity: 0.3; }
|
|
||||||
|
|
||||||
.doc-preview__proj-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 2px 0.625rem;
|
|
||||||
background: color-mix(in srgb, var(--mood-warning, #f59e0b) 15%, transparent);
|
|
||||||
color: var(--mood-warning, #d97706);
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-preview__current-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 2px 0.625rem;
|
|
||||||
background: color-mix(in srgb, var(--mood-success, #16a34a) 12%, transparent);
|
|
||||||
color: var(--mood-success, #16a34a);
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Body ── */
|
|
||||||
.doc-preview__body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section heading */
|
|
||||||
.doc-preview__section {
|
|
||||||
padding-top: 1.25rem;
|
|
||||||
padding-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-preview__section-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
|
||||||
padding-bottom: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Preamble */
|
|
||||||
.doc-preview__preamble {
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 5%, transparent);
|
|
||||||
border-radius: 12px;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Item */
|
|
||||||
.doc-preview__item {
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: color-mix(in srgb, var(--mood-bg) 35%, transparent);
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-preview__item--changed {
|
|
||||||
background: color-mix(in srgb, var(--mood-warning, #f59e0b) 8%, var(--mood-surface));
|
|
||||||
border-left: 3px solid var(--mood-warning, #f59e0b);
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-preview__item-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.375rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-preview__item-pos {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 800;
|
|
||||||
font-family: monospace;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
min-width: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-preview__item-title {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-preview__item-type {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-preview__change-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 1px 0.5rem;
|
|
||||||
background: color-mix(in srgb, var(--mood-warning, #f59e0b) 18%, transparent);
|
|
||||||
color: var(--mood-warning, #d97706);
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-preview__item-text {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--mood-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Footer ── */
|
|
||||||
.doc-preview__footer {
|
|
||||||
margin-top: 3rem;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-preview__footer-main {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-preview__footer-note {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
font-style: italic;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Print ── */
|
|
||||||
@media print {
|
|
||||||
.doc-preview {
|
|
||||||
box-shadow: none;
|
|
||||||
padding: 2rem;
|
|
||||||
background: white;
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
.doc-preview__watermark { opacity: 0.05; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* DocumentTuto — Quick tutorial overlay explaining how the document works.
|
|
||||||
* Shows how permanent voting, inertia, counter-proposals, and thresholds work.
|
|
||||||
*/
|
|
||||||
const open = ref(false)
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
{
|
|
||||||
icon: 'i-lucide-infinity',
|
|
||||||
title: 'Vote permanent',
|
|
||||||
text: 'Chaque engagement est sous vote permanent. À tout moment, vous pouvez proposer une alternative ou voter pour/contre le texte en vigueur.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'i-lucide-sliders-horizontal',
|
|
||||||
title: 'Inertie variable',
|
|
||||||
text: 'Les engagements fondamentaux ont une inertie standard (difficulté de remplacement modérée). Les annexes sont plus faciles à modifier. La formule et ses réglages sont très protégés.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'i-lucide-scale',
|
|
||||||
title: 'Seuil adaptatif',
|
|
||||||
text: 'La formule WoT adapte le seuil à la participation : peu de votants = quasi-unanimité requise ; beaucoup de votants = majorité simple suffit.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'i-lucide-pen-line',
|
|
||||||
title: 'Contre-propositions',
|
|
||||||
text: 'Cliquez sur « Proposer une alternative » pour soumettre un texte de remplacement. Il sera soumis au vote et devra atteindre le seuil d\'adoption pour remplacer le texte en vigueur.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'i-lucide-git-branch',
|
|
||||||
title: 'Dépôt automatique',
|
|
||||||
text: 'Quand une alternative est adoptée, le document officiel est mis à jour, ancré sur IPFS et on-chain, puis déployé dans les applications (Cesium, Gecko).',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<UButton
|
|
||||||
icon="i-lucide-circle-help"
|
|
||||||
variant="ghost"
|
|
||||||
color="neutral"
|
|
||||||
size="sm"
|
|
||||||
@click="open = true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UModal v-model:open="open" :ui="{ content: 'max-w-lg' }">
|
|
||||||
<template #content>
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="flex items-center justify-between mb-5">
|
|
||||||
<h2 class="text-lg font-bold" style="color: var(--mood-text)">
|
|
||||||
Comment ça marche ?
|
|
||||||
</h2>
|
|
||||||
<UButton
|
|
||||||
icon="i-lucide-x"
|
|
||||||
variant="ghost"
|
|
||||||
color="neutral"
|
|
||||||
size="xs"
|
|
||||||
@click="open = false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
v-for="(step, idx) in steps"
|
|
||||||
:key="idx"
|
|
||||||
class="tuto-step"
|
|
||||||
>
|
|
||||||
<div class="tuto-step__icon">
|
|
||||||
<UIcon :name="step.icon" class="text-base" />
|
|
||||||
</div>
|
|
||||||
<div class="tuto-step__content">
|
|
||||||
<h4 class="text-sm font-bold" style="color: var(--mood-text)">
|
|
||||||
{{ step.title }}
|
|
||||||
</h4>
|
|
||||||
<p class="text-xs leading-relaxed" style="color: var(--mood-text-muted)">
|
|
||||||
{{ step.text }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 pt-4 border-t" style="border-color: color-mix(in srgb, var(--mood-text) 8%, transparent)">
|
|
||||||
<p class="text-xs text-center" style="color: var(--mood-text-muted)">
|
|
||||||
Référence : formule g1vote —
|
|
||||||
<a
|
|
||||||
href="https://g1vote-view-237903.pages.duniter.org/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
style="color: var(--mood-accent)"
|
|
||||||
>
|
|
||||||
g1vote-view
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UModal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.tuto-step {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tuto-step__icon {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
|
|
||||||
color: var(--mood-accent);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tuto-step__content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.125rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* EngagementCard — Enhanced item card with inline mini vote board,
|
|
||||||
* inertia indicator, and action buttons.
|
|
||||||
*
|
|
||||||
* Replaces the basic ItemCard for the document detail view.
|
|
||||||
*/
|
|
||||||
import type { DocumentItem } from '~/stores/documents'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
item: DocumentItem
|
|
||||||
documentSlug: string
|
|
||||||
showActions?: boolean
|
|
||||||
showVoteBoard?: boolean
|
|
||||||
}>(), {
|
|
||||||
showActions: false,
|
|
||||||
showVoteBoard: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
propose: [item: DocumentItem]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isSection = computed(() => props.item.item_type === 'section')
|
|
||||||
const isPreamble = computed(() => props.item.item_type === 'preamble')
|
|
||||||
|
|
||||||
const itemTypeIcon = computed(() => {
|
|
||||||
switch (props.item.item_type) {
|
|
||||||
case 'clause': return 'i-lucide-shield-check'
|
|
||||||
case 'rule': return 'i-lucide-scale'
|
|
||||||
case 'verification': return 'i-lucide-check-circle'
|
|
||||||
case 'preamble': return 'i-lucide-scroll-text'
|
|
||||||
case 'section': return 'i-lucide-layout-list'
|
|
||||||
default: return 'i-lucide-file-text'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const itemTypeLabel = computed(() => {
|
|
||||||
switch (props.item.item_type) {
|
|
||||||
case 'clause': return 'Engagement'
|
|
||||||
case 'rule': return 'Variables'
|
|
||||||
case 'verification': return 'Application'
|
|
||||||
case 'preamble': return 'Préambule'
|
|
||||||
case 'section': return 'Titre'
|
|
||||||
default: return props.item.item_type
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mock vote data varies by item for demo — items in "bonnes pratiques" (E8-E11) get lower/mixed votes
|
|
||||||
const mockVotes = computed(() => {
|
|
||||||
const order = props.item.sort_order
|
|
||||||
const pos = props.item.position
|
|
||||||
|
|
||||||
// Conseils et bonnes pratiques: varied votes, some non-adopted
|
|
||||||
if (pos === 'E8') return { votesFor: 4, votesAgainst: 3 } // contested
|
|
||||||
if (pos === 'E9') return { votesFor: 2, votesAgainst: 5 } // rejected
|
|
||||||
if (pos === 'E10') return { votesFor: 6, votesAgainst: 2 } // borderline
|
|
||||||
if (pos === 'E11') return { votesFor: 3, votesAgainst: 4 } // rejected
|
|
||||||
|
|
||||||
// Default: well-adopted items
|
|
||||||
const base = ((order * 7 + 13) % 5) + 8 // 8-12
|
|
||||||
const against = (order % 3) // 0-2
|
|
||||||
return { votesFor: base, votesAgainst: against }
|
|
||||||
})
|
|
||||||
|
|
||||||
function navigateToItem() {
|
|
||||||
navigateTo(`/documents/${props.documentSlug}/items/${props.item.id}`)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- Section header (visual separator, not a card) -->
|
|
||||||
<div v-if="isSection" class="engagement-section">
|
|
||||||
<div class="engagement-section__line" />
|
|
||||||
<div class="engagement-section__content">
|
|
||||||
<h3 class="engagement-section__title">
|
|
||||||
{{ item.title }}
|
|
||||||
</h3>
|
|
||||||
<p class="engagement-section__text">
|
|
||||||
{{ item.current_text }}
|
|
||||||
</p>
|
|
||||||
<InertiaSlider
|
|
||||||
:preset="item.inertia_preset"
|
|
||||||
compact
|
|
||||||
class="mt-2 max-w-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Regular item card -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="engagement-card"
|
|
||||||
:class="{
|
|
||||||
'engagement-card--preamble': isPreamble,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<!-- Card header -->
|
|
||||||
<div class="engagement-card__header" @click="navigateToItem">
|
|
||||||
<div class="flex items-center gap-2.5 min-w-0">
|
|
||||||
<div class="engagement-card__position">
|
|
||||||
{{ item.position }}
|
|
||||||
</div>
|
|
||||||
<UIcon :name="itemTypeIcon" class="text-sm shrink-0" style="color: var(--mood-accent)" />
|
|
||||||
<span v-if="item.title" class="engagement-card__title">
|
|
||||||
{{ item.title }}
|
|
||||||
</span>
|
|
||||||
<span class="engagement-card__type-label">
|
|
||||||
{{ itemTypeLabel }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Item text -->
|
|
||||||
<div class="engagement-card__body" @click="navigateToItem">
|
|
||||||
<MarkdownRenderer :content="item.current_text" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mini vote board -->
|
|
||||||
<div v-if="showVoteBoard" class="engagement-card__vote">
|
|
||||||
<MiniVoteBoard
|
|
||||||
:votes-for="mockVotes.votesFor"
|
|
||||||
:votes-against="mockVotes.votesAgainst"
|
|
||||||
:wot-size="7224"
|
|
||||||
:is-permanent="item.is_permanent_vote"
|
|
||||||
:inertia-preset="item.inertia_preset"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Inertia indicator -->
|
|
||||||
<div class="engagement-card__inertia">
|
|
||||||
<InertiaSlider
|
|
||||||
:preset="item.inertia_preset"
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div v-if="showActions" class="engagement-card__actions">
|
|
||||||
<UButton
|
|
||||||
label="Proposer une alternative"
|
|
||||||
icon="i-lucide-pen-line"
|
|
||||||
variant="soft"
|
|
||||||
color="primary"
|
|
||||||
size="xs"
|
|
||||||
@click.stop="emit('propose', item)"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
label="Voter"
|
|
||||||
icon="i-lucide-vote"
|
|
||||||
variant="soft"
|
|
||||||
color="success"
|
|
||||||
size="xs"
|
|
||||||
@click.stop="navigateToItem"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Section separator */
|
|
||||||
.engagement-section {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1.5rem 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-section__line {
|
|
||||||
width: 4px;
|
|
||||||
background: var(--mood-accent);
|
|
||||||
border-radius: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-section__content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-section__title {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--mood-text);
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-section__text {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card */
|
|
||||||
.engagement-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--mood-surface);
|
|
||||||
border-radius: 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: box-shadow 0.15s, transform 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card:hover {
|
|
||||||
box-shadow: 0 2px 12px color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card--preamble {
|
|
||||||
border-left: 4px solid color-mix(in srgb, var(--mood-accent) 40%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.875rem 1rem 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card__position {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 2rem;
|
|
||||||
height: 1.625rem;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--mood-accent);
|
|
||||||
color: white;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card__title {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-text);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card__type-label {
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
opacity: 0.5;
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 0.125rem 0.375rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 8%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card__body {
|
|
||||||
padding: 0.5rem 1rem 0.625rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--mood-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.engagement-card__body {
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card__vote {
|
|
||||||
padding: 0 1rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
transform: scale(0.92);
|
|
||||||
transform-origin: left center;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card:hover .engagement-card__vote {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card__inertia {
|
|
||||||
padding: 0.25rem 1rem 0.5rem;
|
|
||||||
opacity: 0.6;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card:hover .engagement-card__inertia {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card__actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.625rem 1rem;
|
|
||||||
border-top: 1px solid color-mix(in srgb, var(--mood-text) 6%, transparent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,489 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Genesis block: displays source documents, repos, forum synthesis, and formula trigger
|
|
||||||
* for a reference document. Main block collapsible, each sub-section independently collapsible.
|
|
||||||
*/
|
|
||||||
const props = defineProps<{
|
|
||||||
genesisJson: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const expanded = ref(false)
|
|
||||||
|
|
||||||
// Individual section toggles
|
|
||||||
const sectionOpen = reactive<Record<string, boolean>>({
|
|
||||||
source: true,
|
|
||||||
tools: false,
|
|
||||||
forum: true,
|
|
||||||
process: false,
|
|
||||||
contributors: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
function toggleSection(key: string) {
|
|
||||||
sectionOpen[key] = !sectionOpen[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GenesisData {
|
|
||||||
source_document: {
|
|
||||||
title: string
|
|
||||||
url: string
|
|
||||||
repo: string
|
|
||||||
}
|
|
||||||
reference_tools: Record<string, string>
|
|
||||||
forum_synthesis: Array<{
|
|
||||||
title: string
|
|
||||||
url: string
|
|
||||||
status: string
|
|
||||||
posts?: number
|
|
||||||
}>
|
|
||||||
formula_trigger: string
|
|
||||||
contributors: Array<{
|
|
||||||
name: string
|
|
||||||
role: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
const genesis = computed((): GenesisData | null => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(props.genesisJson)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const statusLabel = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'rejected': return 'Rejetée'
|
|
||||||
case 'in_progress': return 'En cours'
|
|
||||||
case 'reference': return 'Référence'
|
|
||||||
default: return status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusClass = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'rejected': return 'genesis-status--rejected'
|
|
||||||
case 'in_progress': return 'genesis-status--progress'
|
|
||||||
case 'reference': return 'genesis-status--reference'
|
|
||||||
default: return 'genesis-status--default'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="genesis" class="genesis-block">
|
|
||||||
<!-- Header (always visible) -->
|
|
||||||
<button
|
|
||||||
class="genesis-block__header"
|
|
||||||
@click="expanded = !expanded"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="genesis-block__icon">
|
|
||||||
<UIcon name="i-lucide-file-archive" class="text-lg" />
|
|
||||||
</div>
|
|
||||||
<div class="text-left">
|
|
||||||
<h3 class="text-sm font-bold uppercase tracking-wide genesis-accent">
|
|
||||||
Bloc de genèse
|
|
||||||
</h3>
|
|
||||||
<p class="text-xs genesis-text-muted">
|
|
||||||
Sources, références et formule de dépôt
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<UIcon
|
|
||||||
:name="expanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
||||||
class="text-lg genesis-muted-icon"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Expandable content -->
|
|
||||||
<Transition name="genesis-expand">
|
|
||||||
<div v-if="expanded" class="genesis-block__body">
|
|
||||||
<!-- Source document -->
|
|
||||||
<div class="genesis-section">
|
|
||||||
<button class="genesis-section__toggle" @click="toggleSection('source')">
|
|
||||||
<h4 class="genesis-section__title">
|
|
||||||
<UIcon name="i-lucide-file-text" />
|
|
||||||
Document source
|
|
||||||
</h4>
|
|
||||||
<UIcon
|
|
||||||
:name="sectionOpen.source ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
||||||
class="text-sm genesis-muted-icon"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div v-if="sectionOpen.source" class="genesis-section__content">
|
|
||||||
<div class="genesis-card">
|
|
||||||
<p class="font-medium text-sm genesis-text">
|
|
||||||
{{ genesis.source_document.title }}
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-col gap-1 mt-2">
|
|
||||||
<a
|
|
||||||
:href="genesis.source_document.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
class="genesis-link"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-external-link" class="text-xs" />
|
|
||||||
Texte officiel
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
:href="genesis.source_document.repo"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
class="genesis-link"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-git-branch" class="text-xs" />
|
|
||||||
Dépôt git
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reference tools -->
|
|
||||||
<div class="genesis-section">
|
|
||||||
<button class="genesis-section__toggle" @click="toggleSection('tools')">
|
|
||||||
<h4 class="genesis-section__title">
|
|
||||||
<UIcon name="i-lucide-wrench" />
|
|
||||||
Outils de référence
|
|
||||||
</h4>
|
|
||||||
<UIcon
|
|
||||||
:name="sectionOpen.tools ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
||||||
class="text-sm genesis-muted-icon"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div v-if="sectionOpen.tools" class="genesis-section__content">
|
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<a
|
|
||||||
v-for="(url, name) in genesis.reference_tools"
|
|
||||||
:key="name"
|
|
||||||
:href="url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
class="genesis-card genesis-card--tool"
|
|
||||||
>
|
|
||||||
<span class="text-xs font-semibold capitalize genesis-text">
|
|
||||||
{{ name.replace(/_/g, ' ') }}
|
|
||||||
</span>
|
|
||||||
<UIcon name="i-lucide-external-link" class="text-xs genesis-text-muted" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Forum synthesis -->
|
|
||||||
<div class="genesis-section">
|
|
||||||
<button class="genesis-section__toggle" @click="toggleSection('forum')">
|
|
||||||
<h4 class="genesis-section__title">
|
|
||||||
<UIcon name="i-lucide-messages-square" />
|
|
||||||
Synthèse des discussions
|
|
||||||
</h4>
|
|
||||||
<UIcon
|
|
||||||
:name="sectionOpen.forum ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
||||||
class="text-sm genesis-muted-icon"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div v-if="sectionOpen.forum" class="genesis-section__content">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<a
|
|
||||||
v-for="topic in genesis.forum_synthesis"
|
|
||||||
:key="topic.url"
|
|
||||||
:href="topic.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
class="genesis-card genesis-card--forum"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<span class="text-xs font-medium genesis-text">
|
|
||||||
{{ topic.title }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="genesis-status shrink-0"
|
|
||||||
:class="statusClass(topic.status)"
|
|
||||||
>
|
|
||||||
{{ statusLabel(topic.status) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span v-if="topic.posts" class="text-xs genesis-text-muted">
|
|
||||||
{{ topic.posts }} messages
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Formula trigger -->
|
|
||||||
<div class="genesis-section">
|
|
||||||
<button class="genesis-section__toggle" @click="toggleSection('process')">
|
|
||||||
<h4 class="genesis-section__title">
|
|
||||||
<UIcon name="i-lucide-zap" />
|
|
||||||
Processus de dépôt
|
|
||||||
</h4>
|
|
||||||
<UIcon
|
|
||||||
:name="sectionOpen.process ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
||||||
class="text-sm genesis-muted-icon"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div v-if="sectionOpen.process" class="genesis-section__content">
|
|
||||||
<div class="genesis-card">
|
|
||||||
<p class="text-xs leading-relaxed genesis-text">
|
|
||||||
{{ genesis.formula_trigger }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contributors -->
|
|
||||||
<div class="genesis-section">
|
|
||||||
<button class="genesis-section__toggle" @click="toggleSection('contributors')">
|
|
||||||
<h4 class="genesis-section__title">
|
|
||||||
<UIcon name="i-lucide-users" />
|
|
||||||
Contributeurs
|
|
||||||
</h4>
|
|
||||||
<UIcon
|
|
||||||
:name="sectionOpen.contributors ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
||||||
class="text-sm genesis-muted-icon"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div v-if="sectionOpen.contributors" class="genesis-section__content">
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<div
|
|
||||||
v-for="c in genesis.contributors"
|
|
||||||
:key="c.name"
|
|
||||||
class="genesis-contributor"
|
|
||||||
>
|
|
||||||
<span class="font-semibold text-xs genesis-text">{{ c.name }}</span>
|
|
||||||
<span class="text-xs genesis-text-muted">{{ c.role }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.genesis-block {
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 8%, var(--mood-surface));
|
|
||||||
border: 1px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
|
||||||
border-radius: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-block__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
cursor: pointer;
|
|
||||||
background: none;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-block__header:hover {
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 4%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-block__header h3 {
|
|
||||||
color: var(--mood-accent) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-block__header p {
|
|
||||||
color: var(--mood-text-muted) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-block__icon {
|
|
||||||
width: 2.25rem;
|
|
||||||
height: 2.25rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
|
||||||
color: var(--mood-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-block__body {
|
|
||||||
padding: 0 1.25rem 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-section {
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 4%, var(--mood-bg));
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-section__toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-section__toggle:hover {
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 6%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-section__title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-section__toggle .text-sm {
|
|
||||||
color: var(--mood-text-muted) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-section__content {
|
|
||||||
padding: 0 0.75rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-card {
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 5%, var(--mood-surface));
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-card .font-medium,
|
|
||||||
.genesis-card .text-xs,
|
|
||||||
.genesis-text {
|
|
||||||
color: var(--mood-text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-card--tool {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-card--tool .text-xs {
|
|
||||||
color: var(--mood-text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-card--tool:hover {
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-card--forum {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-card--forum:hover {
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-link {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-contributor {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 5%, var(--mood-surface));
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-contributor .font-semibold {
|
|
||||||
color: var(--mood-text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-contributor .text-xs:not(.font-semibold) {
|
|
||||||
color: var(--mood-text-muted) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status badges — palette-aware */
|
|
||||||
.genesis-status {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-status--reference {
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 20%, transparent);
|
|
||||||
color: var(--mood-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-status--progress {
|
|
||||||
background: color-mix(in srgb, var(--mood-warning) 20%, transparent);
|
|
||||||
color: var(--mood-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-status--rejected {
|
|
||||||
background: color-mix(in srgb, var(--mood-error) 20%, transparent);
|
|
||||||
color: var(--mood-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-status--default {
|
|
||||||
background: color-mix(in srgb, var(--mood-text) 8%, transparent);
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Genesis-context text utilities */
|
|
||||||
.genesis-accent {
|
|
||||||
color: var(--mood-accent) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-text {
|
|
||||||
color: var(--mood-text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-text-muted {
|
|
||||||
color: var(--mood-text-muted) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-muted-icon {
|
|
||||||
color: var(--mood-text-muted) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Expand/collapse transition */
|
|
||||||
.genesis-expand-enter-active,
|
|
||||||
.genesis-expand-leave-active {
|
|
||||||
transition: all 0.25s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-expand-enter-from,
|
|
||||||
.genesis-expand-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
max-height: 0;
|
|
||||||
padding-top: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Inertia slider — displays the inertia preset level for a section.
|
|
||||||
* Read-only indicator (voting on the preset uses the standard vote flow).
|
|
||||||
* In full mode: shows formula diagram with simplified curve visualization.
|
|
||||||
*/
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
preset: string
|
|
||||||
compact?: boolean
|
|
||||||
mini?: boolean
|
|
||||||
}>(), {
|
|
||||||
compact: false,
|
|
||||||
mini: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
interface InertiaLevel {
|
|
||||||
label: string
|
|
||||||
gradient: number
|
|
||||||
majority: number
|
|
||||||
color: string
|
|
||||||
position: number // 0-100 for slider position
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const LEVELS: Record<string, InertiaLevel> = {
|
|
||||||
low: {
|
|
||||||
label: 'Remplacement facile',
|
|
||||||
gradient: 0.1,
|
|
||||||
majority: 50,
|
|
||||||
color: '#22c55e',
|
|
||||||
position: 10,
|
|
||||||
description: 'Majorité simple suffit, même à faible participation',
|
|
||||||
},
|
|
||||||
standard: {
|
|
||||||
label: 'Inertie pour le remplacement',
|
|
||||||
gradient: 0.2,
|
|
||||||
majority: 50,
|
|
||||||
color: '#3b82f6',
|
|
||||||
position: 37,
|
|
||||||
description: 'Équilibre : consensus croissant avec la participation',
|
|
||||||
},
|
|
||||||
high: {
|
|
||||||
label: 'Remplacement difficile',
|
|
||||||
gradient: 0.4,
|
|
||||||
majority: 60,
|
|
||||||
color: '#f59e0b',
|
|
||||||
position: 63,
|
|
||||||
description: 'Forte mobilisation et super-majorité requises',
|
|
||||||
},
|
|
||||||
very_high: {
|
|
||||||
label: 'Remplacement très difficile',
|
|
||||||
gradient: 0.6,
|
|
||||||
majority: 66,
|
|
||||||
color: '#ef4444',
|
|
||||||
position: 90,
|
|
||||||
description: 'Quasi-unanimité requise à toute participation',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const level = computed((): InertiaLevel => LEVELS[props.preset] ?? LEVELS.standard!)
|
|
||||||
|
|
||||||
// Generate SVG curve points for the inertia function
|
|
||||||
// Formula simplified: Seuil% = M + (1-M) × (1 - (T/W)^G)
|
|
||||||
// Where T/W = participation rate, so Seuil% goes from ~100% at low participation to M at full participation
|
|
||||||
const curvePath = computed(() => {
|
|
||||||
const G = level.value.gradient
|
|
||||||
const M = level.value.majority / 100
|
|
||||||
const points: string[] = []
|
|
||||||
const steps = 40
|
|
||||||
|
|
||||||
for (let i = 0; i <= steps; i++) {
|
|
||||||
const participation = i / steps // T/W ratio 0..1
|
|
||||||
const threshold = M + (1 - M) * (1 - Math.pow(participation, G))
|
|
||||||
// SVG coordinates: x = participation (0..200), y = threshold inverted (0=100%, 80=20%)
|
|
||||||
const x = 30 + participation * 170
|
|
||||||
const y = 10 + (1 - threshold) * 70
|
|
||||||
points.push(`${x.toFixed(1)},${y.toFixed(1)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return `M ${points.join(' L ')}`
|
|
||||||
})
|
|
||||||
|
|
||||||
// The 4 curve paths for the diagram overlay
|
|
||||||
const allCurves = computed(() => {
|
|
||||||
return Object.entries(LEVELS).map(([key, lvl]) => {
|
|
||||||
const G = lvl.gradient
|
|
||||||
const M = lvl.majority / 100
|
|
||||||
const points: string[] = []
|
|
||||||
const steps = 40
|
|
||||||
|
|
||||||
for (let i = 0; i <= steps; i++) {
|
|
||||||
const participation = i / steps
|
|
||||||
const threshold = M + (1 - M) * (1 - Math.pow(participation, G))
|
|
||||||
const x = 30 + participation * 170
|
|
||||||
const y = 10 + (1 - threshold) * 70
|
|
||||||
points.push(`${x.toFixed(1)},${y.toFixed(1)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
color: lvl.color,
|
|
||||||
path: `M ${points.join(' L ')}`,
|
|
||||||
active: key === props.preset,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="inertia" :class="{ 'inertia--compact': compact, 'inertia--mini': mini }">
|
|
||||||
<!-- Slider track -->
|
|
||||||
<div class="inertia__track">
|
|
||||||
<div class="inertia__fill" :style="{ width: `${level.position}%`, background: level.color }" />
|
|
||||||
<div
|
|
||||||
class="inertia__thumb"
|
|
||||||
:style="{ left: `${level.position}%`, borderColor: level.color }"
|
|
||||||
/>
|
|
||||||
<!-- Level marks -->
|
|
||||||
<div
|
|
||||||
v-for="(lvl, key) in LEVELS"
|
|
||||||
:key="key"
|
|
||||||
class="inertia__mark"
|
|
||||||
:class="{ 'inertia__mark--active': key === preset }"
|
|
||||||
:style="{ left: `${lvl.position}%` }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Label row -->
|
|
||||||
<div v-if="mini" class="inertia__info">
|
|
||||||
<span class="inertia__label inertia__label--mini" :style="{ color: level.color }">
|
|
||||||
Inertie
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-else class="inertia__info">
|
|
||||||
<span class="inertia__label" :style="{ color: level.color }">
|
|
||||||
{{ level.label }}
|
|
||||||
</span>
|
|
||||||
<span v-if="!compact" class="inertia__params">
|
|
||||||
G={{ level.gradient }} M={{ level.majority }}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description (not in compact mode) -->
|
|
||||||
<p v-if="!compact" class="inertia__desc">
|
|
||||||
{{ level.description }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Formula diagram (not in compact mode) -->
|
|
||||||
<div v-if="!compact" class="inertia__diagram">
|
|
||||||
<svg viewBox="0 0 220 100" class="inertia__svg">
|
|
||||||
<!-- Grid -->
|
|
||||||
<line x1="30" y1="10" x2="30" y2="80" class="inertia__axis" />
|
|
||||||
<line x1="30" y1="80" x2="200" y2="80" class="inertia__axis" />
|
|
||||||
|
|
||||||
<!-- Grid lines -->
|
|
||||||
<line x1="30" y1="10" x2="200" y2="10" class="inertia__grid" />
|
|
||||||
<line x1="30" y1="45" x2="200" y2="45" class="inertia__grid" />
|
|
||||||
|
|
||||||
<!-- Majority line M -->
|
|
||||||
<line
|
|
||||||
x1="30"
|
|
||||||
:y1="10 + (1 - level.majority / 100) * 70"
|
|
||||||
x2="200"
|
|
||||||
:y2="10 + (1 - level.majority / 100) * 70"
|
|
||||||
class="inertia__majority-line"
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x="203"
|
|
||||||
:y="13 + (1 - level.majority / 100) * 70"
|
|
||||||
class="inertia__axis-label"
|
|
||||||
style="fill: var(--mood-accent)"
|
|
||||||
>M={{ level.majority }}%</text>
|
|
||||||
|
|
||||||
<!-- Background curves (ghosted) -->
|
|
||||||
<path
|
|
||||||
v-for="curve in allCurves"
|
|
||||||
:key="curve.key"
|
|
||||||
:d="curve.path"
|
|
||||||
fill="none"
|
|
||||||
:stroke="curve.color"
|
|
||||||
:stroke-width="curve.active ? 0 : 1"
|
|
||||||
:opacity="curve.active ? 0 : 0.15"
|
|
||||||
stroke-dasharray="3 3"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Active curve -->
|
|
||||||
<path
|
|
||||||
:d="curvePath"
|
|
||||||
fill="none"
|
|
||||||
:stroke="level.color"
|
|
||||||
stroke-width="2.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Axis labels -->
|
|
||||||
<text x="15" y="14" class="inertia__axis-label">100%</text>
|
|
||||||
<text x="15" y="49" class="inertia__axis-label">50%</text>
|
|
||||||
<text x="15" y="84" class="inertia__axis-label">0%</text>
|
|
||||||
|
|
||||||
<text x="28" y="95" class="inertia__axis-label">0%</text>
|
|
||||||
<text x="105" y="95" class="inertia__axis-label">50%</text>
|
|
||||||
<text x="185" y="95" class="inertia__axis-label">100%</text>
|
|
||||||
|
|
||||||
<!-- Axis titles -->
|
|
||||||
<text x="3" y="50" class="inertia__axis-title" transform="rotate(-90, 6, 50)">Seuil</text>
|
|
||||||
<text x="110" y="100" class="inertia__axis-title">Participation (T/W)</text>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- Simplified formula -->
|
|
||||||
<div class="inertia__formula">
|
|
||||||
<span class="inertia__formula-label">Formule :</span>
|
|
||||||
<code class="inertia__formula-code">Seuil = M + (1-M) × (1 - (T/W)<sup>G</sup>)</code>
|
|
||||||
</div>
|
|
||||||
<div class="inertia__formula-legend">
|
|
||||||
<span><strong>T</strong> = votes exprimés</span>
|
|
||||||
<span><strong>W</strong> = taille WoT</span>
|
|
||||||
<span><strong>M</strong> = majorité cible</span>
|
|
||||||
<span><strong>G</strong> = gradient d'inertie</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.inertia {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia--compact {
|
|
||||||
gap: 0.25rem;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia--mini {
|
|
||||||
gap: 0.125rem;
|
|
||||||
width: fit-content;
|
|
||||||
min-width: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia--mini .inertia__track {
|
|
||||||
height: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia--mini .inertia__thumb {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-width: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__track {
|
|
||||||
position: relative;
|
|
||||||
height: 6px;
|
|
||||||
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia--compact .inertia__track {
|
|
||||||
height: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__fill {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
right: auto;
|
|
||||||
border-radius: 3px;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__thumb {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--mood-bg);
|
|
||||||
border: 3px solid;
|
|
||||||
transition: left 0.3s ease;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia--compact .inertia__thumb {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-width: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__mark {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: color-mix(in srgb, var(--mood-text) 20%, transparent);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__mark--active {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__label {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia--compact .inertia__label {
|
|
||||||
font-size: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__label--mini {
|
|
||||||
font-size: 0.5625rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: none;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__params {
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-family: monospace;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__desc {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Diagram */
|
|
||||||
.inertia__diagram {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__svg {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 320px;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__axis {
|
|
||||||
stroke: color-mix(in srgb, var(--mood-text) 25%, transparent);
|
|
||||||
stroke-width: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__grid {
|
|
||||||
stroke: color-mix(in srgb, var(--mood-text) 8%, transparent);
|
|
||||||
stroke-width: 0.5;
|
|
||||||
stroke-dasharray: 2 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__majority-line {
|
|
||||||
stroke: var(--mood-accent);
|
|
||||||
stroke-width: 0.75;
|
|
||||||
stroke-dasharray: 4 3;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__axis-label {
|
|
||||||
font-size: 5px;
|
|
||||||
fill: var(--mood-text-muted);
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__axis-title {
|
|
||||||
font-size: 5px;
|
|
||||||
fill: var(--mood-text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__formula {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__formula-label {
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__formula-code {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-family: monospace;
|
|
||||||
color: var(--mood-text);
|
|
||||||
padding: 0.125rem 0.375rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 6%, var(--mood-bg));
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__formula-legend {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
font-size: 0.5625rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__formula-legend strong {
|
|
||||||
color: var(--mood-text);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { DocumentItem } from '~/stores/documents'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
item: DocumentItem
|
|
||||||
documentSlug: string
|
|
||||||
showActions?: boolean
|
|
||||||
}>(), {
|
|
||||||
showActions: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
propose: [item: DocumentItem]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const itemTypeLabel = (itemType: string): string => {
|
|
||||||
switch (itemType) {
|
|
||||||
case 'clause': return 'Clause'
|
|
||||||
case 'rule': return 'Regle'
|
|
||||||
case 'verification': return 'Verification'
|
|
||||||
case 'preamble': return 'Preambule'
|
|
||||||
case 'section': return 'Section'
|
|
||||||
default: return itemType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateToItem() {
|
|
||||||
navigateTo(`/documents/${props.documentSlug}/items/${props.item.id}`)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UCard
|
|
||||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
|
|
||||||
@click="navigateToItem"
|
|
||||||
>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<!-- Item header -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<UBadge variant="solid" color="primary" size="xs">
|
|
||||||
{{ item.position }}
|
|
||||||
</UBadge>
|
|
||||||
<span v-if="item.title" class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
||||||
{{ item.title }}
|
|
||||||
</span>
|
|
||||||
<UBadge variant="subtle" color="neutral" size="xs">
|
|
||||||
{{ itemTypeLabel(item.item_type) }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UBadge
|
|
||||||
v-if="item.voting_protocol_id"
|
|
||||||
color="info"
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
Sous vote
|
|
||||||
</UBadge>
|
|
||||||
<UBadge
|
|
||||||
v-else
|
|
||||||
color="neutral"
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
Pas de vote
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Item text -->
|
|
||||||
<div class="pl-2">
|
|
||||||
<MarkdownRenderer :content="item.current_text" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div v-if="showActions" class="flex justify-end pt-2 border-t border-gray-100 dark:border-gray-800">
|
|
||||||
<UButton
|
|
||||||
label="Proposer une modification"
|
|
||||||
icon="i-lucide-pen-line"
|
|
||||||
variant="soft"
|
|
||||||
color="primary"
|
|
||||||
size="xs"
|
|
||||||
@click.stop="emit('propose', item)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</template>
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ItemVersion } from '~/stores/documents'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
version: ItemVersion
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
accept: [versionId: string]
|
|
||||||
reject: [versionId: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const auth = useAuthStore()
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateAddress(address: string | null): string {
|
|
||||||
if (!address) return 'Inconnu'
|
|
||||||
if (address.length <= 16) return address
|
|
||||||
return address.slice(0, 8) + '...' + address.slice(-6)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UCard>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<StatusBadge :status="version.status" type="version" />
|
|
||||||
<span class="text-sm text-gray-500">
|
|
||||||
Propose par
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300 font-mono text-xs">
|
|
||||||
{{ truncateAddress(version.proposed_by) }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-gray-400">
|
|
||||||
{{ formatDate(version.created_at) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Rationale -->
|
|
||||||
<div v-if="version.rationale" class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3">
|
|
||||||
<p class="text-xs font-semibold text-gray-500 uppercase mb-1">Justification</p>
|
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ version.rationale }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Diff view -->
|
|
||||||
<div v-if="version.diff">
|
|
||||||
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Modifications</p>
|
|
||||||
<DiffView :diff="version.diff" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Proposed text (fallback if no diff) -->
|
|
||||||
<div v-else-if="version.proposed_text">
|
|
||||||
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Texte propose</p>
|
|
||||||
<div class="bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
|
||||||
<MarkdownRenderer :content="version.proposed_text" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions for authenticated users -->
|
|
||||||
<div
|
|
||||||
v-if="auth.isAuthenticated && version.status === 'proposed'"
|
|
||||||
class="flex items-center gap-3 pt-2 border-t border-gray-100 dark:border-gray-800"
|
|
||||||
>
|
|
||||||
<UButton
|
|
||||||
label="Accepter"
|
|
||||||
icon="i-lucide-check"
|
|
||||||
color="success"
|
|
||||||
variant="soft"
|
|
||||||
size="xs"
|
|
||||||
@click="emit('accept', version.id)"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
label="Rejeter"
|
|
||||||
icon="i-lucide-x"
|
|
||||||
color="error"
|
|
||||||
variant="soft"
|
|
||||||
size="xs"
|
|
||||||
@click="emit('reject', version.id)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</template>
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* MiniVoteBoard — compact inline vote status for an engagement item.
|
|
||||||
*
|
|
||||||
* Shows: vote bar, counts, threshold, pass/fail, and vote buttons.
|
|
||||||
* Uses mock data when no vote session is linked (dev mode).
|
|
||||||
*/
|
|
||||||
import { useVoteFormula } from '~/composables/useVoteFormula'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
votesFor?: number
|
|
||||||
votesAgainst?: number
|
|
||||||
wotSize?: number
|
|
||||||
isPermanent?: boolean
|
|
||||||
inertiaPreset?: string
|
|
||||||
startsAt?: string | null
|
|
||||||
endsAt?: string | null
|
|
||||||
}>(), {
|
|
||||||
votesFor: 0,
|
|
||||||
votesAgainst: 0,
|
|
||||||
wotSize: 7224,
|
|
||||||
isPermanent: true,
|
|
||||||
inertiaPreset: 'standard',
|
|
||||||
startsAt: null,
|
|
||||||
endsAt: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { computeThreshold } = useVoteFormula()
|
|
||||||
|
|
||||||
const INERTIA_PARAMS: Record<string, { majority_pct: number; base_exponent: number; gradient_exponent: number; constant_base: number }> = {
|
|
||||||
low: { majority_pct: 50, base_exponent: 0.1, gradient_exponent: 0.1, constant_base: 0 },
|
|
||||||
standard: { majority_pct: 50, base_exponent: 0.1, gradient_exponent: 0.2, constant_base: 0 },
|
|
||||||
high: { majority_pct: 60, base_exponent: 0.1, gradient_exponent: 0.4, constant_base: 0 },
|
|
||||||
very_high: { majority_pct: 66, base_exponent: 0.1, gradient_exponent: 0.6, constant_base: 0 },
|
|
||||||
}
|
|
||||||
|
|
||||||
const formulaParams = computed(() => INERTIA_PARAMS[props.inertiaPreset] ?? INERTIA_PARAMS.standard!)
|
|
||||||
|
|
||||||
const totalVotes = computed(() => props.votesFor + props.votesAgainst)
|
|
||||||
|
|
||||||
const threshold = computed(() => {
|
|
||||||
if (totalVotes.value === 0) return 1
|
|
||||||
return computeThreshold(props.wotSize, totalVotes.value, formulaParams.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const isPassing = computed(() => props.votesFor >= threshold.value)
|
|
||||||
|
|
||||||
const forPct = computed(() => {
|
|
||||||
if (totalVotes.value === 0) return 0
|
|
||||||
return (props.votesFor / totalVotes.value) * 100
|
|
||||||
})
|
|
||||||
|
|
||||||
const againstPct = computed(() => {
|
|
||||||
if (totalVotes.value === 0) return 0
|
|
||||||
return (props.votesAgainst / totalVotes.value) * 100
|
|
||||||
})
|
|
||||||
|
|
||||||
const thresholdPct = computed(() => {
|
|
||||||
if (totalVotes.value === 0) return 50
|
|
||||||
return Math.min((threshold.value / totalVotes.value) * 100, 100)
|
|
||||||
})
|
|
||||||
|
|
||||||
const participationRate = computed(() => {
|
|
||||||
if (props.wotSize === 0) return 0
|
|
||||||
return (totalVotes.value / props.wotSize) * 100
|
|
||||||
})
|
|
||||||
|
|
||||||
const remaining = computed(() => {
|
|
||||||
const diff = threshold.value - props.votesFor
|
|
||||||
return diff > 0 ? diff : 0
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatDate(d: string): string {
|
|
||||||
return new Date(d).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="mini-board">
|
|
||||||
<!-- Vote type + status on same line -->
|
|
||||||
<div class="mini-board__header">
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<template v-if="isPermanent">
|
|
||||||
<UIcon name="i-lucide-infinity" class="text-xs" style="color: var(--mood-accent)" />
|
|
||||||
<span class="text-xs font-semibold" style="color: var(--mood-text-muted)">Vote permanent :</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<UIcon name="i-lucide-clock" class="text-xs" style="color: var(--mood-accent)" />
|
|
||||||
<span class="text-xs font-semibold" style="color: var(--mood-text-muted)">Vote temporaire :</span>
|
|
||||||
<span v-if="startsAt && endsAt" class="text-xs" style="color: var(--mood-text-muted)">
|
|
||||||
{{ formatDate(startsAt) }} - {{ formatDate(endsAt) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<UBadge
|
|
||||||
:color="isPassing ? 'success' : 'warning'"
|
|
||||||
:variant="isPassing ? 'solid' : 'subtle'"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
{{ isPassing ? 'Adopté' : 'En attente' }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Progress bar -->
|
|
||||||
<div class="mini-board__bar">
|
|
||||||
<div
|
|
||||||
class="mini-board__bar-for"
|
|
||||||
:style="{ width: `${forPct}%` }"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="mini-board__bar-against"
|
|
||||||
:style="{ left: `${forPct}%`, width: `${againstPct}%` }"
|
|
||||||
/>
|
|
||||||
<!-- Threshold marker -->
|
|
||||||
<div
|
|
||||||
v-if="totalVotes > 0"
|
|
||||||
class="mini-board__bar-threshold"
|
|
||||||
:style="{ left: `${thresholdPct}%` }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stats row -->
|
|
||||||
<div class="mini-board__stats">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="mini-board__stat mini-board__stat--for">
|
|
||||||
{{ votesFor }} pour
|
|
||||||
</span>
|
|
||||||
<span class="mini-board__stat mini-board__stat--against">
|
|
||||||
{{ votesAgainst }} contre
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="mini-board__stat">
|
|
||||||
{{ votesFor }}/{{ threshold }} requis
|
|
||||||
</span>
|
|
||||||
<span v-if="remaining > 0" class="mini-board__stat mini-board__stat--remaining">
|
|
||||||
{{ remaining }} manquant{{ remaining > 1 ? 's' : '' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Participation -->
|
|
||||||
<div class="mini-board__footer">
|
|
||||||
<span class="text-xs" style="color: var(--mood-text-muted)">
|
|
||||||
{{ totalVotes }} vote{{ totalVotes !== 1 ? 's' : '' }} / {{ wotSize }} membres
|
|
||||||
({{ participationRate.toFixed(2) }}%)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.mini-board {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 3%, var(--mood-bg));
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__bar {
|
|
||||||
position: relative;
|
|
||||||
height: 6px;
|
|
||||||
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
|
|
||||||
border-radius: 3px;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__bar-for {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
right: auto;
|
|
||||||
background: #22c55e;
|
|
||||||
border-radius: 3px 0 0 3px;
|
|
||||||
transition: width 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__bar-against {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
right: auto;
|
|
||||||
background: #ef4444;
|
|
||||||
transition: width 0.4s ease, left 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__bar-threshold {
|
|
||||||
position: absolute;
|
|
||||||
top: -3px;
|
|
||||||
bottom: -3px;
|
|
||||||
width: 2px;
|
|
||||||
background: #facc15;
|
|
||||||
border-radius: 1px;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
transition: left 0.4s ease;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__stats {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__stat {
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__stat--for {
|
|
||||||
color: #22c55e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__stat--against {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__stat--remaining {
|
|
||||||
color: #f59e0b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// One line of the collective's activity — route icon, title → fiche, status pill.
|
||||||
|
import type { Decision } from '~/types/domain'
|
||||||
|
import { ROUTE_ICONS, STATUS_LABELS } from '~/lexicon'
|
||||||
|
|
||||||
|
const props = defineProps<{ decision: Decision }>()
|
||||||
|
|
||||||
|
const when = computed(() =>
|
||||||
|
new Date(props.decision.updatedAt).toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<NuxtLink :to="`/decisions/${decision.id}`" class="ld-card ld-card--hover fac">
|
||||||
|
<UIcon
|
||||||
|
:name="ROUTE_ICONS[decision.route]"
|
||||||
|
class="fac__icon"
|
||||||
|
:style="{ color: `var(--route-${decision.route})` }"
|
||||||
|
/>
|
||||||
|
<span class="fac__title">{{ decision.title }}</span>
|
||||||
|
<span class="status-pill fac__pill" :class="`status-${decision.status}`">
|
||||||
|
{{ STATUS_LABELS[decision.status] }}
|
||||||
|
</span>
|
||||||
|
<span class="fac__when">{{ when }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fac {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
.fac__icon { font-size: 1rem; flex-shrink: 0; }
|
||||||
|
.fac__title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.9063rem;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.fac__pill { flex-shrink: 0; }
|
||||||
|
.fac__when {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (min-width: 480px) {
|
||||||
|
.fac__when { display: inline; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Advice request card — three inline positions, one tap each.
|
||||||
|
import type { Advice, Decision } from '~/types/domain'
|
||||||
|
import { ROUTE_ICONS } from '~/lexicon'
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
import { useDecisionsStore } from '~/stores/decisions'
|
||||||
|
|
||||||
|
const props = defineProps<{ decision: Decision }>()
|
||||||
|
|
||||||
|
const col = useCollectiveStore()
|
||||||
|
const decisions = useDecisionsStore()
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const POSITIONS: { value: Advice['position']; label: string; icon: string }[] = [
|
||||||
|
{ value: 'favorable', label: 'Favorable', icon: 'i-lucide-thumbs-up' },
|
||||||
|
{ value: 'reserved', label: 'Réservé', icon: 'i-lucide-minus' },
|
||||||
|
{ value: 'unfavorable', label: 'Défavorable', icon: 'i-lucide-thumbs-down' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const mine = computed(() =>
|
||||||
|
col.advices.find(a => a.decisionId === props.decision.id && a.personId === col.me?.id),
|
||||||
|
)
|
||||||
|
const mineLabel = computed(
|
||||||
|
() => POSITIONS.find(p => p.value === mine.value?.position)?.label ?? '',
|
||||||
|
)
|
||||||
|
|
||||||
|
function give(position: Advice['position']) {
|
||||||
|
const res = decisions.adviseOn(props.decision.id, position)
|
||||||
|
error.value = 'ok' in res ? res.reason : ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<FeedItemCard
|
||||||
|
:title="decision.title"
|
||||||
|
:icon="ROUTE_ICONS.advice"
|
||||||
|
tint="var(--route-advice)"
|
||||||
|
:to="`/decisions/${decision.id}`"
|
||||||
|
>
|
||||||
|
<template #meta>
|
||||||
|
<LdCountdown :ends-at="decision.windowEndsAt" :suspended-at="decision.windowSuspendedAt" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-if="error" class="fa-error">{{ error }}</p>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<span v-if="mine" class="fa-done">
|
||||||
|
<UIcon name="i-lucide-check" /> Ton avis est déposé — {{ mineLabel }}
|
||||||
|
</span>
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
v-for="p in POSITIONS"
|
||||||
|
:key="p.value"
|
||||||
|
class="ld-btn ld-btn--ghost fa-btn"
|
||||||
|
type="button"
|
||||||
|
@click="give(p.value)"
|
||||||
|
>
|
||||||
|
<UIcon :name="p.icon" />
|
||||||
|
<span>{{ p.label }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</FeedItemCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fa-error { margin: 0; font-size: 0.8125rem; color: var(--mood-error); }
|
||||||
|
.fa-done {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-status-vigueur);
|
||||||
|
}
|
||||||
|
.fa-btn { font-size: 0.875rem; padding: 0.4rem 0.875rem; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Sticky capture bar — the ONE entry of every decision (gesture 1 of 2).
|
||||||
|
// Enter → /decider?titre=… ; nothing else is mandatory.
|
||||||
|
import { CAPTURE_PLACEHOLDER } from '~/lexicon'
|
||||||
|
|
||||||
|
const text = ref('')
|
||||||
|
|
||||||
|
function go() {
|
||||||
|
const title = text.value.trim()
|
||||||
|
if (title.length === 0) return
|
||||||
|
navigateTo({ path: '/decider', query: { titre: title } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<div class="feed-capture">
|
||||||
|
<div class="ld-card feed-capture__card">
|
||||||
|
<UIcon name="i-lucide-zap" class="feed-capture__icon" />
|
||||||
|
<input
|
||||||
|
v-model="text"
|
||||||
|
type="text"
|
||||||
|
class="feed-capture__input"
|
||||||
|
:placeholder="CAPTURE_PLACEHOLDER"
|
||||||
|
:aria-label="CAPTURE_PLACEHOLDER"
|
||||||
|
enterkeyhint="go"
|
||||||
|
@keydown.enter.prevent="go"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="text.trim().length > 0"
|
||||||
|
class="ld-btn feed-capture__go"
|
||||||
|
type="button"
|
||||||
|
@click="go"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-arrow-right" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.feed-capture {
|
||||||
|
position: sticky;
|
||||||
|
top: 3.5rem;
|
||||||
|
z-index: 20;
|
||||||
|
padding: 0.5rem 0 0.75rem;
|
||||||
|
background: var(--mood-bg);
|
||||||
|
}
|
||||||
|
.feed-capture__card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.5rem 0.625rem 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
.feed-capture__icon {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.feed-capture__input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: none;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.feed-capture__input:focus-visible { box-shadow: none; }
|
||||||
|
.feed-capture__input::placeholder {
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.feed-capture__go {
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Generic compact card of the Fil: icon container, title (optionally a link),
|
||||||
|
// meta line (slot #meta), body (default slot), inline actions (slot #actions).
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
icon?: string
|
||||||
|
tint?: string
|
||||||
|
to?: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<article class="ld-card feed-item">
|
||||||
|
<div v-if="icon" class="feed-item__stamp" :style="tint ? { color: tint } : undefined">
|
||||||
|
<UIcon :name="icon" />
|
||||||
|
</div>
|
||||||
|
<div class="feed-item__body">
|
||||||
|
<NuxtLink v-if="to" :to="to" class="feed-item__title feed-item__title--link">
|
||||||
|
{{ title }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else class="feed-item__title">{{ title }}</span>
|
||||||
|
<div v-if="$slots.meta" class="feed-item__meta">
|
||||||
|
<slot name="meta" />
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
<div v-if="$slots.actions" class="feed-item__actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.feed-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
}
|
||||||
|
.feed-item__stamp {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: var(--r-icon);
|
||||||
|
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
}
|
||||||
|
.feed-item__body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.feed-item__title {
|
||||||
|
font-size: 0.9688rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--mood-text);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.feed-item__title--link { text-decoration: none; }
|
||||||
|
.feed-item__title--link:hover { color: var(--mood-accent); }
|
||||||
|
.feed-item__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.feed-item__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// « L'épreuve du réel » card — « Le réel a-t-il suivi ? », three inline answers.
|
||||||
|
import type { Decision } from '~/types/domain'
|
||||||
|
import { REVIEW_QUESTION, REVIEW_VERDICTS } from '~/lexicon'
|
||||||
|
import { useDecisionsStore } from '~/stores/decisions'
|
||||||
|
|
||||||
|
const props = defineProps<{ decision: Decision }>()
|
||||||
|
|
||||||
|
const decisions = useDecisionsStore()
|
||||||
|
const error = ref('')
|
||||||
|
const answered = ref<'confirmed' | 'revise' | 'revoke' | null>(null)
|
||||||
|
|
||||||
|
// Aliased: the lexicon export name is a code identifier, kept out of templates.
|
||||||
|
const ANSWER_LABELS = REVIEW_VERDICTS
|
||||||
|
const ANSWERS = [
|
||||||
|
{ value: 'confirmed', icon: 'i-lucide-check' },
|
||||||
|
{ value: 'revise', icon: 'i-lucide-pencil' },
|
||||||
|
{ value: 'revoke', icon: 'i-lucide-undo-2' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function answer(value: 'confirmed' | 'revise' | 'revoke') {
|
||||||
|
const res = decisions.reviewVerdict(props.decision.id, value)
|
||||||
|
if ('ok' in res) {
|
||||||
|
error.value = res.reason
|
||||||
|
return
|
||||||
|
}
|
||||||
|
error.value = ''
|
||||||
|
answered.value = value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<FeedItemCard
|
||||||
|
:title="decision.title"
|
||||||
|
icon="i-lucide-flask-conical"
|
||||||
|
tint="var(--mood-tertiary)"
|
||||||
|
:to="`/decisions/${decision.id}`"
|
||||||
|
>
|
||||||
|
<template #meta>
|
||||||
|
<span class="fr-question">{{ REVIEW_QUESTION }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-if="error" class="fr-error">{{ error }}</p>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<span v-if="answered" class="fr-done">
|
||||||
|
<UIcon name="i-lucide-check" /> {{ ANSWER_LABELS[answered] }} — c'est noté
|
||||||
|
</span>
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
v-for="a in ANSWERS"
|
||||||
|
:key="a.value"
|
||||||
|
class="ld-btn ld-btn--ghost fr-btn"
|
||||||
|
type="button"
|
||||||
|
@click="answer(a.value)"
|
||||||
|
>
|
||||||
|
<UIcon :name="a.icon" />
|
||||||
|
<span>{{ ANSWER_LABELS[a.value] }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</FeedItemCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fr-question { font-weight: 700; font-style: italic; color: var(--mood-tertiary, var(--mood-accent)); }
|
||||||
|
.fr-error { margin: 0; font-size: 0.8125rem; color: var(--mood-error); }
|
||||||
|
.fr-done {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-status-vigueur);
|
||||||
|
}
|
||||||
|
.fr-btn { font-size: 0.875rem; padding: 0.4rem 0.875rem; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// One section of the Fil — header + stacked items. Empty sections are hidden
|
||||||
|
// by the PARENT (v-if on length), never here.
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
icon?: string
|
||||||
|
discrete?: boolean
|
||||||
|
priority?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<section
|
||||||
|
class="feed-section"
|
||||||
|
:class="{ 'feed-section--discrete': discrete, 'feed-section--priority': priority }"
|
||||||
|
>
|
||||||
|
<h2 class="feed-section__title">
|
||||||
|
<UIcon v-if="icon" :name="icon" class="feed-section__icon" />
|
||||||
|
<span>{{ title }}</span>
|
||||||
|
</h2>
|
||||||
|
<div class="feed-section__items">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.feed-section { margin-bottom: 1.75rem; }
|
||||||
|
.feed-section__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0 0 0.625rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.feed-section--priority .feed-section__title { color: var(--mood-status-fenetre); }
|
||||||
|
.feed-section--discrete .feed-section__title {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.feed-section__icon { font-size: 1rem; }
|
||||||
|
.feed-section__items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
.feed-section--discrete .feed-section__items { opacity: 0.85; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Open vote card — miniature participation gauge + link to the vote room.
|
||||||
|
// The gauge shows who already spoke, never a result (results live in the room).
|
||||||
|
import type { Decision, VoteSession } from '~/types/domain'
|
||||||
|
import { ROUTE_ICONS } from '~/lexicon'
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
import { useDecisionsStore } from '~/stores/decisions'
|
||||||
|
|
||||||
|
const props = defineProps<{ session: VoteSession; decision?: Decision }>()
|
||||||
|
|
||||||
|
const col = useCollectiveStore()
|
||||||
|
const decisions = useDecisionsStore()
|
||||||
|
|
||||||
|
const voted = computed(() => decisions.activeVotes(props.session.id).length)
|
||||||
|
const total = computed(() => Math.max(props.session.corpusSize, 1))
|
||||||
|
const pct = computed(() => Math.min(100, Math.round((voted.value / total.value) * 100)))
|
||||||
|
const iVoted = computed(() =>
|
||||||
|
decisions.activeVotes(props.session.id).some(v => v.voterId === col.me?.id),
|
||||||
|
)
|
||||||
|
const title = computed(() => props.decision?.title ?? 'Décision en vote')
|
||||||
|
const roomLink = computed(() =>
|
||||||
|
props.decision ? `/decisions/${props.decision.id}/vote` : undefined,
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<FeedItemCard
|
||||||
|
:title="title"
|
||||||
|
:icon="ROUTE_ICONS.collective"
|
||||||
|
tint="var(--route-collective)"
|
||||||
|
:to="roomLink"
|
||||||
|
>
|
||||||
|
<template #meta>
|
||||||
|
<LdCountdown :ends-at="session.closesAt" />
|
||||||
|
<span>{{ voted }} / {{ total }} se sont prononcés</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="fv-gauge" role="img" :aria-label="`Participation : ${pct} %`">
|
||||||
|
<div class="fv-gauge__fill" :style="{ width: pct + '%' }" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<NuxtLink v-if="roomLink" :to="roomLink" class="ld-btn fv-btn">
|
||||||
|
<UIcon name="i-lucide-door-open" />
|
||||||
|
<span>{{ iVoted ? 'Revoir mon vote' : 'Entrer dans la salle' }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
</FeedItemCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fv-gauge {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--mood-status-vote-bg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.fv-gauge__fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--mood-status-vote);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
.fv-btn { font-size: 0.875rem; padding: 0.4rem 1rem; text-decoration: none; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Objection window card — « Ça me va » creates an Assent; « J'objecte » unfolds
|
||||||
|
// a small argument form (content or boundary). The countdown pauses when the
|
||||||
|
// boundary is contested; non-easy windows show the explicit-agreement mention.
|
||||||
|
import type { Decision } from '~/types/domain'
|
||||||
|
import { ASSENT_MISSING, ROUTE_ICONS, WINDOW_OBJECT, WINDOW_OK } from '~/lexicon'
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
import { useDecisionsStore } from '~/stores/decisions'
|
||||||
|
|
||||||
|
const props = defineProps<{ decision: Decision }>()
|
||||||
|
|
||||||
|
const col = useCollectiveStore()
|
||||||
|
const decisions = useDecisionsStore()
|
||||||
|
|
||||||
|
const objecting = ref(false)
|
||||||
|
const argument = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const myAssent = computed(() =>
|
||||||
|
col.assents.some(a => a.decisionId === props.decision.id && a.personId === col.me?.id),
|
||||||
|
)
|
||||||
|
const myObjection = computed(() =>
|
||||||
|
col.objections.some(
|
||||||
|
o => o.decisionId === props.decision.id && o.personId === col.me?.id && o.status === 'open',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
/** Non-easy windows adopt only on an explicit third-party agreement. */
|
||||||
|
const needsExplicitAssent = computed(
|
||||||
|
() =>
|
||||||
|
props.decision.reversibility !== 'easy'
|
||||||
|
&& !col.assents.some(
|
||||||
|
a => a.decisionId === props.decision.id && a.personId !== props.decision.authorId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
function agree() {
|
||||||
|
const res = decisions.assentTo(props.decision.id)
|
||||||
|
error.value = 'ok' in res ? res.reason : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendObjection(kind: 'content' | 'boundary') {
|
||||||
|
const res = decisions.objectTo(props.decision.id, kind, argument.value)
|
||||||
|
if ('ok' in res) {
|
||||||
|
error.value = res.reason
|
||||||
|
return
|
||||||
|
}
|
||||||
|
error.value = ''
|
||||||
|
argument.value = ''
|
||||||
|
objecting.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<FeedItemCard
|
||||||
|
:title="decision.title"
|
||||||
|
:icon="ROUTE_ICONS[decision.route]"
|
||||||
|
:tint="`var(--route-${decision.route})`"
|
||||||
|
:to="`/decisions/${decision.id}`"
|
||||||
|
>
|
||||||
|
<template #meta>
|
||||||
|
<LdCountdown :ends-at="decision.windowEndsAt" :suspended-at="decision.windowSuspendedAt" />
|
||||||
|
<span v-if="needsExplicitAssent" class="fw-mention">{{ ASSENT_MISSING }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-if="error" class="fw-error">{{ error }}</p>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<template v-if="myAssent">
|
||||||
|
<span class="fw-done"><UIcon name="i-lucide-check" /> Ton accord est noté</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="myObjection">
|
||||||
|
<span class="fw-done"><UIcon name="i-lucide-hand" /> Ton objection est déposée</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="!objecting">
|
||||||
|
<button class="ld-btn fw-btn" type="button" @click="agree">{{ WINDOW_OK }}</button>
|
||||||
|
<button class="ld-btn ld-btn--ghost fw-btn" type="button" @click="objecting = true">
|
||||||
|
{{ WINDOW_OBJECT }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<div v-else class="fw-form">
|
||||||
|
<textarea
|
||||||
|
v-model="argument"
|
||||||
|
class="fw-form__text"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Une objection s'argumente — écris pourquoi."
|
||||||
|
/>
|
||||||
|
<div class="fw-form__row">
|
||||||
|
<button class="ld-btn fw-btn" type="button" @click="sendObjection('content')">
|
||||||
|
Sur le fond
|
||||||
|
</button>
|
||||||
|
<button class="ld-btn ld-btn--ghost fw-btn" type="button" @click="sendObjection('boundary')">
|
||||||
|
Sur la frontière
|
||||||
|
</button>
|
||||||
|
<button class="ld-btn ld-btn--quiet fw-btn" type="button" @click="objecting = false">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FeedItemCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fw-mention {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-status-fenetre);
|
||||||
|
}
|
||||||
|
.fw-error { margin: 0; font-size: 0.8125rem; color: var(--mood-error); }
|
||||||
|
.fw-done {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-status-vigueur);
|
||||||
|
}
|
||||||
|
.fw-btn { font-size: 0.875rem; padding: 0.4rem 1rem; }
|
||||||
|
.fw-form { display: flex; flex-direction: column; gap: 0.5rem; width: 100%; }
|
||||||
|
.fw-form__text { width: 100%; padding: 0.625rem 0.75rem; font-size: 0.9375rem; resize: vertical; }
|
||||||
|
.fw-form__row { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||||
|
</style>
|
||||||
@@ -1,82 +1,132 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
// <!-- ld-v2 --> Carte mandat — titulaire, domaine, échéance, prochain rapport,
|
||||||
* Card component for displaying a mandate in a list.
|
// COMPTEURS BRUTS (Δ7 : aucune jauge, aucun agrégat — des faits comptés).
|
||||||
*
|
import type { Mandate } from '~/types/domain'
|
||||||
* Shows title, type badge, status badge, mandatee, date range.
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
* Navigates to the mandate detail page on click.
|
import {
|
||||||
*/
|
MANDATE_STATUS_LABELS,
|
||||||
import type { Mandate } from '~/stores/mandates'
|
MANDATE_STATUS_PILL,
|
||||||
|
formatDay,
|
||||||
|
mandateFacts,
|
||||||
|
} from './mandateUi'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{ mandate: Mandate }>()
|
||||||
mandate: Mandate
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const typeLabel = (mandateType: string) => {
|
const col = useCollectiveStore()
|
||||||
switch (mandateType) {
|
|
||||||
case 'techcomm': return 'Comite technique'
|
|
||||||
case 'smith': return 'Forgeron'
|
|
||||||
case 'custom': return 'Personnalise'
|
|
||||||
default: return mandateType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string | null): string {
|
const holder = computed(() => col.people.find(p => p.id === props.mandate.holderId))
|
||||||
if (!dateStr) return '-'
|
const domainCircles = computed(() =>
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
props.mandate.domain.circleIds
|
||||||
day: 'numeric',
|
.map(id => col.circles.find(c => c.id === id))
|
||||||
month: 'short',
|
.filter(c => c !== undefined),
|
||||||
year: 'numeric',
|
)
|
||||||
})
|
const facts = computed(() => mandateFacts(props.mandate, col.decisions, col.objections))
|
||||||
}
|
|
||||||
|
|
||||||
function navigate() {
|
|
||||||
navigateTo(`/mandates/${props.mandate.id}`)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UCard
|
<!-- ld-v2 -->
|
||||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 hover:shadow-md transition-all"
|
<NuxtLink :to="`/mandats/${mandate.id}`" class="mcard ld-card ld-card--hover">
|
||||||
@click="navigate"
|
<div class="mcard__head">
|
||||||
>
|
<LdAvatarStack v-if="holder" :people="[{ person: holder }]" :size="34" />
|
||||||
<div class="space-y-3">
|
<div class="mcard__who">
|
||||||
<div class="flex items-start justify-between">
|
<p class="mcard__title">{{ mandate.title }}</p>
|
||||||
<div class="flex items-center gap-2">
|
<p v-if="holder" class="mcard__holder">{{ holder.displayName }}</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="status-pill" :class="MANDATE_STATUS_PILL[mandate.status]">
|
||||||
|
{{ MANDATE_STATUS_LABELS[mandate.status] }}
|
||||||
|
</span>
|
||||||
</div>
|
</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>
|
</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',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* /donnees §1 — les collectifs de cette machine : export JSON (<slug>-<date>.json),
|
||||||
|
* suppression de la copie locale (confirmation), collectif actif marqué, lignée.
|
||||||
|
*/
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
import type { CollectiveIndexEntry } from '~/stores/collective'
|
||||||
|
import { loadState, toBundle } from '~/data/persistence'
|
||||||
|
import { LINEAGE_PREFIX } from '~/lexicon'
|
||||||
|
|
||||||
|
const store = useCollectiveStore()
|
||||||
|
|
||||||
|
const confirmEntry = ref<CollectiveIndexEntry | null>(null)
|
||||||
|
const confirmOpen = computed({
|
||||||
|
get: () => confirmEntry.value !== null,
|
||||||
|
set: (open: boolean) => {
|
||||||
|
if (!open) confirmEntry.value = null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeLineage = computed(() => store.current?.collective.lineage ?? null)
|
||||||
|
|
||||||
|
async function exportEntry(entry: CollectiveIndexEntry) {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
let json: string | null = null
|
||||||
|
if (entry.id === store.activeId) {
|
||||||
|
json = store.exportJson()
|
||||||
|
} else {
|
||||||
|
const state = await loadState(entry.id)
|
||||||
|
if (state) json = JSON.stringify(toBundle(state, now), null, 2)
|
||||||
|
}
|
||||||
|
if (!json) return
|
||||||
|
const blob = new Blob([json], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${entry.slug}-${now.slice(0, 10)}.json`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeConfirmed() {
|
||||||
|
if (!confirmEntry.value) return
|
||||||
|
await store.removeCollective(confirmEntry.value.id)
|
||||||
|
confirmEntry.value = null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<section class="ld-card data-card">
|
||||||
|
<h2 class="data-card__title">
|
||||||
|
<UIcon name="i-lucide-database" /> Collectifs sur cette machine
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div v-if="!store.index.length" class="data-card__empty">
|
||||||
|
<p>Aucun collectif ici pour l'instant.</p>
|
||||||
|
<NuxtLink to="/creer" class="ld-btn ld-btn--ghost">
|
||||||
|
<UIcon name="i-lucide-sparkles" /> Créer un collectif
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-else class="col-list">
|
||||||
|
<li v-for="entry in store.index" :key="entry.id" class="col-row">
|
||||||
|
<span class="col-row__dot" :style="{ background: entry.color }" />
|
||||||
|
<span class="col-row__main">
|
||||||
|
<span class="col-row__name">
|
||||||
|
{{ entry.name }}
|
||||||
|
<span v-if="entry.id === store.activeId" class="status-pill status-adopted">actif</span>
|
||||||
|
</span>
|
||||||
|
<span class="col-row__slug">{{ entry.slug }}</span>
|
||||||
|
<span
|
||||||
|
v-if="entry.id === store.activeId && activeLineage"
|
||||||
|
class="col-row__lineage"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-sprout" /> {{ LINEAGE_PREFIX }} {{ activeLineage.sourceSlug }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="col-row__actions">
|
||||||
|
<button type="button" class="ld-btn ld-btn--ghost" @click="exportEntry(entry)">
|
||||||
|
<UIcon name="i-lucide-download" /> Exporter
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ld-btn ld-btn--quiet col-row__delete"
|
||||||
|
@click="confirmEntry = entry"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-trash-2" /> Supprimer
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<UModal v-model:open="confirmOpen">
|
||||||
|
<template #content>
|
||||||
|
<div class="confirm">
|
||||||
|
<h3 class="confirm__title">Supprimer « {{ confirmEntry?.name }} » ?</h3>
|
||||||
|
<p class="confirm__text">
|
||||||
|
Ce geste supprime uniquement la copie locale, sur cette machine.
|
||||||
|
Un fichier exporté ou une copie ailleurs n'est pas touché.
|
||||||
|
</p>
|
||||||
|
<div class="confirm__actions">
|
||||||
|
<button type="button" class="ld-btn ld-btn--ghost" @click="confirmEntry = null">
|
||||||
|
Garder
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ld-btn confirm__danger" @click="removeConfirmed">
|
||||||
|
<UIcon name="i-lucide-trash-2" /> Supprimer la copie locale
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.data-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: clamp(1rem, 3vw, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-card__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-card__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
.data-card__empty p { margin: 0; }
|
||||||
|
|
||||||
|
.col-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: var(--mood-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-row__dot {
|
||||||
|
width: 0.9rem;
|
||||||
|
height: 0.9rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-row__main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-row__name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-row__slug {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-row__lineage {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--mood-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-row__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-row__delete:hover { color: var(--mood-error); }
|
||||||
|
|
||||||
|
.confirm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.875rem;
|
||||||
|
padding: clamp(1.1rem, 3vw, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm__text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm__danger {
|
||||||
|
background: var(--mood-error);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.col-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.col-row__actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* /donnees §4 — mon identité locale : prénom (stamp + persist) et mes
|
||||||
|
* attributs déclarés (clés/valeurs libres — nourrissent la carte « Pour moi »).
|
||||||
|
*/
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
import { ATTRIBUTES_HINT } from '~/lexicon'
|
||||||
|
|
||||||
|
const store = useCollectiveStore()
|
||||||
|
|
||||||
|
interface AttrRow {
|
||||||
|
key: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameDraft = ref('')
|
||||||
|
const rows = ref<AttrRow[]>([])
|
||||||
|
|
||||||
|
// Re-seed the drafts only when the person changes (collective switch),
|
||||||
|
// never on our own commits — an in-progress empty row must survive.
|
||||||
|
watch(
|
||||||
|
() => store.me?.id,
|
||||||
|
() => {
|
||||||
|
const me = store.me
|
||||||
|
nameDraft.value = me?.displayName ?? ''
|
||||||
|
rows.value = Object.entries(me?.attributes ?? {}).map(([key, value]) => ({ key, value }))
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function commitName() {
|
||||||
|
const me = store.me
|
||||||
|
if (!me) return
|
||||||
|
const value = nameDraft.value.trim()
|
||||||
|
if (!value || value === me.displayName) {
|
||||||
|
nameDraft.value = me.displayName
|
||||||
|
return
|
||||||
|
}
|
||||||
|
me.displayName = value
|
||||||
|
store.stamp(me)
|
||||||
|
store.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitAttributes() {
|
||||||
|
const me = store.me
|
||||||
|
if (!me) return
|
||||||
|
const attributes: Record<string, number> = {}
|
||||||
|
for (const row of rows.value) {
|
||||||
|
const key = row.key.trim()
|
||||||
|
if (!key) continue
|
||||||
|
attributes[key] = Number.isFinite(row.value) ? row.value : 0
|
||||||
|
}
|
||||||
|
me.attributes = attributes
|
||||||
|
store.stamp(me)
|
||||||
|
store.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
rows.value.push({ key: '', value: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(index: number) {
|
||||||
|
rows.value.splice(index, 1)
|
||||||
|
commitAttributes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── « C'est moi » — un collectif importé (seed, fichier) n'a aucun isMe :
|
||||||
|
// on se désigne parmi les personnes existantes, ou on s'ajoute.
|
||||||
|
const newMeName = ref('')
|
||||||
|
|
||||||
|
function claimMe(personId: string) {
|
||||||
|
const current = store.current
|
||||||
|
if (!current || store.me) return
|
||||||
|
const person = current.people.find(p => p.id === personId && !p.archivedAt)
|
||||||
|
if (!person) return
|
||||||
|
person.isMe = true
|
||||||
|
store.stamp(person)
|
||||||
|
store.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMe() {
|
||||||
|
const current = store.current
|
||||||
|
const displayName = newMeName.value.trim()
|
||||||
|
if (!current || store.me || !displayName) return
|
||||||
|
const now = store.now()
|
||||||
|
current.people.push({
|
||||||
|
id: store.newId(),
|
||||||
|
collectiveId: current.collective.id,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
displayName,
|
||||||
|
isMe: true,
|
||||||
|
})
|
||||||
|
newMeName.value = ''
|
||||||
|
store.persist()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<section class="ld-card data-card">
|
||||||
|
<h2 class="data-card__title">
|
||||||
|
<UIcon name="i-lucide-user-round" /> Mon identité
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p v-if="!store.current" class="data-card__empty">
|
||||||
|
Aucun collectif actif — crée ou importe un collectif pour poser ton identité.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-else-if="!store.me">
|
||||||
|
<p class="data-card__text">
|
||||||
|
Personne n'est encore désigné·e comme toi dans ce collectif —
|
||||||
|
retrouve-toi dans la liste, ou ajoute-toi.
|
||||||
|
</p>
|
||||||
|
<div class="claim-chips">
|
||||||
|
<button
|
||||||
|
v-for="person in store.people"
|
||||||
|
:key="person.id"
|
||||||
|
type="button"
|
||||||
|
class="claim-chip"
|
||||||
|
@click="claimMe(person.id)"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-user-round-check" />
|
||||||
|
<span>{{ person.displayName }}</span>
|
||||||
|
<span class="claim-chip__me">c'est moi</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form class="claim-new" @submit.prevent="createMe">
|
||||||
|
<input
|
||||||
|
v-model="newMeName"
|
||||||
|
class="field__input claim-new__input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ton prénom ici"
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
<button type="submit" class="ld-btn" :disabled="newMeName.trim().length === 0">
|
||||||
|
<UIcon name="i-lucide-plus" /> Je m'ajoute
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">Comment tu t'appelles ici</span>
|
||||||
|
<input
|
||||||
|
v-model="nameDraft"
|
||||||
|
class="field__input"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
@change="commitName"
|
||||||
|
@keydown.enter.prevent="commitName"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="field__label">Mes attributs</span>
|
||||||
|
<p class="field__hint">{{ ATTRIBUTES_HINT }}</p>
|
||||||
|
|
||||||
|
<ul v-if="rows.length" class="attr-list">
|
||||||
|
<li v-for="(row, i) in rows" :key="i" class="attr-row">
|
||||||
|
<input
|
||||||
|
v-model="row.key"
|
||||||
|
class="field__input attr-row__key"
|
||||||
|
type="text"
|
||||||
|
placeholder="heures/mois"
|
||||||
|
autocomplete="off"
|
||||||
|
@change="commitAttributes"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model.number="row.value"
|
||||||
|
class="field__input attr-row__value"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
@change="commitAttributes"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="attr-row__x"
|
||||||
|
:aria-label="`Retirer l'attribut ${row.key || i + 1}`"
|
||||||
|
@click="removeRow(i)"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-x" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button type="button" class="ld-btn ld-btn--ghost attr-add" @click="addRow">
|
||||||
|
<UIcon name="i-lucide-plus" /> Ajouter un attribut
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.data-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: clamp(1rem, 3vw, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-card__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-card__empty {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__input {
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
}
|
||||||
|
.field__input:focus-visible {
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--mood-input-focus), 0 0 0 2.5px var(--mood-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attr-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attr-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attr-row__key { flex: 1; min-width: 0; }
|
||||||
|
.attr-row__value { width: 6.5rem; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.attr-row__x {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
transition: background 0.1s ease, color 0.1s ease;
|
||||||
|
}
|
||||||
|
.attr-row__x:hover {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attr-add { align-self: flex-start; }
|
||||||
|
|
||||||
|
/* « C'est moi » — désignation dans un collectif importé */
|
||||||
|
.claim-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0.35rem 0.8rem;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--mood-input-bg);
|
||||||
|
color: var(--mood-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s ease, transform 0.1s ease;
|
||||||
|
}
|
||||||
|
.claim-chip:hover {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.claim-chip:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
.claim-chip__me {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-new {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.claim-new__input { flex: 1; min-width: 0; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* /donnees §2 — importer un collectif : fichier ou glisser-déposer.
|
||||||
|
* Issues en français (bloquantes / avertissements), lignée « essaimé de … ».
|
||||||
|
*/
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
import type { ImportIssue } from '~/data/persistence'
|
||||||
|
import { LINEAGE_PREFIX } from '~/lexicon'
|
||||||
|
|
||||||
|
const store = useCollectiveStore()
|
||||||
|
|
||||||
|
const dragging = ref(false)
|
||||||
|
const busy = ref(false)
|
||||||
|
const issues = ref<ImportIssue[]>([])
|
||||||
|
const importedName = ref<string | null>(null)
|
||||||
|
const importedLineage = ref<string | null>(null)
|
||||||
|
|
||||||
|
const errors = computed(() => issues.value.filter(i => i.level === 'error'))
|
||||||
|
const warnings = computed(() => issues.value.filter(i => i.level === 'warning'))
|
||||||
|
|
||||||
|
async function importFile(file: File | null | undefined) {
|
||||||
|
if (!file || busy.value) return
|
||||||
|
busy.value = true
|
||||||
|
importedName.value = null
|
||||||
|
importedLineage.value = null
|
||||||
|
const text = await file.text()
|
||||||
|
const result = await store.importJson(text)
|
||||||
|
issues.value = result.issues
|
||||||
|
if (result.state && !result.collided) {
|
||||||
|
importedName.value = result.state.collective.name
|
||||||
|
importedLineage.value = result.state.collective.lineage?.sourceSlug ?? null
|
||||||
|
}
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(ev: DragEvent) {
|
||||||
|
dragging.value = false
|
||||||
|
importFile(ev.dataTransfer?.files?.[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPick(ev: Event) {
|
||||||
|
const input = ev.target as HTMLInputElement
|
||||||
|
importFile(input.files?.[0])
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<section class="ld-card data-card">
|
||||||
|
<h2 class="data-card__title">
|
||||||
|
<UIcon name="i-lucide-file-down" /> Importer un collectif
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class="dropzone"
|
||||||
|
:class="{ 'dropzone--over': dragging, 'dropzone--busy': busy }"
|
||||||
|
@dragover.prevent="dragging = true"
|
||||||
|
@dragleave="dragging = false"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-upload" class="dropzone__icon" />
|
||||||
|
<span class="dropzone__text">
|
||||||
|
Dépose un fichier .json ici, ou <span class="dropzone__link">choisis-le</span>
|
||||||
|
</span>
|
||||||
|
<span class="dropzone__sub">Un export libreDecision, d'ici ou d'ailleurs.</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
class="dropzone__input"
|
||||||
|
:disabled="busy"
|
||||||
|
@change="onPick"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<ul v-if="errors.length" class="issues issues--error">
|
||||||
|
<li v-for="issue in errors" :key="issue.message">
|
||||||
|
<UIcon name="i-lucide-circle-alert" /> {{ issue.message }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<ul v-if="warnings.length" class="issues issues--warning">
|
||||||
|
<li v-for="issue in warnings" :key="issue.message">
|
||||||
|
<UIcon name="i-lucide-triangle-alert" /> {{ issue.message }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div v-if="importedName" class="imported">
|
||||||
|
<p class="imported__line">
|
||||||
|
<UIcon name="i-lucide-circle-check" /> « {{ importedName }} » est arrivé sur cette machine — c'est maintenant le collectif actif.
|
||||||
|
</p>
|
||||||
|
<p v-if="importedLineage" class="imported__lineage">
|
||||||
|
<UIcon name="i-lucide-sprout" /> {{ LINEAGE_PREFIX }} {{ importedLineage }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.data-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: clamp(1rem, 3vw, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-card__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: clamp(1.25rem, 4vw, 2rem);
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: var(--mood-bg);
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: box-shadow 0.12s ease, background 0.12s ease;
|
||||||
|
}
|
||||||
|
.dropzone--over {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 2px var(--mood-accent);
|
||||||
|
}
|
||||||
|
.dropzone--busy {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone__icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone__text {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone__link {
|
||||||
|
color: var(--mood-accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone__sub {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone__input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issues {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
list-style: none;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.issues li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
.issues--error {
|
||||||
|
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
|
||||||
|
color: var(--mood-error);
|
||||||
|
}
|
||||||
|
.issues--warning {
|
||||||
|
background: var(--mood-status-fenetre-bg);
|
||||||
|
color: var(--mood-status-fenetre);
|
||||||
|
}
|
||||||
|
|
||||||
|
.imported {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: var(--mood-status-vigueur-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.imported__line {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-status-vigueur);
|
||||||
|
}
|
||||||
|
|
||||||
|
.imported__lineage {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--mood-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/** The six accent swatches offered at creation — well-water field, shared with /creer. */
|
||||||
|
export interface ColorChoice {
|
||||||
|
hex: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COLOR_CHOICES: ColorChoice[] = [
|
||||||
|
{ hex: '#0f7fa8', label: 'Eau vive' },
|
||||||
|
{ hex: '#2e8a72', label: 'Eau végétale' },
|
||||||
|
{ hex: '#4f7a3c', label: 'Mousse' },
|
||||||
|
{ hex: '#96682a', label: 'Bronze' },
|
||||||
|
{ hex: '#b0563c', label: 'Terre cuite' },
|
||||||
|
{ hex: '#5c68b8', label: 'Iris' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/** Étape 1 — nom du collectif, couleur, ambiance. Entrée valide l'étape. */
|
||||||
|
const name = defineModel<string>('name', { required: true })
|
||||||
|
const color = defineModel<string>('color', { required: true })
|
||||||
|
|
||||||
|
const emit = defineEmits<{ next: [] }>()
|
||||||
|
|
||||||
|
const { moods, currentMood, setMood } = useLibreMood()
|
||||||
|
|
||||||
|
const nameInput = ref<HTMLInputElement | null>(null)
|
||||||
|
onMounted(() => nameInput.value?.focus())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<section class="idstep">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">Le nom de votre collectif</span>
|
||||||
|
<input
|
||||||
|
ref="nameInput"
|
||||||
|
v-model="name"
|
||||||
|
class="field__input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Les Jardins du Canal…"
|
||||||
|
autocomplete="off"
|
||||||
|
enterkeyhint="next"
|
||||||
|
@keydown.enter.prevent="emit('next')"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="field__label">Sa couleur</span>
|
||||||
|
<div class="swatches" role="group" aria-label="Couleur du collectif">
|
||||||
|
<button
|
||||||
|
v-for="c in COLOR_CHOICES"
|
||||||
|
:key="c.hex"
|
||||||
|
type="button"
|
||||||
|
class="swatch"
|
||||||
|
:class="{ 'swatch--active': color === c.hex }"
|
||||||
|
:style="{ background: c.hex }"
|
||||||
|
:aria-label="c.label"
|
||||||
|
:aria-pressed="color === c.hex"
|
||||||
|
:title="c.label"
|
||||||
|
@click="color = c.hex"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="field__label">L'ambiance de tes écrans</span>
|
||||||
|
<div class="moods" role="group" aria-label="Ambiance">
|
||||||
|
<button
|
||||||
|
v-for="m in moods"
|
||||||
|
:key="m.id"
|
||||||
|
type="button"
|
||||||
|
class="mood-pill"
|
||||||
|
:class="{ 'mood-pill--active': currentMood === m.id }"
|
||||||
|
:aria-pressed="currentMood === m.id"
|
||||||
|
@click="setMood(m.id)"
|
||||||
|
>
|
||||||
|
<span class="mood-pill__dot" :style="{ background: m.color }" />
|
||||||
|
{{ m.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="field__hint">L'ambiance ne vaut que pour toi, sur cette machine.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.idstep {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: clamp(1.25rem, 3vw, 1.75rem);
|
||||||
|
width: min(100%, 26rem);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__input {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
}
|
||||||
|
.field__input:focus-visible {
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--mood-input-focus), 0 0 0 2.5px var(--mood-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatches {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, 0.35), 0 1px 3px var(--mood-shadow);
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
.swatch:hover { transform: translateY(-1px) scale(1.08); }
|
||||||
|
.swatch:active { transform: translateY(0); }
|
||||||
|
.swatch--active {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1.5px rgba(255, 255, 255, 0.35),
|
||||||
|
0 0 0 2.5px var(--mood-bg),
|
||||||
|
0 0 0 5px var(--mood-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moods {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0.35rem 0.9rem;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--mood-surface);
|
||||||
|
color: var(--mood-text);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
transition: transform 0.1s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
.mood-pill:hover { transform: translateY(-1px); }
|
||||||
|
.mood-pill:active { transform: translateY(0); }
|
||||||
|
.mood-pill--active {
|
||||||
|
box-shadow: var(--shadow-card), 0 0 0 2px var(--mood-accent);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood-pill__dot {
|
||||||
|
width: 0.8rem;
|
||||||
|
height: 0.8rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/** Étape 3 — toi (Person isMe) + les premiers membres, ajout rapide au clavier. */
|
||||||
|
const meName = defineModel<string>('meName', { required: true })
|
||||||
|
const members = defineModel<string[]>('members', { required: true })
|
||||||
|
|
||||||
|
const newMember = ref('')
|
||||||
|
const meInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const memberInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
onMounted(() => meInput.value?.focus())
|
||||||
|
|
||||||
|
function addMember() {
|
||||||
|
const value = newMember.value.trim()
|
||||||
|
if (!value) return
|
||||||
|
if (!members.value.includes(value)) members.value = [...members.value, value]
|
||||||
|
newMember.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMember(index: number) {
|
||||||
|
members.value = members.value.filter((_, i) => i !== index)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<section class="memstep">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">Toi — ton prénom</span>
|
||||||
|
<input
|
||||||
|
ref="meInput"
|
||||||
|
v-model="meName"
|
||||||
|
class="field__input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Camille"
|
||||||
|
autocomplete="given-name"
|
||||||
|
enterkeyhint="next"
|
||||||
|
@keydown.enter.prevent="memberInput?.focus()"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="field__label">Les premières personnes autour de la table</span>
|
||||||
|
<div class="member-add">
|
||||||
|
<input
|
||||||
|
ref="memberInput"
|
||||||
|
v-model="newMember"
|
||||||
|
class="field__input member-add__input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Un prénom, puis Entrée"
|
||||||
|
autocomplete="off"
|
||||||
|
enterkeyhint="enter"
|
||||||
|
@keydown.enter.prevent="addMember"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ld-btn ld-btn--ghost"
|
||||||
|
:disabled="!newMember.trim()"
|
||||||
|
@click="addMember"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-plus" /> Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-if="members.length" class="member-chips">
|
||||||
|
<li v-for="(m, i) in members" :key="m" class="member-chip">
|
||||||
|
{{ m }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="member-chip__x"
|
||||||
|
:aria-label="`Retirer ${m}`"
|
||||||
|
@click="removeMember(i)"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-x" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="field__hint">Tu pourras en ajouter à tout moment — les cercles se dessinent à l'usage.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.memstep {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: clamp(1.25rem, 3vw, 1.75rem);
|
||||||
|
width: min(100%, 26rem);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__input {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
}
|
||||||
|
.field__input:focus-visible {
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--mood-input-focus), 0 0 0 2.5px var(--mood-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-add {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.member-add__input { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.member-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0.3rem 0.4rem 0.3rem 0.85rem;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-chip__x {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
transition: background 0.1s ease, color 0.1s ease;
|
||||||
|
}
|
||||||
|
.member-chip__x:hover {
|
||||||
|
background: var(--mood-surface);
|
||||||
|
color: var(--mood-error);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { TemplateId } from '~/data/templates'
|
||||||
|
|
||||||
|
/** Per-template icon — also becomes Collective.icon at creation (/creer). */
|
||||||
|
export const TEMPLATE_ICONS: Record<TemplateId, string> = {
|
||||||
|
'blank': 'i-lucide-notebook-pen',
|
||||||
|
'informal': 'i-lucide-coffee',
|
||||||
|
'association': 'i-lucide-heart-handshake',
|
||||||
|
'cooperative': 'i-lucide-wheat',
|
||||||
|
'commune': 'i-lucide-map-pinned',
|
||||||
|
'free-currency': 'i-lucide-coins',
|
||||||
|
'symmetric': 'i-lucide-scale',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** « Qui doit être d'accord ? » — the concrete line of each card. */
|
||||||
|
const TEMPLATE_AGREEMENT: Record<TemplateId, string> = {
|
||||||
|
'blank': "celles et ceux que ça touche — sans objection maintenue, c'est adopté.",
|
||||||
|
'informal': "le groupe, par consentement ; rien d'exigé avant de décider.",
|
||||||
|
'association': 'les membres concernés ; nombreux, vous décidez en nuances — jamais deux camps.',
|
||||||
|
'cooperative': 'celles et ceux que ça engage ; les nuances décident des grandes questions.',
|
||||||
|
'commune': 'chaque périmètre à sa juste échelle ; les nuances au-delà du cercle proche.',
|
||||||
|
'free-currency': "la toile entière sur les grands sujets — le pour/contre inertiel hérité de Ğ1.",
|
||||||
|
'symmetric': 'tout le collectif, en pleine lumière — la transparence est la règle.',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/** Étape 2 — les sept gabarits (blank en premier) + les deux collectifs à explorer. */
|
||||||
|
import { TEMPLATE_CARDS } from '~/data/templates'
|
||||||
|
import type { SeedName } from '~/stores/collective'
|
||||||
|
|
||||||
|
const selected = defineModel<TemplateId | null>({ required: true })
|
||||||
|
const emit = defineEmits<{ next: []; seed: [name: SeedName] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<section class="tplstep">
|
||||||
|
<div class="tpl-grid">
|
||||||
|
<button
|
||||||
|
v-for="card in TEMPLATE_CARDS"
|
||||||
|
:key="card.id"
|
||||||
|
type="button"
|
||||||
|
class="ld-card ld-card--hover tpl-card"
|
||||||
|
:class="{ 'tpl-card--active': selected === card.id, 'tpl-card--blank': card.id === 'blank' }"
|
||||||
|
:aria-pressed="selected === card.id"
|
||||||
|
@click="selected = card.id"
|
||||||
|
@dblclick="emit('next')"
|
||||||
|
>
|
||||||
|
<span class="tpl-card__top">
|
||||||
|
<span class="tpl-card__icon"><UIcon :name="TEMPLATE_ICONS[card.id]" /></span>
|
||||||
|
<span class="tpl-card__head">
|
||||||
|
<span class="tpl-card__title">{{ card.title }}</span>
|
||||||
|
<span class="tpl-card__subtitle">{{ card.subtitle }}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="tpl-card__desc">{{ card.description }}</span>
|
||||||
|
<span class="tpl-card__agree">
|
||||||
|
<strong>Qui doit être d'accord ?</strong>
|
||||||
|
{{ TEMPLATE_AGREEMENT[card.id] }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tpl-seeds">
|
||||||
|
<p class="tpl-seeds__label">Ou entre d'abord dans un collectif déjà vivant :</p>
|
||||||
|
<div class="tpl-seeds__row">
|
||||||
|
<button type="button" class="ld-btn ld-btn--ghost" @click="emit('seed', 'duniter-g1')">
|
||||||
|
<UIcon name="i-lucide-coins" /> Explorer Duniter Ğ1
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ld-btn ld-btn--ghost" @click="emit('seed', 'atelier-du-canal')">
|
||||||
|
<UIcon name="i-lucide-hammer" /> Explorer l'Atelier du Canal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tplstep {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: clamp(1.25rem, 3vw, 1.75rem);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.tpl-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
.tpl-card--blank { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: clamp(0.875rem, 2.5vw, 1.15rem);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--mood-text);
|
||||||
|
background: var(--mood-surface);
|
||||||
|
}
|
||||||
|
.tpl-card--active {
|
||||||
|
box-shadow: var(--shadow-card), 0 0 0 2.5px var(--mood-accent);
|
||||||
|
}
|
||||||
|
.tpl-card--blank {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-card__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-card__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: var(--r-icon);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-card__head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-card__title {
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-card__subtitle {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-card__desc {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-card__agree {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.tpl-card__agree strong {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-seeds {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-seeds__label {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-seeds__row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Interactive formula parameter editor.
|
|
||||||
*
|
|
||||||
* Provides sliders and inputs for adjusting all formula parameters,
|
|
||||||
* emitting the updated config on each change.
|
|
||||||
*/
|
|
||||||
import type { FormulaConfig } from '~/stores/protocols'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: FormulaConfig
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: FormulaConfig]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
/** Local reactive copy to avoid direct prop mutation. */
|
|
||||||
const local = reactive({ ...props.modelValue })
|
|
||||||
|
|
||||||
/** Sync incoming prop changes. */
|
|
||||||
watch(() => props.modelValue, (newVal) => {
|
|
||||||
Object.assign(local, newVal)
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
/** Emit on any local change. */
|
|
||||||
watch(local, () => {
|
|
||||||
emit('update:modelValue', { ...local })
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
/** Track optional fields. */
|
|
||||||
const showSmith = ref(local.smith_exponent !== null)
|
|
||||||
const showTechcomm = ref(local.techcomm_exponent !== null)
|
|
||||||
const showNuancedMin = ref(local.nuanced_min_participants !== null)
|
|
||||||
const showNuancedThreshold = ref(local.nuanced_threshold_pct !== null)
|
|
||||||
|
|
||||||
watch(showSmith, (v) => {
|
|
||||||
local.smith_exponent = v ? 0.5 : null
|
|
||||||
})
|
|
||||||
watch(showTechcomm, (v) => {
|
|
||||||
local.techcomm_exponent = v ? 0.5 : null
|
|
||||||
})
|
|
||||||
watch(showNuancedMin, (v) => {
|
|
||||||
local.nuanced_min_participants = v ? 10 : null
|
|
||||||
})
|
|
||||||
watch(showNuancedThreshold, (v) => {
|
|
||||||
local.nuanced_threshold_pct = v ? 66 : null
|
|
||||||
})
|
|
||||||
|
|
||||||
interface ParamDef {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
description: string
|
|
||||||
type: 'input' | 'slider'
|
|
||||||
min: number
|
|
||||||
max: number
|
|
||||||
step: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainParams: ParamDef[] = [
|
|
||||||
{
|
|
||||||
key: 'duration_days',
|
|
||||||
label: 'Duree (jours)',
|
|
||||||
description: 'Duree du vote en jours',
|
|
||||||
type: 'input',
|
|
||||||
min: 1,
|
|
||||||
max: 365,
|
|
||||||
step: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'majority_pct',
|
|
||||||
label: 'Majorite (%)',
|
|
||||||
description: 'Ratio de majorite cible a haute participation',
|
|
||||||
type: 'slider',
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'base_exponent',
|
|
||||||
label: 'Exposant de base (B)',
|
|
||||||
description: 'B^W tend vers 0 si B < 1 ; plancher dynamique',
|
|
||||||
type: 'slider',
|
|
||||||
min: 0.01,
|
|
||||||
max: 1.0,
|
|
||||||
step: 0.01,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'gradient_exponent',
|
|
||||||
label: 'Gradient d\'inertie (G)',
|
|
||||||
description: 'Controle la vitesse de transition vers la majorite simple',
|
|
||||||
type: 'slider',
|
|
||||||
min: 0.01,
|
|
||||||
max: 2.0,
|
|
||||||
step: 0.01,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'constant_base',
|
|
||||||
label: 'Constante de base (C)',
|
|
||||||
description: 'Plancher fixe de votes requis',
|
|
||||||
type: 'input',
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-6">
|
|
||||||
<!-- Main parameters -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div v-for="param in mainParams" :key="param.key" class="space-y-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
{{ param.label }}
|
|
||||||
</label>
|
|
||||||
<span class="text-sm font-mono font-bold text-primary">
|
|
||||||
{{ (local as any)[param.key] }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="param.type === 'slider'">
|
|
||||||
<URange
|
|
||||||
v-model="(local as any)[param.key]"
|
|
||||||
:min="param.min"
|
|
||||||
:max="param.max"
|
|
||||||
:step="param.step"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<UInput
|
|
||||||
v-model.number="(local as any)[param.key]"
|
|
||||||
type="number"
|
|
||||||
:min="param.min"
|
|
||||||
:max="param.max"
|
|
||||||
:step="param.step"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<p class="text-xs text-gray-500">{{ param.description }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Optional parameters -->
|
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">
|
|
||||||
Parametres optionnels
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<!-- Smith exponent -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UCheckbox v-model="showSmith" />
|
|
||||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Critere Smith (S)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<template v-if="showSmith && local.smith_exponent !== null">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-xs text-gray-500">Exposant Smith</span>
|
|
||||||
<span class="text-sm font-mono font-bold text-primary">{{ local.smith_exponent }}</span>
|
|
||||||
</div>
|
|
||||||
<URange
|
|
||||||
v-model="local.smith_exponent"
|
|
||||||
:min="0.01"
|
|
||||||
:max="1.0"
|
|
||||||
:step="0.01"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500">ceil(SmithWotSize^S) votes Smith requis</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- TechComm exponent -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UCheckbox v-model="showTechcomm" />
|
|
||||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Critere TechComm (T)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<template v-if="showTechcomm && local.techcomm_exponent !== null">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-xs text-gray-500">Exposant TechComm</span>
|
|
||||||
<span class="text-sm font-mono font-bold text-primary">{{ local.techcomm_exponent }}</span>
|
|
||||||
</div>
|
|
||||||
<URange
|
|
||||||
v-model="local.techcomm_exponent"
|
|
||||||
:min="0.01"
|
|
||||||
:max="1.0"
|
|
||||||
:step="0.01"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500">ceil(CoTecSize^T) votes TechComm requis</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nuanced min participants -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UCheckbox v-model="showNuancedMin" />
|
|
||||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Participants minimum (nuance)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<template v-if="showNuancedMin && local.nuanced_min_participants !== null">
|
|
||||||
<UInput
|
|
||||||
v-model.number="local.nuanced_min_participants"
|
|
||||||
type="number"
|
|
||||||
:min="1"
|
|
||||||
:max="1000"
|
|
||||||
:step="1"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500">Nombre minimum de participants pour un vote nuance</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nuanced threshold pct -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UCheckbox v-model="showNuancedThreshold" />
|
|
||||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Seuil nuance (%)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<template v-if="showNuancedThreshold && local.nuanced_threshold_pct !== null">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-xs text-gray-500">Pourcentage du seuil</span>
|
|
||||||
<span class="text-sm font-mono font-bold text-primary">{{ local.nuanced_threshold_pct }}%</span>
|
|
||||||
</div>
|
|
||||||
<URange
|
|
||||||
v-model="local.nuanced_threshold_pct"
|
|
||||||
:min="50"
|
|
||||||
:max="100"
|
|
||||||
:step="1"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500">Seuil de score moyen pour adoption en vote nuance</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Display decoded mode params string as labeled badges/chips.
|
|
||||||
*
|
|
||||||
* Parses the compact mode params string and renders each parameter
|
|
||||||
* as a human-readable chip.
|
|
||||||
*/
|
|
||||||
const props = defineProps<{
|
|
||||||
modeParams: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
interface ParamChip {
|
|
||||||
code: string
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const chips = computed((): ParamChip[] => {
|
|
||||||
if (!props.modeParams) return []
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = parseModeParams(props.modeParams)
|
|
||||||
const result: ParamChip[] = []
|
|
||||||
|
|
||||||
result.push({ code: 'D', label: 'Duree', value: `${parsed.duration_days}j`, color: 'primary' })
|
|
||||||
result.push({ code: 'M', label: 'Majorite', value: `${parsed.majority_pct}%`, color: 'info' })
|
|
||||||
result.push({ code: 'B', label: 'Base', value: String(parsed.base_exponent), color: 'neutral' })
|
|
||||||
result.push({ code: 'G', label: 'Gradient', value: String(parsed.gradient_exponent), color: 'neutral' })
|
|
||||||
|
|
||||||
if (parsed.constant_base > 0) {
|
|
||||||
result.push({ code: 'C', label: 'Constante', value: String(parsed.constant_base), color: 'warning' })
|
|
||||||
}
|
|
||||||
if (parsed.smith_exponent !== null) {
|
|
||||||
result.push({ code: 'S', label: 'Smith', value: String(parsed.smith_exponent), color: 'success' })
|
|
||||||
}
|
|
||||||
if (parsed.techcomm_exponent !== null) {
|
|
||||||
result.push({ code: 'T', label: 'TechComm', value: String(parsed.techcomm_exponent), color: 'purple' })
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<span class="font-mono text-xs font-bold text-primary mr-1">{{ modeParams }}</span>
|
|
||||||
<UBadge
|
|
||||||
v-for="chip in chips"
|
|
||||||
:key="chip.code"
|
|
||||||
:color="(chip.color as any)"
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
{{ chip.label }}: {{ chip.value }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Dropdown/select to pick a voting protocol.
|
|
||||||
*
|
|
||||||
* Fetches protocols from the store and renders them in a USelect
|
|
||||||
* with protocol name, mode params, and vote type badge.
|
|
||||||
*/
|
|
||||||
import type { VotingProtocol } from '~/stores/protocols'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: string | null
|
|
||||||
voteType?: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: string | null]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const protocols = useProtocolsStore()
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (protocols.protocols.length === 0) {
|
|
||||||
await protocols.fetchProtocols(props.voteType ? { vote_type: props.voteType } : undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredProtocols = computed(() => {
|
|
||||||
if (!props.voteType) return protocols.protocols
|
|
||||||
return protocols.protocols.filter(p => p.vote_type === props.voteType)
|
|
||||||
})
|
|
||||||
|
|
||||||
const options = computed(() => {
|
|
||||||
return filteredProtocols.value.map(p => ({
|
|
||||||
label: buildLabel(p),
|
|
||||||
value: p.id,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
function buildLabel(p: VotingProtocol): string {
|
|
||||||
const typeLabel = p.vote_type === 'binary' ? 'Binaire' : 'Nuance'
|
|
||||||
const params = p.mode_params ? ` [${p.mode_params}]` : ''
|
|
||||||
return `${p.name} - ${typeLabel}${params}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSelect(value: string) {
|
|
||||||
emit('update:modelValue', value || null)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<USelect
|
|
||||||
:model-value="modelValue ?? undefined"
|
|
||||||
:items="options"
|
|
||||||
placeholder="Selectionnez un protocole..."
|
|
||||||
:loading="protocols.loading"
|
|
||||||
value-key="value"
|
|
||||||
@update:model-value="onSelect"
|
|
||||||
/>
|
|
||||||
<p v-if="protocols.error" class="text-xs text-red-500 mt-1">
|
|
||||||
{{ protocols.error }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps<{
|
|
||||||
txHash: string | null
|
|
||||||
block: number | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const truncatedHash = computed(() => {
|
|
||||||
if (!props.txHash) return null
|
|
||||||
if (props.txHash.length <= 20) return props.txHash
|
|
||||||
return props.txHash.slice(0, 10) + '...' + props.txHash.slice(-6)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<template v-if="txHash">
|
|
||||||
<div class="inline-flex items-center gap-2">
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<UIcon name="i-lucide-link" class="text-sm text-gray-500" />
|
|
||||||
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">
|
|
||||||
{{ truncatedHash }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<UBadge v-if="block" color="neutral" variant="subtle" size="xs">
|
|
||||||
Bloc #{{ block.toLocaleString('fr-FR') }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<UBadge color="warning" variant="subtle" size="xs">
|
|
||||||
Non ancre
|
|
||||||
</UBadge>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps<{
|
|
||||||
cid: string | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const IPFS_GATEWAY = 'https://ipfs.io/ipfs/'
|
|
||||||
|
|
||||||
const truncatedCid = computed(() => {
|
|
||||||
if (!props.cid) return null
|
|
||||||
if (props.cid.length <= 20) return props.cid
|
|
||||||
return props.cid.slice(0, 12) + '...' + props.cid.slice(-6)
|
|
||||||
})
|
|
||||||
|
|
||||||
const gatewayUrl = computed(() => {
|
|
||||||
if (!props.cid) return null
|
|
||||||
return `${IPFS_GATEWAY}${props.cid}`
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<template v-if="cid">
|
|
||||||
<a
|
|
||||||
:href="gatewayUrl!"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex items-center gap-1.5 font-mono text-xs text-primary hover:underline"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-hard-drive" class="text-sm" />
|
|
||||||
<span>{{ truncatedCid }}</span>
|
|
||||||
<UIcon name="i-lucide-external-link" class="text-sm" />
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<UBadge color="neutral" variant="subtle" size="xs">
|
|
||||||
Non disponible
|
|
||||||
</UBadge>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
export interface SanctuaryEntryOut {
|
|
||||||
id: string
|
|
||||||
entry_type: string
|
|
||||||
reference_id: string
|
|
||||||
title: string | null
|
|
||||||
content_hash: string
|
|
||||||
ipfs_cid: string | null
|
|
||||||
chain_tx_hash: string | null
|
|
||||||
chain_block: number | null
|
|
||||||
metadata_json: string | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
entry: SanctuaryEntryOut
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
verify: [id: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const typeLabel = (entryType: string): string => {
|
|
||||||
switch (entryType) {
|
|
||||||
case 'document': return 'Document'
|
|
||||||
case 'decision': return 'Decision'
|
|
||||||
case 'vote_result': return 'Resultat de vote'
|
|
||||||
default: return entryType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeColor = (entryType: string): string => {
|
|
||||||
switch (entryType) {
|
|
||||||
case 'document': return 'primary'
|
|
||||||
case 'decision': return 'success'
|
|
||||||
case 'vote_result': return 'info'
|
|
||||||
default: return 'neutral'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateHash(hash: string | null, length: number = 16): string {
|
|
||||||
if (!hash) return '-'
|
|
||||||
if (hash.length <= length * 2) return hash
|
|
||||||
return hash.slice(0, length) + '...' + hash.slice(-8)
|
|
||||||
}
|
|
||||||
|
|
||||||
const copied = ref(false)
|
|
||||||
|
|
||||||
async function copyHash() {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(props.entry.content_hash)
|
|
||||||
copied.value = true
|
|
||||||
setTimeout(() => { copied.value = false }, 2000)
|
|
||||||
} catch {
|
|
||||||
// Clipboard API not available
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UCard
|
|
||||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
|
|
||||||
@click="navigateTo(`/sanctuary/${entry.id}`)"
|
|
||||||
>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- Entry header -->
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<UIcon name="i-lucide-shield-check" class="text-xl text-primary" />
|
|
||||||
<div>
|
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
|
||||||
{{ entry.title || 'Entree sans titre' }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
{{ formatDate(entry.created_at) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<UBadge :color="(typeColor(entry.entry_type) as any)" variant="subtle" size="xs">
|
|
||||||
{{ typeLabel(entry.entry_type) }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hashes and anchors -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<!-- Content hash -->
|
|
||||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
|
||||||
<div class="flex items-center justify-between mb-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon name="i-lucide-hash" class="text-gray-400 text-sm" />
|
|
||||||
<span class="text-xs font-semibold text-gray-500 uppercase">SHA-256</span>
|
|
||||||
</div>
|
|
||||||
<UButton
|
|
||||||
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
|
|
||||||
variant="ghost"
|
|
||||||
color="neutral"
|
|
||||||
size="xs"
|
|
||||||
class="p-0"
|
|
||||||
@click.stop="copyHash"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p class="font-mono text-xs text-gray-700 dark:text-gray-300 break-all">
|
|
||||||
{{ truncateHash(entry.content_hash) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- IPFS CID -->
|
|
||||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<UIcon name="i-lucide-hard-drive" class="text-gray-400 text-sm" />
|
|
||||||
<span class="text-xs font-semibold text-gray-500 uppercase">IPFS CID</span>
|
|
||||||
</div>
|
|
||||||
<div @click.stop>
|
|
||||||
<IPFSLink :cid="entry.ipfs_cid" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chain anchor -->
|
|
||||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<UIcon name="i-lucide-link" class="text-gray-400 text-sm" />
|
|
||||||
<span class="text-xs font-semibold text-gray-500 uppercase">On-chain</span>
|
|
||||||
</div>
|
|
||||||
<ChainAnchor :tx-hash="entry.chain_tx_hash" :block="entry.chain_block" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Verification status indicators -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-4 text-xs">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<UIcon
|
|
||||||
:name="entry.ipfs_cid ? 'i-lucide-check-circle' : 'i-lucide-clock'"
|
|
||||||
:class="entry.ipfs_cid ? 'text-green-500' : 'text-gray-400'"
|
|
||||||
/>
|
|
||||||
<span :class="entry.ipfs_cid ? 'text-green-600' : 'text-gray-400'">
|
|
||||||
IPFS {{ entry.ipfs_cid ? 'epingle' : 'en attente' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<UIcon
|
|
||||||
:name="entry.chain_tx_hash ? 'i-lucide-check-circle' : 'i-lucide-clock'"
|
|
||||||
:class="entry.chain_tx_hash ? 'text-green-500' : 'text-gray-400'"
|
|
||||||
/>
|
|
||||||
<span :class="entry.chain_tx_hash ? 'text-green-600' : 'text-gray-400'">
|
|
||||||
Chain {{ entry.chain_tx_hash ? 'ancre' : 'en attente' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<UButton
|
|
||||||
label="Verifier"
|
|
||||||
icon="i-lucide-shield-check"
|
|
||||||
variant="soft"
|
|
||||||
color="primary"
|
|
||||||
size="xs"
|
|
||||||
@click.stop="emit('verify', entry.id)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> L'établi de l'Atelier — porté du FormulaEditor v1 (sliders,
|
||||||
|
// exposants Forgeron/Comité) et du FormulaDisplay v1 (rendu KaTeX), branché sur
|
||||||
|
// l'UNIQUE moteur ~/engine (wotThreshold, smithThreshold, techcommThreshold).
|
||||||
|
// La seule maison des lettres : W, T, M, B, G, C n'apparaissent qu'ici.
|
||||||
|
import { wotThreshold, smithThreshold, techcommThreshold } from '~/engine'
|
||||||
|
import { INERTIA_LABELS, thresholdSentence } from '~/lexicon'
|
||||||
|
import type { AtelierInit } from './textsModel'
|
||||||
|
import { INERTIA_COLORS, INERTIA_ORDER, INERTIA_PARAMS, formatInt } from './textsModel'
|
||||||
|
|
||||||
|
const props = defineProps<{ initial?: AtelierInit }>()
|
||||||
|
|
||||||
|
// ── L'état de l'établi ───────────────────────────────────────
|
||||||
|
const W = ref(Math.max(1, Math.round(props.initial?.W ?? 100)))
|
||||||
|
const T = ref(Math.max(0, Math.round(props.initial?.T ?? Math.max(1, Math.round(W.value / 3)))))
|
||||||
|
const M = ref(props.initial?.M ?? 50)
|
||||||
|
const B = ref(props.initial?.B ?? 0.1)
|
||||||
|
const G = ref(props.initial?.G ?? 0.2)
|
||||||
|
const C = ref(props.initial?.C ?? 0)
|
||||||
|
const withSmith = ref(props.initial?.S !== undefined)
|
||||||
|
const S = ref(props.initial?.S ?? 0.1)
|
||||||
|
const smithW = ref(12)
|
||||||
|
const withTech = ref(false)
|
||||||
|
const techExp = ref(0.1)
|
||||||
|
const techW = ref(6)
|
||||||
|
|
||||||
|
watch(W, (w) => { if (T.value > w) T.value = w })
|
||||||
|
|
||||||
|
/** Cas Forgeron 2026 — un clic : W=7224, T=120 ⇒ 94 pour requis. */
|
||||||
|
function loadForgeron2026(): void {
|
||||||
|
W.value = 7224
|
||||||
|
T.value = 120
|
||||||
|
M.value = 50
|
||||||
|
B.value = 0.1
|
||||||
|
G.value = 0.2
|
||||||
|
C.value = 0
|
||||||
|
withSmith.value = true
|
||||||
|
S.value = 0.1
|
||||||
|
withTech.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Les seuils — source unique ~/engine ──────────────────────
|
||||||
|
const threshold = computed(() => wotThreshold(W.value, T.value, M.value, B.value, G.value, C.value))
|
||||||
|
const thresholdPct = computed(() =>
|
||||||
|
T.value > 0 ? Math.round((threshold.value / T.value) * 100) : null)
|
||||||
|
const sentence = computed(() =>
|
||||||
|
T.value > 0 ? thresholdSentence(W.value, T.value, threshold.value) : null)
|
||||||
|
const smithRequired = computed(() =>
|
||||||
|
withSmith.value ? smithThreshold(Math.max(1, smithW.value), S.value) : null)
|
||||||
|
const techRequired = computed(() =>
|
||||||
|
withTech.value ? techcommThreshold(Math.max(1, techW.value), techExp.value) : null)
|
||||||
|
|
||||||
|
// ── KaTeX (chargé globalement — repli <code> sinon) ──────────
|
||||||
|
const MAIN_TEX = 'Seuil = C + B^{W} + \\left(M + (1-M)\\cdot\\left(1 - \\left(\\tfrac{T}{W}\\right)^{G}\\right)\\right)\\cdot\\max(0,\\,T - C)'
|
||||||
|
const katexTick = ref(0)
|
||||||
|
onMounted(() => {
|
||||||
|
const w = window as unknown as { katex?: unknown }
|
||||||
|
if (w.katex) return
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (w.katex) { katexTick.value++; clearInterval(timer) }
|
||||||
|
}, 200)
|
||||||
|
setTimeout(() => clearInterval(timer), 6000)
|
||||||
|
})
|
||||||
|
function renderTex(tex: string): string {
|
||||||
|
const w = window as unknown as {
|
||||||
|
katex?: { renderToString: (t: string, o: object) => string }
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined' && w.katex) {
|
||||||
|
return w.katex.renderToString(tex, { throwOnError: false, displayMode: true })
|
||||||
|
}
|
||||||
|
return `<code class="lab__tex-fallback">${tex}</code>`
|
||||||
|
}
|
||||||
|
const mainFormulaHtml = computed(() => { void katexTick.value; return renderTex(MAIN_TEX) })
|
||||||
|
const smithFormulaHtml = computed(() => {
|
||||||
|
void katexTick.value
|
||||||
|
return withSmith.value ? renderTex(`Seuil_{forgerons} = \\lceil ${smithW.value}^{${S.value}} \\rceil = ${smithRequired.value}`) : null
|
||||||
|
})
|
||||||
|
const techFormulaHtml = computed(() => {
|
||||||
|
void katexTick.value
|
||||||
|
return withTech.value ? renderTex(`Seuil_{comit\\acute{e}} = \\lceil ${techW.value}^{${techExp.value}} \\rceil = ${techRequired.value}`) : null
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Table de participation : T croissant sur W donné ─────────
|
||||||
|
const PARTICIPATION_STEPS = [1, 2, 5, 10, 25, 50, 75, 100]
|
||||||
|
const participationRows = computed(() => {
|
||||||
|
const seen = new Set<number>()
|
||||||
|
return PARTICIPATION_STEPS.flatMap((pct) => {
|
||||||
|
const votes = Math.max(1, Math.round((W.value * pct) / 100))
|
||||||
|
if (seen.has(votes)) return []
|
||||||
|
seen.add(votes)
|
||||||
|
const required = wotThreshold(W.value, votes, M.value, B.value, G.value, C.value)
|
||||||
|
return [{
|
||||||
|
pct,
|
||||||
|
votes,
|
||||||
|
required,
|
||||||
|
ratio: Math.round((required / votes) * 100),
|
||||||
|
isCurrent: Math.abs(votes - T.value) === Math.min(
|
||||||
|
...PARTICIPATION_STEPS.map(p => Math.abs(Math.max(1, Math.round((W.value * p) / 100)) - T.value)),
|
||||||
|
),
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Presets d'inertie comparés (câblage réel du domaine) ─────
|
||||||
|
const presetRows = computed(() => INERTIA_ORDER.map((preset) => {
|
||||||
|
const params = INERTIA_PARAMS[preset]
|
||||||
|
const required = T.value > 0
|
||||||
|
? wotThreshold(W.value, T.value, params.majorityPct, 0.1, params.gradientExponent, 0)
|
||||||
|
: 0
|
||||||
|
return {
|
||||||
|
preset,
|
||||||
|
label: INERTIA_LABELS[preset],
|
||||||
|
color: INERTIA_COLORS[preset],
|
||||||
|
majorityPct: params.majorityPct,
|
||||||
|
gradient: params.gradientExponent,
|
||||||
|
required,
|
||||||
|
ratio: T.value > 0 ? Math.round((required / T.value) * 100) : 0,
|
||||||
|
active: params.majorityPct === M.value && params.gradientExponent === G.value,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** Courbes seuil (%) / participation — portées de l'InertiaSlider v1. */
|
||||||
|
function curvePath(majorityPct: number, gradient: number): string {
|
||||||
|
const m = majorityPct / 100
|
||||||
|
const points: string[] = []
|
||||||
|
for (let i = 0; i <= 40; i++) {
|
||||||
|
const participation = i / 40
|
||||||
|
const ratio = m + (1 - m) * (1 - participation ** gradient)
|
||||||
|
points.push(`${(30 + participation * 170).toFixed(1)},${(10 + (1 - ratio) * 70).toFixed(1)}`)
|
||||||
|
}
|
||||||
|
return `M ${points.join(' L ')}`
|
||||||
|
}
|
||||||
|
const presetCurves = computed(() => presetRows.value.map(row => ({
|
||||||
|
...row,
|
||||||
|
path: curvePath(row.majorityPct, row.gradient),
|
||||||
|
})))
|
||||||
|
const currentCurve = computed(() => curvePath(M.value, G.value))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<section class="lab">
|
||||||
|
<!-- La formule, rendue -->
|
||||||
|
<div class="lab__formula ld-card">
|
||||||
|
<div class="lab__tex" v-html="mainFormulaHtml" />
|
||||||
|
<div class="lab__legend">
|
||||||
|
<span><strong>W</strong> membres éligibles</span>
|
||||||
|
<span><strong>T</strong> votes exprimés</span>
|
||||||
|
<span><strong>M</strong> majorité cible</span>
|
||||||
|
<span><strong>B</strong> plancher dynamique</span>
|
||||||
|
<span><strong>G</strong> gradient d'inertie</span>
|
||||||
|
<span><strong>C</strong> plancher fixe</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lab__grid">
|
||||||
|
<!-- Les réglages -->
|
||||||
|
<div class="lab__controls ld-card">
|
||||||
|
<div class="lab__scenario">
|
||||||
|
<label class="lab__field">
|
||||||
|
<span class="lab__field-label">W — membres éligibles</span>
|
||||||
|
<input v-model.number="W" type="number" min="1" max="1000000" class="lab__number">
|
||||||
|
</label>
|
||||||
|
<label class="lab__field">
|
||||||
|
<span class="lab__field-label">T — votes exprimés</span>
|
||||||
|
<input v-model.number="T" type="number" min="0" :max="W" class="lab__number">
|
||||||
|
</label>
|
||||||
|
<button type="button" class="ld-btn ld-btn--ghost lab__forgeron" @click="loadForgeron2026">
|
||||||
|
<UIcon name="i-lucide-hammer" />
|
||||||
|
Cas Forgeron 2026
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="lab__slider">
|
||||||
|
<span class="lab__slider-head">
|
||||||
|
<span>M — majorité cible</span><strong>{{ M }} %</strong>
|
||||||
|
</span>
|
||||||
|
<input v-model.number="M" type="range" min="0" max="100" step="1">
|
||||||
|
</label>
|
||||||
|
<label class="lab__slider">
|
||||||
|
<span class="lab__slider-head">
|
||||||
|
<span>B — plancher dynamique</span><strong>{{ B }}</strong>
|
||||||
|
</span>
|
||||||
|
<input v-model.number="B" type="range" min="0.01" max="1" step="0.01">
|
||||||
|
</label>
|
||||||
|
<label class="lab__slider">
|
||||||
|
<span class="lab__slider-head">
|
||||||
|
<span>G — gradient d'inertie</span><strong>{{ G }}</strong>
|
||||||
|
</span>
|
||||||
|
<input v-model.number="G" type="range" min="0.01" max="2" step="0.01">
|
||||||
|
</label>
|
||||||
|
<label class="lab__slider">
|
||||||
|
<span class="lab__slider-head">
|
||||||
|
<span>C — plancher fixe</span><strong>{{ C }}</strong>
|
||||||
|
</span>
|
||||||
|
<input v-model.number="C" type="range" min="0" max="100" step="1">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="lab__optional">
|
||||||
|
<label class="lab__check">
|
||||||
|
<input v-model="withSmith" type="checkbox">
|
||||||
|
<span>Critère Forgeron</span>
|
||||||
|
</label>
|
||||||
|
<div v-if="withSmith" class="lab__sub">
|
||||||
|
<label class="lab__slider">
|
||||||
|
<span class="lab__slider-head"><span>S — exposant</span><strong>{{ S }}</strong></span>
|
||||||
|
<input v-model.number="S" type="range" min="0.01" max="1" step="0.01">
|
||||||
|
</label>
|
||||||
|
<label class="lab__field lab__field--inline">
|
||||||
|
<span class="lab__field-label">forgerons actifs</span>
|
||||||
|
<input v-model.number="smithW" type="number" min="1" max="10000" class="lab__number">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="lab__check">
|
||||||
|
<input v-model="withTech" type="checkbox">
|
||||||
|
<span>Critère Comité technique</span>
|
||||||
|
</label>
|
||||||
|
<div v-if="withTech" class="lab__sub">
|
||||||
|
<label class="lab__slider">
|
||||||
|
<span class="lab__slider-head"><span>exposant</span><strong>{{ techExp }}</strong></span>
|
||||||
|
<input v-model.number="techExp" type="range" min="0.01" max="1" step="0.01">
|
||||||
|
</label>
|
||||||
|
<label class="lab__field lab__field--inline">
|
||||||
|
<span class="lab__field-label">membres du comité</span>
|
||||||
|
<input v-model.number="techW" type="number" min="1" max="1000" class="lab__number">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Le résultat -->
|
||||||
|
<div class="lab__result ld-card">
|
||||||
|
<p class="lab__result-heading">Le seuil, ici et maintenant</p>
|
||||||
|
<p class="lab__result-figure">
|
||||||
|
<span class="lab__result-number">{{ formatInt(threshold) }}</span>
|
||||||
|
<span class="lab__result-unit">pour requis</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="thresholdPct !== null" class="lab__result-pct">
|
||||||
|
soit {{ thresholdPct }} % des {{ formatInt(T) }} votes exprimés
|
||||||
|
</p>
|
||||||
|
<p v-if="sentence" class="lab__result-sentence">{{ sentence }}</p>
|
||||||
|
<div v-if="smithFormulaHtml" class="lab__criterion" v-html="smithFormulaHtml" />
|
||||||
|
<div v-if="techFormulaHtml" class="lab__criterion" v-html="techFormulaHtml" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table de participation -->
|
||||||
|
<div class="lab__table-card ld-card">
|
||||||
|
<p class="lab__block-title">Le seuil descend quand la participation monte</p>
|
||||||
|
<p class="lab__block-sub">T croissant, sur une toile de {{ formatInt(W) }} membres</p>
|
||||||
|
<div class="lab__table-scroll">
|
||||||
|
<table class="lab__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>participation</th>
|
||||||
|
<th>T</th>
|
||||||
|
<th>seuil</th>
|
||||||
|
<th>% des exprimés</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="row in participationRows"
|
||||||
|
:key="row.pct"
|
||||||
|
:class="{ 'lab__row--current': row.isCurrent }"
|
||||||
|
>
|
||||||
|
<td>{{ row.pct }} %</td>
|
||||||
|
<td>{{ formatInt(row.votes) }}</td>
|
||||||
|
<td class="lab__cell-strong">{{ formatInt(row.required) }}</td>
|
||||||
|
<td>{{ row.ratio }} %</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Presets d'inertie comparés -->
|
||||||
|
<div class="lab__presets ld-card">
|
||||||
|
<p class="lab__block-title">Les quatre inerties, comparées</p>
|
||||||
|
<p class="lab__block-sub">
|
||||||
|
mêmes W et T — seule l'inertie change ; ce sont les presets réels des clauses
|
||||||
|
</p>
|
||||||
|
<svg viewBox="0 0 230 100" class="lab__curves" role="img" aria-label="Courbes de seuil par inertie">
|
||||||
|
<line x1="30" y1="10" x2="30" y2="80" class="lab__axis" />
|
||||||
|
<line x1="30" y1="80" x2="200" y2="80" class="lab__axis" />
|
||||||
|
<path
|
||||||
|
v-for="curve in presetCurves"
|
||||||
|
:key="curve.preset"
|
||||||
|
:d="curve.path"
|
||||||
|
fill="none"
|
||||||
|
:style="{ stroke: curve.color }"
|
||||||
|
:stroke-width="curve.active ? 2.6 : 1.4"
|
||||||
|
:opacity="curve.active ? 1 : 0.55"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
v-if="!presetCurves.some(curve => curve.active)"
|
||||||
|
:d="currentCurve"
|
||||||
|
fill="none"
|
||||||
|
class="lab__curve-current"
|
||||||
|
stroke-width="2.2"
|
||||||
|
stroke-dasharray="5 4"
|
||||||
|
/>
|
||||||
|
<text x="14" y="14" class="lab__axis-label">100%</text>
|
||||||
|
<text x="18" y="84" class="lab__axis-label">M</text>
|
||||||
|
<text x="30" y="94" class="lab__axis-label">0%</text>
|
||||||
|
<text x="180" y="94" class="lab__axis-label">100%</text>
|
||||||
|
<text x="110" y="99" class="lab__axis-title">participation T/W</text>
|
||||||
|
<text x="4" y="52" class="lab__axis-title" transform="rotate(-90, 8, 52)">seuil</text>
|
||||||
|
</svg>
|
||||||
|
<div class="lab__preset-rows">
|
||||||
|
<button
|
||||||
|
v-for="row in presetRows"
|
||||||
|
:key="row.preset"
|
||||||
|
type="button"
|
||||||
|
class="lab__preset"
|
||||||
|
:class="{ 'lab__preset--active': row.active }"
|
||||||
|
:style="{ '--preset-color': row.color }"
|
||||||
|
@click="M = row.majorityPct; G = row.gradient; B = 0.1; C = 0"
|
||||||
|
>
|
||||||
|
<span class="lab__preset-dot" />
|
||||||
|
<span class="lab__preset-label">{{ row.label }}</span>
|
||||||
|
<span class="lab__preset-params">M{{ row.majorityPct }} · G{{ row.gradient }}</span>
|
||||||
|
<span class="lab__preset-required">{{ formatInt(row.required) }} pour ({{ row.ratio }} %)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lab { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
|
||||||
|
.lab__formula { padding: clamp(0.9rem, 3vw, 1.4rem); overflow-x: auto; }
|
||||||
|
.lab__tex { text-align: center; }
|
||||||
|
.lab__tex :deep(.lab__tex-fallback) {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
.lab__legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem 1rem;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.lab__legend strong { color: var(--mood-accent); font-family: monospace; }
|
||||||
|
|
||||||
|
.lab__grid { display: grid; grid-template-columns: 1fr; gap: 1rem; }
|
||||||
|
@media (min-width: 768px) { .lab__grid { grid-template-columns: 3fr 2fr; } }
|
||||||
|
|
||||||
|
.lab__controls {
|
||||||
|
padding: clamp(0.9rem, 3vw, 1.3rem);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
.lab__scenario { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: flex-end; }
|
||||||
|
.lab__field { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||||
|
.lab__field--inline { flex-direction: row; align-items: center; gap: 0.5rem; }
|
||||||
|
.lab__field-label {
|
||||||
|
font-size: 0.71875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.lab__number {
|
||||||
|
width: 7.5rem;
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
|
||||||
|
}
|
||||||
|
.lab__forgeron { font-size: 0.8125rem; }
|
||||||
|
|
||||||
|
.lab__slider { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
.lab__slider-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.lab__slider-head strong {
|
||||||
|
color: var(--mood-accent);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.lab__slider input[type='range'] {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: var(--mood-accent);
|
||||||
|
background: transparent;
|
||||||
|
min-height: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab__optional {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
box-shadow: 0 -1px 0 color-mix(in srgb, var(--mood-text) 8%, transparent);
|
||||||
|
}
|
||||||
|
.lab__check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lab__check input { accent-color: var(--mood-accent); width: 1rem; height: 1rem; }
|
||||||
|
.lab__sub {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab__result {
|
||||||
|
padding: clamp(0.9rem, 3vw, 1.3rem);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(150deg, var(--mood-accent-soft), transparent 60%),
|
||||||
|
var(--mood-surface);
|
||||||
|
}
|
||||||
|
.lab__result-heading {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.lab__result-figure { display: flex; align-items: baseline; gap: 0.5rem; }
|
||||||
|
.lab__result-number {
|
||||||
|
font-size: clamp(2.2rem, 7vw, 3rem);
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.lab__result-unit { font-size: 0.9375rem; font-weight: 700; color: var(--mood-text); }
|
||||||
|
.lab__result-pct { font-size: 0.875rem; color: var(--mood-text-muted); }
|
||||||
|
.lab__result-sentence {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--mood-text);
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: color-mix(in srgb, var(--mood-text) 4%, transparent);
|
||||||
|
}
|
||||||
|
.lab__criterion { overflow-x: auto; font-size: 0.875rem; }
|
||||||
|
|
||||||
|
.lab__table-card, .lab__presets { padding: clamp(0.9rem, 3vw, 1.3rem); }
|
||||||
|
.lab__block-title { font-size: 0.9375rem; font-weight: 800; color: var(--mood-text); }
|
||||||
|
.lab__block-sub { font-size: 0.75rem; color: var(--mood-text-muted); margin-bottom: 0.7rem; }
|
||||||
|
.lab__table-scroll { overflow-x: auto; }
|
||||||
|
.lab__table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
|
||||||
|
.lab__table th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
}
|
||||||
|
.lab__table td {
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
.lab__table tbody tr:nth-child(odd) td {
|
||||||
|
background: color-mix(in srgb, var(--mood-text) 3%, transparent);
|
||||||
|
}
|
||||||
|
.lab__row--current td { background: var(--mood-accent-soft) !important; font-weight: 700; }
|
||||||
|
.lab__cell-strong { font-weight: 800; color: var(--mood-accent); }
|
||||||
|
|
||||||
|
.lab__curves { width: 100%; max-width: 26rem; height: auto; margin: 0 auto 0.5rem; display: block; }
|
||||||
|
.lab__axis { stroke: color-mix(in srgb, var(--mood-text) 25%, transparent); stroke-width: 1; }
|
||||||
|
.lab__axis-label { font-size: 5.5px; fill: var(--mood-text-muted); font-family: monospace; }
|
||||||
|
.lab__axis-title { font-size: 5.5px; fill: var(--mood-text-muted); font-weight: 600; }
|
||||||
|
.lab__curve-current { stroke: var(--mood-text); }
|
||||||
|
|
||||||
|
.lab__preset-rows { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.lab__preset {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: color-mix(in srgb, var(--preset-color) 6%, transparent);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||||
|
}
|
||||||
|
.lab__preset:hover { transform: translateY(-1px); box-shadow: 0 3px 10px var(--mood-shadow); }
|
||||||
|
.lab__preset--active { box-shadow: 0 0 0 2px var(--preset-color); }
|
||||||
|
.lab__preset-dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--preset-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.lab__preset-label { font-size: 0.8125rem; font-weight: 800; color: var(--mood-text); }
|
||||||
|
.lab__preset-params {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.lab__preset-required {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--preset-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> Mini-démo interactive du Réglage collectif : cinq curseurs-votes
|
||||||
|
// jouets, la médiane basse recalculée en direct par ~/engine (medianByElement),
|
||||||
|
// et la règle de cristallisation — l'agrégat éclaire, le geste cristallise.
|
||||||
|
import { medianByElement } from '~/engine'
|
||||||
|
import { CRYSTALLIZE_ACTION, MEDIAN_EXPLANATION } from '~/lexicon'
|
||||||
|
|
||||||
|
const TOY_VOTERS = ['Ana', 'Brice', 'Chloé', 'Dado', 'Elsa'] as const
|
||||||
|
|
||||||
|
const votes = ref<number[]>([20, 35, 40, 65, 80])
|
||||||
|
|
||||||
|
const median = computed(() => {
|
||||||
|
const result = medianByElement(votes.value.map(v => [v]))
|
||||||
|
return result[0] ?? 0
|
||||||
|
})
|
||||||
|
|
||||||
|
/** La médiane est toujours une valeur réellement votée — on la nomme. */
|
||||||
|
const medianVoters = computed(() =>
|
||||||
|
TOY_VOTERS.filter((_, i) => votes.value[i] === median.value))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<div class="median-demo">
|
||||||
|
<div class="median-demo__sliders">
|
||||||
|
<label v-for="(name, i) in TOY_VOTERS" :key="name" class="median-demo__vote">
|
||||||
|
<span class="median-demo__voter">{{ name }}</span>
|
||||||
|
<input
|
||||||
|
v-model.number="votes[i]"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="5"
|
||||||
|
:aria-label="`Curseur-vote de ${name}`"
|
||||||
|
>
|
||||||
|
<span class="median-demo__value">{{ votes[i] }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Le faisceau : chaque point est un vote, le trait épais est la médiane -->
|
||||||
|
<div class="median-demo__strip" aria-hidden="true">
|
||||||
|
<span
|
||||||
|
v-for="(vote, i) in votes"
|
||||||
|
:key="i"
|
||||||
|
class="median-demo__dot"
|
||||||
|
:style="{ left: `${vote}%` }"
|
||||||
|
/>
|
||||||
|
<span class="median-demo__median-mark" :style="{ left: `${median}%` }" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="median-demo__readout">
|
||||||
|
Médiane basse : <strong>{{ median }}</strong>
|
||||||
|
<span v-if="medianVoters.length" class="median-demo__who">
|
||||||
|
— c'est la position de {{ medianVoters.join(' et ') }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="median-demo__explain">{{ MEDIAN_EXPLANATION }}</p>
|
||||||
|
|
||||||
|
<div class="median-demo__rule">
|
||||||
|
<UIcon name="i-lucide-stamp" class="median-demo__rule-icon" />
|
||||||
|
<div>
|
||||||
|
<p class="median-demo__rule-title">L'agrégat éclaire, le geste cristallise.</p>
|
||||||
|
<p class="median-demo__rule-body">
|
||||||
|
À la clôture, les votes sont figés et la médiane s'affiche — mais rien n'est
|
||||||
|
adopté tant qu'un·e garant·e n'a pas fait le geste « {{ CRYSTALLIZE_ACTION }} »,
|
||||||
|
daté et signé. Jamais d'adoption automatique.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.median-demo { display: flex; flex-direction: column; gap: 0.9rem; }
|
||||||
|
|
||||||
|
.median-demo__sliders { display: flex; flex-direction: column; gap: 0.45rem; }
|
||||||
|
.median-demo__vote {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 3.4rem 1fr 2.4rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.median-demo__voter { font-size: 0.8125rem; font-weight: 700; color: var(--mood-text-muted); }
|
||||||
|
.median-demo__vote input[type='range'] {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: var(--mood-tertiary);
|
||||||
|
background: transparent;
|
||||||
|
min-height: 1.3rem;
|
||||||
|
}
|
||||||
|
.median-demo__value {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.median-demo__strip {
|
||||||
|
position: relative;
|
||||||
|
height: 2rem;
|
||||||
|
margin: 0 0.3rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: color-mix(in srgb, var(--mood-text) 5%, transparent);
|
||||||
|
}
|
||||||
|
.median-demo__dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mood-tertiary);
|
||||||
|
opacity: 0.55;
|
||||||
|
transition: left 0.15s ease;
|
||||||
|
}
|
||||||
|
.median-demo__median-mark {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--mood-accent);
|
||||||
|
transition: left 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.median-demo__readout { font-size: 0.9375rem; color: var(--mood-text); }
|
||||||
|
.median-demo__readout strong {
|
||||||
|
color: var(--mood-accent);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.median-demo__who { font-size: 0.8125rem; color: var(--mood-text-muted); }
|
||||||
|
|
||||||
|
.median-demo__explain {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.median-demo__rule {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.7rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.8rem 0.95rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
}
|
||||||
|
.median-demo__rule-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.median-demo__rule-title { font-size: 0.875rem; font-weight: 800; color: var(--mood-text); }
|
||||||
|
.median-demo__rule-body {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> Carte d'une clause dans la vue structurée : code, titre,
|
||||||
|
// mini-jauge de session réelle, pastille d'inertie câblée, statut — tap pour
|
||||||
|
// déplier le détail complet (ClauseDetail).
|
||||||
|
import type { ClauseView } from './textsModel'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
view: ClauseView
|
||||||
|
expanded: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{ (e: 'toggle'): void }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<article class="clause-card ld-card">
|
||||||
|
<button type="button" class="clause-card__row" @click="$emit('toggle')">
|
||||||
|
<span class="clause-card__code">{{ view.clause.code }}</span>
|
||||||
|
<span class="clause-card__title">{{ view.clause.title }}</span>
|
||||||
|
<span class="clause-card__side">
|
||||||
|
<SessionMiniGauge v-if="view.gauge" :gauge="view.gauge" @click.stop />
|
||||||
|
<InertiaBadge
|
||||||
|
:preset="view.clause.inertia"
|
||||||
|
:amend-protocol="view.amendProtocol"
|
||||||
|
:protected-clause="view.protectedClause"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
<span class="status-pill clause-card__status" :class="view.status.css">
|
||||||
|
{{ view.status.label }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="view.settingText && !expanded" class="clause-card__setting">
|
||||||
|
{{ view.settingText }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ClauseDetail
|
||||||
|
v-if="expanded"
|
||||||
|
class="clause-card__detail"
|
||||||
|
:clause="view.clause"
|
||||||
|
:current-version="view.current"
|
||||||
|
:proposed="view.proposed"
|
||||||
|
:founding="view.founding"
|
||||||
|
:amendments="view.amendments"
|
||||||
|
:setting-text="view.settingText"
|
||||||
|
:gauge="view.gauge"
|
||||||
|
:atelier-link="view.atelierLink"
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.clause-card { overflow: hidden; }
|
||||||
|
.clause-card__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.clause-card__row:hover { background: color-mix(in srgb, var(--mood-accent) 4%, transparent); }
|
||||||
|
.clause-card__code {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 7px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.clause-card__title {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 10rem;
|
||||||
|
}
|
||||||
|
.clause-card__side { display: inline-flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.clause-card__status { font-size: 0.6875rem; padding: 2px 9px; }
|
||||||
|
.clause-card__setting {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
padding: 0 0.9rem 0.7rem;
|
||||||
|
}
|
||||||
|
.clause-card__detail { margin: 0 0.55rem 0.55rem; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> Détail dépliable d'une clause : version courante (markdown
|
||||||
|
// simple), versions proposées avec diff (contenu d'abord, auteur replié),
|
||||||
|
// décision fondatrice et amendements chaînés, valeur en clair des clauses de
|
||||||
|
// réglage du Pacte (« effet immédiat à l'adoption »), geste « Proposer une version ».
|
||||||
|
import type { Clause, ClauseVersion } from '~/types/domain'
|
||||||
|
import { STATUS_LABELS } from '~/lexicon'
|
||||||
|
import type { ChainEntry, ClauseSessionGauge, ProposedEntry } from './textsModel'
|
||||||
|
import { formatDateFr } from './textsModel'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
clause: Clause
|
||||||
|
currentVersion?: ClauseVersion
|
||||||
|
proposed?: ProposedEntry[]
|
||||||
|
founding?: ChainEntry
|
||||||
|
amendments?: ChainEntry[]
|
||||||
|
/** Valeur en clair (clauses de réglage du Pacte). */
|
||||||
|
settingText?: string | null
|
||||||
|
gauge?: ClauseSessionGauge | null
|
||||||
|
/** Atelier pré-réglé sur le protocole d'amendement de cette clause. */
|
||||||
|
atelierLink?: string | null
|
||||||
|
}>(), { proposed: () => [], amendments: () => [], settingText: null, gauge: null, atelierLink: null })
|
||||||
|
|
||||||
|
const hasChain = computed(() => props.founding !== undefined || props.amendments.length > 0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<div class="clause-detail">
|
||||||
|
<!-- Valeur en clair — Pacte -->
|
||||||
|
<div v-if="settingText" class="clause-detail__setting">
|
||||||
|
<UIcon name="i-lucide-settings-2" class="clause-detail__setting-icon" />
|
||||||
|
<div>
|
||||||
|
<p class="clause-detail__setting-value">{{ settingText }}</p>
|
||||||
|
<p class="clause-detail__setting-note">Effet immédiat à l'adoption — le Pacte est le réglage.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Session en cours -->
|
||||||
|
<SessionMiniGauge v-if="gauge" :gauge="gauge" with-title class="clause-detail__gauge" />
|
||||||
|
|
||||||
|
<!-- Version courante -->
|
||||||
|
<div v-if="currentVersion" class="clause-detail__current">
|
||||||
|
<p class="clause-detail__heading">
|
||||||
|
Version en vigueur
|
||||||
|
<span class="clause-detail__version-label">{{ currentVersion.versionLabel }}</span>
|
||||||
|
<span v-if="currentVersion.adoptedAt" class="clause-detail__date">
|
||||||
|
adoptée le {{ formatDateFr(currentVersion.adoptedAt) }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<MarkdownRenderer :content="currentVersion.content" class="clause-detail__content" />
|
||||||
|
</div>
|
||||||
|
<p v-else class="clause-detail__empty">Cette clause n'a pas encore de version en vigueur.</p>
|
||||||
|
|
||||||
|
<!-- Versions proposées : le contenu d'abord, l'auteur replié -->
|
||||||
|
<div v-if="proposed.length" class="clause-detail__proposals">
|
||||||
|
<p class="clause-detail__heading">
|
||||||
|
{{ proposed.length > 1 ? `${proposed.length} versions proposées` : 'Une version est proposée' }}
|
||||||
|
</p>
|
||||||
|
<article
|
||||||
|
v-for="entry in proposed"
|
||||||
|
:key="entry.version.id"
|
||||||
|
class="clause-detail__proposal"
|
||||||
|
>
|
||||||
|
<ClauseVersionDiff
|
||||||
|
:current="currentVersion?.content ?? ''"
|
||||||
|
:proposed="entry.version.content"
|
||||||
|
/>
|
||||||
|
<details class="clause-detail__author">
|
||||||
|
<summary class="clause-detail__author-summary">
|
||||||
|
<UIcon name="i-lucide-user" />
|
||||||
|
Qui propose ? <span class="clause-detail__version-label">{{ entry.version.versionLabel }}</span>
|
||||||
|
</summary>
|
||||||
|
<p class="clause-detail__author-body">
|
||||||
|
Proposée par <strong>{{ entry.authorName }}</strong>
|
||||||
|
<template v-if="entry.decisionId">
|
||||||
|
— dans le cadre de
|
||||||
|
<NuxtLink :to="`/decisions/${entry.decisionId}`" class="clause-detail__link">
|
||||||
|
{{ entry.decisionTitle ?? 'la décision liée' }}
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Décision fondatrice + amendements chaînés -->
|
||||||
|
<div v-if="hasChain" class="clause-detail__chain">
|
||||||
|
<p class="clause-detail__heading">D'où vient cette clause</p>
|
||||||
|
<ol class="clause-detail__chain-list">
|
||||||
|
<li v-if="founding" class="clause-detail__chain-item">
|
||||||
|
<UIcon name="i-lucide-sprout" class="clause-detail__chain-icon" />
|
||||||
|
<NuxtLink :to="`/decisions/${founding.id}`" class="clause-detail__link">
|
||||||
|
{{ founding.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-if="founding.decidedAt" class="clause-detail__date">
|
||||||
|
{{ formatDateFr(founding.decidedAt) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
v-for="amendment in amendments"
|
||||||
|
:key="amendment.id"
|
||||||
|
class="clause-detail__chain-item"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-git-commit-vertical" class="clause-detail__chain-icon" />
|
||||||
|
<NuxtLink :to="`/decisions/${amendment.id}`" class="clause-detail__link">
|
||||||
|
{{ amendment.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span class="status-pill" :class="`status-${amendment.status}`">
|
||||||
|
{{ STATUS_LABELS[amendment.status] }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Le geste -->
|
||||||
|
<div class="clause-detail__actions">
|
||||||
|
<NuxtLink :to="`/decider?clause=${clause.id}`" class="ld-btn clause-detail__propose">
|
||||||
|
<UIcon name="i-lucide-pen-line" />
|
||||||
|
Proposer une version
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink v-if="atelierLink" :to="atelierLink" class="ld-btn ld-btn--quiet clause-detail__propose">
|
||||||
|
<UIcon name="i-lucide-flask-conical" />
|
||||||
|
Comprendre ce seuil
|
||||||
|
</NuxtLink>
|
||||||
|
<p class="clause-detail__actions-note">
|
||||||
|
Amender, c'est décider — le chemin s'ouvre avec l'inertie de cette clause.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.clause-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.9rem 1rem 1.1rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: color-mix(in srgb, var(--mood-text) 2.5%, var(--mood-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.clause-detail__setting {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
}
|
||||||
|
.clause-detail__setting-icon { color: var(--mood-accent); font-size: 1.1rem; margin-top: 2px; }
|
||||||
|
.clause-detail__setting-value { font-size: 0.9375rem; font-weight: 800; color: var(--mood-text); }
|
||||||
|
.clause-detail__setting-note { font-size: 0.75rem; color: var(--mood-text-muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
.clause-detail__gauge { align-self: flex-start; }
|
||||||
|
|
||||||
|
.clause-detail__heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.clause-detail__version-label {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: color-mix(in srgb, var(--mood-text) 7%, transparent);
|
||||||
|
color: var(--mood-text);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.clause-detail__date {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.clause-detail__content { font-size: 0.875rem; }
|
||||||
|
.clause-detail__empty { font-size: 0.8125rem; color: var(--mood-text-muted); font-style: italic; }
|
||||||
|
|
||||||
|
.clause-detail__proposal { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.clause-detail__proposal + .clause-detail__proposal { margin-top: 0.75rem; }
|
||||||
|
|
||||||
|
.clause-detail__author-summary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.clause-detail__author-body { font-size: 0.8125rem; color: var(--mood-text); margin-top: 0.35rem; }
|
||||||
|
|
||||||
|
.clause-detail__chain-list { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
.clause-detail__chain-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.clause-detail__chain-icon { color: var(--mood-accent); font-size: 0.95rem; flex-shrink: 0; }
|
||||||
|
.clause-detail__chain-item .status-pill { font-size: 0.6875rem; padding: 2px 9px; }
|
||||||
|
|
||||||
|
.clause-detail__link { color: var(--mood-accent); text-decoration: none; font-weight: 600; }
|
||||||
|
.clause-detail__link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.clause-detail__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.clause-detail__propose { text-decoration: none; font-size: 0.875rem; }
|
||||||
|
.clause-detail__actions-note { font-size: 0.75rem; color: var(--mood-text-muted); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> Diff inline +/− entre la version courante et une version
|
||||||
|
// proposée — contenu d'abord, calculé localement (LCS ligne à ligne, sans lib).
|
||||||
|
const props = defineProps<{
|
||||||
|
current: string
|
||||||
|
proposed: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
interface DiffLine {
|
||||||
|
text: string
|
||||||
|
type: 'kept' | 'removed' | 'added'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line-based LCS diff — texts are clause-sized, the DP table stays tiny. */
|
||||||
|
function lineDiff(a: string[], b: string[]): DiffLine[] {
|
||||||
|
const n = a.length
|
||||||
|
const m = b.length
|
||||||
|
// lcs[i][j] = LCS length of a[i:] / b[j:]
|
||||||
|
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
|
||||||
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
|
for (let j = m - 1; j >= 0; j--) {
|
||||||
|
lcs[i]![j] = a[i] === b[j]
|
||||||
|
? lcs[i + 1]![j + 1]! + 1
|
||||||
|
: Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out: DiffLine[] = []
|
||||||
|
let i = 0
|
||||||
|
let j = 0
|
||||||
|
while (i < n && j < m) {
|
||||||
|
if (a[i] === b[j]) {
|
||||||
|
out.push({ text: a[i]!, type: 'kept' })
|
||||||
|
i++
|
||||||
|
j++
|
||||||
|
} else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {
|
||||||
|
out.push({ text: a[i]!, type: 'removed' })
|
||||||
|
i++
|
||||||
|
} else {
|
||||||
|
out.push({ text: b[j]!, type: 'added' })
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (i < n) out.push({ text: a[i++]!, type: 'removed' })
|
||||||
|
while (j < m) out.push({ text: b[j++]!, type: 'added' })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = computed(() => {
|
||||||
|
const a = props.current.split('\n')
|
||||||
|
const b = props.proposed.split('\n')
|
||||||
|
return lineDiff(a, b).filter(l => !(l.type === 'kept' && l.text.trim() === ''))
|
||||||
|
})
|
||||||
|
|
||||||
|
const changedCount = computed(() => lines.value.filter(l => l.type !== 'kept').length)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<div class="clause-diff">
|
||||||
|
<div v-if="changedCount === 0" class="clause-diff__same">
|
||||||
|
Aucun changement de texte — les deux versions sont identiques.
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(line, index) in lines"
|
||||||
|
v-else
|
||||||
|
:key="index"
|
||||||
|
class="clause-diff__line"
|
||||||
|
:class="`clause-diff__line--${line.type}`"
|
||||||
|
>
|
||||||
|
<span class="clause-diff__marker" aria-hidden="true">
|
||||||
|
{{ line.type === 'added' ? '+' : line.type === 'removed' ? '−' : '' }}
|
||||||
|
</span>
|
||||||
|
<span class="clause-diff__text">{{ line.text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.clause-diff {
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
overflow: hidden;
|
||||||
|
background: color-mix(in srgb, var(--mood-text) 3%, var(--mood-surface));
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.clause-diff__same {
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.clause-diff__line {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.15rem 0.9rem;
|
||||||
|
}
|
||||||
|
.clause-diff__line--removed {
|
||||||
|
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
|
||||||
|
color: color-mix(in srgb, var(--mood-error) 80%, var(--mood-text));
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
}
|
||||||
|
.clause-diff__line--added {
|
||||||
|
background: color-mix(in srgb, var(--mood-success) 11%, transparent);
|
||||||
|
color: color-mix(in srgb, var(--mood-success) 75%, var(--mood-text));
|
||||||
|
}
|
||||||
|
.clause-diff__marker {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 0.9rem;
|
||||||
|
font-weight: 800;
|
||||||
|
font-family: monospace;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.clause-diff__text { white-space: pre-wrap; word-break: break-word; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> Pastille d'inertie d'une clause — CÂBLÉE au protocole
|
||||||
|
// d'amendement résolu par le Pacte (settings.protocolByRange.clauseByInertia) :
|
||||||
|
// le tooltip dit par quel protocole cette clause s'amende, en français,
|
||||||
|
// sans aucune lettre de formule (elles vivent à l'Atelier seulement).
|
||||||
|
import type { InertiaPreset } from '~/types/domain'
|
||||||
|
import { INERTIA_LABELS } from '~/lexicon'
|
||||||
|
import { INERTIA_COLORS, INERTIA_ORDER } from './textsModel'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
preset: InertiaPreset
|
||||||
|
/** Résolu par la page : « Vote WoT standard — 30 jours ». */
|
||||||
|
amendProtocol?: string
|
||||||
|
/** Clause qui protège le réglage de l'inertie lui-même (pastille max). */
|
||||||
|
protectedClause?: boolean
|
||||||
|
compact?: boolean
|
||||||
|
}>(), { compact: false, protectedClause: false })
|
||||||
|
|
||||||
|
const color = computed(() => INERTIA_COLORS[props.preset])
|
||||||
|
const label = computed(() => INERTIA_LABELS[props.preset])
|
||||||
|
const steps = computed(() => INERTIA_ORDER.indexOf(props.preset) + 1)
|
||||||
|
|
||||||
|
const tooltip = computed(() => {
|
||||||
|
const lines: string[] = [label.value]
|
||||||
|
if (props.amendProtocol) lines.push(`S'amende par : ${props.amendProtocol}`)
|
||||||
|
if (props.protectedClause) lines.push('Clause protégée — elle garde les règles qui gardent les règles.')
|
||||||
|
return lines.join('\n')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<span
|
||||||
|
class="inertia-badge"
|
||||||
|
:class="{ 'inertia-badge--compact': compact }"
|
||||||
|
:style="{ '--ib-color': color }"
|
||||||
|
:title="tooltip"
|
||||||
|
>
|
||||||
|
<span class="inertia-badge__dots" aria-hidden="true">
|
||||||
|
<span
|
||||||
|
v-for="i in 4"
|
||||||
|
:key="i"
|
||||||
|
class="inertia-badge__dot"
|
||||||
|
:class="{ 'inertia-badge__dot--on': i <= steps }"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span v-if="!compact" class="inertia-badge__label">{{ label }}</span>
|
||||||
|
<UIcon v-if="protectedClause" name="i-lucide-lock" class="inertia-badge__lock" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.inertia-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: color-mix(in srgb, var(--ib-color) 11%, transparent);
|
||||||
|
color: var(--ib-color);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: help;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.inertia-badge--compact { padding: 3px 7px; gap: 0.3rem; }
|
||||||
|
|
||||||
|
.inertia-badge__dots {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2.5px;
|
||||||
|
}
|
||||||
|
.inertia-badge__dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--ib-color) 25%, transparent);
|
||||||
|
}
|
||||||
|
.inertia-badge__dot--on { background: var(--ib-color); }
|
||||||
|
|
||||||
|
.inertia-badge__label { letter-spacing: 0.01em; }
|
||||||
|
.inertia-badge__lock { font-size: 0.8rem; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> Mini-jauge d'une session RÉELLE en cours sur une version
|
||||||
|
// proposée : positions déposées / liste arrêtée, échéance, état figé.
|
||||||
|
import { FROZEN_LABEL } from '~/lexicon'
|
||||||
|
import type { ClauseSessionGauge } from './textsModel'
|
||||||
|
import { formatDateFr } from './textsModel'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
gauge: ClauseSessionGauge
|
||||||
|
/** Affiche le titre de la décision au-dessus de la jauge. */
|
||||||
|
withTitle?: boolean
|
||||||
|
}>(), { withTitle: false })
|
||||||
|
|
||||||
|
const ratio = computed(() => {
|
||||||
|
if (props.gauge.listSize <= 0) return 0
|
||||||
|
return Math.min(100, Math.round((props.gauge.positions / props.gauge.listSize) * 100))
|
||||||
|
})
|
||||||
|
|
||||||
|
const tooltip = computed(() =>
|
||||||
|
`${props.gauge.decisionTitle}\n${props.gauge.positions} position${props.gauge.positions > 1 ? 's' : ''} `
|
||||||
|
+ `sur une liste arrêtée de ${props.gauge.listSize} — clôture le ${formatDateFr(props.gauge.closesAt)}`)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<NuxtLink class="mini-gauge" :to="`/decisions/${gauge.decisionId}`" :title="tooltip">
|
||||||
|
<span v-if="withTitle" class="mini-gauge__title">{{ gauge.decisionTitle }}</span>
|
||||||
|
<span class="mini-gauge__row">
|
||||||
|
<UIcon :name="gauge.frozen ? 'i-lucide-snowflake' : 'i-lucide-vote'" class="mini-gauge__icon" />
|
||||||
|
<span class="mini-gauge__track">
|
||||||
|
<span class="mini-gauge__fill" :style="{ width: `${ratio}%` }" />
|
||||||
|
</span>
|
||||||
|
<span class="mini-gauge__count">{{ gauge.positions }}/{{ gauge.listSize }}</span>
|
||||||
|
<span v-if="gauge.frozen" class="mini-gauge__frozen">{{ FROZEN_LABEL }}</span>
|
||||||
|
</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mini-gauge {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 0;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.mini-gauge__title {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 15rem;
|
||||||
|
}
|
||||||
|
.mini-gauge__row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.mini-gauge__icon {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--mood-status-vote);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mini-gauge__track {
|
||||||
|
position: relative;
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.mini-gauge__fill {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--mood-status-vote);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
.mini-gauge__count {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-status-vote);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.mini-gauge__frozen {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--mood-status-fige-bg);
|
||||||
|
color: var(--mood-status-fige);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> Carte d'un document de la bibliothèque : titre, rôle,
|
||||||
|
// nombre de clauses, mini-jauges des sessions en cours sur ses clauses,
|
||||||
|
// provenance résumée (version, vote d'origine), badge 井 si gravures.
|
||||||
|
import type { TextDoc } from '~/types/domain'
|
||||||
|
import { PACT_BADGE } from '~/lexicon'
|
||||||
|
import type { ClauseSessionGauge } from './textsModel'
|
||||||
|
import { formatInt } from './textsModel'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
doc: TextDoc
|
||||||
|
clauseCount: number
|
||||||
|
gauges?: ClauseSessionGauge[]
|
||||||
|
engraved?: number
|
||||||
|
/** Carte épinglée du Pacte — mise en avant. */
|
||||||
|
pinned?: boolean
|
||||||
|
}>(), { gauges: () => [], engraved: 0, pinned: false })
|
||||||
|
|
||||||
|
const roleLabel = computed(() => (props.doc.role === 'pact' ? PACT_BADGE : 'document de référence'))
|
||||||
|
|
||||||
|
const version = computed(() =>
|
||||||
|
props.doc.provenance?.sources.find(s => s.version)?.version)
|
||||||
|
|
||||||
|
const voteSummary = computed(() => {
|
||||||
|
const r = props.doc.provenance?.voteRecord?.result
|
||||||
|
if (!r) return null
|
||||||
|
return `${formatInt(r.for)} pour · ${formatInt(r.against)} contre — toile de ${formatInt(r.wotSize)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleGauges = computed(() => props.gauges.slice(0, 3))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/textes/${doc.slug}`"
|
||||||
|
class="doc-card ld-card ld-card--hover"
|
||||||
|
:class="{ 'doc-card--pinned': pinned }"
|
||||||
|
>
|
||||||
|
<header class="doc-card__head">
|
||||||
|
<span class="doc-card__role" :class="{ 'doc-card__role--pact': doc.role === 'pact' }">
|
||||||
|
<UIcon :name="doc.role === 'pact' ? 'i-lucide-scroll' : 'i-lucide-book-open'" />
|
||||||
|
{{ roleLabel }}
|
||||||
|
</span>
|
||||||
|
<span v-if="engraved > 0" class="doc-card__engraved" title="Décisions gravées sur ce texte">
|
||||||
|
井 {{ engraved }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<h3 class="doc-card__title">{{ doc.title }}</h3>
|
||||||
|
<p class="doc-card__desc">{{ doc.description }}</p>
|
||||||
|
|
||||||
|
<div class="doc-card__meta">
|
||||||
|
<span class="doc-card__chip">
|
||||||
|
<UIcon name="i-lucide-list" />
|
||||||
|
{{ clauseCount }} clause{{ clauseCount > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
<span v-if="version" class="doc-card__chip doc-card__chip--mono">v{{ version }}</span>
|
||||||
|
<span v-if="voteSummary" class="doc-card__chip doc-card__chip--vote">
|
||||||
|
<UIcon name="i-lucide-vote" />
|
||||||
|
{{ voteSummary }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="visibleGauges.length" class="doc-card__sessions">
|
||||||
|
<p class="doc-card__sessions-heading">
|
||||||
|
{{ gauges.length > 1 ? `${gauges.length} sessions en cours` : 'Une session en cours' }}
|
||||||
|
</p>
|
||||||
|
<div class="doc-card__gauges">
|
||||||
|
<SessionMiniGauge
|
||||||
|
v-for="gauge in visibleGauges"
|
||||||
|
:key="gauge.decisionId"
|
||||||
|
:gauge="gauge"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.doc-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: clamp(1rem, 3vw, 1.35rem);
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.doc-card--pinned {
|
||||||
|
background:
|
||||||
|
linear-gradient(140deg, var(--mood-accent-soft), transparent 55%),
|
||||||
|
var(--mood-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-card__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.doc-card__role {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: color-mix(in srgb, var(--mood-text) 7%, transparent);
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.doc-card__role--pact { background: var(--mood-accent-soft); color: var(--mood-accent); }
|
||||||
|
.doc-card__engraved {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-status-vigueur);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-card__title {
|
||||||
|
font-size: clamp(1rem, 2.5vw, 1.15rem);
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-text);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.doc-card__desc {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-card__meta { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.15rem; }
|
||||||
|
.doc-card__chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.71875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: color-mix(in srgb, var(--mood-text) 6%, transparent);
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.doc-card__chip--mono { font-family: monospace; }
|
||||||
|
.doc-card__chip--vote {
|
||||||
|
background: var(--mood-status-vigueur-bg);
|
||||||
|
color: var(--mood-status-vigueur);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-card__sessions {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
box-shadow: 0 -1px 0 color-mix(in srgb, var(--mood-text) 7%, transparent);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.doc-card__sessions-heading {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--mood-status-vote);
|
||||||
|
}
|
||||||
|
.doc-card__gauges { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// <!-- ld-v2 --> Provenance d'un document — le bloc de genèse porté du v1 :
|
||||||
|
// sources datées, vote d'origine (résultats bruts, période, lien), contributeurs,
|
||||||
|
// notes (les typos des textes votés sont signalées, jamais corrigées).
|
||||||
|
// Aucune lettre de formule ici : « comprendre ce seuil » mène à l'Atelier pré-réglé.
|
||||||
|
import type { Provenance } from '~/types/domain'
|
||||||
|
import { parseModeParams } from '~/engine'
|
||||||
|
import { formatInt } from './textsModel'
|
||||||
|
|
||||||
|
const props = defineProps<{ provenance: Provenance }>()
|
||||||
|
|
||||||
|
const record = computed(() => props.provenance.voteRecord)
|
||||||
|
|
||||||
|
/** Query Atelier pré-réglée depuis le vote d'origine (lettres côté URL seulement). */
|
||||||
|
const atelierLink = computed(() => {
|
||||||
|
const r = record.value
|
||||||
|
if (!r) return null
|
||||||
|
const p = parseModeParams(r.modeParams)
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
W: String(r.result.wotSize),
|
||||||
|
T: String(r.result.for + r.result.against),
|
||||||
|
M: String(p.majority_pct),
|
||||||
|
B: String(p.base_exponent),
|
||||||
|
G: String(p.gradient_exponent),
|
||||||
|
C: String(p.constant_base),
|
||||||
|
})
|
||||||
|
if (p.smith_exponent !== null) query.set('S', String(p.smith_exponent))
|
||||||
|
return `/textes/formules?${query.toString()}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const sourcesOpen = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<section class="prov ld-card">
|
||||||
|
<header class="prov__header">
|
||||||
|
<UIcon name="i-lucide-landmark" class="prov__header-icon" />
|
||||||
|
<div>
|
||||||
|
<h2 class="prov__title">Provenance</h2>
|
||||||
|
<p class="prov__sub">d'où vient ce texte, et par quel geste il fait loi</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Vote d'origine -->
|
||||||
|
<div v-if="record" class="prov__vote">
|
||||||
|
<p class="prov__vote-heading">Le vote d'origine</p>
|
||||||
|
<div class="prov__vote-figures">
|
||||||
|
<span class="prov__figure prov__figure--for">{{ formatInt(record.result.for) }} pour</span>
|
||||||
|
<span class="prov__figure prov__figure--against">{{ formatInt(record.result.against) }} contre</span>
|
||||||
|
<span v-if="record.result.invalid" class="prov__figure prov__figure--muted">
|
||||||
|
{{ formatInt(record.result.invalid) }} invalides
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="prov__vote-line">
|
||||||
|
Toile de {{ formatInt(record.result.wotSize) }} membres —
|
||||||
|
seuil requis : {{ formatInt(record.result.thresholdRequired) }} pour.
|
||||||
|
Résultat : <strong>{{ record.result.status }}</strong>.
|
||||||
|
</p>
|
||||||
|
<p class="prov__vote-line prov__vote-line--muted">Période : {{ record.period }}</p>
|
||||||
|
<div class="prov__vote-actions">
|
||||||
|
<NuxtLink v-if="atelierLink" :to="atelierLink" class="ld-btn ld-btn--ghost prov__btn">
|
||||||
|
<UIcon name="i-lucide-flask-conical" />
|
||||||
|
Comprendre ce seuil
|
||||||
|
</NuxtLink>
|
||||||
|
<a :href="record.url" target="_blank" rel="noopener" class="ld-btn ld-btn--quiet prov__btn">
|
||||||
|
<UIcon name="i-lucide-external-link" />
|
||||||
|
Le fil du vote
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sources -->
|
||||||
|
<div v-if="provenance.sources.length" class="prov__block">
|
||||||
|
<button type="button" class="prov__toggle" @click="sourcesOpen = !sourcesOpen">
|
||||||
|
<UIcon name="i-lucide-library" />
|
||||||
|
{{ provenance.sources.length }} source{{ provenance.sources.length > 1 ? 's' : '' }}
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-chevron-down"
|
||||||
|
class="prov__chevron"
|
||||||
|
:class="{ 'prov__chevron--open': sourcesOpen }"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<ul v-show="sourcesOpen" class="prov__sources">
|
||||||
|
<li v-for="source in provenance.sources" :key="source.url" class="prov__source">
|
||||||
|
<a :href="source.url" target="_blank" rel="noopener" class="prov__source-link">
|
||||||
|
{{ source.title }}
|
||||||
|
</a>
|
||||||
|
<span v-if="source.version" class="prov__source-meta">v{{ source.version }}</span>
|
||||||
|
<span v-if="source.date" class="prov__source-meta">{{ source.date }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contributeurs -->
|
||||||
|
<div v-if="provenance.contributors?.length" class="prov__block">
|
||||||
|
<p class="prov__block-heading">
|
||||||
|
<UIcon name="i-lucide-users" />
|
||||||
|
Contributeurs
|
||||||
|
</p>
|
||||||
|
<ul class="prov__contributors">
|
||||||
|
<li v-for="person in provenance.contributors" :key="person" class="prov__contributor">
|
||||||
|
{{ person }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<details v-if="provenance.notes" class="prov__notes">
|
||||||
|
<summary class="prov__notes-summary">
|
||||||
|
<UIcon name="i-lucide-sticky-note" />
|
||||||
|
Notes de provenance — les coquilles des textes votés sont signalées, jamais corrigées
|
||||||
|
</summary>
|
||||||
|
<p class="prov__notes-body">{{ provenance.notes }}</p>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.prov {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.1rem;
|
||||||
|
padding: clamp(1rem, 3vw, 1.5rem);
|
||||||
|
}
|
||||||
|
.prov__header { display: flex; align-items: center; gap: 0.75rem; }
|
||||||
|
.prov__header-icon {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: var(--r-icon);
|
||||||
|
padding: 0.5rem;
|
||||||
|
box-sizing: content-box;
|
||||||
|
}
|
||||||
|
.prov__title { font-size: 1.05rem; font-weight: 800; color: var(--mood-text); }
|
||||||
|
.prov__sub { font-size: 0.8125rem; color: var(--mood-text-muted); }
|
||||||
|
|
||||||
|
.prov__vote {
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: color-mix(in srgb, var(--mood-accent) 5%, transparent);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.prov__vote-heading {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.prov__vote-figures { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||||
|
.prov__figure {
|
||||||
|
padding: 3px 11px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.prov__figure--for { background: var(--mood-status-vigueur-bg); color: var(--mood-status-vigueur); }
|
||||||
|
.prov__figure--against { background: var(--mood-status-revoque-bg); color: var(--mood-status-revoque); }
|
||||||
|
.prov__figure--muted { background: var(--mood-status-clos-bg); color: var(--mood-status-clos); }
|
||||||
|
.prov__vote-line { font-size: 0.875rem; color: var(--mood-text); }
|
||||||
|
.prov__vote-line--muted { font-size: 0.8125rem; color: var(--mood-text-muted); }
|
||||||
|
.prov__vote-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.25rem; }
|
||||||
|
.prov__btn { font-size: 0.8125rem; min-height: 2rem; padding: 0.35rem 0.9rem; text-decoration: none; }
|
||||||
|
|
||||||
|
.prov__block { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.prov__block-heading,
|
||||||
|
.prov__toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
.prov__toggle { background: none; cursor: pointer; padding: 0; align-self: flex-start; }
|
||||||
|
.prov__chevron { transition: transform 0.2s ease; color: var(--mood-text-muted); }
|
||||||
|
.prov__chevron--open { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
.prov__sources { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
.prov__source { display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.prov__source-link {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.prov__source-link:hover { text-decoration: underline; }
|
||||||
|
.prov__source-meta { font-size: 0.6875rem; color: var(--mood-text-muted); font-family: monospace; }
|
||||||
|
|
||||||
|
.prov__contributors { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||||
|
.prov__contributor {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: color-mix(in srgb, var(--mood-secondary) 12%, transparent);
|
||||||
|
color: var(--mood-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prov__notes-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.prov__notes-body {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
/**
|
||||||
|
* Shared pure helpers for the texts screens — library (/textes), living
|
||||||
|
* document (/textes/[slug]) and formula atelier (/textes/formules).
|
||||||
|
* No I/O, no store access: pages assemble, these functions compute.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Clause,
|
||||||
|
ClauseVersion,
|
||||||
|
CollectiveSettings,
|
||||||
|
Decision,
|
||||||
|
DecisionStatus,
|
||||||
|
Id,
|
||||||
|
InertiaPreset,
|
||||||
|
ISODate,
|
||||||
|
Json,
|
||||||
|
Person,
|
||||||
|
Protocol,
|
||||||
|
Vote,
|
||||||
|
VoteSession,
|
||||||
|
} from '~/types/domain'
|
||||||
|
import { INERTIA_LABELS } from '~/lexicon'
|
||||||
|
|
||||||
|
// ── Shared view-model shapes (SFC scripts cannot export types) ──
|
||||||
|
/** A proposed clause version, content first — author folded behind it. */
|
||||||
|
export interface ProposedEntry {
|
||||||
|
version: ClauseVersion
|
||||||
|
authorName: string
|
||||||
|
decisionId?: Id
|
||||||
|
decisionTitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One link of a clause's decision chain (founding, then amendments). */
|
||||||
|
export interface ChainEntry {
|
||||||
|
id: Id
|
||||||
|
title: string
|
||||||
|
status: DecisionStatus
|
||||||
|
decidedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query-preset state of the formula atelier (?W=&T=&M=&B=&G=&C=&S=). */
|
||||||
|
export interface AtelierInit {
|
||||||
|
W?: number
|
||||||
|
T?: number
|
||||||
|
M?: number
|
||||||
|
B?: number
|
||||||
|
G?: number
|
||||||
|
C?: number
|
||||||
|
S?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inertia presets — REAL wiring (types/domain.ts) ──────────
|
||||||
|
export const INERTIA_PARAMS: Record<InertiaPreset, { majorityPct: number; gradientExponent: number }> = {
|
||||||
|
low: { majorityPct: 50, gradientExponent: 0.1 },
|
||||||
|
standard: { majorityPct: 50, gradientExponent: 0.2 },
|
||||||
|
high: { majorityPct: 60, gradientExponent: 0.4 },
|
||||||
|
max: { majorityPct: 66, gradientExponent: 0.6 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tint per preset — derived from the mood custom properties. */
|
||||||
|
export const INERTIA_COLORS: Record<InertiaPreset, string> = {
|
||||||
|
low: 'var(--mood-success)',
|
||||||
|
standard: 'var(--mood-accent)',
|
||||||
|
high: 'var(--mood-warning)',
|
||||||
|
max: 'var(--mood-error)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INERTIA_ORDER: readonly InertiaPreset[] = ['low', 'standard', 'high', 'max']
|
||||||
|
|
||||||
|
// ── Sections — labels + icons for known tags, graceful fallback ──
|
||||||
|
const SECTION_META: Record<string, { label: string; icon: string }> = {
|
||||||
|
preambule: { label: 'Préambule', icon: 'i-lucide-compass' },
|
||||||
|
introduction: { label: 'Introduction', icon: 'i-lucide-scroll-text' },
|
||||||
|
mission: { label: 'Mission', icon: 'i-lucide-target' },
|
||||||
|
composition: { label: 'Composition', icon: 'i-lucide-users' },
|
||||||
|
engagements: { label: 'Engagements', icon: 'i-lucide-heart-handshake' },
|
||||||
|
fondamental: { label: 'Engagements fondamentaux', icon: 'i-lucide-shield-check' },
|
||||||
|
technique: { label: 'Engagements techniques', icon: 'i-lucide-wrench' },
|
||||||
|
qualification: { label: 'Qualification', icon: 'i-lucide-graduation-cap' },
|
||||||
|
aspirant: { label: 'Aspirant forgeron', icon: 'i-lucide-user-plus' },
|
||||||
|
certificateur: { label: 'Certificateur forgeron', icon: 'i-lucide-stamp' },
|
||||||
|
conclusion: { label: 'Conclusion', icon: 'i-lucide-bookmark' },
|
||||||
|
annexe: { label: 'Annexes', icon: 'i-lucide-paperclip' },
|
||||||
|
formule: { label: 'Formule de vote', icon: 'i-lucide-calculator' },
|
||||||
|
inertie: { label: 'Réglage de l\'inertie', icon: 'i-lucide-sliders-horizontal' },
|
||||||
|
ordonnancement: { label: 'Ordonnancement', icon: 'i-lucide-list-ordered' },
|
||||||
|
// the raw tag never reaches the UI — this label replaces it
|
||||||
|
triage: { label: 'Seuils et fenêtres', icon: 'i-lucide-route' },
|
||||||
|
protocoles: { label: 'Protocoles de vote', icon: 'i-lucide-vote' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sectionMeta(tag: string): { label: string; icon: string } {
|
||||||
|
const known = SECTION_META[tag]
|
||||||
|
if (known) return known
|
||||||
|
const label = tag.charAt(0).toUpperCase() + tag.slice(1)
|
||||||
|
return { label, icon: 'i-lucide-file-text' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Formatting ───────────────────────────────────────────────
|
||||||
|
export function formatDateFr(iso: ISODate | undefined): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatInt(n: number): string {
|
||||||
|
return n.toLocaleString('fr-FR')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Live sessions on clause amendments — the mini-gauges ─────
|
||||||
|
export interface ClauseSessionGauge {
|
||||||
|
clauseId: Id
|
||||||
|
clauseCode: string
|
||||||
|
decisionId: Id
|
||||||
|
decisionTitle: string
|
||||||
|
positions: number // last active votes cast
|
||||||
|
listSize: number // arrested voter list size
|
||||||
|
closesAt: ISODate
|
||||||
|
frozen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last-active-vote count of a session (supersedes chains resolved). */
|
||||||
|
function activeVoteCount(sessionId: Id, votes: Vote[]): number {
|
||||||
|
const sessionVotes = votes.filter(v => v.sessionId === sessionId)
|
||||||
|
const superseded = new Set(
|
||||||
|
sessionVotes.map(v => v.supersedesVoteId).filter((id): id is Id => id !== undefined),
|
||||||
|
)
|
||||||
|
return sessionVotes.filter(v => !superseded.has(v.id)).length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real running sessions (open or frozen) over decisions that amend one of
|
||||||
|
* `clauses` — one gauge per amending decision, latest session wins.
|
||||||
|
*/
|
||||||
|
export function liveClauseGauges(
|
||||||
|
clauses: Clause[],
|
||||||
|
decisions: Decision[],
|
||||||
|
sessions: VoteSession[],
|
||||||
|
votes: Vote[],
|
||||||
|
): ClauseSessionGauge[] {
|
||||||
|
const byId = new Map(clauses.map(c => [c.id, c]))
|
||||||
|
const gauges: ClauseSessionGauge[] = []
|
||||||
|
for (const decision of decisions) {
|
||||||
|
if (!decision.amendsClauseId) continue
|
||||||
|
const clause = byId.get(decision.amendsClauseId)
|
||||||
|
if (!clause) continue
|
||||||
|
const latest = sessions
|
||||||
|
.filter(s => s.decisionId === decision.id)
|
||||||
|
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0]
|
||||||
|
if (!latest || (latest.status !== 'open' && latest.status !== 'frozen')) continue
|
||||||
|
gauges.push({
|
||||||
|
clauseId: clause.id,
|
||||||
|
clauseCode: clause.code,
|
||||||
|
decisionId: decision.id,
|
||||||
|
decisionTitle: decision.title,
|
||||||
|
positions: activeVoteCount(latest.id, votes),
|
||||||
|
listSize: latest.corpusSize,
|
||||||
|
closesAt: latest.closesAt,
|
||||||
|
frozen: latest.status === 'frozen',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return gauges
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Engraved decisions linked to these clauses (井 filter of the library). */
|
||||||
|
export function engravedCount(clauses: Clause[], decisions: Decision[]): number {
|
||||||
|
const ids = new Set(clauses.map(c => c.id))
|
||||||
|
return decisions.filter(d => d.engraving && d.amendsClauseId && ids.has(d.amendsClauseId)).length
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Versions per clause ──────────────────────────────────────
|
||||||
|
/** The 'current' version of a clause, preferring clause.currentVersionId. */
|
||||||
|
export function currentVersionOf(clause: Clause, versions: ClauseVersion[]): ClauseVersion | undefined {
|
||||||
|
const mine = versions.filter(v => v.clauseId === clause.id)
|
||||||
|
if (clause.currentVersionId) {
|
||||||
|
const pinned = mine.find(v => v.id === clause.currentVersionId)
|
||||||
|
if (pinned) return pinned
|
||||||
|
}
|
||||||
|
return mine.find(v => v.status === 'current')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function proposedVersionsOf(clause: Clause, versions: ClauseVersion[]): ClauseVersion[] {
|
||||||
|
return versions
|
||||||
|
.filter(v => v.clauseId === clause.id && v.status === 'proposed')
|
||||||
|
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pact setting clauses — the VALUE IN CLEAR ────────────────
|
||||||
|
const EFFECTS_LABELS: Record<string, string> = {
|
||||||
|
none: 'aucune exigence',
|
||||||
|
structural: 'décisions structurantes',
|
||||||
|
binding: 'décisions engageantes et structurantes',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One French sentence stating the current value of a Pact setting clause
|
||||||
|
* (« Petit groupe : jusqu'à 5 personnes »). `protocolName` resolves the
|
||||||
|
* protocols.* keys; returns null when nothing readable can be said.
|
||||||
|
*/
|
||||||
|
export function settingSentence(
|
||||||
|
key: string,
|
||||||
|
value: Json | undefined,
|
||||||
|
protocolName?: string,
|
||||||
|
): string | null {
|
||||||
|
if (value === undefined && !key.startsWith('protocols.')) return null
|
||||||
|
switch (key) {
|
||||||
|
case 'triage.smallGroupMax': return `Petit groupe : jusqu'à ${value} personnes`
|
||||||
|
case 'triage.collectiveMin': return `Grand corps : à partir de ${value} personnes`
|
||||||
|
case 'triage.consentMax': return `Tour de consentement : jusqu'à ${value} personnes`
|
||||||
|
case 'triage.objectionWindowHours': return `Fenêtre d'objection : ${value} heures`
|
||||||
|
case 'triage.adviceWindowHours': return `Fenêtre d'avis : ${value} heures`
|
||||||
|
case 'triage.framingDays': return `Temps de formulation : ${value} jours`
|
||||||
|
case 'triage.concernEscalateRatio':
|
||||||
|
return `Affluence : traitement obligatoire à partir de ${Math.round(Number(value) * 100)} % de concernés`
|
||||||
|
case 'triage.recurrenceThreshold':
|
||||||
|
return `Récurrence : ${value} décisions semblables suggèrent une règle`
|
||||||
|
case 'triage.reviewDelayDays': return `Épreuve du réel : ${value} jours après adoption`
|
||||||
|
case 'triage.requireEffects':
|
||||||
|
return `Matière exigée : ${EFFECTS_LABELS[String(value)] ?? String(value)}`
|
||||||
|
}
|
||||||
|
if (key.startsWith('protocols.clauseByInertia.')) {
|
||||||
|
const preset = key.slice('protocols.clauseByInertia.'.length) as InertiaPreset
|
||||||
|
const label = INERTIA_LABELS[preset]
|
||||||
|
return protocolName && label ? `Amender une clause en ${label} : ${protocolName}` : null
|
||||||
|
}
|
||||||
|
if (key.startsWith('protocols.')) {
|
||||||
|
return protocolName ? `Protocole retenu : ${protocolName}` : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full clause view assembly (page /textes/[slug]) ──────────
|
||||||
|
export interface ClauseViewCtx {
|
||||||
|
versions: ClauseVersion[]
|
||||||
|
decisions: Decision[]
|
||||||
|
people: Person[]
|
||||||
|
protocols: Protocol[]
|
||||||
|
settings: CollectiveSettings | null
|
||||||
|
gauges: Map<Id, ClauseSessionGauge>
|
||||||
|
isPact: boolean
|
||||||
|
memberCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClauseView {
|
||||||
|
clause: Clause
|
||||||
|
current?: ClauseVersion
|
||||||
|
proposed: ProposedEntry[]
|
||||||
|
founding?: ChainEntry
|
||||||
|
amendments: ChainEntry[]
|
||||||
|
settingText: string | null
|
||||||
|
gauge: ClauseSessionGauge | null
|
||||||
|
/** « Vote WoT standard — 30 jours » — le protocole d'amendement résolu. */
|
||||||
|
amendProtocol?: string
|
||||||
|
atelierLink: string | null
|
||||||
|
protectedClause: boolean
|
||||||
|
status: { label: string; css: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The amendment protocol a clause resolves to, via the Pact settings. */
|
||||||
|
function amendProtocolOf(preset: InertiaPreset, ctx: ClauseViewCtx): Protocol | undefined {
|
||||||
|
const id = ctx.settings?.protocolByRange.clauseByInertia?.[preset]
|
||||||
|
?? ctx.settings?.protocolByRange.consent
|
||||||
|
return ctx.protocols.find(p => p.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything a clause row + detail needs, computed once per clause. */
|
||||||
|
export function buildClauseView(clause: Clause, ctx: ClauseViewCtx): ClauseView {
|
||||||
|
const current = currentVersionOf(clause, ctx.versions)
|
||||||
|
const proposedVersions = proposedVersionsOf(clause, ctx.versions)
|
||||||
|
|
||||||
|
const proposed: ProposedEntry[] = proposedVersions.map((version) => {
|
||||||
|
const decision = ctx.decisions.find(d => d.id === version.decisionId)
|
||||||
|
const author = ctx.people.find(p => p.id === decision?.authorId)
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
authorName: author?.displayName ?? 'quelqu\'un du collectif',
|
||||||
|
...(decision !== undefined
|
||||||
|
? { decisionId: decision.id, decisionTitle: decision.title }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const chain: ChainEntry[] = ctx.decisions
|
||||||
|
.filter(d => d.amendsClauseId === clause.id)
|
||||||
|
.sort((a, b) => ((a.decidedAt ?? a.createdAt) < (b.decidedAt ?? b.createdAt) ? -1 : 1))
|
||||||
|
.map(d => ({
|
||||||
|
id: d.id,
|
||||||
|
title: d.title,
|
||||||
|
status: d.status,
|
||||||
|
...(d.decidedAt !== undefined ? { decidedAt: d.decidedAt } : {}),
|
||||||
|
}))
|
||||||
|
const [founding, ...amendments] = chain
|
||||||
|
|
||||||
|
const protocolNameForValue = typeof current?.settingValue === 'string'
|
||||||
|
? ctx.protocols.find(p => p.id === current.settingValue)?.name
|
||||||
|
: undefined
|
||||||
|
const settingText = ctx.isPact && clause.settingKey
|
||||||
|
? settingSentence(clause.settingKey, current?.settingValue, protocolNameForValue)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const protocol = amendProtocolOf(clause.inertia, ctx)
|
||||||
|
const atelierLink = protocol?.method === 'binary'
|
||||||
|
? `/textes/formules?W=${ctx.memberCount}&M=${protocol.formula.majorityPct}`
|
||||||
|
+ `&B=${protocol.formula.baseExponent}&G=${protocol.formula.gradientExponent}`
|
||||||
|
+ `&C=${protocol.formula.constantBase}`
|
||||||
|
: null
|
||||||
|
|
||||||
|
const gauge = ctx.gauges.get(clause.id) ?? null
|
||||||
|
const status = gauge
|
||||||
|
? { label: 'en vote', css: 'status-voting' }
|
||||||
|
: proposed.length > 0
|
||||||
|
? { label: 'version proposée', css: 'status-framing' }
|
||||||
|
: { label: 'en vigueur', css: 'status-adopted' }
|
||||||
|
|
||||||
|
return {
|
||||||
|
clause,
|
||||||
|
...(current !== undefined ? { current } : {}),
|
||||||
|
proposed,
|
||||||
|
...(founding !== undefined ? { founding } : {}),
|
||||||
|
amendments,
|
||||||
|
settingText,
|
||||||
|
gauge,
|
||||||
|
...(protocol !== undefined
|
||||||
|
? { amendProtocol: `${protocol.name} — ${protocol.durationDays} jours` }
|
||||||
|
: {}),
|
||||||
|
atelierLink,
|
||||||
|
protectedClause: clause.settingKey?.startsWith('protocols.clauseByInertia.') === true,
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,659 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* ContextMapper — Recommandeur de méthode de décision.
|
|
||||||
* 4 questions de contexte → méthode optimale + justification.
|
|
||||||
* Basé sur : Smith (WoT G1), Laloux (advice process), sociocracie.
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Option { value: string; label: string; icon: string }
|
|
||||||
interface Question { id: string; question: string; hint?: string; options: Option[] }
|
|
||||||
|
|
||||||
interface MethodRec {
|
|
||||||
name: string
|
|
||||||
icon: string
|
|
||||||
tag: string
|
|
||||||
tagColor: string
|
|
||||||
description: string
|
|
||||||
formula?: string
|
|
||||||
when: string
|
|
||||||
pros: string[]
|
|
||||||
cons: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const questions: Question[] = [
|
|
||||||
{
|
|
||||||
id: 'urgency',
|
|
||||||
question: 'Quelle est l\'urgence ?',
|
|
||||||
hint: 'Le délai disponible avant que la décision soit nécessaire',
|
|
||||||
options: [
|
|
||||||
{ value: 'immediate', label: 'Immédiate', icon: 'i-lucide-zap' },
|
|
||||||
{ value: 'short', label: '< 48h', icon: 'i-lucide-clock' },
|
|
||||||
{ value: 'normal', label: 'Planifiable', icon: 'i-lucide-calendar' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'stakes',
|
|
||||||
question: 'Quel est l\'enjeu ?',
|
|
||||||
hint: 'L\'impact et la réversibilité de la décision',
|
|
||||||
options: [
|
|
||||||
{ value: 'irreversible', label: 'Irréversible', icon: 'i-lucide-lock' },
|
|
||||||
{ value: 'major', label: 'Majeur', icon: 'i-lucide-alert-triangle' },
|
|
||||||
{ value: 'moderate', label: 'Modéré', icon: 'i-lucide-minus-circle' },
|
|
||||||
{ value: 'minor', label: 'Mineur', icon: 'i-lucide-info' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'groupSize',
|
|
||||||
question: 'Taille du groupe ?',
|
|
||||||
hint: 'Nombre de personnes concernées ou habilitées à voter',
|
|
||||||
options: [
|
|
||||||
{ value: 'small', label: '< 10', icon: 'i-lucide-user' },
|
|
||||||
{ value: 'medium', label: '10 – 100', icon: 'i-lucide-users' },
|
|
||||||
{ value: 'large', label: '100+', icon: 'i-lucide-globe' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'nature',
|
|
||||||
question: 'Nature de la décision ?',
|
|
||||||
hint: 'Le type de compétence principalement sollicité',
|
|
||||||
options: [
|
|
||||||
{ value: 'technical', label: 'Technique', icon: 'i-lucide-cpu' },
|
|
||||||
{ value: 'political', label: 'Politique', icon: 'i-lucide-landmark' },
|
|
||||||
{ value: 'operational', label: 'Opérationnelle', icon: 'i-lucide-settings' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const answers = ref<Record<string, string>>({})
|
|
||||||
const step = ref(0)
|
|
||||||
const animating = ref(false)
|
|
||||||
|
|
||||||
const currentQuestion = computed(() => questions[step.value])
|
|
||||||
const isComplete = computed(() => Object.keys(answers.value).length === questions.length)
|
|
||||||
const progress = computed(() => (step.value / questions.length) * 100)
|
|
||||||
|
|
||||||
function selectAnswer(questionId: string, value: string) {
|
|
||||||
answers.value = { ...answers.value, [questionId]: value }
|
|
||||||
if (step.value < questions.length - 1) {
|
|
||||||
animating.value = true
|
|
||||||
setTimeout(() => {
|
|
||||||
step.value++
|
|
||||||
animating.value = false
|
|
||||||
}, 160)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goBack() {
|
|
||||||
if (step.value > 0) step.value--
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
answers.value = {}
|
|
||||||
step.value = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const recommendation = computed((): MethodRec | null => {
|
|
||||||
if (!isComplete.value) return null
|
|
||||||
const { urgency, stakes, groupSize, nature } = answers.value
|
|
||||||
|
|
||||||
// Immediate → Advice process (Laloux)
|
|
||||||
if (urgency === 'immediate') {
|
|
||||||
return {
|
|
||||||
name: 'Processus de sollicitation d\'avis',
|
|
||||||
icon: 'i-lucide-message-circle',
|
|
||||||
tag: 'Laloux / Teal',
|
|
||||||
tagColor: 'teal',
|
|
||||||
description: 'Le décideur identifié consulte les personnes expertes et impactées, puis décide seul et en rend compte. Rapide, non-bloquant, responsabilisant.',
|
|
||||||
formula: 'Pas de vote — consultation libre → décision documentée → compte-rendu',
|
|
||||||
when: 'Urgence opérationnelle, décision réversible, responsable clairement identifié.',
|
|
||||||
pros: ['Rapide (< 2h)', 'Non-bloquant', 'Responsabilise le décideur'],
|
|
||||||
cons: ['Requiert confiance dans le décideur', 'Pas de validation collective'],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Technical + medium/large → Smith WoT
|
|
||||||
if (nature === 'technical' && groupSize !== 'small') {
|
|
||||||
return {
|
|
||||||
name: 'Vote inertiel WoT + critère Smith',
|
|
||||||
icon: 'i-lucide-network',
|
|
||||||
tag: 'G1 standard',
|
|
||||||
tagColor: 'accent',
|
|
||||||
description: 'Vote communautaire avec seuil adaptatif à la participation. Le critère Smith garantit que la décision reflète l\'expertise des validateurs.',
|
|
||||||
formula: 'R = C + B^W + (M + (1−M)·(1−(T/W)^G))·max(0,T−C)\nSeuil Smith : ⌈SmithWoT^S⌉',
|
|
||||||
when: 'Décision technique nécessitant validation par les experts WoT (forgerons, CoTec).',
|
|
||||||
pros: ['Validé par expertise', 'Adaptatif à la participation', 'Tracé on-chain'],
|
|
||||||
cons: ['Durée minimum 7-30j', 'Complexité de la formule'],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Irreversible + large → High threshold WoT
|
|
||||||
if (stakes === 'irreversible' && groupSize === 'large') {
|
|
||||||
return {
|
|
||||||
name: 'Vote inertiel WoT (inertie forte)',
|
|
||||||
icon: 'i-lucide-shield',
|
|
||||||
tag: 'G1 renforcé',
|
|
||||||
tagColor: 'secondary',
|
|
||||||
description: 'Pour les décisions irréversibles à fort impact : seuil de quasi-unanimité si faible participation, majorité qualifiée avec forte participation.',
|
|
||||||
formula: 'R = C + B^W + (M + (1−M)·(1−(T/W)^G))·max(0,T−C)\nParamètres : M=67%, G=0.3 (inertie forte)',
|
|
||||||
when: 'Textes fondateurs, modifications structurelles, décisions irréversibles pour 100+ membres.',
|
|
||||||
pros: ['Protection maximale', 'Légitimité forte', 'Résistant aux minorités actives'],
|
|
||||||
cons: ['Durée longue (30+ jours)', 'Peut bloquer les évolutions nécessaires'],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small group → Sociocratic consent
|
|
||||||
if (groupSize === 'small') {
|
|
||||||
return {
|
|
||||||
name: 'Consentement sociocratique',
|
|
||||||
icon: 'i-lucide-check-circle-2',
|
|
||||||
tag: 'Sociocracie',
|
|
||||||
tagColor: 'tertiary',
|
|
||||||
description: 'Adoption si aucune objection grave n\'est soulevée. Une objection grave = la décision nuit à la mission commune, pas juste une préférence personnelle.',
|
|
||||||
formula: 'Adoptée si : aucune objection grave parmi les membres du cercle',
|
|
||||||
when: 'Cercle de travail (< 10 membres), enjeu modéré, décision réversible.',
|
|
||||||
pros: ['Rapide', 'Inclusif', 'Distingue objection grave et préférence'],
|
|
||||||
cons: ['Ne convient pas aux grands groupes', 'Risque de pression sociale'],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Political + medium → WoT majority
|
|
||||||
if (nature === 'political') {
|
|
||||||
return {
|
|
||||||
name: 'Vote majoritaire WoT',
|
|
||||||
icon: 'i-lucide-vote',
|
|
||||||
tag: 'G1 standard',
|
|
||||||
tagColor: 'accent',
|
|
||||||
description: 'Vote binaire (Pour/Contre) avec seuil adaptatif à la participation WoT. Standard pour les décisions politiques de la communauté.',
|
|
||||||
formula: 'R = C + B^W + (M + (1−M)·(1−(T/W)^G))·max(0,T−C)',
|
|
||||||
when: 'Décision politique communautaire, participation variable, groupe >10.',
|
|
||||||
pros: ['Standard WoT', 'Adaptatif', 'Tracé on-chain'],
|
|
||||||
cons: ['Durée 7-30j', 'Participation faible possible'],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: minor/operational
|
|
||||||
return {
|
|
||||||
name: 'Advice process + validation légère',
|
|
||||||
icon: 'i-lucide-thumbs-up',
|
|
||||||
tag: 'Léger',
|
|
||||||
tagColor: 'teal',
|
|
||||||
description: 'Pour les décisions mineures ou opérationnelles : consultation des parties concernées, décision par le responsable désigné, notification de la communauté.',
|
|
||||||
formula: 'Consultation → Décision → Notification (sans vote formel)',
|
|
||||||
when: 'Décision opérationnelle de faible impact, facilement réversible.',
|
|
||||||
pros: ['Très rapide', 'Non-bloquant', 'Adapté à l\'opérationnel'],
|
|
||||||
cons: ['Légitimité limitée', 'Ne convient pas aux enjeux majeurs'],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{ use: [name: string] }>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="cmap">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="cmap__head">
|
|
||||||
<UIcon name="i-lucide-compass" class="cmap__head-icon" />
|
|
||||||
<div>
|
|
||||||
<h3 class="cmap__title">Choisir une méthode</h3>
|
|
||||||
<p class="cmap__subtitle">4 questions pour la méthode adaptée</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Result -->
|
|
||||||
<Transition name="fade-up" mode="out-in">
|
|
||||||
<div v-if="isComplete" key="result" class="cmap__result">
|
|
||||||
<div class="cmap__result-header">
|
|
||||||
<div class="cmap__result-icon">
|
|
||||||
<UIcon :name="recommendation!.icon" />
|
|
||||||
</div>
|
|
||||||
<div class="cmap__result-info">
|
|
||||||
<span class="cmap__result-tag" :class="`cmap__result-tag--${recommendation!.tagColor}`">
|
|
||||||
{{ recommendation!.tag }}
|
|
||||||
</span>
|
|
||||||
<h4 class="cmap__result-name">{{ recommendation!.name }}</h4>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="cmap__result-desc">{{ recommendation!.description }}</p>
|
|
||||||
|
|
||||||
<div v-if="recommendation!.formula" class="cmap__formula">
|
|
||||||
<span class="cmap__formula-label">Formule</span>
|
|
||||||
<pre class="cmap__formula-code">{{ recommendation!.formula }}</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cmap__pros-cons">
|
|
||||||
<div>
|
|
||||||
<span class="cmap__pros-label">Pour</span>
|
|
||||||
<ul class="cmap__list cmap__list--pro">
|
|
||||||
<li v-for="p in recommendation!.pros" :key="p">{{ p }}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="cmap__cons-label">Contre</span>
|
|
||||||
<ul class="cmap__list cmap__list--con">
|
|
||||||
<li v-for="c in recommendation!.cons" :key="c">{{ c }}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="cmap__when">
|
|
||||||
<UIcon name="i-lucide-lightbulb" />
|
|
||||||
{{ recommendation!.when }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="cmap__result-actions">
|
|
||||||
<button class="cmap__btn-reset" @click="reset">
|
|
||||||
<UIcon name="i-lucide-refresh-cw" />
|
|
||||||
Recommencer
|
|
||||||
</button>
|
|
||||||
<button class="cmap__btn-use" @click="emit('use', recommendation!.name)">
|
|
||||||
<UIcon name="i-lucide-play" />
|
|
||||||
Utiliser cette méthode
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quiz -->
|
|
||||||
<div v-else key="quiz" class="cmap__quiz">
|
|
||||||
<!-- Progress -->
|
|
||||||
<div class="cmap__progress">
|
|
||||||
<div class="cmap__progress-bar" :style="{ width: `${progress}%` }" />
|
|
||||||
</div>
|
|
||||||
<span class="cmap__step-label">{{ step + 1 }} / {{ questions.length }}</span>
|
|
||||||
|
|
||||||
<!-- Question -->
|
|
||||||
<Transition name="slide-right" mode="out-in">
|
|
||||||
<div :key="step" class="cmap__question-block">
|
|
||||||
<p class="cmap__question">{{ currentQuestion.question }}</p>
|
|
||||||
<p v-if="currentQuestion.hint" class="cmap__hint">{{ currentQuestion.hint }}</p>
|
|
||||||
|
|
||||||
<div class="cmap__options">
|
|
||||||
<button
|
|
||||||
v-for="opt in currentQuestion.options"
|
|
||||||
:key="opt.value"
|
|
||||||
class="cmap__option"
|
|
||||||
:class="{ 'cmap__option--selected': answers[currentQuestion.id] === opt.value }"
|
|
||||||
@click="selectAnswer(currentQuestion.id, opt.value)"
|
|
||||||
>
|
|
||||||
<div class="cmap__option-icon">
|
|
||||||
<UIcon :name="opt.icon" />
|
|
||||||
</div>
|
|
||||||
<span class="cmap__option-label">{{ opt.label }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<button v-if="step > 0" class="cmap__back" @click="goBack">
|
|
||||||
<UIcon name="i-lucide-chevron-left" />
|
|
||||||
Retour
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.cmap {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__head {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__head-icon {
|
|
||||||
font-size: 1.375rem;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__title {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--mood-text);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__subtitle {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress */
|
|
||||||
.cmap__progress {
|
|
||||||
height: 4px;
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__progress-bar {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--mood-accent);
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__step-label {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Question */
|
|
||||||
.cmap__question-block {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__question {
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-text);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__hint {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__options {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
border-radius: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.1s ease, box-shadow 0.1s ease, background 0.1s ease;
|
|
||||||
text-align: left;
|
|
||||||
min-height: 2.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__option:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 3px 10px var(--mood-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__option:active { transform: translateY(0); }
|
|
||||||
|
|
||||||
.cmap__option--selected {
|
|
||||||
background: var(--mood-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__option--selected .cmap__option-icon,
|
|
||||||
.cmap__option--selected .cmap__option-label {
|
|
||||||
color: var(--mood-accent-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__option-icon {
|
|
||||||
width: 1.75rem;
|
|
||||||
height: 1.75rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--mood-surface);
|
|
||||||
color: var(--mood-accent);
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__option--selected .cmap__option-icon {
|
|
||||||
background: rgba(255,255,255,0.2);
|
|
||||||
color: var(--mood-accent-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__option-label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__back {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.375rem 0;
|
|
||||||
transition: color 0.1s ease;
|
|
||||||
}
|
|
||||||
.cmap__back:hover { color: var(--mood-text); }
|
|
||||||
|
|
||||||
/* Result */
|
|
||||||
.cmap__result {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__result-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__result-icon {
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
color: var(--mood-accent);
|
|
||||||
font-size: 1.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__result-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__result-tag {
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 20px;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
.cmap__result-tag--accent {
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
color: var(--mood-accent);
|
|
||||||
}
|
|
||||||
.cmap__result-tag--teal {
|
|
||||||
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
|
|
||||||
color: var(--mood-success);
|
|
||||||
}
|
|
||||||
.cmap__result-tag--secondary {
|
|
||||||
background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 15%, transparent);
|
|
||||||
color: var(--mood-secondary, var(--mood-accent));
|
|
||||||
}
|
|
||||||
.cmap__result-tag--tertiary {
|
|
||||||
background: color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 15%, transparent);
|
|
||||||
color: var(--mood-tertiary, var(--mood-accent));
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__result-name {
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--mood-text);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__result-desc {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
line-height: 1.6;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__formula {
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 0.625rem 0.875rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__formula-label {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__formula-code {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--mood-text);
|
|
||||||
margin: 0;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__pros-cons {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__pros-label,
|
|
||||||
.cmap__cons-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
.cmap__pros-label { color: var(--mood-success); }
|
|
||||||
.cmap__cons-label { color: var(--mood-error); }
|
|
||||||
|
|
||||||
.cmap__list {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__list li {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
padding-left: 0.875rem;
|
|
||||||
position: relative;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__list--pro li::before {
|
|
||||||
content: '✓';
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
color: var(--mood-success);
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.5rem;
|
|
||||||
top: 0.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__list--con li::before {
|
|
||||||
content: '·';
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
color: var(--mood-error);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__when {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__result-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmap__btn-reset {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.5rem 0.875rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
border-radius: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.1s ease;
|
|
||||||
}
|
|
||||||
.cmap__btn-reset:hover { transform: translateY(-1px); color: var(--mood-text); }
|
|
||||||
|
|
||||||
.cmap__btn-use {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.5rem 1.125rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-accent-text);
|
|
||||||
background: var(--mood-accent);
|
|
||||||
border-radius: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
|
||||||
}
|
|
||||||
.cmap__btn-use:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px var(--mood-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Transitions */
|
|
||||||
.fade-up-enter-active, .fade-up-leave-active { transition: all 0.2s ease; }
|
|
||||||
.fade-up-enter-from { opacity: 0; transform: translateY(8px); }
|
|
||||||
.fade-up-leave-to { opacity: 0; transform: translateY(-4px); }
|
|
||||||
|
|
||||||
.slide-right-enter-active, .slide-right-leave-active { transition: all 0.16s ease; }
|
|
||||||
.slide-right-enter-from { opacity: 0; transform: translateX(12px); }
|
|
||||||
.slide-right-leave-to { opacity: 0; transform: translateX(-8px); }
|
|
||||||
</style>
|
|
||||||
@@ -1,666 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* SocioElection — Guide processus d'élection sociocratique.
|
|
||||||
* 6 étapes canoniques + advice process Laloux + clarté de rôle.
|
|
||||||
* Référence : "La Sociocracie" (Robertson), "Reinventing Organizations" (Laloux).
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Step {
|
|
||||||
num: number
|
|
||||||
title: string
|
|
||||||
actor: string
|
|
||||||
duration: string
|
|
||||||
icon: string
|
|
||||||
description: string
|
|
||||||
tips: string[]
|
|
||||||
pitfall?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const steps: Step[] = [
|
|
||||||
{
|
|
||||||
num: 1,
|
|
||||||
title: 'Clarifier le rôle',
|
|
||||||
actor: 'Facilitateur + cercle',
|
|
||||||
duration: '10-15 min',
|
|
||||||
icon: 'i-lucide-clipboard-list',
|
|
||||||
description: 'Définir ensemble la mission du rôle, ses domaines d\'autorité, ses redevabilités et la durée du mandat. Le rôle précède la personne.',
|
|
||||||
tips: [
|
|
||||||
'Distinguer redevabilités (obligations) et autorité (domaine de décision)',
|
|
||||||
'Fixer une durée standard (ex: 1 an renouvelable)',
|
|
||||||
'Identifier les compétences nécessaires — pas souhaitables',
|
|
||||||
],
|
|
||||||
pitfall: 'Ne pas définir le rôle sur mesure pour un candidat déjà imaginé.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
num: 2,
|
|
||||||
title: 'Nommer en silence',
|
|
||||||
actor: 'Tous les membres',
|
|
||||||
duration: '3-5 min',
|
|
||||||
icon: 'i-lucide-pencil',
|
|
||||||
description: 'Chacun écrit sur papier le nom d\'une personne (y compris soi-même) et la raison principale de son choix. En silence, sans influence mutuelle.',
|
|
||||||
tips: [
|
|
||||||
'Pas de discussion pendant cette étape',
|
|
||||||
'S\'auto-nommer est bienvenu et valorisé',
|
|
||||||
'Une seule nomination par personne',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
num: 3,
|
|
||||||
title: 'Recueillir les nominations',
|
|
||||||
actor: 'Facilitateur',
|
|
||||||
duration: '5-10 min',
|
|
||||||
icon: 'i-lucide-list-checks',
|
|
||||||
description: 'Le facilitateur lit chaque nomination à voix haute avec la raison. Pas de commentaire, pas de débat. Pure collecte.',
|
|
||||||
tips: [
|
|
||||||
'Lire nom + raison tels qu\'écrits',
|
|
||||||
'Le facilitateur lit aussi sa propre nomination',
|
|
||||||
'Compter et afficher les nominations',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
num: 4,
|
|
||||||
title: 'Argumenter',
|
|
||||||
actor: 'Chaque membre',
|
|
||||||
duration: '1-2 min / personne',
|
|
||||||
icon: 'i-lucide-message-square',
|
|
||||||
description: 'Chaque membre peut changer sa nomination et expliquer pourquoi (brièvement). Tour de table structuré, pas de croisements.',
|
|
||||||
tips: [
|
|
||||||
'1 minute maximum par personne',
|
|
||||||
'Argumenter pour, pas contre',
|
|
||||||
'Les candidats s\'expriment aussi brièvement',
|
|
||||||
],
|
|
||||||
pitfall: 'Éviter les longues plaidoiries — la clarté du rôle doit guider.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
num: 5,
|
|
||||||
title: 'Lever les objections',
|
|
||||||
actor: 'Facilitateur + cercle',
|
|
||||||
duration: '5-15 min',
|
|
||||||
icon: 'i-lucide-shield-check',
|
|
||||||
description: 'Le facilitateur propose l\'élection de la personne la plus nommée. Silence = consentement. Une objection grave peut être soulevée et traitée.',
|
|
||||||
tips: [
|
|
||||||
'Objection grave ≠ préférence — nuit-elle à la mission du cercle ?',
|
|
||||||
'Une objection peut mener à reconsidérer une candidature',
|
|
||||||
'L\'élu·e peut décliner — c\'est légitime',
|
|
||||||
],
|
|
||||||
pitfall: 'Une objection n\'est pas un veto — elle doit être travaillée collectivement.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
num: 6,
|
|
||||||
title: 'Célébrer',
|
|
||||||
actor: 'Tous',
|
|
||||||
duration: '2-3 min',
|
|
||||||
icon: 'i-lucide-star',
|
|
||||||
description: 'L\'élection est proclamée. L\'élu·e remercie et s\'engage publiquement. La communauté accueille le nouveau rôle.',
|
|
||||||
tips: [
|
|
||||||
'Documenter l\'élection (date, durée, personnes présentes)',
|
|
||||||
'Annoncer à la communauté au sens large',
|
|
||||||
'Fixer la prochaine évaluation du rôle',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const expandedStep = ref<number | null>(null)
|
|
||||||
|
|
||||||
function toggleStep(num: number) {
|
|
||||||
expandedStep.value = expandedStep.value === num ? null : num
|
|
||||||
}
|
|
||||||
|
|
||||||
// Advice process (Laloux)
|
|
||||||
const adviceSteps = [
|
|
||||||
{ icon: 'i-lucide-search', text: 'Identifier les personnes expertes ET impactées' },
|
|
||||||
{ icon: 'i-lucide-message-circle', text: 'Les consulter — écouter vraiment' },
|
|
||||||
{ icon: 'i-lucide-user-check', text: 'Décider seul·e, en intégrant les avis reçus' },
|
|
||||||
{ icon: 'i-lucide-file-text', text: 'Documenter et communiquer la décision + raisons' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Role clarity framework
|
|
||||||
interface RoleAxis {
|
|
||||||
label: string
|
|
||||||
icon: string
|
|
||||||
question: string
|
|
||||||
example: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleAxes: RoleAxis[] = [
|
|
||||||
{
|
|
||||||
label: 'Mission',
|
|
||||||
icon: 'i-lucide-target',
|
|
||||||
question: 'Pourquoi ce rôle existe-t-il ?',
|
|
||||||
example: 'Assurer la disponibilité des nœuds validateurs 24h/24',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Domaine',
|
|
||||||
icon: 'i-lucide-shield',
|
|
||||||
question: 'Sur quoi a-t-il autorité exclusive ?',
|
|
||||||
example: 'Configuration des serveurs de forge, rotation des clés',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Redevabilités',
|
|
||||||
icon: 'i-lucide-check-square',
|
|
||||||
question: 'Quelles activités doit-il assurer ?',
|
|
||||||
example: 'Publier un rapport mensuel, alerter en cas d\'incident',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Durée',
|
|
||||||
icon: 'i-lucide-calendar',
|
|
||||||
question: 'Pour combien de temps ?',
|
|
||||||
example: '1 an, renouvelable une fois, réévaluation à 6 mois',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const activeTab = ref<'election' | 'advice' | 'role'>('election')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="se">
|
|
||||||
<!-- Tabs -->
|
|
||||||
<div class="se__tabs">
|
|
||||||
<button
|
|
||||||
class="se__tab"
|
|
||||||
:class="{ 'se__tab--active': activeTab === 'election' }"
|
|
||||||
@click="activeTab = 'election'"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-users" />
|
|
||||||
Élection
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="se__tab"
|
|
||||||
:class="{ 'se__tab--active': activeTab === 'advice' }"
|
|
||||||
@click="activeTab = 'advice'"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-message-circle" />
|
|
||||||
Conseil
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="se__tab"
|
|
||||||
:class="{ 'se__tab--active': activeTab === 'role' }"
|
|
||||||
@click="activeTab = 'role'"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-clipboard-list" />
|
|
||||||
Rôle
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Election sociocratique -->
|
|
||||||
<div v-if="activeTab === 'election'" class="se__panel">
|
|
||||||
<p class="se__intro">
|
|
||||||
Processus en 6 étapes garantissant que l'élection repose sur la clarté du rôle
|
|
||||||
et le consentement collectif — pas sur la popularité.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="se__steps">
|
|
||||||
<div
|
|
||||||
v-for="s in steps"
|
|
||||||
:key="s.num"
|
|
||||||
class="se__step"
|
|
||||||
:class="{ 'se__step--open': expandedStep === s.num }"
|
|
||||||
>
|
|
||||||
<button class="se__step-head" @click="toggleStep(s.num)">
|
|
||||||
<div class="se__step-num">{{ s.num }}</div>
|
|
||||||
<div class="se__step-icon">
|
|
||||||
<UIcon :name="s.icon" />
|
|
||||||
</div>
|
|
||||||
<div class="se__step-info">
|
|
||||||
<span class="se__step-title">{{ s.title }}</span>
|
|
||||||
<span class="se__step-meta">{{ s.actor }} · {{ s.duration }}</span>
|
|
||||||
</div>
|
|
||||||
<UIcon
|
|
||||||
:name="expandedStep === s.num ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
||||||
class="se__step-toggle"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Transition name="expand">
|
|
||||||
<div v-if="expandedStep === s.num" class="se__step-body">
|
|
||||||
<p class="se__step-desc">{{ s.description }}</p>
|
|
||||||
<ul class="se__step-tips">
|
|
||||||
<li v-for="tip in s.tips" :key="tip">{{ tip }}</li>
|
|
||||||
</ul>
|
|
||||||
<div v-if="s.pitfall" class="se__step-pitfall">
|
|
||||||
<UIcon name="i-lucide-alert-triangle" />
|
|
||||||
{{ s.pitfall }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Advice process -->
|
|
||||||
<div v-if="activeTab === 'advice'" class="se__panel">
|
|
||||||
<div class="se__advice-header">
|
|
||||||
<span class="se__advice-tag">Laloux / Teal</span>
|
|
||||||
<h4 class="se__advice-title">Processus de sollicitation d'avis</h4>
|
|
||||||
<p class="se__advice-subtitle">
|
|
||||||
Toute personne peut prendre une décision — à condition d'avoir d'abord
|
|
||||||
consulté les experts et les impactés.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="se__advice-steps">
|
|
||||||
<div v-for="(as, i) in adviceSteps" :key="i" class="se__advice-step">
|
|
||||||
<div class="se__advice-dot">
|
|
||||||
<UIcon :name="as.icon" />
|
|
||||||
</div>
|
|
||||||
<span class="se__advice-text">{{ as.text }}</span>
|
|
||||||
<div v-if="i < adviceSteps.length - 1" class="se__advice-line" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="se__advice-rule">
|
|
||||||
<UIcon name="i-lucide-lightbulb" class="se__advice-rule-icon" />
|
|
||||||
<div>
|
|
||||||
<strong>Règle d'or :</strong> plus la décision est impactante, plus il faut
|
|
||||||
consulter largement. Mais la décision finale appartient toujours à celui ou
|
|
||||||
celle qui l'a initiée.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="se__advice-when">
|
|
||||||
<div class="se__advice-when-item se__advice-when-item--yes">
|
|
||||||
<span class="se__advice-when-label">Adapter pour</span>
|
|
||||||
<ul>
|
|
||||||
<li>Décisions urgentes</li>
|
|
||||||
<li>Rôles bien définis</li>
|
|
||||||
<li>Culture de confiance</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="se__advice-when-item se__advice-when-item--no">
|
|
||||||
<span class="se__advice-when-label">Éviter si</span>
|
|
||||||
<ul>
|
|
||||||
<li>Décision irréversible</li>
|
|
||||||
<li>Groupe > 100 personnes</li>
|
|
||||||
<li>Enjeu fondateur</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Role clarity -->
|
|
||||||
<div v-if="activeTab === 'role'" class="se__panel">
|
|
||||||
<p class="se__intro">
|
|
||||||
Un rôle bien défini évite les zones grises, les conflits d'autorité
|
|
||||||
et les mandats flous. Quatre axes suffisent.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="se__role-axes">
|
|
||||||
<div v-for="axis in roleAxes" :key="axis.label" class="se__role-axis">
|
|
||||||
<div class="se__role-axis-icon">
|
|
||||||
<UIcon :name="axis.icon" />
|
|
||||||
</div>
|
|
||||||
<div class="se__role-axis-body">
|
|
||||||
<span class="se__role-axis-label">{{ axis.label }}</span>
|
|
||||||
<p class="se__role-axis-question">{{ axis.question }}</p>
|
|
||||||
<p class="se__role-axis-example">ex: {{ axis.example }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="se__role-tip">
|
|
||||||
<UIcon name="i-lucide-info" />
|
|
||||||
<span>Un rôle n'est pas une fiche de poste. Il peut évoluer au prochain cycle
|
|
||||||
de gouvernance sans changer la personne qui le tient.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.se { display: flex; flex-direction: column; gap: 1rem; }
|
|
||||||
|
|
||||||
/* Tabs */
|
|
||||||
.se__tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__tab {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.5rem 0.625rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__tab--active {
|
|
||||||
background: var(--mood-surface);
|
|
||||||
color: var(--mood-accent);
|
|
||||||
box-shadow: 0 1px 4px var(--mood-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__panel { display: flex; flex-direction: column; gap: 0.875rem; }
|
|
||||||
|
|
||||||
.se__intro {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
line-height: 1.6;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Steps */
|
|
||||||
.se__steps { display: flex; flex-direction: column; gap: 0.375rem; }
|
|
||||||
|
|
||||||
.se__step {
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__step--open { background: var(--mood-surface); }
|
|
||||||
|
|
||||||
.se__step-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem 0.875rem;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__step-icon {
|
|
||||||
width: 1.75rem;
|
|
||||||
height: 1.75rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--mood-surface);
|
|
||||||
color: var(--mood-accent);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__step-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__step-title {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__step-meta {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__step-toggle {
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__step-body {
|
|
||||||
padding: 0 0.875rem 0.875rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__step-desc {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
line-height: 1.6;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__step-tips {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0 0 0 1rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
list-style-type: disc;
|
|
||||||
line-height: 1.6;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.125rem;
|
|
||||||
}
|
|
||||||
.se__step-tips li::marker { color: var(--mood-accent); }
|
|
||||||
|
|
||||||
.se__step-pitfall {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.5rem 0.625rem;
|
|
||||||
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: var(--mood-error);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Advice */
|
|
||||||
.se__advice-header { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
||||||
|
|
||||||
.se__advice-tag {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 20px;
|
|
||||||
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
|
|
||||||
color: var(--mood-success);
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__advice-title {
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--mood-text);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__advice-subtitle {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__advice-steps { display: flex; flex-direction: column; gap: 0; }
|
|
||||||
|
|
||||||
.se__advice-step {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.625rem;
|
|
||||||
position: relative;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__advice-dot {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
color: var(--mood-accent);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__advice-text {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text);
|
|
||||||
padding-top: 0.375rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__advice-line {
|
|
||||||
position: absolute;
|
|
||||||
left: calc(1rem - 1px);
|
|
||||||
top: calc(0.5rem + 2rem);
|
|
||||||
width: 2px;
|
|
||||||
height: calc(100% - 2rem + 0.5rem);
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 20%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__advice-rule {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.625rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.se__advice-rule-icon { color: var(--mood-accent); flex-shrink: 0; margin-top: 0.1rem; }
|
|
||||||
.se__advice-rule strong { color: var(--mood-text); }
|
|
||||||
|
|
||||||
.se__advice-when {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__advice-when-item {
|
|
||||||
padding: 0.625rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__advice-when-item--yes {
|
|
||||||
background: color-mix(in srgb, var(--mood-success) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__advice-when-item--no {
|
|
||||||
background: color-mix(in srgb, var(--mood-error) 8%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__advice-when-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__advice-when-item--yes .se__advice-when-label { color: var(--mood-success); }
|
|
||||||
.se__advice-when-item--no .se__advice-when-label { color: var(--mood-error); }
|
|
||||||
|
|
||||||
.se__advice-when-item ul {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0 0 0 0.875rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
list-style-type: disc;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Role */
|
|
||||||
.se__role-axes { display: flex; flex-direction: column; gap: 0.625rem; }
|
|
||||||
|
|
||||||
.se__role-axis {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__role-axis-icon {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--mood-surface);
|
|
||||||
color: var(--mood-accent);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__role-axis-body { flex: 1; min-width: 0; }
|
|
||||||
|
|
||||||
.se__role-axis-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
margin-bottom: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__role-axis-question {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__role-axis-example {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
margin: 0.125rem 0 0;
|
|
||||||
line-height: 1.4;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.se__role-tip {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
line-height: 1.5;
|
|
||||||
padding: 0.625rem 0.75rem;
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Expand transition */
|
|
||||||
.expand-enter-active, .expand-leave-active {
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.expand-enter-from, .expand-leave-to {
|
|
||||||
max-height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
.expand-enter-to, .expand-leave-from {
|
|
||||||
max-height: 500px;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* ToolboxSection — Wrapper accordéon pour la boîte à outils.
|
|
||||||
* Toggle le contenu pour économiser la hauteur visible.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
title: string
|
|
||||||
icon?: string
|
|
||||||
defaultOpen?: boolean
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
icon: undefined,
|
|
||||||
defaultOpen: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const open = ref(props.defaultOpen)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="tsection" :class="{ 'tsection--open': open }">
|
|
||||||
<button class="tsection__header" @click="open = !open">
|
|
||||||
<UIcon v-if="icon" :name="icon" class="tsection__icon" />
|
|
||||||
<span class="tsection__title">{{ title }}</span>
|
|
||||||
<UIcon name="i-lucide-chevron-down" class="tsection__chevron" />
|
|
||||||
</button>
|
|
||||||
<div v-show="open" class="tsection__body">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.tsection {
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
border-radius: 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tsection__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem 0.875rem;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
transition: background 0.12s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tsection__header:hover {
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tsection__icon {
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tsection__title {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tsection__chevron {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
opacity: 0.6;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tsection--open .tsection__chevron {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tsection__body {
|
|
||||||
padding: 0 0.875rem 0.875rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.625rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user