forked from yvv/decision
Compare commits
27
Commits
3b339b643c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b1732916d | ||
|
|
ef0347b6aa | ||
|
|
a80f82cb64 | ||
|
|
db156884c9 | ||
|
|
493836ff11 | ||
|
|
e9f064dc78 | ||
|
|
a8998f0624 | ||
|
|
3301b764f7 | ||
|
|
38fd053e87 | ||
|
|
cf59bbca0d | ||
|
|
f2829339cf | ||
|
|
7cfc7ea352 | ||
|
|
e164b5f6c1 | ||
|
|
d886302b59 | ||
|
|
6f2bf62295 | ||
|
|
f707b5f15d | ||
|
|
53d8752e40 | ||
|
|
172eab4c7c | ||
|
|
39883ee444 | ||
|
|
55020033ee | ||
|
|
a9e586cda1 | ||
|
|
59fff64f9e | ||
|
|
f56d84e76b | ||
|
|
3423ac2e7e | ||
|
|
2d2ac79cd5 | ||
|
|
21bca67e6a | ||
|
|
56d72eeec2 |
@@ -25,6 +25,7 @@ Thumbs.db
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db.bak*
|
||||
*.sqlite
|
||||
|
||||
# Docker volumes
|
||||
@@ -40,3 +41,4 @@ htmlcov/
|
||||
|
||||
# IPFS
|
||||
ipfs-data/
|
||||
docs/*.pdf
|
||||
|
||||
+23
-7
@@ -25,6 +25,8 @@ steps:
|
||||
- name: test-frontend
|
||||
image: node:20-slim
|
||||
commands:
|
||||
# git requis : dépendance @yvv/nuxt-base en git+https (node:20-slim n'a pas git)
|
||||
- apt-get update && apt-get install -y --no-install-recommends git ca-certificates
|
||||
- cd frontend
|
||||
- npm ci
|
||||
- npm run build
|
||||
@@ -63,8 +65,9 @@ steps:
|
||||
- apk add --no-cache curl
|
||||
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
|
||||
- mkdir -p .reports
|
||||
- syft libredecision-backend:latest -o cyclonedx-json --file .reports/sbom-backend.cyclonedx.json
|
||||
- syft libredecision-frontend:latest -o cyclonedx-json --file .reports/sbom-frontend.cyclonedx.json
|
||||
# spec 1.5 épinglée : syft « latest » émet plus récent que ce que le trivy du runner décode
|
||||
- syft libredecision-backend:latest -o cyclonedx-json@1.5 --file .reports/sbom-backend.cyclonedx.json
|
||||
- syft libredecision-frontend:latest -o cyclonedx-json@1.5 --file .reports/sbom-frontend.cyclonedx.json
|
||||
- echo "SBOM generes"
|
||||
|
||||
# NOTE: volumes + pas de from_secret : compatible
|
||||
@@ -80,8 +83,12 @@ steps:
|
||||
- echo "Scan CVE termine"
|
||||
|
||||
# NOTE: from_secret + pas de volumes : compatible
|
||||
# failure: ignore — la publication vers Dependency-Track est de la télémétrie :
|
||||
# elle ne doit pas bloquer le déploiement quand DTrack est éteint ou le secret
|
||||
# dtrack_domain absent (le scan trivy, lui, reste bloquant). Vu en panne le 12/08.
|
||||
- name: sbom-publish
|
||||
image: alpine/curl:latest
|
||||
failure: ignore
|
||||
depends_on:
|
||||
- sbom-scan
|
||||
environment:
|
||||
@@ -153,6 +160,10 @@ steps:
|
||||
- chmod 600 /opt/libredecision/.env
|
||||
- cp docker/docker-compose.yml /opt/libredecision/docker-compose.yml
|
||||
# Arreter avant le challenge ACME : libere le webroot pour sonic-acme-1
|
||||
# L'org forge a ete renommee (EHV -> LPK) : le nom de projet compose a change,
|
||||
# on arrete donc aussi l'ancienne stack (ehv-decision-main-*) pour eviter la
|
||||
# double inscription fabio et le trafic partage v1/v2.
|
||||
- docker ps -q --filter "name=decision-main" | xargs -r docker stop
|
||||
- cd /opt/libredecision && docker compose stop
|
||||
- |
|
||||
DOMAIN=$(grep '^APP_DOMAIN=' /opt/libredecision/.env | cut -d= -f2)
|
||||
@@ -163,13 +174,18 @@ steps:
|
||||
--webroot /usr/share/nginx/html \
|
||||
--server letsencrypt \
|
||||
--accountemail support+acme@asycn.io || ACME_EXIT=$?
|
||||
if [ "$ACME_EXIT" -ne 0 ] && [ "$ACME_EXIT" -ne 2 ]; then
|
||||
echo "ERREUR: acme.sh a echoue (exit $ACME_EXIT)"
|
||||
if [ "$ACME_EXIT" -eq 0 ] || [ "$ACME_EXIT" -eq 2 ]; then
|
||||
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/fullchain.cer /host/certs/$DOMAIN-cert.pem
|
||||
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/$DOMAIN.key /host/certs/$DOMAIN-key.pem
|
||||
echo "TLS OK (acme exit $ACME_EXIT)"
|
||||
elif docker exec sonic-acme-1 test -f "/host/certs/$DOMAIN-cert.pem"; then
|
||||
echo "AVERTISSEMENT: emission ACME en echec (exit $ACME_EXIT) - certificat existant conserve"
|
||||
echo " Cause vue le 12/08 : challenge HTTP-01 invalide en IPv6 (AAAA present mais webroot"
|
||||
echo " non servi en v6) - corriger frontal/DNS avant l'expiration du certificat."
|
||||
else
|
||||
echo "ERREUR: acme.sh a echoue (exit $ACME_EXIT) et aucun certificat existant pour $DOMAIN"
|
||||
exit 1
|
||||
fi
|
||||
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/fullchain.cer /host/certs/$DOMAIN-cert.pem
|
||||
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/$DOMAIN.key /host/certs/$DOMAIN-key.pem
|
||||
echo "TLS OK (acme exit $ACME_EXIT)"
|
||||
# Images construites localement dans la pipeline : pas de docker compose pull
|
||||
- cd /opt/libredecision && docker compose up -d --remove-orphans
|
||||
- cd /opt/libredecision && docker compose ps
|
||||
|
||||
@@ -1,75 +1,73 @@
|
||||
# libreDecision
|
||||
|
||||
Boîte à outils de gouvernance collective pour la communauté Duniter/G1.
|
||||
Documents modulaires sous vote permanent + protocoles de vote + mandats.
|
||||
Architecture marque blanche — vocation à être intégré dans sweethomeCloud et librodrome.
|
||||
Boîte à outils de la première démocratie — décider ensemble, du trio à la population.
|
||||
v2 local-first : 6 routes de décision, 5 modalités, documents sous vote permanent, mandats,
|
||||
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
|
||||
|
||||
1. `git pull --rebase origin main`
|
||||
2. Si des migrations DB sont attendues : `cd backend && alembic upgrade head`
|
||||
3. Si l'objectif de la session n'est pas précisé, le demander
|
||||
2. Si l'objectif de la session n'est pas précisé, le demander
|
||||
|
||||
## Stack
|
||||
|
||||
- **Frontend** : Nuxt 4 (Vue 3, TypeScript) + Nuxt UI v3 + Pinia + UnoCSS ; package manager : npm
|
||||
- **Backend** : Python FastAPI + SQLAlchemy 2.0 async + PostgreSQL asyncpg ; migrations Alembic
|
||||
- **Auth** : Duniter V2 Ed25519 challenge-response (substrate-interface — stub en dev)
|
||||
- **Sanctuaire** : IPFS kubo + hash on-chain (system.remark) — TODO sprint 2
|
||||
- Déploiement : Docker multi-stage + Traefik (postgres + backend + frontend + ipfs) ; CI Woodpecker
|
||||
- **Frontend** : Nuxt 4 (Vue 3, TypeScript, SPA — SSR off) + Nuxt UI v3 + Pinia + UnoCSS ;
|
||||
package manager : npm ; port **3002** strict
|
||||
- **Local-first** : IndexedDB (idb-keyval) via `app/data/persistence.ts` — une clé
|
||||
`ld2:<collectiveId>` par collectif, export/import de bundles JSON, seeds par le même chemin
|
||||
- **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
|
||||
|
||||
```
|
||||
frontend/
|
||||
app/
|
||||
components/ # composants Vue
|
||||
layouts/ # layouts Nuxt
|
||||
pages/ # routing file-based (9 pages sprint 1)
|
||||
composables/ # (1 composable sprint 1)
|
||||
stores/ # 5 Pinia stores (auth, ...)
|
||||
assets/css/
|
||||
moods.css # système de palettes (.mood-* sur <html>)
|
||||
utils/ # (2 utils sprint 1)
|
||||
nuxt.config.ts # port 3002, host 0.0.0.0, apiBase via NUXT_PUBLIC_API_BASE
|
||||
backend/
|
||||
app/
|
||||
routers/ # 8 routers : auth, communes, documents, protocols, votes, ...
|
||||
services/ # 6 services
|
||||
engine/ # 5 modules : formule inertie, critères Smith/TechComm, médiane
|
||||
models/ # 14 tables SQLAlchemy
|
||||
alembic/versions/ # migrations
|
||||
tests/ # 186 tests (63 intégration TDD sprint 1)
|
||||
seed.py # Engagement Certification (33 items) + Forgeron (51 items) + Runtime Upgrade
|
||||
docker/
|
||||
docker-compose.yml # postgres + backend + frontend + ipfs
|
||||
backend.Dockerfile
|
||||
frontend.Dockerfile
|
||||
docs/content/ # 7 docs dev + 8 docs user
|
||||
public/
|
||||
hexagram-tsing.svg # sceau 井 (embossed)
|
||||
hexagram-tsing-flat.svg
|
||||
engine/ # moteurs PURS (zéro dépendance Nuxt) : threshold (+electionResult),
|
||||
# nuanced, modeParams, parametric (+crystallize), settings, state (canTransition),
|
||||
# triage, impact — import UNIQUEMENT via le barrel ~/engine
|
||||
data/
|
||||
persistence.ts # IndexedDB local-first
|
||||
templates.ts # 7 gabarits de collectif (invariant : protocole Consentement partout)
|
||||
seeds/ # duniter-g1.bundle.json (324 Ko) + atelier-du-canal.bundle.json
|
||||
stores/
|
||||
collective.ts # le tenant : collectif actif, état complet, cycle de vie des bundles
|
||||
decisions.ts # façade d'actions sans état propre (LWW updatedAt, persist debounced)
|
||||
composables/ # useMood (useLibreMood), useFeed (le Fil = sélecteur pur), useSearch
|
||||
components/ # par domaine : chemin/ decisions/ votes/ texts/ mandates/ feed/
|
||||
# onboarding/ common/ (primitives Ld*)
|
||||
pages/ # 14 routes : / · decider · decisions (+[id], vote, observatoire) ·
|
||||
# textes (+[slug], formules) · mandats (+[id], nouveau) ·
|
||||
# creer (layout bare) · donnees
|
||||
lexicon.ts # SOURCE UNIQUE des libellés FR + FORBIDDEN_UI_TERMS
|
||||
types/domain.ts # le noyau du modèle
|
||||
tests/ # 342 tests vitest : engine/, stores/, seeds/ (non-perte), lexicon.spec.ts
|
||||
backend/ # v1 FastAPI conservé pour la synchro future — ne pas développer dessus
|
||||
scripts/export_seed_bundle.py # extrait seed.py → duniter-g1.bundle.json (source vivante = bundle)
|
||||
docker/ # compose + Dockerfiles
|
||||
docs/dev/BLUEPRINT-V2.md # la référence produit v2
|
||||
public/hexagram-tsing*.svg # sceau 井 (#48 Tsing)
|
||||
```
|
||||
|
||||
## Données runtime
|
||||
|
||||
- **postgres-data** : volume Docker PostgreSQL — jamais écrasé par les builds
|
||||
- **ipfs-data** : volume Docker IPFS kubo
|
||||
- `.env` à la racine : `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `SECRET_KEY`, `DOMAIN`, `DUNITER_RPC_URL`
|
||||
- **postgres-data** / **ipfs-data** : volumes Docker du backend v1 — jamais écrasés
|
||||
- `.env` racine : secrets backend — jamais commité
|
||||
|
||||
## Commandes
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
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
|
||||
# Frontend (tout le dev v2)
|
||||
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 compose -f docker/docker-compose.yml up
|
||||
@@ -78,11 +76,25 @@ docker compose -f docker/docker-compose.yml up
|
||||
## Conventions / pièges
|
||||
|
||||
- **UI français, code anglais** (variables, commentaires, docstrings)
|
||||
- **API** : préfixe `/api/v1/`, Pydantic v2 pour tous les schémas, async partout (SQLAlchemy + FastAPI)
|
||||
- **Auth** : `get_current_admin` (24h), `get_current_citizen` (4h), `require_super_admin`
|
||||
- **Formule inertie** : `Result = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)` — voir `backend/app/engine/`
|
||||
- **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)
|
||||
- **Sceau** `井` (#48 Tsing) : `.app-seal` dans `app.vue`, right-aligned ; SVGs dans `public/`
|
||||
- **CSS drop-shadow()** safe pour effets emboss ; `<filter>` SVG inline cause des artefacts de rendu
|
||||
- **lexicon.ts d'abord** : aucun libellé UI en dur si le lexicon peut le porter ; ton = tutoyer
|
||||
la personne, sujet grammatical = la personne ou le collectif, jamais la formule ni le système
|
||||
- **Anti-lexique** (`tests/lexicon.spec.ts`) : `FORBIDDEN_UI_TERMS` scanné sur lexicon +
|
||||
templates `.vue` (insensible casse/accents) — « triage », « verdict », « déléguer »… restent
|
||||
des identifiants de code, jamais des textes affichés
|
||||
- **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)
|
||||
- **Ed25519 verification** : stub en dev (substrate-interface), autoritaire en prod — ne pas bypasser sans test
|
||||
|
||||
+68
-29
@@ -1,52 +1,91 @@
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# Backend (Python 3.11+)
|
||||
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+)
|
||||
# Frontend — suffit pour tout le développement v2 (local-first, aucun serveur requis)
|
||||
cd frontend
|
||||
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
|
||||
|
||||
- **UI** : français — **Code** : anglais (variables, commentaires, docstrings)
|
||||
- **CSS** : scoped, sans bordures (`border: none`), profondeur via `box-shadow`
|
||||
- **Composants** : `pathPrefix: false` — noms courts, auto-import
|
||||
- **API** : versionnée `/api/v1/`, Pydantic v2, async partout
|
||||
- **Ports stricts** : frontend=3002, backend=8002 — jamais de fallback
|
||||
- **UI en français, code en anglais** (variables, commentaires, docstrings).
|
||||
- **`app/lexicon.ts` est la source unique des libellés français.** Aucun libellé UI en dur
|
||||
dans un composant s'il existe (ou devrait exister) dans le lexicon. Ton : tutoyer la
|
||||
personne dans ses gestes ; actes collectifs à l'infinitif ; le sujet grammatical est
|
||||
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 :
|
||||
- Contenu principal (slot `#default`)
|
||||
- Boîte à outils sticky (slot `#toolbox`) — 30rem, flottante, zéro scroll
|
||||
- **Jamais de score sur une personne.** Pas de note, pas de réputation, pas d'agrégat
|
||||
individuel. `Person.attributes` ne sont jamais affichés à autrui ni agrégés ; l'historique
|
||||
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 :
|
||||
- `ToolboxSection` : accordéon collapsible générique
|
||||
- `ToolboxVignette` : carte compacte avec bullets toggleables
|
||||
- `toolbox/ContextMapper` : recommandeur de méthode (4 questions → méthode optimale)
|
||||
- `toolbox/SocioElection` : guide élection sociocratique + advice process
|
||||
- `toolbox/WorkflowMilestones` : jalons de protocole (Ostrom)
|
||||
## Ajouter une modalité de vote
|
||||
|
||||
1. `app/types/domain.ts` — étendre `VoteMethod` et les types de session/vote nécessaires.
|
||||
2. `app/engine/` — le calcul dans un module pur + ses tests (`tests/engine/`). Exporter via
|
||||
`engine/index.ts`.
|
||||
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
|
||||
|
||||
```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
|
||||
|
||||
Plateforme de décisions collectives pour la communauté Duniter/G1.
|
||||
Boîte à outils gouvernance multi-collectifs, architecture white-label.
|
||||
Boîte à outils de la première démocratie — décider ensemble, du trio à la population.
|
||||
|
||||
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
|
||||
|
||||
- **Frontend** : Nuxt 4 + Vue 3 + Pinia + UnoCSS (port 3002)
|
||||
- **Backend** : Python FastAPI + SQLAlchemy async + SQLite (port 8002)
|
||||
- **Auth** : Duniter V2 Ed25519 challenge-response
|
||||
- **Sanctuaire** : IPFS (kubo) + hash on-chain (system.remark)
|
||||
- **Frontend** : Nuxt 4 (SPA, SSR off) + Vue 3 + TypeScript + Nuxt UI v3 + Pinia + UnoCSS ;
|
||||
npm ; port **3002**
|
||||
- **Moteurs purs** : `frontend/app/engine/` — triage, machine à états, seuils, médiane
|
||||
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
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend && .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload
|
||||
|
||||
# Frontend
|
||||
cd frontend && npm run dev
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:3002
|
||||
npm test # vitest — 342 tests
|
||||
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
|
||||
- **Documents** : documents de référence sous vote permanent, niveaux d'inertie, sanctuaire IPFS
|
||||
- **Protocoles** : protocoles opérationnels, jalons de workflow, formules de vote
|
||||
```
|
||||
frontend/app/
|
||||
engine/ # moteurs purs (barrel index.ts — seul point d'import)
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 0b9c1d2e3f4a
|
||||
Revises:
|
||||
Create Date: 2026-04-23 00:00:00.000000+00:00
|
||||
|
||||
Idempotent: uses CREATE TABLE IF NOT EXISTS so it is safe to run
|
||||
against a DB that was already bootstrapped via SQLAlchemy create_all().
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0b9c1d2e3f4a'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS duniter_identities (
|
||||
id UUID PRIMARY KEY,
|
||||
address VARCHAR(64) NOT NULL,
|
||||
display_name VARCHAR(128),
|
||||
wot_status VARCHAR(32) NOT NULL DEFAULT 'unknown',
|
||||
is_smith BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_techcomm BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_duniter_identities_address ON duniter_identities (address)")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id UUID PRIMARY KEY,
|
||||
token_hash VARCHAR(128) NOT NULL,
|
||||
identity_id UUID NOT NULL REFERENCES duniter_identities(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_sessions_token_hash ON sessions (token_hash)")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
slug VARCHAR(128) NOT NULL,
|
||||
org_type VARCHAR(64) NOT NULL DEFAULT 'community',
|
||||
is_transparent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
color VARCHAR(32),
|
||||
icon VARCHAR(64),
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_organizations_slug ON organizations (slug)")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS org_members (
|
||||
id UUID PRIMARY KEY,
|
||||
org_id UUID NOT NULL REFERENCES organizations(id),
|
||||
identity_id UUID NOT NULL REFERENCES duniter_identities(id),
|
||||
role VARCHAR(32) NOT NULL DEFAULT 'member',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS formula_configs (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
description TEXT,
|
||||
duration_days INTEGER NOT NULL DEFAULT 30,
|
||||
majority_pct INTEGER NOT NULL DEFAULT 50,
|
||||
base_exponent FLOAT NOT NULL DEFAULT 0.1,
|
||||
gradient_exponent FLOAT NOT NULL DEFAULT 0.2,
|
||||
constant_base FLOAT NOT NULL DEFAULT 0.0,
|
||||
smith_exponent FLOAT,
|
||||
techcomm_exponent FLOAT,
|
||||
nuanced_min_participants INTEGER,
|
||||
nuanced_threshold_pct INTEGER,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS voting_protocols (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
description TEXT,
|
||||
vote_type VARCHAR(32) NOT NULL,
|
||||
formula_config_id UUID NOT NULL REFERENCES formula_configs(id),
|
||||
mode_params VARCHAR(64),
|
||||
is_meta_governed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sanctuary_entries (
|
||||
id UUID PRIMARY KEY,
|
||||
entry_type VARCHAR(64) NOT NULL,
|
||||
reference_id UUID NOT NULL,
|
||||
title VARCHAR(256),
|
||||
content_hash VARCHAR(128) NOT NULL,
|
||||
ipfs_cid VARCHAR(128),
|
||||
chain_tx_hash VARCHAR(128),
|
||||
chain_block INTEGER,
|
||||
metadata_json TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS blockchain_cache (
|
||||
id UUID PRIMARY KEY,
|
||||
cache_key VARCHAR(256) NOT NULL,
|
||||
cache_value JSON NOT NULL,
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_blockchain_cache_cache_key ON blockchain_cache (cache_key)")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id UUID PRIMARY KEY,
|
||||
slug VARCHAR(128) NOT NULL,
|
||||
title VARCHAR(256) NOT NULL,
|
||||
doc_type VARCHAR(64) NOT NULL,
|
||||
version VARCHAR(32) NOT NULL DEFAULT '0.1.0',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'draft',
|
||||
description TEXT,
|
||||
ipfs_cid VARCHAR(128),
|
||||
chain_anchor VARCHAR(128),
|
||||
genesis_json TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_documents_slug ON documents (slug)")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id UUID PRIMARY KEY,
|
||||
title VARCHAR(256) NOT NULL,
|
||||
description TEXT,
|
||||
context TEXT,
|
||||
decision_type VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'draft',
|
||||
voting_protocol_id UUID REFERENCES voting_protocols(id),
|
||||
created_by_id UUID REFERENCES duniter_identities(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS item_versions (
|
||||
id UUID PRIMARY KEY,
|
||||
item_id UUID NOT NULL,
|
||||
proposed_text TEXT NOT NULL,
|
||||
diff_text TEXT,
|
||||
rationale TEXT,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'proposed',
|
||||
decision_id UUID REFERENCES decisions(id),
|
||||
proposed_by_id UUID REFERENCES duniter_identities(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS vote_sessions (
|
||||
id UUID PRIMARY KEY,
|
||||
decision_id UUID REFERENCES decisions(id),
|
||||
item_version_id UUID REFERENCES item_versions(id),
|
||||
voting_protocol_id UUID NOT NULL REFERENCES voting_protocols(id),
|
||||
wot_size INTEGER NOT NULL DEFAULT 0,
|
||||
smith_size INTEGER NOT NULL DEFAULT 0,
|
||||
techcomm_size INTEGER NOT NULL DEFAULT 0,
|
||||
starts_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
ends_at TIMESTAMPTZ NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'open',
|
||||
votes_for INTEGER NOT NULL DEFAULT 0,
|
||||
votes_against INTEGER NOT NULL DEFAULT 0,
|
||||
votes_total INTEGER NOT NULL DEFAULT 0,
|
||||
smith_votes_for INTEGER NOT NULL DEFAULT 0,
|
||||
techcomm_votes_for INTEGER NOT NULL DEFAULT 0,
|
||||
threshold_required FLOAT NOT NULL DEFAULT 0.0,
|
||||
result VARCHAR(32),
|
||||
chain_recorded BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
chain_tx_hash VARCHAR(128),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS decision_steps (
|
||||
id UUID PRIMARY KEY,
|
||||
decision_id UUID NOT NULL REFERENCES decisions(id),
|
||||
step_order INTEGER NOT NULL,
|
||||
step_type VARCHAR(32) NOT NULL,
|
||||
title VARCHAR(256),
|
||||
description TEXT,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
vote_session_id UUID REFERENCES vote_sessions(id),
|
||||
outcome TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS document_items (
|
||||
id UUID PRIMARY KEY,
|
||||
document_id UUID NOT NULL REFERENCES documents(id),
|
||||
position VARCHAR(16) NOT NULL,
|
||||
item_type VARCHAR(32) NOT NULL DEFAULT 'clause',
|
||||
title VARCHAR(256),
|
||||
current_text TEXT NOT NULL,
|
||||
voting_protocol_id UUID REFERENCES voting_protocols(id),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
section_tag VARCHAR(64),
|
||||
inertia_preset VARCHAR(16) NOT NULL DEFAULT 'standard',
|
||||
is_permanent_vote BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'item_versions_item_id_fkey'
|
||||
AND table_name = 'item_versions'
|
||||
) THEN
|
||||
ALTER TABLE item_versions
|
||||
ADD CONSTRAINT item_versions_item_id_fkey
|
||||
FOREIGN KEY (item_id) REFERENCES document_items(id);
|
||||
END IF;
|
||||
END $$
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS votes (
|
||||
id UUID PRIMARY KEY,
|
||||
session_id UUID NOT NULL REFERENCES vote_sessions(id),
|
||||
voter_id UUID NOT NULL REFERENCES duniter_identities(id),
|
||||
vote_value VARCHAR(32) NOT NULL,
|
||||
nuanced_level INTEGER,
|
||||
comment TEXT,
|
||||
signature TEXT NOT NULL,
|
||||
signed_payload TEXT NOT NULL,
|
||||
voter_wot_status VARCHAR(32) NOT NULL DEFAULT 'member',
|
||||
voter_is_smith BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
voter_is_techcomm BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS mandates (
|
||||
id UUID PRIMARY KEY,
|
||||
title VARCHAR(256) NOT NULL,
|
||||
description TEXT,
|
||||
mandate_type VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'draft',
|
||||
mandatee_id UUID REFERENCES duniter_identities(id),
|
||||
decision_id UUID REFERENCES decisions(id),
|
||||
starts_at TIMESTAMPTZ,
|
||||
ends_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS mandate_steps (
|
||||
id UUID PRIMARY KEY,
|
||||
mandate_id UUID NOT NULL REFERENCES mandates(id),
|
||||
step_order INTEGER NOT NULL,
|
||||
step_type VARCHAR(32) NOT NULL,
|
||||
title VARCHAR(256),
|
||||
description TEXT,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
vote_session_id UUID REFERENCES vote_sessions(id),
|
||||
outcome TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Intentionally left empty — dropping the initial schema would destroy all data.
|
||||
pass
|
||||
@@ -12,22 +12,21 @@ import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '70914b334cfb'
|
||||
down_revision: Union[str, None] = None
|
||||
down_revision: Union[str, None] = '0b9c1d2e3f4a'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# SQLite does not support ADD CONSTRAINT via ALTER TABLE — FK constraints
|
||||
# are declared in models only; integrity is enforced at app layer.
|
||||
op.add_column('decisions', sa.Column('organization_id', sa.Uuid(), nullable=True))
|
||||
op.create_index(op.f('ix_decisions_organization_id'), 'decisions', ['organization_id'], unique=False)
|
||||
op.add_column('documents', sa.Column('organization_id', sa.Uuid(), nullable=True))
|
||||
op.create_index(op.f('ix_documents_organization_id'), 'documents', ['organization_id'], unique=False)
|
||||
op.add_column('mandates', sa.Column('organization_id', sa.Uuid(), nullable=True))
|
||||
op.create_index(op.f('ix_mandates_organization_id'), 'mandates', ['organization_id'], unique=False)
|
||||
op.add_column('voting_protocols', sa.Column('organization_id', sa.Uuid(), nullable=True))
|
||||
op.create_index(op.f('ix_voting_protocols_organization_id'), 'voting_protocols', ['organization_id'], unique=False)
|
||||
# ADD COLUMN IF NOT EXISTS — idempotent (safe on DBs bootstrapped via create_all)
|
||||
op.execute("ALTER TABLE decisions ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_decisions_organization_id ON decisions (organization_id)")
|
||||
op.execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_documents_organization_id ON documents (organization_id)")
|
||||
op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_mandates_organization_id ON mandates (organization_id)")
|
||||
op.execute("ALTER TABLE voting_protocols ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_voting_protocols_organization_id ON voting_protocols (organization_id)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
+11
-16
@@ -16,23 +16,18 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'qualification_protocols',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sa.String(128), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('small_group_max', sa.Integer(), nullable=False, server_default='5'),
|
||||
sa.Column('collective_wot_min', sa.Integer(), nullable=False, server_default='50'),
|
||||
sa.Column(
|
||||
'default_modalities_json',
|
||||
sa.Text(),
|
||||
nullable=False,
|
||||
server_default='["vote_wot","vote_smith","consultation_avis","election"]',
|
||||
),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qualification_protocols (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
description TEXT,
|
||||
small_group_max INTEGER NOT NULL DEFAULT 5,
|
||||
collective_wot_min INTEGER NOT NULL DEFAULT 50,
|
||||
default_modalities_json TEXT NOT NULL DEFAULT '["vote_wot","vote_smith","consultation_avis","election"]',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -17,31 +17,28 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"groups",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("name", sa.String(128), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("organization_id", sa.Uuid(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
description TEXT,
|
||||
organization_id UUID REFERENCES organizations(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
op.create_index("ix_groups_organization_id", "groups", ["organization_id"])
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_groups_organization_id ON groups (organization_id)")
|
||||
|
||||
op.create_table(
|
||||
"group_members",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("group_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("identity_id", sa.Uuid(), nullable=True),
|
||||
sa.Column("display_name", sa.String(128), nullable=False),
|
||||
sa.Column("added_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["group_id"], ["groups.id"]),
|
||||
sa.ForeignKeyConstraint(["identity_id"], ["duniter_identities.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
id UUID PRIMARY KEY,
|
||||
group_id UUID NOT NULL REFERENCES groups(id),
|
||||
identity_id UUID REFERENCES duniter_identities(id),
|
||||
display_name VARCHAR(128) NOT NULL,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
op.create_index("ix_group_members_group_id", "group_members", ["group_id"])
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_group_members_group_id ON group_members (group_id)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -17,7 +17,7 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("mandates", sa.Column("origin", sa.Text(), nullable=True))
|
||||
op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS origin TEXT")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Mandate origin: replace free-text with FK to duniter_identities.
|
||||
|
||||
Revision ID: e3f4a5b6c7d8
|
||||
Revises: d91a3c7f8b02
|
||||
Create Date: 2026-04-25 10:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "e3f4a5b6c7d8"
|
||||
down_revision = "d91a3c7f8b02"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE mandates DROP COLUMN IF EXISTS origin")
|
||||
op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS origin_id UUID REFERENCES duniter_identities(id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_mandates_origin_id ON mandates (origin_id)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_mandates_origin_id")
|
||||
op.execute("ALTER TABLE mandates DROP COLUMN IF EXISTS origin_id")
|
||||
op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS origin TEXT")
|
||||
@@ -12,10 +12,10 @@ class Mandate(Base):
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
origin: Mapped[str | None] = mapped_column(Text) # contexte / déclencheur du mandat
|
||||
origin_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"), nullable=True, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) # techcomm, smith, custom
|
||||
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, candidacy, voting, active, reporting, completed, revoked
|
||||
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(32), default="draft")
|
||||
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
|
||||
mandatee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
|
||||
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
|
||||
@@ -24,7 +24,27 @@ class Mandate(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
steps: Mapped[list["MandateStep"]] = relationship(back_populates="mandate", cascade="all, delete-orphan", order_by="MandateStep.step_order")
|
||||
steps: Mapped[list["MandateStep"]] = relationship(
|
||||
back_populates="mandate", cascade="all, delete-orphan", order_by="MandateStep.step_order"
|
||||
)
|
||||
origin_identity: Mapped["DuniterIdentity | None"] = relationship( # type: ignore[name-defined]
|
||||
"DuniterIdentity", foreign_keys=[origin_id]
|
||||
)
|
||||
mandatee: Mapped["DuniterIdentity | None"] = relationship( # type: ignore[name-defined]
|
||||
"DuniterIdentity", foreign_keys=[mandatee_id]
|
||||
)
|
||||
|
||||
@property
|
||||
def origin_display_name(self) -> str | None:
|
||||
if self.origin_identity is not None:
|
||||
return self.origin_identity.display_name or self.origin_identity.address
|
||||
return None
|
||||
|
||||
@property
|
||||
def mandatee_display_name(self) -> str | None:
|
||||
if self.mandatee is not None:
|
||||
return self.mandatee.display_name or self.mandatee.address
|
||||
return None
|
||||
|
||||
|
||||
class MandateStep(Base):
|
||||
@@ -33,12 +53,16 @@ class MandateStep(Base):
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
mandate_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("mandates.id"), nullable=False)
|
||||
step_order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
step_type: Mapped[str] = mapped_column(String(32), nullable=False) # formulation, candidacy, vote, assignment, reporting, completion, revocation
|
||||
step_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
title: Mapped[str | None] = mapped_column(String(256))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending") # pending, active, completed, skipped
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||
vote_session_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("vote_sessions.id"))
|
||||
outcome: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
mandate: Mapped["Mandate"] = relationship(back_populates="steps")
|
||||
|
||||
|
||||
# Avoid circular import — DuniterIdentity imported at runtime by SQLAlchemy relationship resolution
|
||||
from app.models.user import DuniterIdentity # noqa: E402, F401
|
||||
|
||||
@@ -5,7 +5,8 @@ from __future__ import annotations
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -132,7 +133,11 @@ async def verify_challenge(
|
||||
detail="Challenge invalide",
|
||||
)
|
||||
|
||||
# 4. Verify signature (bypass for demo profiles in dev/demo mode)
|
||||
# 4. Verify signature
|
||||
# TODO: trustWallet — déléguer la vérification au protocole trustWallet (librodrome)
|
||||
# Quand trustWallet sera disponible : remplacer le bloc ci-dessous par une vérification
|
||||
# du token signé fourni par trustWallet (JWT ou preuve Ed25519 via iframe postMessage).
|
||||
# Le bypass DEMO_MODE sera alors supprimé.
|
||||
_demo_addresses = {p["address"] for p in DEV_PROFILES}
|
||||
is_demo_bypass = (settings.DEMO_MODE or settings.ENVIRONMENT == "development") and payload.address in _demo_addresses
|
||||
|
||||
@@ -228,3 +233,24 @@ async def logout(
|
||||
for session in sessions:
|
||||
await db.delete(session)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("/identities", response_model=list[IdentityOut])
|
||||
async def search_identities(
|
||||
q: str = Query(..., min_length=1, description="Recherche par adresse ou nom"),
|
||||
limit: int = Query(default=10, ge=1, le=50),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[IdentityOut]:
|
||||
"""Search Duniter identities by address prefix or display_name."""
|
||||
result = await db.execute(
|
||||
select(DuniterIdentity)
|
||||
.where(
|
||||
or_(
|
||||
DuniterIdentity.address.ilike(f"{q}%"),
|
||||
DuniterIdentity.display_name.ilike(f"%{q}%"),
|
||||
)
|
||||
)
|
||||
.order_by(DuniterIdentity.display_name)
|
||||
.limit(limit)
|
||||
)
|
||||
return [IdentityOut.model_validate(i) for i in result.scalars().all()]
|
||||
|
||||
@@ -31,13 +31,20 @@ router = APIRouter()
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_decision(db: AsyncSession, decision_id: uuid.UUID) -> Decision:
|
||||
"""Fetch a decision by ID with its steps eagerly loaded, or raise 404."""
|
||||
result = await db.execute(
|
||||
async def _get_decision(
|
||||
db: AsyncSession, decision_id: uuid.UUID, org_id: uuid.UUID | None = None
|
||||
) -> Decision:
|
||||
"""Fetch a decision by ID within the active org scope, or raise 404."""
|
||||
stmt = (
|
||||
select(Decision)
|
||||
.options(selectinload(Decision.steps))
|
||||
.where(Decision.id == decision_id)
|
||||
)
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(Decision.organization_id == org_id)
|
||||
else:
|
||||
stmt = stmt.where(Decision.organization_id.is_(None))
|
||||
result = await db.execute(stmt)
|
||||
decision = result.scalar_one_or_none()
|
||||
if decision is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Decision introuvable")
|
||||
@@ -61,6 +68,8 @@ async def list_decisions(
|
||||
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(Decision.organization_id == org_id)
|
||||
else:
|
||||
stmt = stmt.where(Decision.organization_id.is_(None))
|
||||
if decision_type is not None:
|
||||
stmt = stmt.where(Decision.decision_type == decision_type)
|
||||
if status_filter is not None:
|
||||
@@ -91,7 +100,7 @@ async def create_decision(
|
||||
await db.refresh(decision)
|
||||
|
||||
# Reload with steps (empty at creation)
|
||||
decision = await _get_decision(db, decision.id)
|
||||
decision = await _get_decision(db, decision.id, org_id)
|
||||
return DecisionOut.model_validate(decision)
|
||||
|
||||
|
||||
@@ -99,9 +108,10 @@ async def create_decision(
|
||||
async def get_decision(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> DecisionOut:
|
||||
"""Get a single decision with all its steps."""
|
||||
decision = await _get_decision(db, id)
|
||||
decision = await _get_decision(db, id, org_id)
|
||||
return DecisionOut.model_validate(decision)
|
||||
|
||||
|
||||
@@ -111,9 +121,10 @@ async def update_decision(
|
||||
payload: DecisionUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> DecisionOut:
|
||||
"""Update a decision's metadata (title, description, status, protocol)."""
|
||||
decision = await _get_decision(db, id)
|
||||
decision = await _get_decision(db, id, org_id)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
@@ -123,7 +134,7 @@ async def update_decision(
|
||||
await db.refresh(decision)
|
||||
|
||||
# Reload with steps
|
||||
decision = await _get_decision(db, decision.id)
|
||||
decision = await _get_decision(db, decision.id, org_id)
|
||||
return DecisionOut.model_validate(decision)
|
||||
|
||||
|
||||
@@ -136,10 +147,11 @@ async def add_step(
|
||||
payload: DecisionStepCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> DecisionStepOut:
|
||||
"""Add a step to a decision process."""
|
||||
# Verify decision exists
|
||||
decision = await _get_decision(db, id)
|
||||
decision = await _get_decision(db, id, org_id)
|
||||
|
||||
step = DecisionStep(
|
||||
decision_id=decision.id,
|
||||
@@ -160,6 +172,7 @@ async def advance_decision_endpoint(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> DecisionAdvanceOut:
|
||||
"""Advance a decision to its next step or status."""
|
||||
try:
|
||||
@@ -168,7 +181,7 @@ async def advance_decision_endpoint(
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
# Reload with steps for complete output
|
||||
decision = await _get_decision(db, decision.id)
|
||||
decision = await _get_decision(db, decision.id, org_id)
|
||||
data = DecisionOut.model_validate(decision).model_dump()
|
||||
data["message"] = f"Decision avancee au statut : {decision.status}"
|
||||
return DecisionAdvanceOut(**data)
|
||||
@@ -184,6 +197,7 @@ async def create_vote_session_for_step_endpoint(
|
||||
step_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> VoteSessionOut:
|
||||
"""Create a vote session linked to a decision step."""
|
||||
try:
|
||||
@@ -199,9 +213,10 @@ async def delete_decision(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> None:
|
||||
"""Delete a decision (only if in draft status)."""
|
||||
decision = await _get_decision(db, id)
|
||||
decision = await _get_decision(db, id, org_id)
|
||||
|
||||
if decision.status != "draft":
|
||||
raise HTTPException(
|
||||
|
||||
@@ -77,6 +77,8 @@ async def list_documents(
|
||||
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(Document.organization_id == org_id)
|
||||
else:
|
||||
stmt = stmt.where(Document.organization_id.is_(None))
|
||||
if doc_type is not None:
|
||||
stmt = stmt.where(Document.doc_type == doc_type)
|
||||
if status_filter is not None:
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies.org import get_active_org_id
|
||||
from app.models.group import Group, GroupMember
|
||||
from app.schemas.group import GroupCreate, GroupMemberCreate, GroupMemberOut, GroupOut, GroupSummary
|
||||
from app.services.auth_service import get_current_identity
|
||||
@@ -18,24 +19,18 @@ from app.services.auth_service import get_current_identity
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _org_id_from_header(request_headers) -> uuid.UUID | None:
|
||||
raw = request_headers.get("x-organization")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/", response_model=list[GroupSummary])
|
||||
async def list_groups(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> list[GroupSummary]:
|
||||
"""List all groups. No auth required — groups are public within the workspace."""
|
||||
result = await db.execute(
|
||||
select(Group).options(selectinload(Group.members)).order_by(Group.name)
|
||||
)
|
||||
"""List groups within the active workspace."""
|
||||
stmt = select(Group).options(selectinload(Group.members)).order_by(Group.name)
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(Group.organization_id == org_id)
|
||||
else:
|
||||
stmt = stmt.where(Group.organization_id.is_(None))
|
||||
result = await db.execute(stmt)
|
||||
groups = result.scalars().all()
|
||||
return [
|
||||
GroupSummary(
|
||||
@@ -54,8 +49,9 @@ async def create_group(
|
||||
payload: GroupCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_identity=Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> GroupOut:
|
||||
group = Group(name=payload.name, description=payload.description)
|
||||
group = Group(name=payload.name, description=payload.description, organization_id=org_id)
|
||||
db.add(group)
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
|
||||
@@ -37,19 +37,36 @@ router = APIRouter()
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
|
||||
"""Fetch a mandate by ID with its steps eagerly loaded, or raise 404."""
|
||||
result = await db.execute(
|
||||
async def _get_mandate(
|
||||
db: AsyncSession, mandate_id: uuid.UUID, org_id: uuid.UUID | None = None
|
||||
) -> Mandate:
|
||||
stmt = (
|
||||
select(Mandate)
|
||||
.options(selectinload(Mandate.steps))
|
||||
.options(
|
||||
selectinload(Mandate.steps),
|
||||
selectinload(Mandate.origin_identity),
|
||||
selectinload(Mandate.mandatee),
|
||||
)
|
||||
.where(Mandate.id == mandate_id)
|
||||
)
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(Mandate.organization_id == org_id)
|
||||
else:
|
||||
stmt = stmt.where(Mandate.organization_id.is_(None))
|
||||
result = await db.execute(stmt)
|
||||
mandate = result.scalar_one_or_none()
|
||||
if mandate is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mandat introuvable")
|
||||
return mandate
|
||||
|
||||
|
||||
def _mandate_out(mandate: Mandate) -> MandateOut:
|
||||
out = MandateOut.model_validate(mandate)
|
||||
out.origin_display_name = mandate.origin_display_name
|
||||
out.mandatee_display_name = mandate.mandatee_display_name
|
||||
return out
|
||||
|
||||
|
||||
# ── Mandate routes ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -57,16 +74,21 @@ async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
|
||||
async def list_mandates(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
mandate_type: str | None = Query(default=None, description="Filtrer par type de mandat"),
|
||||
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||
mandate_type: str | None = Query(default=None),
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[MandateOut]:
|
||||
"""List all mandates with optional filters."""
|
||||
stmt = select(Mandate).options(selectinload(Mandate.steps))
|
||||
stmt = select(Mandate).options(
|
||||
selectinload(Mandate.steps),
|
||||
selectinload(Mandate.origin_identity),
|
||||
selectinload(Mandate.mandatee),
|
||||
)
|
||||
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(Mandate.organization_id == org_id)
|
||||
else:
|
||||
stmt = stmt.where(Mandate.organization_id.is_(None))
|
||||
if mandate_type is not None:
|
||||
stmt = stmt.where(Mandate.mandate_type == mandate_type)
|
||||
if status_filter is not None:
|
||||
@@ -76,7 +98,7 @@ async def list_mandates(
|
||||
result = await db.execute(stmt)
|
||||
mandates = result.scalars().unique().all()
|
||||
|
||||
return [MandateOut.model_validate(m) for m in mandates]
|
||||
return [_mandate_out(m) for m in mandates]
|
||||
|
||||
|
||||
@router.post("/", response_model=MandateOut, status_code=status.HTTP_201_CREATED)
|
||||
@@ -86,25 +108,30 @@ async def create_mandate(
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> MandateOut:
|
||||
"""Create a new mandate."""
|
||||
mandate = Mandate(**payload.model_dump(), organization_id=org_id)
|
||||
data = payload.model_dump()
|
||||
nomination_mode = data.pop("nomination_mode", "postpone")
|
||||
|
||||
mandate = Mandate(**data, organization_id=org_id)
|
||||
|
||||
if nomination_mode == "auto":
|
||||
mandate.mandatee_id = identity.id
|
||||
|
||||
db.add(mandate)
|
||||
await db.commit()
|
||||
await db.refresh(mandate)
|
||||
|
||||
# Reload with steps (empty at creation)
|
||||
mandate = await _get_mandate(db, mandate.id)
|
||||
return MandateOut.model_validate(mandate)
|
||||
mandate = await _get_mandate(db, mandate.id, org_id)
|
||||
return _mandate_out(mandate)
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=MandateOut)
|
||||
async def get_mandate(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> MandateOut:
|
||||
"""Get a single mandate with all its steps."""
|
||||
mandate = await _get_mandate(db, id)
|
||||
return MandateOut.model_validate(mandate)
|
||||
mandate = await _get_mandate(db, id, org_id)
|
||||
return _mandate_out(mandate)
|
||||
|
||||
|
||||
@router.put("/{id}", response_model=MandateOut)
|
||||
@@ -113,20 +140,16 @@ async def update_mandate(
|
||||
payload: MandateUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> MandateOut:
|
||||
"""Update a mandate's metadata."""
|
||||
mandate = await _get_mandate(db, id)
|
||||
mandate = await _get_mandate(db, id, org_id)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(mandate, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(mandate)
|
||||
|
||||
# Reload with steps
|
||||
mandate = await _get_mandate(db, mandate.id)
|
||||
return MandateOut.model_validate(mandate)
|
||||
mandate = await _get_mandate(db, mandate.id, org_id)
|
||||
return _mandate_out(mandate)
|
||||
|
||||
|
||||
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
|
||||
@@ -134,9 +157,9 @@ async def delete_mandate(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> None:
|
||||
"""Delete a mandate (only if in draft status)."""
|
||||
mandate = await _get_mandate(db, id)
|
||||
mandate = await _get_mandate(db, id, org_id)
|
||||
|
||||
if mandate.status != "draft":
|
||||
raise HTTPException(
|
||||
@@ -157,14 +180,11 @@ async def add_step(
|
||||
payload: MandateStepCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> MandateStepOut:
|
||||
"""Add a step to a mandate process."""
|
||||
mandate = await _get_mandate(db, id)
|
||||
mandate = await _get_mandate(db, id, org_id)
|
||||
|
||||
step = MandateStep(
|
||||
mandate_id=mandate.id,
|
||||
**payload.model_dump(),
|
||||
)
|
||||
step = MandateStep(mandate_id=mandate.id, **payload.model_dump())
|
||||
db.add(step)
|
||||
await db.commit()
|
||||
await db.refresh(step)
|
||||
@@ -176,9 +196,9 @@ async def add_step(
|
||||
async def list_steps(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> list[MandateStepOut]:
|
||||
"""List all steps for a mandate, ordered by step_order."""
|
||||
mandate = await _get_mandate(db, id)
|
||||
mandate = await _get_mandate(db, id, org_id)
|
||||
return [MandateStepOut.model_validate(s) for s in mandate.steps]
|
||||
|
||||
|
||||
@@ -190,18 +210,19 @@ async def advance_mandate_endpoint(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> MandateAdvanceOut:
|
||||
"""Advance a mandate to its next step or status."""
|
||||
try:
|
||||
mandate = await advance_mandate(id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
# Reload with steps for complete output
|
||||
mandate = await _get_mandate(db, mandate.id)
|
||||
data = MandateOut.model_validate(mandate).model_dump()
|
||||
data["message"] = f"Mandat avance au statut : {mandate.status}"
|
||||
return MandateAdvanceOut(**data)
|
||||
mandate = await _get_mandate(db, mandate.id, org_id)
|
||||
out = _mandate_out(mandate)
|
||||
return MandateAdvanceOut(
|
||||
**out.model_dump(),
|
||||
message=f"Mandat avance au statut : {mandate.status}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{id}/assign", response_model=MandateOut)
|
||||
@@ -210,16 +231,15 @@ async def assign_mandatee_endpoint(
|
||||
payload: MandateAssignRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> MandateOut:
|
||||
"""Assign a mandatee to a mandate."""
|
||||
try:
|
||||
mandate = await assign_mandatee(id, payload.mandatee_id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
# Reload with steps
|
||||
mandate = await _get_mandate(db, mandate.id)
|
||||
return MandateOut.model_validate(mandate)
|
||||
mandate = await _get_mandate(db, mandate.id, org_id)
|
||||
return _mandate_out(mandate)
|
||||
|
||||
|
||||
@router.post("/{id}/revoke", response_model=MandateOut)
|
||||
@@ -227,16 +247,15 @@ async def revoke_mandate_endpoint(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> MandateOut:
|
||||
"""Revoke an active mandate."""
|
||||
try:
|
||||
mandate = await revoke_mandate(id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
# Reload with steps
|
||||
mandate = await _get_mandate(db, mandate.id)
|
||||
return MandateOut.model_validate(mandate)
|
||||
mandate = await _get_mandate(db, mandate.id, org_id)
|
||||
return _mandate_out(mandate)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -249,8 +268,8 @@ async def create_vote_session_for_step_endpoint(
|
||||
step_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> VoteSessionOut:
|
||||
"""Create a vote session linked to a mandate step."""
|
||||
try:
|
||||
session = await create_vote_session_for_step(id, step_id, db)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -34,13 +34,20 @@ router = APIRouter()
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_protocol(db: AsyncSession, protocol_id: uuid.UUID) -> VotingProtocol:
|
||||
"""Fetch a voting protocol by ID with its formula config, or raise 404."""
|
||||
result = await db.execute(
|
||||
async def _get_protocol(
|
||||
db: AsyncSession, protocol_id: uuid.UUID, org_id: uuid.UUID | None = None
|
||||
) -> VotingProtocol:
|
||||
"""Fetch a voting protocol by ID within the active org scope, or raise 404."""
|
||||
stmt = (
|
||||
select(VotingProtocol)
|
||||
.options(selectinload(VotingProtocol.formula_config))
|
||||
.where(VotingProtocol.id == protocol_id)
|
||||
)
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(VotingProtocol.organization_id == org_id)
|
||||
else:
|
||||
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
|
||||
result = await db.execute(stmt)
|
||||
protocol = result.scalar_one_or_none()
|
||||
if protocol is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Protocole introuvable")
|
||||
@@ -74,6 +81,8 @@ async def list_protocols(
|
||||
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(VotingProtocol.organization_id == org_id)
|
||||
else:
|
||||
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
|
||||
if vote_type is not None:
|
||||
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
|
||||
|
||||
@@ -111,7 +120,7 @@ async def create_protocol(
|
||||
await db.refresh(protocol)
|
||||
|
||||
# Reload with formula config
|
||||
protocol = await _get_protocol(db, protocol.id)
|
||||
protocol = await _get_protocol(db, protocol.id, org_id)
|
||||
return VotingProtocolOut.model_validate(protocol)
|
||||
|
||||
|
||||
@@ -119,9 +128,10 @@ async def create_protocol(
|
||||
async def get_protocol(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> VotingProtocolOut:
|
||||
"""Get a single voting protocol with its formula configuration."""
|
||||
protocol = await _get_protocol(db, id)
|
||||
protocol = await _get_protocol(db, id, org_id)
|
||||
return VotingProtocolOut.model_validate(protocol)
|
||||
|
||||
|
||||
@@ -131,12 +141,13 @@ async def update_protocol(
|
||||
payload: VotingProtocolUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> VotingProtocolOut:
|
||||
"""Update a voting protocol (meta-governance).
|
||||
|
||||
Only provided fields will be updated. Requires authentication.
|
||||
"""
|
||||
protocol = await _get_protocol(db, id)
|
||||
protocol = await _get_protocol(db, id, org_id)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
@@ -145,7 +156,7 @@ async def update_protocol(
|
||||
await db.commit()
|
||||
|
||||
# Reload with formula config
|
||||
protocol = await _get_protocol(db, protocol.id)
|
||||
protocol = await _get_protocol(db, protocol.id, org_id)
|
||||
return VotingProtocolOut.model_validate(protocol)
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.schemas.vote import (
|
||||
VoteSessionListOut,
|
||||
VoteSessionOut,
|
||||
)
|
||||
from app.dependencies.org import get_active_org_id
|
||||
from app.services.auth_service import get_current_identity
|
||||
from app.services.vote_service import (
|
||||
close_session as svc_close_session,
|
||||
@@ -170,14 +171,22 @@ def _compute_result(
|
||||
@router.get("/sessions", response_model=list[VoteSessionListOut])
|
||||
async def list_vote_sessions(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
session_status: str | None = Query(default=None, alias="status", description="Filtrer par statut (open, closed, tallied)"),
|
||||
decision_id: uuid.UUID | None = Query(default=None, description="Filtrer par decision_id"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[VoteSessionListOut]:
|
||||
"""List all vote sessions with optional filters by status and decision_id."""
|
||||
stmt = select(VoteSession)
|
||||
stmt = (
|
||||
select(VoteSession)
|
||||
.join(VotingProtocol, VoteSession.voting_protocol_id == VotingProtocol.id)
|
||||
)
|
||||
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(VotingProtocol.organization_id == org_id)
|
||||
else:
|
||||
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
|
||||
if session_status is not None:
|
||||
stmt = stmt.where(VoteSession.status == session_status)
|
||||
if decision_id is not None:
|
||||
|
||||
@@ -10,21 +10,13 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class MandateStepCreate(BaseModel):
|
||||
"""Payload for creating a step within a mandate process."""
|
||||
|
||||
step_order: int = Field(..., ge=0)
|
||||
step_type: str = Field(
|
||||
...,
|
||||
max_length=32,
|
||||
description="formulation, candidacy, vote, assignment, reporting, completion, revocation",
|
||||
)
|
||||
step_type: str = Field(..., max_length=32)
|
||||
title: str | None = Field(default=None, max_length=256)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class MandateStepOut(BaseModel):
|
||||
"""Full mandate step representation."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
@@ -43,44 +35,45 @@ class MandateStepOut(BaseModel):
|
||||
|
||||
|
||||
class MandateCreate(BaseModel):
|
||||
"""Payload for creating a new mandate."""
|
||||
|
||||
title: str = Field(..., min_length=1, max_length=256)
|
||||
origin: str | None = None
|
||||
origin_id: UUID | None = None
|
||||
description: str | None = None
|
||||
mandate_type: str = Field(..., max_length=64, description="techcomm, smith, custom")
|
||||
mandate_type: str = Field(..., max_length=64)
|
||||
nomination_mode: str = Field(
|
||||
default="postpone",
|
||||
description="auto (auto-assign author), collective, postpone",
|
||||
)
|
||||
decision_id: UUID | None = None
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
|
||||
|
||||
class MandateUpdate(BaseModel):
|
||||
"""Partial update for a mandate."""
|
||||
|
||||
title: str | None = Field(default=None, max_length=256)
|
||||
origin_id: UUID | None = None
|
||||
description: str | None = None
|
||||
mandate_type: str | None = Field(default=None, max_length=64)
|
||||
decision_id: UUID | None = None
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
|
||||
|
||||
class MandateAssignRequest(BaseModel):
|
||||
"""Request body for assigning a mandatee to a mandate."""
|
||||
|
||||
mandatee_id: UUID = Field(..., description="ID de l'identite Duniter du mandataire")
|
||||
mandatee_id: UUID = Field(..., description="UUID de l'identite Duniter du mandataire")
|
||||
|
||||
|
||||
class MandateOut(BaseModel):
|
||||
"""Full mandate representation returned by the API."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
title: str
|
||||
origin: str | None = None
|
||||
origin_id: UUID | None = None
|
||||
origin_display_name: str | None = None
|
||||
description: str | None = None
|
||||
mandate_type: str
|
||||
status: str
|
||||
mandatee_id: UUID | None = None
|
||||
mandatee_display_name: str | None = None
|
||||
decision_id: UUID | None = None
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
@@ -89,22 +82,5 @@ class MandateOut(BaseModel):
|
||||
steps: list[MandateStepOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MandateAdvanceOut(BaseModel):
|
||||
"""Output after advancing a mandate through its workflow."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
title: str
|
||||
origin: str | None = None
|
||||
description: str | None = None
|
||||
mandate_type: str
|
||||
status: str
|
||||
mandatee_id: UUID | None = None
|
||||
decision_id: UUID | None = None
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
steps: list[MandateStepOut] = Field(default_factory=list)
|
||||
class MandateAdvanceOut(MandateOut):
|
||||
message: str = Field(..., description="Message decrivant l'avancement effectue")
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Integration tests for mandate flows: nomination, lifecycle, assignment, revocation.
|
||||
|
||||
Uses a real in-memory SQLite database — no mocks of the DB layer.
|
||||
Tests the service functions directly to verify interconnected business logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy import select
|
||||
|
||||
import app.models # noqa: F401 — registers all models with Base.metadata
|
||||
from app.database import Base
|
||||
from app.models.mandate import Mandate, MandateStep
|
||||
from app.models.user import DuniterIdentity
|
||||
from app.services.mandate_service import (
|
||||
advance_mandate,
|
||||
assign_mandatee,
|
||||
revoke_mandate,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def engine():
|
||||
eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
||||
async with eng.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield eng
|
||||
await eng.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db(engine):
|
||||
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def _mk_identity(db: AsyncSession, display_name: str = "Alice") -> DuniterIdentity:
|
||||
ident = DuniterIdentity(
|
||||
id=uuid.uuid4(),
|
||||
address=f"5{uuid.uuid4().hex[:46]}",
|
||||
display_name=display_name,
|
||||
wot_status="member",
|
||||
is_smith=False,
|
||||
is_techcomm=False,
|
||||
)
|
||||
db.add(ident)
|
||||
await db.commit()
|
||||
await db.refresh(ident)
|
||||
return ident
|
||||
|
||||
|
||||
async def _mk_mandate(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
mandatee_id: uuid.UUID | None = None,
|
||||
origin_id: uuid.UUID | None = None,
|
||||
status: str = "draft",
|
||||
steps: list[dict] | None = None,
|
||||
) -> Mandate:
|
||||
mandate = Mandate(
|
||||
id=uuid.uuid4(),
|
||||
title="Mandat test",
|
||||
mandate_type="functional",
|
||||
status=status,
|
||||
mandatee_id=mandatee_id,
|
||||
origin_id=origin_id,
|
||||
)
|
||||
db.add(mandate)
|
||||
await db.flush()
|
||||
|
||||
for i, s in enumerate(steps or []):
|
||||
step = MandateStep(
|
||||
id=uuid.uuid4(),
|
||||
mandate_id=mandate.id,
|
||||
step_order=i,
|
||||
step_type=s.get("step_type", "formulation"),
|
||||
title=s.get("title"),
|
||||
status=s.get("status", "pending"),
|
||||
)
|
||||
db.add(step)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(mandate)
|
||||
return mandate
|
||||
|
||||
|
||||
async def _reload(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
|
||||
result = await db.execute(
|
||||
select(Mandate)
|
||||
.options(
|
||||
selectinload(Mandate.steps),
|
||||
selectinload(Mandate.origin_identity),
|
||||
selectinload(Mandate.mandatee),
|
||||
)
|
||||
.where(Mandate.id == mandate_id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMandateOrigin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMandateOrigin:
|
||||
"""origin_id must link to a real DuniterIdentity and expose display_name."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_origin_id_linked(self, db: AsyncSession):
|
||||
author = await _mk_identity(db, "Baptiste")
|
||||
mandate = await _mk_mandate(db, origin_id=author.id)
|
||||
|
||||
loaded = await _reload(db, mandate.id)
|
||||
|
||||
assert loaded.origin_id == author.id
|
||||
assert loaded.origin_display_name == "Baptiste"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_origin_id_optional(self, db: AsyncSession):
|
||||
mandate = await _mk_mandate(db)
|
||||
|
||||
loaded = await _reload(db, mandate.id)
|
||||
|
||||
assert loaded.origin_id is None
|
||||
assert loaded.origin_display_name is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestAutoNomination
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutoNomination:
|
||||
"""Auto-désignation: mandatee = author, no candidacy steps needed."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_assign_author(self, db: AsyncSession):
|
||||
author = await _mk_identity(db, "Constance")
|
||||
mandate = await _mk_mandate(db, mandatee_id=author.id)
|
||||
|
||||
loaded = await _reload(db, mandate.id)
|
||||
|
||||
assert loaded.mandatee_id == author.id
|
||||
assert loaded.mandatee_display_name == "Constance"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_assign_then_advance_to_active(self, db: AsyncSession):
|
||||
author = await _mk_identity(db, "David")
|
||||
mandate = await _mk_mandate(
|
||||
db,
|
||||
mandatee_id=author.id,
|
||||
steps=[
|
||||
{"step_type": "formulation", "status": "pending"},
|
||||
{"step_type": "assignment", "status": "pending"},
|
||||
{"step_type": "reporting", "status": "pending"},
|
||||
],
|
||||
)
|
||||
|
||||
# First advance: draft → candidacy, activates step 0
|
||||
result = await advance_mandate(mandate.id, db)
|
||||
assert result.status == "candidacy"
|
||||
|
||||
loaded = await _reload(db, mandate.id)
|
||||
assert loaded.steps[0].status == "active"
|
||||
assert loaded.steps[1].status == "pending"
|
||||
|
||||
# Second advance: step 0 → completed, step 1 → active
|
||||
await advance_mandate(mandate.id, db)
|
||||
loaded = await _reload(db, mandate.id)
|
||||
assert loaded.steps[0].status == "completed"
|
||||
assert loaded.steps[1].status == "active"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMandateAssign
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMandateAssign:
|
||||
"""assign_mandatee service: proper UUID lookup, display_name populated."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_sets_mandatee_and_starts_at(self, db: AsyncSession):
|
||||
identity = await _mk_identity(db, "Elodie")
|
||||
mandate = await _mk_mandate(db, status="active")
|
||||
|
||||
await assign_mandatee(mandate.id, identity.id, db)
|
||||
|
||||
loaded = await _reload(db, mandate.id)
|
||||
assert loaded.mandatee_id == identity.id
|
||||
assert loaded.starts_at is not None
|
||||
assert loaded.mandatee_display_name == "Elodie"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_unknown_identity_raises(self, db: AsyncSession):
|
||||
mandate = await _mk_mandate(db, status="active")
|
||||
|
||||
with pytest.raises(ValueError, match="Identite Duniter introuvable"):
|
||||
await assign_mandatee(mandate.id, uuid.uuid4(), db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_completed_mandate_raises(self, db: AsyncSession):
|
||||
identity = await _mk_identity(db, "Fabien")
|
||||
mandate = await _mk_mandate(db, status="completed")
|
||||
|
||||
with pytest.raises(ValueError, match="statut terminal"):
|
||||
await assign_mandatee(mandate.id, identity.id, db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reassign_replaces_mandatee(self, db: AsyncSession):
|
||||
first = await _mk_identity(db, "Gilles")
|
||||
second = await _mk_identity(db, "Hélène")
|
||||
mandate = await _mk_mandate(db, mandatee_id=first.id, status="active")
|
||||
|
||||
await assign_mandatee(mandate.id, second.id, db)
|
||||
|
||||
loaded = await _reload(db, mandate.id)
|
||||
assert loaded.mandatee_id == second.id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMandateLifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMandateLifecycle:
|
||||
"""advance_mandate: full lifecycle with and without steps."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_lifecycle_no_steps(self, db: AsyncSession):
|
||||
mandate = await _mk_mandate(db)
|
||||
statuses = [mandate.status]
|
||||
|
||||
for _ in range(10):
|
||||
m = await advance_mandate(mandate.id, db)
|
||||
statuses.append(m.status)
|
||||
if m.status == "completed":
|
||||
break
|
||||
|
||||
assert statuses == ["draft", "candidacy", "voting", "active", "reporting", "completed"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_steps_activate_in_order(self, db: AsyncSession):
|
||||
mandate = await _mk_mandate(
|
||||
db,
|
||||
steps=[
|
||||
{"step_type": "formulation", "status": "pending"},
|
||||
{"step_type": "candidacy", "status": "pending"},
|
||||
{"step_type": "vote", "status": "pending"},
|
||||
],
|
||||
)
|
||||
|
||||
# Advance 1: activates step 0, moves to candidacy
|
||||
await advance_mandate(mandate.id, db)
|
||||
loaded = await _reload(db, mandate.id)
|
||||
assert loaded.status == "candidacy"
|
||||
assert loaded.steps[0].status == "active"
|
||||
assert loaded.steps[1].status == "pending"
|
||||
|
||||
# Advance 2: step 0 → completed, step 1 → active
|
||||
await advance_mandate(mandate.id, db)
|
||||
loaded = await _reload(db, mandate.id)
|
||||
assert loaded.steps[0].status == "completed"
|
||||
assert loaded.steps[1].status == "active"
|
||||
|
||||
# Advance 3: step 1 → completed, step 2 → active
|
||||
await advance_mandate(mandate.id, db)
|
||||
loaded = await _reload(db, mandate.id)
|
||||
assert loaded.steps[1].status == "completed"
|
||||
assert loaded.steps[2].status == "active"
|
||||
|
||||
# Advance 4: step 2 → completed, no more pending → advance mandate status
|
||||
await advance_mandate(mandate.id, db)
|
||||
loaded = await _reload(db, mandate.id)
|
||||
assert loaded.steps[2].status == "completed"
|
||||
assert loaded.status == "voting"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advance_terminal_raises(self, db: AsyncSession):
|
||||
for terminal in ("completed", "revoked"):
|
||||
mandate = await _mk_mandate(db, status=terminal)
|
||||
with pytest.raises(ValueError, match="statut terminal"):
|
||||
await advance_mandate(mandate.id, db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMandateRevocation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMandateRevocation:
|
||||
"""revoke_mandate: active/pending steps cancelled, completed steps preserved."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_cancels_active_and_pending(self, db: AsyncSession):
|
||||
mandate = await _mk_mandate(
|
||||
db,
|
||||
status="active",
|
||||
steps=[
|
||||
{"step_type": "formulation", "status": "completed"},
|
||||
{"step_type": "assignment", "status": "active"},
|
||||
{"step_type": "reporting", "status": "pending"},
|
||||
],
|
||||
)
|
||||
|
||||
await revoke_mandate(mandate.id, db)
|
||||
|
||||
loaded = await _reload(db, mandate.id)
|
||||
assert loaded.status == "revoked"
|
||||
assert loaded.ends_at is not None
|
||||
assert loaded.steps[0].status == "completed"
|
||||
assert loaded.steps[1].status == "cancelled"
|
||||
assert loaded.steps[2].status == "cancelled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_sets_ends_at(self, db: AsyncSession):
|
||||
mandate = await _mk_mandate(db, status="draft")
|
||||
|
||||
await revoke_mandate(mandate.id, db)
|
||||
|
||||
loaded = await _reload(db, mandate.id)
|
||||
assert loaded.ends_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_already_revoked_raises(self, db: AsyncSession):
|
||||
mandate = await _mk_mandate(db, status="revoked")
|
||||
|
||||
with pytest.raises(ValueError, match="statut terminal"):
|
||||
await revoke_mandate(mandate.id, db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestNominationInteractions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNominationInteractions:
|
||||
"""Cross-process: nomination + assignment + lifecycle are consistent."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_then_advance_full_cycle(self, db: AsyncSession):
|
||||
"""Assigning a mandatee then running the full lifecycle completes cleanly."""
|
||||
mandatee = await _mk_identity(db, "Isabelle")
|
||||
mandate = await _mk_mandate(
|
||||
db,
|
||||
steps=[
|
||||
{"step_type": "formulation", "status": "pending"},
|
||||
{"step_type": "assignment", "status": "pending"},
|
||||
{"step_type": "completion", "status": "pending"},
|
||||
],
|
||||
)
|
||||
|
||||
# Assign before starting
|
||||
await assign_mandatee(mandate.id, mandatee.id, db)
|
||||
|
||||
# Run through all steps
|
||||
for _ in range(5):
|
||||
m = await advance_mandate(mandate.id, db)
|
||||
if m.status in ("completed", "revoked"):
|
||||
break
|
||||
|
||||
loaded = await _reload(db, mandate.id)
|
||||
# All steps completed, mandate advanced beyond steps
|
||||
assert all(s.status == "completed" for s in loaded.steps)
|
||||
assert loaded.mandatee_id == mandatee.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_after_assign_preserves_mandatee_id(self, db: AsyncSession):
|
||||
"""Revoking a mandate keeps mandatee_id (for audit trail)."""
|
||||
mandatee = await _mk_identity(db, "Jacques")
|
||||
mandate = await _mk_mandate(db, mandatee_id=mandatee.id, status="active")
|
||||
|
||||
await revoke_mandate(mandate.id, db)
|
||||
|
||||
loaded = await _reload(db, mandate.id)
|
||||
assert loaded.status == "revoked"
|
||||
assert loaded.mandatee_id == mandatee.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_origin_and_mandatee_can_differ(self, db: AsyncSession):
|
||||
"""The person who proposed the mandate (origin) is different from the mandatee."""
|
||||
proposer = await _mk_identity(db, "Kim")
|
||||
mandatee = await _mk_identity(db, "Laurent")
|
||||
|
||||
mandate = await _mk_mandate(db, origin_id=proposer.id)
|
||||
await assign_mandatee(mandate.id, mandatee.id, db)
|
||||
|
||||
loaded = await _reload(db, mandate.id)
|
||||
assert loaded.origin_id == proposer.id
|
||||
assert loaded.mandatee_id == mandatee.id
|
||||
assert loaded.origin_display_name == "Kim"
|
||||
assert loaded.mandatee_display_name == "Laurent"
|
||||
@@ -14,8 +14,21 @@ from __future__ import annotations
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
import app.models # noqa: F401 — registers all models with Base.metadata before create_all
|
||||
from app.database import init_db
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
async def _create_tables():
|
||||
"""Create DB tables once for this module.
|
||||
|
||||
ASGITransport does not trigger the FastAPI lifespan, so init_db() would
|
||||
never run. Tests that hit endpoints backed by the DB need the tables to
|
||||
exist beforehand.
|
||||
"""
|
||||
await init_db()
|
||||
|
||||
ORIGIN = "http://localhost:3002"
|
||||
CHALLENGE_URL = "/api/v1/auth/challenge"
|
||||
VERIFY_URL = "/api/v1/auth/verify"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,4 +41,4 @@ COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
WORKDIR /app
|
||||
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002", "--reload"]
|
||||
|
||||
@@ -7,7 +7,7 @@ FROM node:${NODE_VERSION} AS base
|
||||
WORKDIR /src
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends curl && \
|
||||
apt-get install -y --no-install-recommends curl git ca-certificates && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Build ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
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">
|
||||
const auth = useAuthStore()
|
||||
const orgsStore = useOrganizationsStore()
|
||||
const documentsStore = useDocumentsStore()
|
||||
const decisionsStore = useDecisionsStore()
|
||||
const protocolsStore = useProtocolsStore()
|
||||
const mandatesStore = useMandatesStore()
|
||||
const route = useRoute()
|
||||
const { initMood } = useMood()
|
||||
// Shell v2 — app.vue MINCE : ambiance + hydratation du collectif, rien d'autre.
|
||||
// Toute la structure (header, sidebar, drawer, sceau) vit dans layouts/default.vue.
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
|
||||
const navigationItems = [
|
||||
{
|
||||
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',
|
||||
},
|
||||
]
|
||||
const { initMood } = useLibreMood()
|
||||
|
||||
/** 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 () => {
|
||||
onMounted(() => {
|
||||
initMood()
|
||||
const savedCollapsed = localStorage.getItem('libred-sidebar-collapsed')
|
||||
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()
|
||||
useCollectiveStore().init?.()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
ws.disconnect()
|
||||
})
|
||||
|
||||
function isActive(to: string) {
|
||||
return route.path === to || route.path.startsWith(to + '/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<UApp>
|
||||
<OfflineBanner />
|
||||
|
||||
<div
|
||||
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 (proportions calées sur avatars Yvv) -->
|
||||
<svg class="app-seal" viewBox="0 0 130 100" fill="currentColor" aria-hidden="true">
|
||||
<!-- Line 6 (top) — yin -->
|
||||
<rect x="5" y="5" width="49" height="5" rx="1"/>
|
||||
<rect x="76" y="5" width="49" height="5" rx="1"/>
|
||||
<!-- Line 5 — yang -->
|
||||
<rect x="5" y="22" width="120" height="5" rx="1"/>
|
||||
<!-- Line 4 — yin -->
|
||||
<rect x="5" y="39" width="49" height="5" rx="1"/>
|
||||
<rect x="76" y="39" width="49" height="5" rx="1"/>
|
||||
<!-- Line 3 — yang -->
|
||||
<rect x="5" y="56" width="120" height="5" rx="1"/>
|
||||
<!-- Line 2 — yang -->
|
||||
<rect x="5" y="73" width="120" height="5" rx="1"/>
|
||||
<!-- Line 1 (bottom) — yin -->
|
||||
<rect x="5" y="90" width="49" height="5" rx="1"/>
|
||||
<rect x="76" y="90" width="49" height="5" rx="1"/>
|
||||
</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>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</UApp>
|
||||
</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
|
||||
Palettes harmoniques variees, colores en lite, lumineux en dark.
|
||||
libreDecision v2 — Ambiances (champ lexical du puits 井)
|
||||
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)
|
||||
Palette: corail vif / ocre / indigo / vert franc
|
||||
Source — l'eau vive au grand jour (Light)
|
||||
-------------------------------------------------------------------------- */
|
||||
.mood-peps {
|
||||
--mood-bg: #faf8f5;
|
||||
--mood-surface: #ffffff;
|
||||
--mood-surface-hover: #fdf2ec;
|
||||
--mood-text: #1e1410;
|
||||
--mood-text-muted: #7a5e48;
|
||||
--mood-accent: #d44a10;
|
||||
--mood-accent-soft: rgba(212, 74, 16, 0.08);
|
||||
.mood-source {
|
||||
--mood-bg: #f0f5f6;
|
||||
--mood-surface: #fcfefe;
|
||||
--mood-surface-hover: #e6eff2;
|
||||
--mood-text: #102126;
|
||||
--mood-text-muted: #52707a;
|
||||
--mood-accent: #0f7fa8;
|
||||
--mood-accent-soft: rgba(15, 127, 168, 0.09);
|
||||
--mood-accent-text: #ffffff;
|
||||
--mood-border: #e8d8c8;
|
||||
--mood-secondary: #4a5ec0;
|
||||
--mood-tertiary: #c07820;
|
||||
--mood-success: #1a8c3e;
|
||||
--mood-warning: #c47d0a;
|
||||
--mood-error: #c23028;
|
||||
--mood-gradient: linear-gradient(145deg, #fdf8f0 0%, #ffffff 40%, #f0f4fb 100%);
|
||||
--mood-shadow: rgba(180, 80, 20, 0.07);
|
||||
--mood-input-bg: #ffffff;
|
||||
--mood-input-border: #d4c0aa;
|
||||
--mood-input-focus: #d44a10;
|
||||
--mood-secondary: #a8742c; /* laiton — le mandat, le pouvoir confié */
|
||||
--mood-tertiary: #2e8a72; /* eau végétale — l'avis, le dialogue */
|
||||
--mood-success: #1d8a4a;
|
||||
--mood-warning: #b07c14;
|
||||
--mood-error: #bc3d33;
|
||||
--mood-gradient: linear-gradient(150deg, #eaf3f5 0%, #fcfefe 45%, #eef2ee 100%);
|
||||
--mood-shadow: rgba(20, 90, 120, 0.08);
|
||||
--mood-input-bg: #fcfefe;
|
||||
--mood-input-border: #b8ccd2;
|
||||
--mood-input-focus: #0f7fa8;
|
||||
|
||||
--mood-status-prepa: #b8600e;
|
||||
--mood-status-prepa-bg: rgba(184, 96, 14, 0.12);
|
||||
--mood-status-vote: #4a5ec0;
|
||||
--mood-status-vote-bg: rgba(74, 94, 192, 0.10);
|
||||
--mood-status-vigueur: #1a8c3e;
|
||||
--mood-status-vigueur-bg: rgba(26, 140, 62, 0.10);
|
||||
--mood-status-clos: #6e5844;
|
||||
--mood-status-clos-bg: rgba(110, 88, 68, 0.08);
|
||||
--mood-status-prepa: #8a7040;
|
||||
--mood-status-prepa-bg: rgba(138, 112, 64, 0.10);
|
||||
--mood-status-fenetre: #b07c14;
|
||||
--mood-status-fenetre-bg: rgba(176, 124, 20, 0.11);
|
||||
--mood-status-vote: #2661b8;
|
||||
--mood-status-vote-bg: rgba(38, 97, 184, 0.10);
|
||||
--mood-status-fige: #58708c;
|
||||
--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)
|
||||
Palette: vert franc / terre sienne / bleu canard / bois chaud
|
||||
Margelle — la pierre du puits, calcaire et bronze (Light)
|
||||
-------------------------------------------------------------------------- */
|
||||
.mood-zen {
|
||||
--mood-bg: #f4f6f0;
|
||||
--mood-surface: #fbfcf8;
|
||||
--mood-surface-hover: #eaf0e4;
|
||||
--mood-text: #1a2418;
|
||||
--mood-text-muted: #5a7050;
|
||||
--mood-accent: #2e8b48;
|
||||
--mood-accent-soft: rgba(46, 139, 72, 0.09);
|
||||
.mood-margelle {
|
||||
--mood-bg: #f6f3ec;
|
||||
--mood-surface: #fdfbf6;
|
||||
--mood-surface-hover: #efe9dc;
|
||||
--mood-text: #221c10;
|
||||
--mood-text-muted: #77684c;
|
||||
--mood-accent: #96682a;
|
||||
--mood-accent-soft: rgba(150, 104, 42, 0.10);
|
||||
--mood-accent-text: #ffffff;
|
||||
--mood-border: #b8d0a8;
|
||||
--mood-secondary: #8a6030;
|
||||
--mood-tertiary: #2a7888;
|
||||
--mood-success: #268040;
|
||||
--mood-warning: #a87818;
|
||||
--mood-error: #b83838;
|
||||
--mood-gradient: linear-gradient(145deg, #f0f5ea 0%, #fbfcf8 40%, #f4efe6 100%);
|
||||
--mood-shadow: rgba(40, 100, 50, 0.07);
|
||||
--mood-input-bg: #fbfcf8;
|
||||
--mood-input-border: #a8c898;
|
||||
--mood-input-focus: #2e8b48;
|
||||
--mood-secondary: #285e88; /* le ciel dans l'eau du seau */
|
||||
--mood-tertiary: #4f7a3c; /* mousse entre les pierres */
|
||||
--mood-success: #3d7e34;
|
||||
--mood-warning: #a8720e;
|
||||
--mood-error: #b23f30;
|
||||
--mood-gradient: linear-gradient(150deg, #f3eee2 0%, #fdfbf6 45%, #f0eee6 100%);
|
||||
--mood-shadow: rgba(120, 90, 40, 0.09);
|
||||
--mood-input-bg: #fdfbf6;
|
||||
--mood-input-border: #cfc2a6;
|
||||
--mood-input-focus: #96682a;
|
||||
|
||||
--mood-status-prepa: #a87818;
|
||||
--mood-status-prepa-bg: rgba(168, 120, 24, 0.11);
|
||||
--mood-status-vote: #2a7888;
|
||||
--mood-status-vote-bg: rgba(42, 120, 136, 0.10);
|
||||
--mood-status-vigueur: #268040;
|
||||
--mood-status-vigueur-bg: rgba(38, 128, 64, 0.10);
|
||||
--mood-status-clos: #607858;
|
||||
--mood-status-clos-bg: rgba(96, 120, 88, 0.08);
|
||||
--mood-status-prepa: #7c6a48;
|
||||
--mood-status-prepa-bg: rgba(124, 106, 72, 0.10);
|
||||
--mood-status-fenetre: #a8720e;
|
||||
--mood-status-fenetre-bg: rgba(168, 114, 14, 0.11);
|
||||
--mood-status-vote: #285e88;
|
||||
--mood-status-vote-bg: rgba(40, 94, 136, 0.10);
|
||||
--mood-status-fige: #6e6a58;
|
||||
--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)
|
||||
Palette: bleu lavande / ambre chaud / rose ancien / vert d'eau
|
||||
Nappe — l'eau profonde sous la ville (Dark)
|
||||
-------------------------------------------------------------------------- */
|
||||
.mood-chagrine {
|
||||
--mood-bg: #0e1018;
|
||||
--mood-surface: #151a28;
|
||||
--mood-surface-hover: #1c2640;
|
||||
--mood-text: #dce0ec;
|
||||
--mood-text-muted: #8898b8;
|
||||
--mood-accent: #6488d8;
|
||||
--mood-accent-soft: rgba(100, 136, 216, 0.12);
|
||||
--mood-accent-text: #ffffff;
|
||||
--mood-border: #222e48;
|
||||
--mood-secondary: #d0a040;
|
||||
--mood-tertiary: #c87090;
|
||||
--mood-success: #48c87a;
|
||||
--mood-warning: #d8a838;
|
||||
--mood-error: #d86060;
|
||||
--mood-gradient: linear-gradient(145deg, #0e1018 0%, #151a28 40%, #101420 100%);
|
||||
--mood-shadow: rgba(60, 100, 180, 0.12);
|
||||
--mood-input-bg: #151a28;
|
||||
--mood-input-border: #2a3858;
|
||||
--mood-input-focus: #6488d8;
|
||||
.mood-nappe {
|
||||
--mood-bg: #0a141b;
|
||||
--mood-surface: #101f2a;
|
||||
--mood-surface-hover: #16293a;
|
||||
--mood-text: #d6e3ea;
|
||||
--mood-text-muted: #7e98a8;
|
||||
--mood-accent: #3fa9cc;
|
||||
--mood-accent-soft: rgba(63, 169, 204, 0.13);
|
||||
--mood-accent-text: #06141c;
|
||||
--mood-secondary: #cfa14a; /* laiton à la lueur de la lampe */
|
||||
--mood-tertiary: #52b894;
|
||||
--mood-success: #46bf7c;
|
||||
--mood-warning: #d2a13c;
|
||||
--mood-error: #d86458;
|
||||
--mood-gradient: linear-gradient(150deg, #0a141b 0%, #101f2a 45%, #0d1a24 100%);
|
||||
--mood-shadow: rgba(30, 120, 160, 0.14);
|
||||
--mood-input-bg: #101f2a;
|
||||
--mood-input-border: #24425a;
|
||||
--mood-input-focus: #3fa9cc;
|
||||
|
||||
--mood-status-prepa: #d8a838;
|
||||
--mood-status-prepa-bg: rgba(216, 168, 56, 0.14);
|
||||
--mood-status-vote: #6488d8;
|
||||
--mood-status-vote-bg: rgba(100, 136, 216, 0.14);
|
||||
--mood-status-vigueur: #48c87a;
|
||||
--mood-status-vigueur-bg: rgba(72, 200, 122, 0.14);
|
||||
--mood-status-clos: #7888a8;
|
||||
--mood-status-clos-bg: rgba(120, 136, 168, 0.10);
|
||||
--mood-status-prepa: #a5987c;
|
||||
--mood-status-prepa-bg: rgba(165, 152, 124, 0.12);
|
||||
--mood-status-fenetre: #d2a13c;
|
||||
--mood-status-fenetre-bg: rgba(210, 161, 60, 0.14);
|
||||
--mood-status-vote: #5f97e0;
|
||||
--mood-status-vote-bg: rgba(95, 151, 224, 0.14);
|
||||
--mood-status-fige: #7d97b5;
|
||||
--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)
|
||||
Palette: ambre dore / ocre rouge / bleu ardoise / vert mousse
|
||||
Minuit — l'encre, et la lanterne posée sur la margelle (Dark)
|
||||
-------------------------------------------------------------------------- */
|
||||
.mood-grave {
|
||||
--mood-bg: #131210;
|
||||
--mood-surface: #1c1a16;
|
||||
--mood-surface-hover: #28241c;
|
||||
--mood-text: #ece4d4;
|
||||
--mood-text-muted: #a89870;
|
||||
--mood-accent: #d8a030;
|
||||
--mood-accent-soft: rgba(216, 160, 48, 0.11);
|
||||
--mood-accent-text: #131210;
|
||||
--mood-border: #342e24;
|
||||
--mood-secondary: #6898b8;
|
||||
--mood-tertiary: #c07040;
|
||||
--mood-success: #58c870;
|
||||
--mood-warning: #d0a030;
|
||||
--mood-error: #d05050;
|
||||
--mood-gradient: linear-gradient(145deg, #131210 0%, #1c1a16 40%, #16181c 100%);
|
||||
--mood-shadow: rgba(180, 130, 40, 0.10);
|
||||
--mood-input-bg: #1c1a16;
|
||||
--mood-input-border: #3c3428;
|
||||
--mood-input-focus: #d8a030;
|
||||
.mood-minuit {
|
||||
--mood-bg: #101014;
|
||||
--mood-surface: #17171e;
|
||||
--mood-surface-hover: #202029;
|
||||
--mood-text: #e2e2e8;
|
||||
--mood-text-muted: #8e8e9e;
|
||||
--mood-accent: #cf9c3e;
|
||||
--mood-accent-soft: rgba(207, 156, 62, 0.12);
|
||||
--mood-accent-text: #131309;
|
||||
--mood-secondary: #6c94d8;
|
||||
--mood-tertiary: #58ae8e;
|
||||
--mood-success: #52bd7e;
|
||||
--mood-warning: #cfa03a;
|
||||
--mood-error: #d25f55;
|
||||
--mood-gradient: linear-gradient(150deg, #101014 0%, #17171e 45%, #131318 100%);
|
||||
--mood-shadow: rgba(190, 140, 50, 0.11);
|
||||
--mood-input-bg: #17171e;
|
||||
--mood-input-border: #33333f;
|
||||
--mood-input-focus: #cf9c3e;
|
||||
|
||||
--mood-status-prepa: #c07040;
|
||||
--mood-status-prepa-bg: rgba(192, 112, 64, 0.15);
|
||||
--mood-status-vote: #6898b8;
|
||||
--mood-status-vote-bg: rgba(104, 152, 184, 0.14);
|
||||
--mood-status-vigueur: #58c870;
|
||||
--mood-status-vigueur-bg: rgba(88, 200, 112, 0.14);
|
||||
--mood-status-clos: #807860;
|
||||
--mood-status-clos-bg: rgba(128, 120, 96, 0.12);
|
||||
--mood-status-prepa: #9e9584;
|
||||
--mood-status-prepa-bg: rgba(158, 149, 132, 0.12);
|
||||
--mood-status-fenetre: #cfa03a;
|
||||
--mood-status-fenetre-bg: rgba(207, 160, 58, 0.14);
|
||||
--mood-status-vote: #6c94d8;
|
||||
--mood-status-vote-bg: rgba(108, 148, 216, 0.14);
|
||||
--mood-status-fige: #8b8ba0;
|
||||
--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);
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
border-color: transparent;
|
||||
/* Rayons (consignes) */
|
||||
--r-card: 16px;
|
||||
--r-input: 12px;
|
||||
--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 {
|
||||
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
|
||||
font-size: 16px;
|
||||
@@ -160,89 +198,154 @@ html {
|
||||
|
||||
body {
|
||||
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;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* --- No borders anywhere --- */
|
||||
button, input, select, textarea {
|
||||
border: none !important;
|
||||
outline: none;
|
||||
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;
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 5px 14px;
|
||||
border-radius: 20px;
|
||||
gap: 0.3rem;
|
||||
padding: 4px 13px;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
user-select: none;
|
||||
border: none !important;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.status-pill:hover {
|
||||
filter: brightness(1.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.status-pill.active {
|
||||
box-shadow: 0 0 0 2.5px var(--mood-accent);
|
||||
.status-pill.is-clickable { cursor: pointer; }
|
||||
.status-pill.is-clickable:hover { filter: brightness(1.08); transform: translateY(-1px); }
|
||||
.status-pill.active { box-shadow: 0 0 0 2.5px var(--mood-accent); }
|
||||
|
||||
.status-draft { background: var(--mood-status-prepa-bg); color: var(--mood-status-prepa); }
|
||||
.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 {
|
||||
background: var(--mood-status-prepa-bg);
|
||||
color: var(--mood-status-prepa);
|
||||
}
|
||||
.status-vote {
|
||||
background: var(--mood-status-vote-bg);
|
||||
color: var(--mood-status-vote);
|
||||
}
|
||||
.status-vigueur {
|
||||
background: var(--mood-status-vigueur-bg);
|
||||
/* --- Le tampon 井 (signature) --- */
|
||||
.ld-stamp {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 10px;
|
||||
transform: rotate(-10deg);
|
||||
font-weight: 800;
|
||||
font-size: 0.8125rem;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--mood-status-vigueur);
|
||||
}
|
||||
.status-clos {
|
||||
background: var(--mood-status-clos-bg);
|
||||
color: var(--mood-status-clos);
|
||||
box-shadow: inset 0 0 0 2px var(--mood-status-vigueur);
|
||||
background: var(--mood-status-vigueur-bg);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Nuxt UI overrides — borderless everything
|
||||
========================================================================== */
|
||||
|
||||
:root .mood-peps,
|
||||
:root .mood-zen,
|
||||
:root .mood-chagrine,
|
||||
:root .mood-grave {
|
||||
--ui-border: transparent;
|
||||
--ui-bg: var(--mood-input-bg);
|
||||
--ui-text-highlighted: var(--mood-accent);
|
||||
/* --- Sceau hexagramme #48 Tsing --- */
|
||||
.app-seal {
|
||||
display: block;
|
||||
width: 44px;
|
||||
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));
|
||||
}
|
||||
|
||||
:root button,
|
||||
:root [class*="UButton"],
|
||||
:root [class*="u-button"],
|
||||
:root [data-variant] {
|
||||
border: none !important;
|
||||
font-family: 'Plus Jakarta Sans', system-ui, sans-serif !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;
|
||||
/* --- Impression A4 (PV, affiches de session) --- */
|
||||
@media print {
|
||||
body { background: #ffffff !important; color: #111111 !important; }
|
||||
.app-header, .app-sidebar, .app-fab, .app-seal, .no-print { display: none !important; }
|
||||
.ld-card { box-shadow: none !important; }
|
||||
.print-only { display: block !important; }
|
||||
}
|
||||
.print-only { display: none; }
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Overlays Nuxt UI (UModal / USlideover) — géométrie posée à la main.
|
||||
* Les composants @nuxt/ui portent des classes utilitaires tailwind que ce
|
||||
* projet ne génère pas (UnoCSS ne scanne pas node_modules) : sans ces règles,
|
||||
* voile et panneaux se rendent à leur position statique, en fin de <body>,
|
||||
* donc hors écran. On cible la structure stable posée par reka-ui.
|
||||
*/
|
||||
|
||||
/* Voile */
|
||||
body > div.fixed.inset-0[data-state] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
background: rgb(15 20 25 / 0.45);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
body > div.fixed.inset-0[data-state='open'] {
|
||||
animation: ldov-fade-in 0.15s ease;
|
||||
}
|
||||
|
||||
/* Panneau latéral (USlideover — drawer mobile) */
|
||||
body > [role='dialog'][data-side] {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: min(20rem, 86vw);
|
||||
z-index: 91;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
background: var(--mood-surface);
|
||||
color: var(--mood-text);
|
||||
box-shadow: 0 12px 40px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
body > [role='dialog'][data-side='left'] {
|
||||
left: 0;
|
||||
right: auto;
|
||||
border-radius: 0 16px 16px 0;
|
||||
}
|
||||
body > [role='dialog'][data-side='right'] {
|
||||
right: 0;
|
||||
left: auto;
|
||||
border-radius: 16px 0 0 16px;
|
||||
}
|
||||
body > [role='dialog'][data-side='left'][data-state='open'] {
|
||||
animation: ldov-slide-left 0.18s ease;
|
||||
}
|
||||
body > [role='dialog'][data-side='right'][data-state='open'] {
|
||||
animation: ldov-slide-right 0.18s ease;
|
||||
}
|
||||
|
||||
/* Modale centrée (UModal) */
|
||||
body > [role='dialog']:not([data-side]) {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(26rem, calc(100vw - 2rem));
|
||||
max-height: calc(100dvh - 2rem);
|
||||
overflow-y: auto;
|
||||
z-index: 91;
|
||||
background: var(--mood-surface);
|
||||
color: var(--mood-text);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 18px 50px rgb(0 0 0 / 0.28);
|
||||
}
|
||||
body > [role='dialog']:not([data-side])[data-state='open'] {
|
||||
animation: ldov-pop 0.15s ease;
|
||||
}
|
||||
|
||||
/* Blocs internes générés par Nuxt UI — classes posées via la prop :ui */
|
||||
.ldov-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 3.5rem 0.5rem 1.25rem;
|
||||
}
|
||||
.ldov-title {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
/* description a11y sans rendu visuel (lecteurs d'écran seulement) */
|
||||
.ldov-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ldov-desc {
|
||||
margin: 0;
|
||||
padding: 0 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.ldov-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 1.25rem 1.25rem;
|
||||
}
|
||||
.ldov-close {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.25rem;
|
||||
min-height: 2.25rem;
|
||||
border-radius: 14px;
|
||||
background: var(--mood-input-bg);
|
||||
color: var(--mood-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease, transform 0.1s ease;
|
||||
}
|
||||
.ldov-close:hover {
|
||||
background: var(--mood-accent-soft);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.ldov-close:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@keyframes ldov-fade-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
@keyframes ldov-slide-left {
|
||||
from { transform: translateX(-100%); }
|
||||
}
|
||||
@keyframes ldov-slide-right {
|
||||
from { transform: translateX(100%); }
|
||||
}
|
||||
@keyframes ldov-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.96);
|
||||
}
|
||||
}
|
||||
@@ -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 } = useMood()
|
||||
</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,273 @@
|
||||
<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;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
/* la table ne sait pas rétrécir sous la largeur de son contenu : elle défile */
|
||||
.vig__table { display: block; overflow-x: auto; }
|
||||
}
|
||||
.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">
|
||||
/**
|
||||
* Card component for displaying a mandate in a list.
|
||||
*
|
||||
* Shows title, type badge, status badge, mandatee, date range.
|
||||
* Navigates to the mandate detail page on click.
|
||||
*/
|
||||
import type { Mandate } from '~/stores/mandates'
|
||||
// <!-- ld-v2 --> Carte mandat — titulaire, domaine, échéance, prochain rapport,
|
||||
// COMPTEURS BRUTS (Δ7 : aucune jauge, aucun agrégat — des faits comptés).
|
||||
import type { Mandate } from '~/types/domain'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import {
|
||||
MANDATE_STATUS_LABELS,
|
||||
MANDATE_STATUS_PILL,
|
||||
formatDay,
|
||||
mandateFacts,
|
||||
} from './mandateUi'
|
||||
|
||||
const props = defineProps<{
|
||||
mandate: Mandate
|
||||
}>()
|
||||
const props = defineProps<{ mandate: Mandate }>()
|
||||
|
||||
const typeLabel = (mandateType: string) => {
|
||||
switch (mandateType) {
|
||||
case 'techcomm': return 'Comite technique'
|
||||
case 'smith': return 'Forgeron'
|
||||
case 'custom': return 'Personnalise'
|
||||
default: return mandateType
|
||||
}
|
||||
}
|
||||
const col = useCollectiveStore()
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function navigate() {
|
||||
navigateTo(`/mandates/${props.mandate.id}`)
|
||||
}
|
||||
const holder = computed(() => col.people.find(p => p.id === props.mandate.holderId))
|
||||
const domainCircles = computed(() =>
|
||||
props.mandate.domain.circleIds
|
||||
.map(id => col.circles.find(c => c.id === id))
|
||||
.filter(c => c !== undefined),
|
||||
)
|
||||
const facts = computed(() => mandateFacts(props.mandate, col.decisions, col.objections))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard
|
||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 hover:shadow-md transition-all"
|
||||
@click="navigate"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-user-check" class="text-gray-400" />
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ mandate.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<StatusBadge :status="mandate.status" type="mandate" />
|
||||
</div>
|
||||
|
||||
<p v-if="mandate.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{{ mandate.description }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<UBadge variant="subtle" color="primary" size="xs">
|
||||
{{ typeLabel(mandate.mandate_type) }}
|
||||
</UBadge>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ mandate.steps.length }} etape(s)
|
||||
</span>
|
||||
<span v-if="mandate.mandatee_id" class="text-xs text-gray-500 flex items-center gap-1">
|
||||
<UIcon name="i-lucide-user" class="text-xs" />
|
||||
{{ mandate.mandatee_id.slice(0, 8) }}...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-xs text-gray-500">
|
||||
<div>
|
||||
<span class="block font-medium">Debut</span>
|
||||
{{ formatDate(mandate.starts_at) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="block font-medium">Fin</span>
|
||||
{{ formatDate(mandate.ends_at) }}
|
||||
</div>
|
||||
<!-- ld-v2 -->
|
||||
<NuxtLink :to="`/mandats/${mandate.id}`" class="mcard ld-card ld-card--hover">
|
||||
<div class="mcard__head">
|
||||
<LdAvatarStack v-if="holder" :people="[{ person: holder }]" :size="34" />
|
||||
<div class="mcard__who">
|
||||
<p class="mcard__title">{{ mandate.title }}</p>
|
||||
<p v-if="holder" class="mcard__holder">{{ holder.displayName }}</p>
|
||||
</div>
|
||||
<span class="status-pill" :class="MANDATE_STATUS_PILL[mandate.status]">
|
||||
{{ MANDATE_STATUS_LABELS[mandate.status] }}
|
||||
</span>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<div class="mcard__domain">
|
||||
<span v-for="c in domainCircles" :key="c.id" class="mcard__chip">
|
||||
<UIcon name="i-lucide-circle-dashed" />
|
||||
{{ c.name }}
|
||||
</span>
|
||||
<span v-for="tag in mandate.domain.tags" :key="tag" class="mcard__tag">#{{ tag }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mcard__meta">
|
||||
<span class="mcard__meta-item">
|
||||
<UIcon name="i-lucide-calendar-range" />
|
||||
jusqu'au {{ formatDay(mandate.endsAt) }}
|
||||
</span>
|
||||
<span v-if="facts.nextReportDueAt" class="mcard__meta-item mcard__meta-item--due">
|
||||
<UIcon name="i-lucide-file-clock" />
|
||||
rapport dû le {{ formatDay(facts.nextReportDueAt) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="mcard__facts">{{ facts.line }}</p>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mcard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1.125rem 1.25rem;
|
||||
text-decoration: none;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
.mcard__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.mcard__who { flex: 1; min-width: 0; }
|
||||
.mcard__title {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.mcard__holder {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.mcard__domain {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
.mcard__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-accent);
|
||||
background: var(--mood-accent-soft);
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.mcard__tag {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.mcard__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem 1rem;
|
||||
}
|
||||
.mcard__meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.mcard__meta-item--due { color: var(--mood-status-fenetre); font-weight: 600; }
|
||||
.mcard__facts {
|
||||
margin: 0;
|
||||
padding-top: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--mood-text);
|
||||
box-shadow: 0 -1px 0 color-mix(in srgb, var(--mood-accent) 10%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> « Exercice du mandat » (Δ7) — des FAITS COMPTÉS, jamais
|
||||
// synthétisés : décisions tracées, objections listées, rapports rendus/dus.
|
||||
// Le titulaire rend son rapport ici quand il est dû — contenu dépliable.
|
||||
import type { Mandate } from '~/types/domain'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { MANDATE_EXERCISE, STATUS_LABELS } from '~/lexicon'
|
||||
import {
|
||||
OBJECTION_STATUS_LABELS,
|
||||
formatDay,
|
||||
mandateFacts,
|
||||
reportIntervalMs,
|
||||
} from './mandateUi'
|
||||
|
||||
const props = defineProps<{ mandate: Mandate }>()
|
||||
|
||||
const col = useCollectiveStore()
|
||||
|
||||
const facts = computed(() => mandateFacts(props.mandate, col.decisions, col.objections))
|
||||
|
||||
const objectionRows = computed(() =>
|
||||
facts.value.objections.map(o => ({
|
||||
id: o.id,
|
||||
decisionId: o.decisionId,
|
||||
decisionTitle: col.decisions.find(d => d.id === o.decisionId)?.title ?? '—',
|
||||
argument: o.argument,
|
||||
statusLabel: OBJECTION_STATUS_LABELS[o.status],
|
||||
open: o.status === 'open',
|
||||
})),
|
||||
)
|
||||
|
||||
const sortedReports = computed(() =>
|
||||
[...props.mandate.reports].sort((a, b) => (a.dueAt < b.dueAt ? -1 : 1)),
|
||||
)
|
||||
|
||||
/** Le rapport dû maintenant, à rendre par le titulaire (moi). */
|
||||
const dueReport = computed(() => {
|
||||
if (col.me?.id !== props.mandate.holderId) return undefined
|
||||
if (props.mandate.status !== 'active') return undefined
|
||||
return sortedReports.value.find(r => r.deliveredAt === undefined && r.dueAt <= col.now())
|
||||
})
|
||||
|
||||
const reportText = ref('')
|
||||
|
||||
function deliverReport(): void {
|
||||
const target = dueReport.value
|
||||
if (!target || reportText.value.trim().length === 0) return
|
||||
target.deliveredAt = col.now()
|
||||
target.content = reportText.value.trim()
|
||||
// Cadence : le prochain rapport garde l'espacement existant, borné à l'échéance.
|
||||
const interval = reportIntervalMs(props.mandate)
|
||||
if (interval !== undefined) {
|
||||
const nextDue = new Date(new Date(target.dueAt).getTime() + interval).toISOString()
|
||||
if (nextDue < props.mandate.endsAt) props.mandate.reports.push({ dueAt: nextDue })
|
||||
}
|
||||
col.stamp(props.mandate)
|
||||
col.persist()
|
||||
reportText.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<section class="mex ld-card">
|
||||
<h2 class="mex__title">
|
||||
<UIcon name="i-lucide-list-checks" />
|
||||
{{ MANDATE_EXERCISE }}
|
||||
</h2>
|
||||
<p class="mex__facts">{{ facts.line }}</p>
|
||||
|
||||
<!-- Décisions tracées -->
|
||||
<div v-if="facts.traced.length" class="mex__block">
|
||||
<h3 class="mex__block-title">Décisions tracées</h3>
|
||||
<ul class="mex__list">
|
||||
<li v-for="d in facts.traced" :key="d.id">
|
||||
<NuxtLink :to="`/decisions/${d.id}`" class="mex__row">
|
||||
<span class="mex__row-title">{{ d.title }}</span>
|
||||
<span class="status-pill" :class="`status-${d.status}`">
|
||||
{{ STATUS_LABELS[d.status] }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Objections — liste cliquable -->
|
||||
<div v-if="objectionRows.length" class="mex__block">
|
||||
<h3 class="mex__block-title">Objections</h3>
|
||||
<ul class="mex__list">
|
||||
<li v-for="o in objectionRows" :key="o.id">
|
||||
<NuxtLink :to="`/decisions/${o.decisionId}`" class="mex__row">
|
||||
<span class="mex__row-title">{{ o.argument }}</span>
|
||||
<span class="mex__obj-status" :class="{ 'mex__obj-status--open': o.open }">
|
||||
{{ o.statusLabel }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Rapports — rendus dépliables, saisie quand dû -->
|
||||
<div v-if="sortedReports.length || dueReport" class="mex__block">
|
||||
<h3 class="mex__block-title">
|
||||
Rapports — {{ facts.reportsDelivered }}/{{ facts.reportsTotal }} rendus
|
||||
</h3>
|
||||
<ul class="mex__list">
|
||||
<li v-for="r in sortedReports" :key="r.dueAt">
|
||||
<details v-if="r.deliveredAt" class="mex__report">
|
||||
<summary class="mex__report-summary">
|
||||
<UIcon name="i-lucide-file-check-2" />
|
||||
<span>Rapport rendu le {{ formatDay(r.deliveredAt) }}</span>
|
||||
<span class="mex__report-due">dû le {{ formatDay(r.dueAt) }}</span>
|
||||
</summary>
|
||||
<p class="mex__report-content">{{ r.content }}</p>
|
||||
</details>
|
||||
<div v-else class="mex__report mex__report--pending">
|
||||
<UIcon name="i-lucide-file-clock" />
|
||||
<span>Rapport dû le {{ formatDay(r.dueAt) }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form v-if="dueReport" class="mex__deliver" @submit.prevent="deliverReport">
|
||||
<label class="mex__deliver-label" for="mex-report">
|
||||
Ton rapport est dû — quelques phrases suffisent.
|
||||
</label>
|
||||
<textarea
|
||||
id="mex-report"
|
||||
v-model="reportText"
|
||||
rows="4"
|
||||
lang="fr"
|
||||
spellcheck="true"
|
||||
placeholder="Ce qui a été fait, ce qui engage la suite…"
|
||||
/>
|
||||
<button type="submit" class="ld-btn" :disabled="reportText.trim().length === 0">
|
||||
<UIcon name="i-lucide-send" />
|
||||
Rendre le rapport
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mex { padding: 1.375rem 1.5rem; display: flex; flex-direction: column; gap: 1rem; }
|
||||
.mex__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.mex__facts {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.mex__block { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.mex__block-title {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.mex__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.375rem; }
|
||||
.mex__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--mood-accent-soft);
|
||||
text-decoration: none;
|
||||
color: var(--mood-text);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
.mex__row:hover { transform: translateY(-1px); }
|
||||
.mex__row-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mex__obj-status {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.mex__obj-status--open { color: var(--mood-status-fenetre); }
|
||||
.mex__report {
|
||||
border-radius: var(--r-input);
|
||||
background: var(--mood-accent-soft);
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.mex__report-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.mex__report-summary::-webkit-details-marker { display: none; }
|
||||
.mex__report-due { margin-left: auto; font-size: 0.75rem; color: var(--mood-text-muted); }
|
||||
.mex__report-content {
|
||||
margin: 0.625rem 0 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: var(--mood-text);
|
||||
white-space: pre-line;
|
||||
}
|
||||
.mex__report--pending {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-status-fenetre);
|
||||
background: var(--mood-status-fenetre-bg);
|
||||
}
|
||||
.mex__deliver { display: flex; flex-direction: column; gap: 0.625rem; margin-top: 0.375rem; }
|
||||
.mex__deliver-label { font-size: 0.875rem; font-weight: 600; }
|
||||
.mex__deliver textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--mood-text);
|
||||
background: var(--mood-input-bg);
|
||||
border: none;
|
||||
border-radius: var(--r-input);
|
||||
box-shadow: inset 0 0 0 1px var(--mood-input-border);
|
||||
resize: vertical;
|
||||
}
|
||||
.mex__deliver textarea:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--mood-input-focus);
|
||||
}
|
||||
.mex__deliver .ld-btn { align-self: flex-start; }
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Étape 2 du wizard mandat — les 6 modalités de nomination,
|
||||
// avec leurs pédagogies courtes (pro / limite). Pour l'élection sans candidat,
|
||||
// la règle de clôture est affichée : vous départagez, jamais l'outil.
|
||||
import type { NominationMethod } from '~/types/domain'
|
||||
import { NOMINATION_LABELS } from '~/lexicon'
|
||||
import { NOMINATION_PEDAGOGY } from './mandateUi'
|
||||
|
||||
const props = defineProps<{ modelValue: NominationMethod | null }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: NominationMethod): void }>()
|
||||
|
||||
const METHODS = Object.keys(NOMINATION_LABELS) as NominationMethod[]
|
||||
|
||||
function select(method: NominationMethod): void {
|
||||
emit('update:modelValue', method)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<div class="mnom">
|
||||
<button
|
||||
v-for="method in METHODS"
|
||||
:key="method"
|
||||
type="button"
|
||||
class="mnom__card"
|
||||
:class="{ 'mnom__card--active': props.modelValue === method }"
|
||||
@click="select(method)"
|
||||
>
|
||||
<div class="mnom__head">
|
||||
<span class="mnom__icon">
|
||||
<UIcon :name="NOMINATION_PEDAGOGY[method].icon" />
|
||||
</span>
|
||||
<span class="mnom__name">{{ NOMINATION_LABELS[method] }}</span>
|
||||
<UIcon
|
||||
:name="props.modelValue === method ? 'i-lucide-circle-check' : 'i-lucide-circle'"
|
||||
class="mnom__check"
|
||||
/>
|
||||
</div>
|
||||
<p class="mnom__desc">{{ NOMINATION_PEDAGOGY[method].desc }}</p>
|
||||
<p class="mnom__pro">
|
||||
<UIcon name="i-lucide-plus" />
|
||||
{{ NOMINATION_PEDAGOGY[method].pro }}
|
||||
</p>
|
||||
<p class="mnom__con">
|
||||
<UIcon name="i-lucide-minus" />
|
||||
{{ NOMINATION_PEDAGOGY[method].con }}
|
||||
</p>
|
||||
<p v-if="NOMINATION_PEDAGOGY[method].rule" class="mnom__rule">
|
||||
<UIcon name="i-lucide-scale" />
|
||||
{{ NOMINATION_PEDAGOGY[method].rule }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mnom {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.mnom { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
.mnom__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 1rem 1.125rem;
|
||||
text-align: left;
|
||||
background: var(--mood-surface);
|
||||
border-radius: var(--r-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
cursor: pointer;
|
||||
color: var(--mood-text);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.mnom__card:hover { transform: translateY(-3px); box-shadow: var(--shadow-card-hover); }
|
||||
.mnom__card:active { transform: translateY(0); }
|
||||
.mnom__card--active {
|
||||
box-shadow: 0 0 0 2px var(--mood-accent), var(--shadow-card);
|
||||
background: var(--mood-accent-soft);
|
||||
}
|
||||
.mnom__head { display: flex; align-items: center; gap: 0.625rem; }
|
||||
.mnom__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.125rem;
|
||||
height: 2.125rem;
|
||||
border-radius: var(--r-icon);
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-accent);
|
||||
font-size: 1.0625rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mnom__name { flex: 1; font-size: 0.9375rem; font-weight: 700; }
|
||||
.mnom__check { color: var(--mood-accent); flex-shrink: 0; }
|
||||
.mnom__desc { margin: 0; font-size: 0.8438rem; line-height: 1.5; color: var(--mood-text-muted); }
|
||||
.mnom__pro,
|
||||
.mnom__con,
|
||||
.mnom__rule {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.mnom__pro { color: var(--mood-success); }
|
||||
.mnom__con { color: var(--mood-text-muted); }
|
||||
.mnom__rule {
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--mood-status-vote-bg);
|
||||
color: var(--mood-status-vote);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Période du mandat — barre de temps simple : début, fin,
|
||||
// marqueur aujourd'hui. Un fait daté, pas une jauge de quoi que ce soit.
|
||||
import { formatDay } from './mandateUi'
|
||||
|
||||
const props = defineProps<{ startsAt: string; endsAt: string }>()
|
||||
|
||||
const pct = computed(() => {
|
||||
const start = new Date(props.startsAt).getTime()
|
||||
const end = new Date(props.endsAt).getTime()
|
||||
if (end <= start) return 100
|
||||
return Math.min(100, Math.max(0, ((Date.now() - start) / (end - start)) * 100))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<div class="mperiod">
|
||||
<div class="mperiod__track">
|
||||
<div class="mperiod__fill" :style="{ width: `${pct}%` }" />
|
||||
<div class="mperiod__today" :style="{ left: `${pct}%` }" title="aujourd'hui" />
|
||||
</div>
|
||||
<div class="mperiod__dates">
|
||||
<span>{{ formatDay(startsAt) }}</span>
|
||||
<span>{{ formatDay(endsAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mperiod { display: flex; flex-direction: column; gap: 0.375rem; }
|
||||
.mperiod__track {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--mood-accent-soft);
|
||||
}
|
||||
.mperiod__fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
border-radius: 4px 0 0 4px;
|
||||
background: var(--mood-accent);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.mperiod__today {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--mood-accent);
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 0 0 2px var(--mood-surface);
|
||||
}
|
||||
.mperiod__dates {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> « Feux de la rampe » (Δ7) — le flux public des décisions
|
||||
// prises sous ce mandat, chacune avec sa fenêtre : contestables, donc visibles.
|
||||
import type { Mandate } from '~/types/domain'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { MANDATE_SPOTLIGHT, MANDATE_SPOTLIGHT_SUB, STATUS_LABELS } from '~/lexicon'
|
||||
import { formatDay } from './mandateUi'
|
||||
|
||||
const props = defineProps<{ mandate: Mandate }>()
|
||||
|
||||
const col = useCollectiveStore()
|
||||
|
||||
const traced = computed(() =>
|
||||
col.decisions
|
||||
.filter(d => d.underMandateId === props.mandate.id)
|
||||
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<section class="mspot ld-card">
|
||||
<h2 class="mspot__title">
|
||||
<UIcon name="i-lucide-lamp" />
|
||||
{{ MANDATE_SPOTLIGHT }}
|
||||
</h2>
|
||||
<p class="mspot__sub">{{ MANDATE_SPOTLIGHT_SUB }}</p>
|
||||
|
||||
<ul v-if="traced.length" class="mspot__list">
|
||||
<li v-for="d in traced" :key="d.id" class="mspot__item">
|
||||
<NuxtLink :to="`/decisions/${d.id}`" class="mspot__link">
|
||||
<div class="mspot__main">
|
||||
<span class="mspot__decision-title">{{ d.title }}</span>
|
||||
<span class="mspot__date">{{ formatDay(d.decidedAt ?? d.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="mspot__side">
|
||||
<LdCountdown
|
||||
v-if="d.windowEndsAt && !d.decidedAt"
|
||||
:ends-at="d.windowEndsAt"
|
||||
:suspended-at="d.windowSuspendedAt"
|
||||
/>
|
||||
<span class="status-pill" :class="`status-${d.status}`">
|
||||
{{ STATUS_LABELS[d.status] }}
|
||||
</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="mspot__empty">
|
||||
Aucune décision sous ce mandat pour l'instant — les feux s'allumeront à la première trace.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mspot { padding: 1.375rem 1.5rem; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.mspot__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.mspot__sub {
|
||||
margin: -0.375rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.mspot__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.375rem; }
|
||||
.mspot__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--mood-accent-soft);
|
||||
text-decoration: none;
|
||||
color: var(--mood-text);
|
||||
transition: transform 0.1s ease;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mspot__link:hover { transform: translateY(-1px); }
|
||||
.mspot__main { display: flex; flex-direction: column; gap: 0.125rem; min-width: 0; flex: 1; }
|
||||
.mspot__decision-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mspot__date { font-size: 0.75rem; color: var(--mood-text-muted); }
|
||||
.mspot__side { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
|
||||
.mspot__empty { margin: 0; font-size: 0.875rem; color: var(--mood-text-muted); font-style: italic; }
|
||||
</style>
|
||||
@@ -1,138 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Visual timeline for mandate lifecycle steps.
|
||||
*
|
||||
* Displays each step with its type, status, and visual indicators.
|
||||
* Similar pattern to DecisionWorkflow but with mandate-specific step types.
|
||||
*/
|
||||
import type { MandateStep } from '~/stores/mandates'
|
||||
|
||||
const props = defineProps<{
|
||||
steps: MandateStep[]
|
||||
currentStatus: string
|
||||
}>()
|
||||
|
||||
const sortedSteps = computed(() => {
|
||||
return [...props.steps].sort((a, b) => a.step_order - b.step_order)
|
||||
})
|
||||
|
||||
const stepTypeLabel = (stepType: string) => {
|
||||
switch (stepType) {
|
||||
case 'candidacy': return 'Candidature'
|
||||
case 'voting': return 'Vote'
|
||||
case 'active': return 'Actif'
|
||||
case 'reporting': return 'Rapport'
|
||||
case 'completed': return 'Termine'
|
||||
default: return stepType
|
||||
}
|
||||
}
|
||||
|
||||
const stepTypeIcon = (stepType: string) => {
|
||||
switch (stepType) {
|
||||
case 'candidacy': return 'i-lucide-user-plus'
|
||||
case 'voting': return 'i-lucide-vote'
|
||||
case 'active': return 'i-lucide-shield-check'
|
||||
case 'reporting': return 'i-lucide-file-text'
|
||||
case 'completed': return 'i-lucide-check-circle'
|
||||
default: return 'i-lucide-circle'
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="sortedSteps.length === 0" class="text-center py-8">
|
||||
<UIcon name="i-lucide-list-checks" class="text-4xl text-gray-400 mb-3" />
|
||||
<p class="text-gray-500">Aucune etape definie pour ce mandat</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="relative">
|
||||
<!-- Timeline line -->
|
||||
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="step in sortedSteps"
|
||||
:key="step.id"
|
||||
class="relative pl-12"
|
||||
>
|
||||
<!-- Timeline dot -->
|
||||
<div
|
||||
class="absolute left-2 w-5 h-5 rounded-full border-2 flex items-center justify-center"
|
||||
:class="{
|
||||
'bg-green-500 border-green-500': step.status === 'completed',
|
||||
'bg-primary border-primary': step.status === 'active' || step.status === 'in_progress',
|
||||
'bg-yellow-400 border-yellow-400': step.status === 'pending',
|
||||
'bg-red-500 border-red-500': step.status === 'revoked',
|
||||
'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600': step.status === 'draft',
|
||||
}"
|
||||
>
|
||||
<UIcon
|
||||
v-if="step.status === 'completed'"
|
||||
name="i-lucide-check"
|
||||
class="text-white text-xs"
|
||||
/>
|
||||
<UIcon
|
||||
v-else-if="step.status === 'revoked'"
|
||||
name="i-lucide-x"
|
||||
class="text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon :name="stepTypeIcon(step.step_type)" class="text-gray-500" />
|
||||
<span class="text-sm font-mono text-gray-400">Etape {{ step.step_order }}</span>
|
||||
<UBadge variant="subtle" color="neutral" size="xs">
|
||||
{{ stepTypeLabel(step.step_type) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<StatusBadge :status="step.status" type="mandate" />
|
||||
</div>
|
||||
|
||||
<h3 v-if="step.title" class="font-medium text-gray-900 dark:text-white">
|
||||
{{ step.title }}
|
||||
</h3>
|
||||
|
||||
<p v-if="step.description" class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
|
||||
<div class="text-xs text-gray-500">
|
||||
Cree le {{ formatDate(step.created_at) }}
|
||||
</div>
|
||||
|
||||
<div v-if="step.outcome" class="flex items-center gap-2 mt-2">
|
||||
<UIcon name="i-lucide-flag" class="text-gray-400" />
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Resultat : {{ step.outcome }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="step.vote_session_id" class="mt-2">
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="soft"
|
||||
color="primary"
|
||||
icon="i-lucide-vote"
|
||||
label="Voir la session de vote"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Barres horizontales sobres de l'Observatoire — une mesure,
|
||||
// une teinte (l'accent du mood, jamais codée en dur) ; l'identité est portée
|
||||
// par le libellé, la grandeur par la longueur. Extrémité arrondie côté donnée,
|
||||
// base carrée sur la ligne de base (specs dataviz).
|
||||
export interface BarItem {
|
||||
label: string
|
||||
value: number
|
||||
display?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{ items: BarItem[] }>()
|
||||
|
||||
const max = computed(() => Math.max(1, ...props.items.map(i => i.value)))
|
||||
|
||||
function width(value: number): string {
|
||||
if (value <= 0) return '0'
|
||||
return `${Math.max(2, (value / max.value) * 100)}%`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<div class="obars" role="img">
|
||||
<div v-for="item in items" :key="item.label" class="obars__row">
|
||||
<span class="obars__label">{{ item.label }}</span>
|
||||
<span class="obars__plot">
|
||||
<span class="obars__fill" :style="{ width: width(item.value) }" />
|
||||
</span>
|
||||
<span class="obars__value">{{ item.display ?? item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.obars { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.obars__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(5.5rem, 8rem) 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.obars__label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.obars__plot {
|
||||
position: relative;
|
||||
height: 14px;
|
||||
/* ligne de base : filet discret, une teinte du mood */
|
||||
box-shadow: inset 1px 0 0 color-mix(in srgb, var(--mood-text-muted) 35%, transparent);
|
||||
}
|
||||
.obars__fill {
|
||||
position: absolute;
|
||||
inset: 1px auto 1px 0;
|
||||
background: var(--mood-accent);
|
||||
border-radius: 0 4px 4px 0; /* extrémité donnée arrondie, base carrée */
|
||||
}
|
||||
.obars__value {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Section de l'Observatoire — carte titrée, ton curieux.
|
||||
defineProps<{ title: string; icon: string; sub?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<section class="osec ld-card">
|
||||
<h2 class="osec__title">
|
||||
<UIcon :name="icon" />
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="sub" class="osec__sub">{{ sub }}</p>
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.osec {
|
||||
padding: 1.25rem 1.375rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
.osec__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.osec__title :deep(svg),
|
||||
.osec__title :deep(span[class^='i-']) { color: var(--mood-accent); }
|
||||
.osec__sub {
|
||||
margin: -0.5rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
font-style: italic;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Tuile de stat de l'Observatoire — libellé, valeur, note.
|
||||
// Chiffres proportionnels (pas de tabular à cette taille), encre = tokens texte.
|
||||
defineProps<{ label: string; value: string; hint?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<div class="ostat">
|
||||
<p class="ostat__label">{{ label }}</p>
|
||||
<p class="ostat__value">{{ value }}</p>
|
||||
<p v-if="hint" class="ostat__hint">{{ hint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ostat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: var(--r-icon);
|
||||
background: var(--mood-accent-soft);
|
||||
min-width: 0;
|
||||
}
|
||||
.ostat__label {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.ostat__value {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
line-height: 1.15;
|
||||
}
|
||||
.ostat__hint { margin: 0; font-size: 0.75rem; color: var(--mood-text-muted); }
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Shared UI helpers of the mandate screens (carte, fiche, wizard) — v2.
|
||||
* INVARIANT Δ7 : no gauge, no score, no aggregate on a mandate — COUNTED
|
||||
* FACTS only, never synthesized. The lexicon stays the single source for
|
||||
* everything it already names (MANDATE_EXERCISE, NOMINATION_LABELS…).
|
||||
*/
|
||||
|
||||
import type {
|
||||
Decision,
|
||||
Id,
|
||||
Mandate,
|
||||
MandateReport,
|
||||
NominationMethod,
|
||||
Objection,
|
||||
} from '~/types/domain'
|
||||
|
||||
/** Mandate statuses in plain French — proposé / actif / expiré / révoqué. */
|
||||
export const MANDATE_STATUS_LABELS: Record<Mandate['status'], string> = {
|
||||
proposed: 'proposé',
|
||||
active: 'actif',
|
||||
expired: 'expiré',
|
||||
revoked: 'révoqué',
|
||||
}
|
||||
|
||||
/** Global status-pill classes reused for mandates (moods.css). */
|
||||
export const MANDATE_STATUS_PILL: Record<Mandate['status'], string> = {
|
||||
proposed: 'status-draft',
|
||||
active: 'status-adopted',
|
||||
expired: 'status-closed',
|
||||
revoked: 'status-revoked',
|
||||
}
|
||||
|
||||
/** Objection statuses in plain French (fiche mandat — listes cliquables). */
|
||||
export const OBJECTION_STATUS_LABELS: Record<Objection['status'], string> = {
|
||||
open: 'ouverte',
|
||||
withdrawn: 'retirée',
|
||||
integrated: 'intégrée',
|
||||
escalated: 'escaladée',
|
||||
}
|
||||
|
||||
/** Counted facts of a mandate — raw numbers, never a synthesis. */
|
||||
export interface MandateFacts {
|
||||
traced: Decision[]
|
||||
objections: Objection[]
|
||||
reportsDelivered: number
|
||||
reportsTotal: number
|
||||
nextReportDueAt?: string
|
||||
line: string
|
||||
}
|
||||
|
||||
export function mandateFacts(
|
||||
mandate: Mandate,
|
||||
decisions: Decision[],
|
||||
objections: Objection[],
|
||||
): MandateFacts {
|
||||
const traced = decisions
|
||||
.filter(d => d.underMandateId === mandate.id)
|
||||
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
|
||||
const tracedIds = new Set<Id>(traced.map(d => d.id))
|
||||
const own = objections.filter(o => tracedIds.has(o.decisionId))
|
||||
const delivered = mandate.reports.filter(r => r.deliveredAt !== undefined).length
|
||||
const total = mandate.reports.length
|
||||
const next = mandate.reports
|
||||
.filter(r => r.deliveredAt === undefined)
|
||||
.sort((a, b) => (a.dueAt < b.dueAt ? -1 : 1))[0]
|
||||
const parts = [
|
||||
`${traced.length} décision${traced.length > 1 ? 's' : ''} tracée${traced.length > 1 ? 's' : ''}`,
|
||||
`${own.length} objection${own.length > 1 ? 's' : ''}`,
|
||||
]
|
||||
if (total > 0) parts.push(`${delivered}/${total} rapport${total > 1 ? 's' : ''}`)
|
||||
const facts: MandateFacts = {
|
||||
traced,
|
||||
objections: own,
|
||||
reportsDelivered: delivered,
|
||||
reportsTotal: total,
|
||||
line: parts.join(' · '),
|
||||
}
|
||||
if (next) facts.nextReportDueAt = next.dueAt
|
||||
return facts
|
||||
}
|
||||
|
||||
/** Next report cadence, inferred from the existing dueAt spacing. */
|
||||
export function reportIntervalMs(mandate: Mandate): number | undefined {
|
||||
const dues = mandate.reports.map((r: MandateReport) => r.dueAt).sort()
|
||||
const last = dues[dues.length - 1]
|
||||
if (last === undefined) return undefined
|
||||
const previous = dues.length >= 2 ? dues[dues.length - 2]! : mandate.startsAt
|
||||
const gap = new Date(last).getTime() - new Date(previous).getTime()
|
||||
return gap > 0 ? gap : undefined
|
||||
}
|
||||
|
||||
/** Short pedagogy of the 6 nomination modalities (wizard step 2). */
|
||||
export interface NominationPedagogy {
|
||||
icon: string
|
||||
desc: string
|
||||
pro: string
|
||||
con: string
|
||||
rule?: string
|
||||
}
|
||||
|
||||
export const ELECTION_CLOSE_RULE
|
||||
= 'Pluralité, blanc possible — en cas d\'égalité, vous départagez, jamais l\'outil.'
|
||||
|
||||
export const NOMINATION_PEDAGOGY: Record<NominationMethod, NominationPedagogy> = {
|
||||
'ratified-self': {
|
||||
icon: 'i-lucide-user-check',
|
||||
desc: 'Tu te proposes toi-même ; le cercle ratifie par consentement.',
|
||||
pro: 'le plus rapide — l\'élan vient de la personne',
|
||||
con: 'suppose un vrai espace d\'objection',
|
||||
},
|
||||
'election-no-candidate': {
|
||||
icon: 'i-lucide-vote',
|
||||
desc: 'Chacun désigne la personne qu\'il juge la plus indiquée — personne ne se déclare.',
|
||||
pro: 'révèle la légitimité réelle, sans campagne',
|
||||
con: 'demande un cercle qui se connaît',
|
||||
rule: ELECTION_CLOSE_RULE,
|
||||
},
|
||||
'nuanced-vote': {
|
||||
icon: 'i-lucide-list-ordered',
|
||||
desc: 'Chaque nom proposé reçoit une nuance, de « Pas du tout » à « Tout à fait ».',
|
||||
pro: 'mesure l\'adhésion, pas seulement la préférence',
|
||||
con: 'plus long à dépouiller qu\'un consentement',
|
||||
},
|
||||
'consent': {
|
||||
icon: 'i-lucide-handshake',
|
||||
desc: 'La nomination passe si aucune objection argumentée ne tient.',
|
||||
pro: 'sobre et robuste — l\'objection nourrit la décision',
|
||||
con: 'le silence doit pouvoir être un vrai oui',
|
||||
},
|
||||
'draw': {
|
||||
icon: 'i-lucide-dices',
|
||||
desc: 'Le sort désigne parmi les volontaires du cercle électeur.',
|
||||
pro: 'égalité parfaite — casse les notabilités',
|
||||
con: 'demande d\'accompagner la personne tirée',
|
||||
},
|
||||
'rotation': {
|
||||
icon: 'i-lucide-rotate-ccw',
|
||||
desc: 'Le mandat tourne à échéance fixe entre les membres du cercle.',
|
||||
pro: 'le pouvoir circule par construction',
|
||||
con: 'transmission à soigner à chaque tour',
|
||||
},
|
||||
}
|
||||
|
||||
/** jj mois aaaa — the one date format of the mandate screens. */
|
||||
export function formatDay(iso: string | undefined): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric', month: 'short', year: 'numeric',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
<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"
|
||||
:title="confirmEntry ? `Supprimer « ${confirmEntry.name} » ?` : 'Supprimer'"
|
||||
description="Ce geste supprime uniquement la copie locale, sur cette machine."
|
||||
>
|
||||
<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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user