forked from yvv/decision
Compare commits
37
Commits
224e5b0f5e
..
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 | ||
|
|
3b339b643c | ||
|
|
3ba9c43ce3 | ||
|
|
1f92f153c5 | ||
|
|
9b6322c546 | ||
|
|
9a8f10efdf | ||
|
|
e2ae8b196e | ||
|
|
5c51cffc93 | ||
|
|
428299c9c8 | ||
|
|
fc84600f97 | ||
|
|
79e468b40f |
@@ -25,6 +25,7 @@ Thumbs.db
|
|||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
|
*.db.bak*
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|
||||||
# Docker volumes
|
# Docker volumes
|
||||||
@@ -40,3 +41,4 @@ htmlcov/
|
|||||||
|
|
||||||
# IPFS
|
# IPFS
|
||||||
ipfs-data/
|
ipfs-data/
|
||||||
|
docs/*.pdf
|
||||||
|
|||||||
+23
-7
@@ -25,6 +25,8 @@ steps:
|
|||||||
- name: test-frontend
|
- name: test-frontend
|
||||||
image: node:20-slim
|
image: node:20-slim
|
||||||
commands:
|
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
|
- cd frontend
|
||||||
- npm ci
|
- npm ci
|
||||||
- npm run build
|
- npm run build
|
||||||
@@ -63,8 +65,9 @@ steps:
|
|||||||
- apk add --no-cache curl
|
- apk add --no-cache curl
|
||||||
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
|
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
|
||||||
- mkdir -p .reports
|
- mkdir -p .reports
|
||||||
- syft libredecision-backend:latest -o cyclonedx-json --file .reports/sbom-backend.cyclonedx.json
|
# spec 1.5 épinglée : syft « latest » émet plus récent que ce que le trivy du runner décode
|
||||||
- syft libredecision-frontend:latest -o cyclonedx-json --file .reports/sbom-frontend.cyclonedx.json
|
- 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"
|
- echo "SBOM generes"
|
||||||
|
|
||||||
# NOTE: volumes + pas de from_secret : compatible
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
@@ -80,8 +83,12 @@ steps:
|
|||||||
- echo "Scan CVE termine"
|
- echo "Scan CVE termine"
|
||||||
|
|
||||||
# NOTE: from_secret + pas de volumes : compatible
|
# 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
|
- name: sbom-publish
|
||||||
image: alpine/curl:latest
|
image: alpine/curl:latest
|
||||||
|
failure: ignore
|
||||||
depends_on:
|
depends_on:
|
||||||
- sbom-scan
|
- sbom-scan
|
||||||
environment:
|
environment:
|
||||||
@@ -153,6 +160,10 @@ steps:
|
|||||||
- chmod 600 /opt/libredecision/.env
|
- chmod 600 /opt/libredecision/.env
|
||||||
- cp docker/docker-compose.yml /opt/libredecision/docker-compose.yml
|
- cp docker/docker-compose.yml /opt/libredecision/docker-compose.yml
|
||||||
# Arreter avant le challenge ACME : libere le webroot pour sonic-acme-1
|
# 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
|
- cd /opt/libredecision && docker compose stop
|
||||||
- |
|
- |
|
||||||
DOMAIN=$(grep '^APP_DOMAIN=' /opt/libredecision/.env | cut -d= -f2)
|
DOMAIN=$(grep '^APP_DOMAIN=' /opt/libredecision/.env | cut -d= -f2)
|
||||||
@@ -163,13 +174,18 @@ steps:
|
|||||||
--webroot /usr/share/nginx/html \
|
--webroot /usr/share/nginx/html \
|
||||||
--server letsencrypt \
|
--server letsencrypt \
|
||||||
--accountemail support+acme@asycn.io || ACME_EXIT=$?
|
--accountemail support+acme@asycn.io || ACME_EXIT=$?
|
||||||
if [ "$ACME_EXIT" -ne 0 ] && [ "$ACME_EXIT" -ne 2 ]; then
|
if [ "$ACME_EXIT" -eq 0 ] || [ "$ACME_EXIT" -eq 2 ]; then
|
||||||
echo "ERREUR: acme.sh a echoue (exit $ACME_EXIT)"
|
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
|
exit 1
|
||||||
fi
|
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
|
# 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 up -d --remove-orphans
|
||||||
- cd /opt/libredecision && docker compose ps
|
- cd /opt/libredecision && docker compose ps
|
||||||
|
|||||||
@@ -1,75 +1,73 @@
|
|||||||
# libreDecision
|
# libreDecision
|
||||||
|
|
||||||
Boîte à outils de gouvernance collective pour la communauté Duniter/G1.
|
Boîte à outils de la première démocratie — décider ensemble, du trio à la population.
|
||||||
Documents modulaires sous vote permanent + protocoles de vote + mandats.
|
v2 local-first : 6 routes de décision, 5 modalités, documents sous vote permanent, mandats,
|
||||||
Architecture marque blanche — vocation à être intégré dans sweethomeCloud et librodrome.
|
observatoire. Marque blanche par construction (seeds Ğ1 + Atelier du Canal).
|
||||||
|
**Référence produit : `docs/dev/BLUEPRINT-V2.md`** — toute décision de comportement s'y adosse.
|
||||||
|
|
||||||
## Protocole de début de session
|
## Protocole de début de session
|
||||||
|
|
||||||
1. `git pull --rebase origin main`
|
1. `git pull --rebase origin main`
|
||||||
2. Si des migrations DB sont attendues : `cd backend && alembic upgrade head`
|
2. Si l'objectif de la session n'est pas précisé, le demander
|
||||||
3. Si l'objectif de la session n'est pas précisé, le demander
|
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Frontend** : Nuxt 4 (Vue 3, TypeScript) + Nuxt UI v3 + Pinia + UnoCSS ; package manager : npm
|
- **Frontend** : Nuxt 4 (Vue 3, TypeScript, SPA — SSR off) + Nuxt UI v3 + Pinia + UnoCSS ;
|
||||||
- **Backend** : Python FastAPI + SQLAlchemy 2.0 async + PostgreSQL asyncpg ; migrations Alembic
|
package manager : npm ; port **3002** strict
|
||||||
- **Auth** : Duniter V2 Ed25519 challenge-response (substrate-interface — stub en dev)
|
- **Local-first** : IndexedDB (idb-keyval) via `app/data/persistence.ts` — une clé
|
||||||
- **Sanctuaire** : IPFS kubo + hash on-chain (system.remark) — TODO sprint 2
|
`ld2:<collectiveId>` par collectif, export/import de bundles JSON, seeds par le même chemin
|
||||||
- Déploiement : Docker multi-stage + Traefik (postgres + backend + frontend + ipfs) ; CI Woodpecker
|
- **Backend v1** (FastAPI + SQLAlchemy async, port 8002) : conservé pour la **future synchro**
|
||||||
|
— non requis pour le dev frontend ; `runtimeConfig.public.apiBase` gardé dans nuxt.config
|
||||||
|
en prévision
|
||||||
|
- Déploiement : Docker multi-stage + Traefik ; CI Woodpecker
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/
|
frontend/
|
||||||
app/
|
app/
|
||||||
components/ # composants Vue
|
engine/ # moteurs PURS (zéro dépendance Nuxt) : threshold (+electionResult),
|
||||||
layouts/ # layouts Nuxt
|
# nuanced, modeParams, parametric (+crystallize), settings, state (canTransition),
|
||||||
pages/ # routing file-based (9 pages sprint 1)
|
# triage, impact — import UNIQUEMENT via le barrel ~/engine
|
||||||
composables/ # (1 composable sprint 1)
|
data/
|
||||||
stores/ # 5 Pinia stores (auth, ...)
|
persistence.ts # IndexedDB local-first
|
||||||
assets/css/
|
templates.ts # 7 gabarits de collectif (invariant : protocole Consentement partout)
|
||||||
moods.css # système de palettes (.mood-* sur <html>)
|
seeds/ # duniter-g1.bundle.json (324 Ko) + atelier-du-canal.bundle.json
|
||||||
utils/ # (2 utils sprint 1)
|
stores/
|
||||||
nuxt.config.ts # port 3002, host 0.0.0.0, apiBase via NUXT_PUBLIC_API_BASE
|
collective.ts # le tenant : collectif actif, état complet, cycle de vie des bundles
|
||||||
backend/
|
decisions.ts # façade d'actions sans état propre (LWW updatedAt, persist debounced)
|
||||||
app/
|
composables/ # useMood (useLibreMood), useFeed (le Fil = sélecteur pur), useSearch
|
||||||
routers/ # 8 routers : auth, communes, documents, protocols, votes, ...
|
components/ # par domaine : chemin/ decisions/ votes/ texts/ mandates/ feed/
|
||||||
services/ # 6 services
|
# onboarding/ common/ (primitives Ld*)
|
||||||
engine/ # 5 modules : formule inertie, critères Smith/TechComm, médiane
|
pages/ # 14 routes : / · decider · decisions (+[id], vote, observatoire) ·
|
||||||
models/ # 14 tables SQLAlchemy
|
# textes (+[slug], formules) · mandats (+[id], nouveau) ·
|
||||||
alembic/versions/ # migrations
|
# creer (layout bare) · donnees
|
||||||
tests/ # 186 tests (63 intégration TDD sprint 1)
|
lexicon.ts # SOURCE UNIQUE des libellés FR + FORBIDDEN_UI_TERMS
|
||||||
seed.py # Engagement Certification (33 items) + Forgeron (51 items) + Runtime Upgrade
|
types/domain.ts # le noyau du modèle
|
||||||
docker/
|
tests/ # 342 tests vitest : engine/, stores/, seeds/ (non-perte), lexicon.spec.ts
|
||||||
docker-compose.yml # postgres + backend + frontend + ipfs
|
backend/ # v1 FastAPI conservé pour la synchro future — ne pas développer dessus
|
||||||
backend.Dockerfile
|
scripts/export_seed_bundle.py # extrait seed.py → duniter-g1.bundle.json (source vivante = bundle)
|
||||||
frontend.Dockerfile
|
docker/ # compose + Dockerfiles
|
||||||
docs/content/ # 7 docs dev + 8 docs user
|
docs/dev/BLUEPRINT-V2.md # la référence produit v2
|
||||||
public/
|
public/hexagram-tsing*.svg # sceau 井 (#48 Tsing)
|
||||||
hexagram-tsing.svg # sceau 井 (embossed)
|
|
||||||
hexagram-tsing-flat.svg
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Données runtime
|
## Données runtime
|
||||||
|
|
||||||
- **postgres-data** : volume Docker PostgreSQL — jamais écrasé par les builds
|
- **postgres-data** / **ipfs-data** : volumes Docker du backend v1 — jamais écrasés
|
||||||
- **ipfs-data** : volume Docker IPFS kubo
|
- `.env` racine : secrets backend — jamais commité
|
||||||
- `.env` à la racine : `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `SECRET_KEY`, `DOMAIN`, `DUNITER_RPC_URL`
|
|
||||||
|
|
||||||
## Commandes
|
## Commandes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
# Frontend (tout le dev v2)
|
||||||
cd backend && . venv/bin/activate
|
|
||||||
uvicorn app.main:app --port 8002 --host 0.0.0.0 --reload
|
|
||||||
pytest tests/ -v
|
|
||||||
alembic upgrade head
|
|
||||||
python seed.py # reseed Engagement Certification + Forgeron + Runtime Upgrade
|
|
||||||
|
|
||||||
# Frontend
|
|
||||||
cd frontend && npm run dev # :3002
|
cd frontend && npm run dev # :3002
|
||||||
npm run build
|
npm test # vitest — 342 tests, référence à maintenir
|
||||||
|
npm run build # zéro erreur exigé avant commit
|
||||||
|
|
||||||
|
# Seeds : le bundle est la source vivante ; seed.py (backend) est remplacé par
|
||||||
|
cd backend && .venv/bin/python scripts/export_seed_bundle.py # régénère duniter-g1.bundle.json
|
||||||
|
# → test de non-perte bloquant : tests/seeds/duniter-g1.spec.ts
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker compose -f docker/docker-compose.yml up
|
docker compose -f docker/docker-compose.yml up
|
||||||
@@ -78,11 +76,25 @@ docker compose -f docker/docker-compose.yml up
|
|||||||
## Conventions / pièges
|
## Conventions / pièges
|
||||||
|
|
||||||
- **UI français, code anglais** (variables, commentaires, docstrings)
|
- **UI français, code anglais** (variables, commentaires, docstrings)
|
||||||
- **API** : préfixe `/api/v1/`, Pydantic v2 pour tous les schémas, async partout (SQLAlchemy + FastAPI)
|
- **lexicon.ts d'abord** : aucun libellé UI en dur si le lexicon peut le porter ; ton = tutoyer
|
||||||
- **Auth** : `get_current_admin` (24h), `get_current_citizen` (4h), `require_super_admin`
|
la personne, sujet grammatical = la personne ou le collectif, jamais la formule ni le système
|
||||||
- **Formule inertie** : `Result = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)` — voir `backend/app/engine/`
|
- **Anti-lexique** (`tests/lexicon.spec.ts`) : `FORBIDDEN_UI_TERMS` scanné sur lexicon +
|
||||||
- **Mood system** : `useMood.ts` synchronise `colorMode.preference` avec la palette — **jamais** de `:global()` dans `<style scoped>` pour les styles mood-dépendants (causa le bug dark mode veil)
|
templates `.vue` (insensible casse/accents) — « triage », « verdict », « déléguer »… restent
|
||||||
- **Sceau** `井` (#48 Tsing) : `.app-seal` dans `app.vue`, right-aligned ; SVGs dans `public/`
|
des identifiants de code, jamais des textes affichés
|
||||||
- **CSS drop-shadow()** safe pour effets emboss ; `<filter>` SVG inline cause des artefacts de rendu
|
- **Invariants doctrinaux** : jamais de score sur une personne (attributs jamais affichés à
|
||||||
|
autrui, re-votes visibles du seul auteur) ; jamais de contenu adopté sans geste humain
|
||||||
|
(cristallisation, clôture de dossier, départage = gestes datés) ; repli universel → consent ;
|
||||||
|
dérogation asymétrique (alourdir libre, alléger motivé)
|
||||||
|
- **Budget 400 lignes max par page** (`app/pages/`)
|
||||||
|
- **Moteurs purs** : engine/ sans Nuxt/Pinia/DOM, import via `~/engine` seulement ; stores en
|
||||||
|
imports explicites (testables sous vitest nu) ; toute mutation d'état passe par
|
||||||
|
`canTransition(decision, to, ctx)`
|
||||||
|
- **Formule inertie** : `R = C + B^W + (M + (1−M)(1−(T/W)^G))·max(0, T−C)` —
|
||||||
|
`frontend/app/engine/threshold.ts` ; l'Atelier (`/textes/formules`) est la seule maison des
|
||||||
|
lettres W/T/M/B/G/C et de KaTeX
|
||||||
|
- **Mood system** : `useLibreMood()` (`composables/useMood.ts`) — **jamais** de `:global()`
|
||||||
|
dans `<style scoped>` pour les styles mood-dépendants (causa le bug dark mode veil)
|
||||||
|
- **Sceau** `井` (#48 Tsing) : `LdSeal` dans les layouts ; SVGs dans `public/`
|
||||||
|
- **CSS `drop-shadow()`** safe pour l'emboss ; `<filter>` SVG inline cause des artefacts
|
||||||
|
- **Composants** : `pathPrefix: false` — noms de fichiers uniques, auto-import
|
||||||
- **Domaine** : decision.librodrome.org (Woodpecker CI ; ancien dossier : Glibredecision)
|
- **Domaine** : decision.librodrome.org (Woodpecker CI ; ancien dossier : Glibredecision)
|
||||||
- **Ed25519 verification** : stub en dev (substrate-interface), autoritaire en prod — ne pas bypasser sans test
|
|
||||||
|
|||||||
+68
-29
@@ -1,52 +1,91 @@
|
|||||||
# Contribuer à libreDecision
|
# Contribuer à libreDecision
|
||||||
|
|
||||||
|
La référence produit est `docs/dev/BLUEPRINT-V2.md` — toute contribution qui touche au
|
||||||
|
comportement doit s'y conformer (ou proposer un amendement du blueprint, argumenté).
|
||||||
|
|
||||||
## Environnement
|
## Environnement
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend (Python 3.11+)
|
# Frontend — suffit pour tout le développement v2 (local-first, aucun serveur requis)
|
||||||
cd backend
|
|
||||||
python -m venv .venv && source .venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
alembic upgrade head
|
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload
|
|
||||||
|
|
||||||
# Frontend (Node 20+)
|
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev # http://localhost:3002 — port strict, jamais de fallback
|
||||||
|
npm test # vitest — 342 tests
|
||||||
|
npm run build # doit passer sans erreur avant tout commit
|
||||||
|
|
||||||
|
# Backend v1 (optionnel — conservé pour la future synchro)
|
||||||
|
cd backend
|
||||||
|
python -m venv venv && source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **UI** : français — **Code** : anglais (variables, commentaires, docstrings)
|
- **UI en français, code en anglais** (variables, commentaires, docstrings).
|
||||||
- **CSS** : scoped, sans bordures (`border: none`), profondeur via `box-shadow`
|
- **`app/lexicon.ts` est la source unique des libellés français.** Aucun libellé UI en dur
|
||||||
- **Composants** : `pathPrefix: false` — noms courts, auto-import
|
dans un composant s'il existe (ou devrait exister) dans le lexicon. Ton : tutoyer la
|
||||||
- **API** : versionnée `/api/v1/`, Pydantic v2, async partout
|
personne dans ses gestes ; actes collectifs à l'infinitif ; le sujet grammatical est
|
||||||
- **Ports stricts** : frontend=3002, backend=8002 — jamais de fallback
|
toujours la personne ou le collectif — jamais la formule ni le système.
|
||||||
|
- **Test anti-lexique** (`tests/lexicon.spec.ts`) : la liste `FORBIDDEN_UI_TERMS` interdit
|
||||||
|
certains mots dans toute chaîne visible (lexicon + templates `.vue`), insensible à la casse
|
||||||
|
et aux accents. « Triage », « verdict », « déléguer »… peuvent exister comme identifiants de
|
||||||
|
code, jamais à l'écran.
|
||||||
|
- **CSS** : borderless (profondeur via background + box-shadow), palettes par classes
|
||||||
|
`.mood-*` sur `<html>`, composants natifs (`<input>`, `<select>`, `<button>` custom).
|
||||||
|
- **Composants** : `pathPrefix: false` — noms de fichiers uniques et parlants, auto-import.
|
||||||
|
|
||||||
## Architecture toolbox
|
## Invariants doctrinaux (non négociables)
|
||||||
|
|
||||||
Chaque section expose une `<SectionLayout>` avec :
|
- **Jamais de score sur une personne.** Pas de note, pas de réputation, pas d'agrégat
|
||||||
- Contenu principal (slot `#default`)
|
individuel. `Person.attributes` ne sont jamais affichés à autrui ni agrégés ; l'historique
|
||||||
- Boîte à outils sticky (slot `#toolbox`) — 30rem, flottante, zéro scroll
|
de re-vote n'est visible que de son auteur.
|
||||||
|
- **Jamais de contenu adopté sans geste humain.** Le moteur constate des seuils sur des
|
||||||
|
contenus formulés par des humains ; la cristallisation paramétrique, la clôture d'un
|
||||||
|
dossier découpé, le départage d'une égalité sont des gestes datés — jamais des automatismes.
|
||||||
|
- **Le repli universel est le consentement** : tout collectif a un protocole Consentement
|
||||||
|
(`protocolByRange.consent`), tout repli de résolution y mène.
|
||||||
|
- **Dérogation asymétrique** : alourdir libre (1 tap journalisé) ; alléger = note obligatoire
|
||||||
|
+ fenêtre d'objection.
|
||||||
|
- **Budget 400 lignes maximum par page** (`app/pages/`). Au-delà, extraire des composants.
|
||||||
|
- **Moteurs purs** : `app/engine/` ne dépend de rien (ni Nuxt, ni Pinia, ni DOM), s'importe
|
||||||
|
uniquement via le barrel `~/engine`, et chaque module a ses tests vitest. Les stores
|
||||||
|
s'écrivent avec des imports explicites (pas d'auto-import Nuxt) pour rester testables sous
|
||||||
|
vitest pur.
|
||||||
|
|
||||||
Composants toolbox :
|
## Ajouter une modalité de vote
|
||||||
- `ToolboxSection` : accordéon collapsible générique
|
|
||||||
- `ToolboxVignette` : carte compacte avec bullets toggleables
|
1. `app/types/domain.ts` — étendre `VoteMethod` et les types de session/vote nécessaires.
|
||||||
- `toolbox/ContextMapper` : recommandeur de méthode (4 questions → méthode optimale)
|
2. `app/engine/` — le calcul dans un module pur + ses tests (`tests/engine/`). Exporter via
|
||||||
- `toolbox/SocioElection` : guide élection sociocratique + advice process
|
`engine/index.ts`.
|
||||||
- `toolbox/WorkflowMilestones` : jalons de protocole (Ostrom)
|
3. `app/engine/state.ts` — la règle de clôture (automatique à `closesAt`, ou geste humain).
|
||||||
|
4. `app/lexicon.ts` — les libellés (et vérifier l'anti-lexique).
|
||||||
|
5. `app/components/votes/VoteX.vue` — l'écran de la modalité, branché dans
|
||||||
|
`pages/decisions/[id]/vote.vue`.
|
||||||
|
6. Seeds et gabarits — un protocole nommé qui la référence (le modifier = amender le Pacte).
|
||||||
|
|
||||||
|
## Ajouter un gabarit de collectif
|
||||||
|
|
||||||
|
Dans `app/data/templates.ts` : une entrée `TEMPLATE_CARDS` + le montage dans
|
||||||
|
`buildTemplateBundle()`. Invariants : protocole Consentement au minimum, clause A1
|
||||||
|
(« Notre finalité »), préambule Autonomie–Équilibre–Liaison proposé et modifiable, cercles
|
||||||
|
typés `kind`. Un bundle seed passe par le même `importBundle()` que l'import utilisateur.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend && pytest tests/ -v
|
cd frontend && npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
186 tests, zéro dette technique acceptée depuis le sprint 1.
|
- `tests/engine/` — les moteurs purs (seuils, triage, états, médiane, impact…).
|
||||||
|
- `tests/stores/` — la façade decisions sous Pinia nu.
|
||||||
|
- `tests/seeds/` — tests de non-perte : le bundle Ğ1 doit conserver 33+59 clauses, décomptes
|
||||||
|
et provenance exacts ; l'Atelier du Canal doit couvrir tous les états.
|
||||||
|
- `tests/lexicon.spec.ts` — l'anti-lexique.
|
||||||
|
|
||||||
## Formule de vote inertiel
|
Le compteur de référence est **342 tests verts**. Toute PR qui en casse un est refusée ;
|
||||||
|
toute fonctionnalité de moteur arrive avec les siens.
|
||||||
|
|
||||||
`R = C + B^W + (M + (1-M)·(1-(T/W)^G))·max(0, T-C)`
|
## Build
|
||||||
|
|
||||||
Voir `docs/content/dev/` pour la documentation complète.
|
`npm run build` doit rester vert (zéro erreur, zéro warning nouveau) avant chaque commit.
|
||||||
|
Vérifier mobile + desktop (zéro régression CSS).
|
||||||
|
|||||||
@@ -1,32 +1,113 @@
|
|||||||
# libreDecision
|
# libreDecision
|
||||||
|
|
||||||
Plateforme de décisions collectives pour la communauté Duniter/G1.
|
Boîte à outils de la première démocratie — décider ensemble, du trio à la population.
|
||||||
Boîte à outils gouvernance multi-collectifs, architecture white-label.
|
|
||||||
|
libreDecision fait de la décision un geste quotidien : chaque question tient en une phrase,
|
||||||
|
l'outil propose le chemin le plus court — jamais plus lourd que l'enjeu — et tout ce qui est
|
||||||
|
décidé reste révisable, mesuré, consigné. Le même outil sert un trio d'amis, une association,
|
||||||
|
une coopérative ou la toile de confiance Ğ1 (marque blanche par construction).
|
||||||
|
|
||||||
|
## Manifeste
|
||||||
|
|
||||||
|
> Personne n'a jamais vécu en démocratie — alors nous la rêvons de jour, et nous la commençons
|
||||||
|
> à notre échelle. libreDecision attrape chaque décision en une phrase et propose le chemin le
|
||||||
|
> plus court : décide seul et trace-le, décide sous mandat, écoute puis décide, ou décidez
|
||||||
|
> ensemble — jamais plus lourd que l'enjeu. Ici on ne délègue pas, on mandate, et on reprend ;
|
||||||
|
> on n'est pas consulté, on s'instruit, puis on se prononce ; on ne s'affronte pas en deux
|
||||||
|
> camps, on règle des curseurs — et la position de chacun reste visible dans le faisceau, la
|
||||||
|
> médiane n'écrase personne, la dernière main est toujours humaine. Les concernés en premier
|
||||||
|
> lieu ont voix par construction, chacun voit pourquoi ; lever la main « ça me concerne » est
|
||||||
|
> un droit, pas une demande. Chaque décision dit ce qu'elle engage, ce qu'elle cherche à
|
||||||
|
> changer, et repasse l'épreuve du réel : rien n'est définitif, toute décision en vigueur garde
|
||||||
|
> sa poignée, se tromper est une donnée qu'on consigne. Les règles du jeu elles-mêmes — le
|
||||||
|
> Pacte — s'écrivent au fil de l'eau, par ceux qu'elles gouvernent, sous les boussoles
|
||||||
|
> Autonomie, Équilibre, Liaison. Du trio d'amis à la toile Ğ1 : le même geste, dix secondes,
|
||||||
|
> et l'observatoire qui apprend de nous. La ville change, le puits demeure.
|
||||||
|
|
||||||
|
## Les 6 routes de décision
|
||||||
|
|
||||||
|
Le triage (moteur pur, toujours expliqué en une phrase, toujours contournable) oriente chaque
|
||||||
|
décision vers l'une de six routes :
|
||||||
|
|
||||||
|
| Route | Geste |
|
||||||
|
|---|---|
|
||||||
|
| **solo** | « Personne d'autre n'est concerné — décide. » 2 gestes, moins de 10 secondes, trace facultative |
|
||||||
|
| **record** | « Déjà tranché — je le consigne. » L'observatoire en un geste, adopté immédiatement |
|
||||||
|
| **mandate** | « Ton mandat couvre — décide, c'est tracé. » Trace obligatoire + fenêtre d'objection 48 h |
|
||||||
|
| **transmit** | « Le mandat d'un autre couvre — transmets-lui. » (ou conteste le mandat) |
|
||||||
|
| **advice** | « Réversible et petit cercle — demande leur avis, puis décide. » 72 h |
|
||||||
|
| **collective** | « Nous décidons. » Formulation, liste des concernés arrêtée, session de vote |
|
||||||
|
|
||||||
|
Dérogation asymétrique : alourdir est libre (1 tap journalisé) ; alléger exige une note motivée
|
||||||
|
et une fenêtre d'objection.
|
||||||
|
|
||||||
|
## Les 5 modalités
|
||||||
|
|
||||||
|
Chaque protocole est nommé et référencé par une clause du Pacte — le modifier, c'est amender
|
||||||
|
le Pacte.
|
||||||
|
|
||||||
|
1. **Consentement** — « Ça me va / J'objecte » : zéro objection maintenue = adopté. Le repli
|
||||||
|
universel : tout collectif en a un (invariant).
|
||||||
|
2. **Nuancé 6 niveaux** — de « Pas du tout » à « Tout à fait », jamais « contre » ;
|
||||||
|
histogramme visible, commentaire obligatoire sur les niveaux bas.
|
||||||
|
3. **Paramétrique** — décider au curseur : faisceau des positions, médiane vivante, carte
|
||||||
|
« Pour moi » ; la cristallisation finale est un geste humain daté du garant, jamais un
|
||||||
|
automatisme.
|
||||||
|
4. **Élection** — réservée aux mandats et aux problématiques d'échelle (6 méthodes de
|
||||||
|
nomination, vote blanc, jamais de départage par l'outil).
|
||||||
|
5. **Binaire inertiel** — le seuil `C + B^W + (M + (1−M)(1−(T/W)^G))·max(0, T−C)`, héritage
|
||||||
|
Ğ1 remis à sa place : l'outil de dernier recours des très grands corps. L'Atelier des
|
||||||
|
formules (`/textes/formules`) reste son école.
|
||||||
|
|
||||||
|
## Local-first
|
||||||
|
|
||||||
|
- **Aucun serveur requis** : tout l'état vit dans le navigateur (IndexedDB via idb-keyval,
|
||||||
|
une clé par collectif).
|
||||||
|
- **Export / import** de bundles JSON — la fédération, c'est l'échange de bundles, jamais une
|
||||||
|
table.
|
||||||
|
- **Seeds** livrés et importés par le même chemin que l'import utilisateur :
|
||||||
|
- `duniter-g1.bundle.json` — la toile Ğ1 : 33 clauses Certification + 59 clauses Forgeron
|
||||||
|
(textes votés intacts, provenance et décomptes conservés), protocoles, 3 mandats ;
|
||||||
|
- `atelier-du-canal.bundle.json` — association fictive de 12 membres, le test permanent de
|
||||||
|
marque blanche : décisions à tous les états, paramétrique vivant, épreuve du réel due.
|
||||||
|
- **7 gabarits** de collectif à la création (`/creer`) : page blanche, informel, association,
|
||||||
|
coopérative, commune, monnaie libre, institution symétrique.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Frontend** : Nuxt 4 + Vue 3 + Pinia + UnoCSS (port 3002)
|
- **Frontend** : Nuxt 4 (SPA, SSR off) + Vue 3 + TypeScript + Nuxt UI v3 + Pinia + UnoCSS ;
|
||||||
- **Backend** : Python FastAPI + SQLAlchemy async + SQLite (port 8002)
|
npm ; port **3002**
|
||||||
- **Auth** : Duniter V2 Ed25519 challenge-response
|
- **Moteurs purs** : `frontend/app/engine/` — triage, machine à états, seuils, médiane
|
||||||
- **Sanctuaire** : IPFS (kubo) + hash on-chain (system.remark)
|
paramétrique, nuancé, périmètres — zéro dépendance Nuxt, testés vitest (**342 tests**)
|
||||||
|
- **Backend v1** (FastAPI + SQLAlchemy async) : conservé dans `backend/` pour la future
|
||||||
|
synchronisation multi-appareils — non requis pour utiliser ou développer le frontend
|
||||||
|
|
||||||
## Démarrage
|
## Démarrage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
cd frontend
|
||||||
cd backend && .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload
|
npm install
|
||||||
|
npm run dev # http://localhost:3002
|
||||||
# Frontend
|
npm test # vitest — 342 tests
|
||||||
cd frontend && npm run dev
|
npm run build # build de production
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sections
|
## Structure v2
|
||||||
|
|
||||||
- **Décisions** : processus de vote collectif avec boîte à outils (ContextMapper, consentement, advice process)
|
```
|
||||||
- **Mandats** : élection sociocratique, cycle de mandat, révocation
|
frontend/app/
|
||||||
- **Documents** : documents de référence sous vote permanent, niveaux d'inertie, sanctuaire IPFS
|
engine/ # moteurs purs (barrel index.ts — seul point d'import)
|
||||||
- **Protocoles** : protocoles opérationnels, jalons de workflow, formules de vote
|
data/ # persistence.ts (IndexedDB), templates.ts (7 gabarits), seeds/*.bundle.json
|
||||||
|
stores/ # collective.ts (le tenant) + decisions.ts (façade d'actions)
|
||||||
|
composables/ # useMood, useFeed, useSearch
|
||||||
|
components/ # par domaine : chemin/ decisions/ votes/ texts/ mandates/ feed/ onboarding/ common/
|
||||||
|
pages/ # 14 routes : / decider decisions(+id, vote, observatoire)
|
||||||
|
# textes(+slug, formules) mandats(+id, nouveau) creer donnees
|
||||||
|
lexicon.ts # source unique des libellés FR + test anti-lexique
|
||||||
|
types/domain.ts # le noyau du modèle
|
||||||
|
docs/dev/BLUEPRINT-V2.md # la référence produit
|
||||||
|
```
|
||||||
|
|
||||||
## Architecture
|
## Licence
|
||||||
|
|
||||||
Voir `CLAUDE.md` pour les conventions et `docs/content/dev/` pour la documentation technique.
|
Logiciel libre. Le fichier LICENSE sera posé avant la première publication.
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ from app.database import Base
|
|||||||
from app.models import ( # noqa: F401
|
from app.models import ( # noqa: F401
|
||||||
DuniterIdentity,
|
DuniterIdentity,
|
||||||
Session,
|
Session,
|
||||||
|
Organization,
|
||||||
|
OrgMember,
|
||||||
Document,
|
Document,
|
||||||
DocumentItem,
|
DocumentItem,
|
||||||
ItemVersion,
|
ItemVersion,
|
||||||
@@ -37,6 +39,7 @@ from app.models import ( # noqa: F401
|
|||||||
MandateStep,
|
MandateStep,
|
||||||
VotingProtocol,
|
VotingProtocol,
|
||||||
FormulaConfig,
|
FormulaConfig,
|
||||||
|
QualificationProtocol,
|
||||||
SanctuaryEntry,
|
SanctuaryEntry,
|
||||||
BlockchainCache,
|
BlockchainCache,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""add organizations
|
||||||
|
|
||||||
|
Revision ID: 70914b334cfb
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-04-23 12:27:56.220214+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = '70914b334cfb'
|
||||||
|
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:
|
||||||
|
# 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:
|
||||||
|
op.drop_index(op.f('ix_voting_protocols_organization_id'), table_name='voting_protocols')
|
||||||
|
op.drop_column('voting_protocols', 'organization_id')
|
||||||
|
op.drop_index(op.f('ix_mandates_organization_id'), table_name='mandates')
|
||||||
|
op.drop_column('mandates', 'organization_id')
|
||||||
|
op.drop_index(op.f('ix_documents_organization_id'), table_name='documents')
|
||||||
|
op.drop_column('documents', 'organization_id')
|
||||||
|
op.drop_index(op.f('ix_decisions_organization_id'), table_name='decisions')
|
||||||
|
op.drop_column('decisions', 'organization_id')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""add_qualification_protocol
|
||||||
|
|
||||||
|
Revision ID: b78571ae9e00
|
||||||
|
Revises: 70914b334cfb
|
||||||
|
Create Date: 2026-04-23 17:08:07.161306+00:00
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = 'b78571ae9e00'
|
||||||
|
down_revision: Union[str, None] = '70914b334cfb'
|
||||||
|
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 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:
|
||||||
|
op.drop_table('qualification_protocols')
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Add groups and group_members tables.
|
||||||
|
|
||||||
|
Revision ID: c4e812fb3a01
|
||||||
|
Revises: b78571ae9e00
|
||||||
|
Create Date: 2026-04-23 19:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "c4e812fb3a01"
|
||||||
|
down_revision = "b78571ae9e00"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
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.execute("CREATE INDEX IF NOT EXISTS ix_groups_organization_id ON groups (organization_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.execute("CREATE INDEX IF NOT EXISTS ix_group_members_group_id ON group_members (group_id)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_group_members_group_id", table_name="group_members")
|
||||||
|
op.drop_table("group_members")
|
||||||
|
op.drop_index("ix_groups_organization_id", table_name="groups")
|
||||||
|
op.drop_table("groups")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Add origin column to mandates table.
|
||||||
|
|
||||||
|
Revision ID: d91a3c7f8b02
|
||||||
|
Revises: c4e812fb3a01
|
||||||
|
Create Date: 2026-04-24 10:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "d91a3c7f8b02"
|
||||||
|
down_revision = "c4e812fb3a01"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS origin TEXT")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("mandates", "origin")
|
||||||
@@ -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")
|
||||||
@@ -39,6 +39,9 @@ class Settings(BaseSettings):
|
|||||||
RATE_LIMIT_AUTH: int = 10
|
RATE_LIMIT_AUTH: int = 10
|
||||||
RATE_LIMIT_VOTE: int = 30
|
RATE_LIMIT_VOTE: int = 30
|
||||||
|
|
||||||
|
# AI — Qwen3.6 (MacStudio) endpoint, branché plus tard
|
||||||
|
QWEN_API_URL: str = ""
|
||||||
|
|
||||||
# Blockchain cache
|
# Blockchain cache
|
||||||
BLOCKCHAIN_CACHE_TTL_SECONDS: int = 3600
|
BLOCKCHAIN_CACHE_TTL_SECONDS: int = 3600
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""FastAPI dependency: resolve X-Organization header → org UUID."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import Depends, Header
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.services.org_service import get_organization_by_slug
|
||||||
|
|
||||||
|
|
||||||
|
async def get_active_org_id(
|
||||||
|
x_organization: str | None = Header(default=None, alias="X-Organization"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> uuid.UUID | None:
|
||||||
|
"""Return the UUID of the org named in the X-Organization header, or None.
|
||||||
|
|
||||||
|
None means no org filter — used for backward compat and internal tooling.
|
||||||
|
An unknown slug is silently treated as None (don't break the client).
|
||||||
|
"""
|
||||||
|
if not x_organization:
|
||||||
|
return None
|
||||||
|
org = await get_organization_by_slug(db, x_organization)
|
||||||
|
return org.id if org else None
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Decision qualification engine.
|
||||||
|
|
||||||
|
Pure functions — no database, no I/O.
|
||||||
|
Takes a QualificationInput + QualificationConfig and returns a QualificationResult.
|
||||||
|
|
||||||
|
LLM integration (suggest_modalities_from_context) is stubbed pending local Qwen deployment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionType(str, Enum):
|
||||||
|
INDIVIDUAL = "individual"
|
||||||
|
COLLECTIVE = "collective"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration (thresholds — stored as QualificationProtocol in DB)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QualificationConfig:
|
||||||
|
"""Configurable thresholds for the qualification engine.
|
||||||
|
|
||||||
|
Seeded as a QualificationProtocol record so they can be adjusted
|
||||||
|
through the admin interface without code changes.
|
||||||
|
|
||||||
|
small_group_max: affected_count <= this → individual recommended, collective available
|
||||||
|
collective_wot_min: affected_count > this → WoT formula applicable (still recommended, not required)
|
||||||
|
|
||||||
|
affected_count must be >= 2 — decisions affecting only the author
|
||||||
|
have no place in this tool.
|
||||||
|
"""
|
||||||
|
small_group_max: int = 5
|
||||||
|
collective_wot_min: int = 50
|
||||||
|
|
||||||
|
default_modalities: list[str] = field(default_factory=lambda: [
|
||||||
|
"vote_wot",
|
||||||
|
"vote_smith",
|
||||||
|
"consultation_avis",
|
||||||
|
"election",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Input / Output
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QualificationInput:
|
||||||
|
within_mandate: bool = False
|
||||||
|
affected_count: int | None = None # must be >= 2 when within_mandate=False
|
||||||
|
is_structural: bool = False
|
||||||
|
context_description: str | None = None # reserved for LLM suggestion
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QualificationResult:
|
||||||
|
decision_type: DecisionType
|
||||||
|
process: str
|
||||||
|
recommended_modalities: list[str]
|
||||||
|
recommend_onchain: bool
|
||||||
|
onchain_reason: str | None
|
||||||
|
confidence: str # "required" | "recommended" | "optional"
|
||||||
|
collective_available: bool
|
||||||
|
record_in_observatory: bool # True → decision must be logged in Observatoire
|
||||||
|
reasons: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# LLM stub
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_modalities_from_context(
|
||||||
|
context: str,
|
||||||
|
config: QualificationConfig,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Suggest voting modalities based on a natural-language context description.
|
||||||
|
|
||||||
|
Stub — returns empty list until local Qwen (qwen3.6, MacStudio) is integrated.
|
||||||
|
When implemented, will call the LLM API and return an ordered subset of
|
||||||
|
config.default_modalities ranked by contextual relevance.
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core engine
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def qualify(inp: QualificationInput, config: QualificationConfig) -> QualificationResult:
|
||||||
|
"""Qualify a decision and recommend a type, process, and modalities.
|
||||||
|
|
||||||
|
Rules (in priority order):
|
||||||
|
R1/R2 within_mandate → individual + consultation_avis, no vote modalities,
|
||||||
|
decision must be recorded in Observatoire des décisions
|
||||||
|
R4 2 ≤ affected_count ≤ small_group_max → individual recommended, collective available
|
||||||
|
R5 small_group_max < affected_count ≤ collective_wot_min → collective recommended
|
||||||
|
R6 affected_count > collective_wot_min → collective recommended (WoT formula applicable)
|
||||||
|
R7/R8 is_structural → recommend_onchain with reason
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
# ── R1/R2: mandate scope overrides everything ───────────────────────────
|
||||||
|
if inp.within_mandate:
|
||||||
|
reasons.append("Décision dans le périmètre d'un mandat existant.")
|
||||||
|
return QualificationResult(
|
||||||
|
decision_type=DecisionType.INDIVIDUAL,
|
||||||
|
process="consultation_avis",
|
||||||
|
recommended_modalities=[],
|
||||||
|
recommend_onchain=_onchain(inp, reasons),
|
||||||
|
onchain_reason=_onchain_reason(inp),
|
||||||
|
confidence="required",
|
||||||
|
collective_available=False,
|
||||||
|
record_in_observatory=True,
|
||||||
|
reasons=reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
count = inp.affected_count if inp.affected_count is not None else 2
|
||||||
|
|
||||||
|
# ── R4: small group → individual recommended, collective available ───────
|
||||||
|
if count <= config.small_group_max:
|
||||||
|
reasons.append(
|
||||||
|
f"{count} personnes concernées : décision individuelle recommandée, "
|
||||||
|
"vote collectif possible."
|
||||||
|
)
|
||||||
|
return QualificationResult(
|
||||||
|
decision_type=DecisionType.INDIVIDUAL,
|
||||||
|
process="personal",
|
||||||
|
recommended_modalities=[],
|
||||||
|
recommend_onchain=_onchain(inp, reasons),
|
||||||
|
onchain_reason=_onchain_reason(inp),
|
||||||
|
confidence="recommended",
|
||||||
|
collective_available=True,
|
||||||
|
record_in_observatory=False,
|
||||||
|
reasons=reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── R5/R6: medium or large group → collective ────────────────────────────
|
||||||
|
modalities = _collect_modalities(inp, config)
|
||||||
|
|
||||||
|
if count <= config.collective_wot_min:
|
||||||
|
reasons.append(f"{count} personnes concernées : vote collectif recommandé.")
|
||||||
|
confidence = "recommended"
|
||||||
|
else:
|
||||||
|
reasons.append(
|
||||||
|
f"{count} personnes concernées : vote collectif recommandé "
|
||||||
|
"(formule WoT applicable à cette échelle)."
|
||||||
|
)
|
||||||
|
confidence = "recommended"
|
||||||
|
if "vote_wot" not in modalities:
|
||||||
|
modalities = ["vote_wot"] + modalities
|
||||||
|
|
||||||
|
return QualificationResult(
|
||||||
|
decision_type=DecisionType.COLLECTIVE,
|
||||||
|
process="vote_collective",
|
||||||
|
recommended_modalities=modalities,
|
||||||
|
recommend_onchain=_onchain(inp, reasons),
|
||||||
|
onchain_reason=_onchain_reason(inp),
|
||||||
|
confidence=confidence,
|
||||||
|
collective_available=True,
|
||||||
|
record_in_observatory=False,
|
||||||
|
reasons=reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _onchain(inp: QualificationInput, reasons: list[str]) -> bool:
|
||||||
|
if inp.is_structural:
|
||||||
|
reasons.append(
|
||||||
|
"Décision structurante : gravure on-chain recommandée "
|
||||||
|
"(a force de loi ou déclenche une action machine)."
|
||||||
|
)
|
||||||
|
return inp.is_structural
|
||||||
|
|
||||||
|
|
||||||
|
def _onchain_reason(inp: QualificationInput) -> str | None:
|
||||||
|
if not inp.is_structural:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
"Cette décision est structurante : elle a valeur de loi au sein de la "
|
||||||
|
"communauté ou déclenche une action machine (ex : runtime upgrade). "
|
||||||
|
"La gravure on-chain (IPFS + system.remark) garantit son immuabilité "
|
||||||
|
"et sa vérifiabilité publique."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_modalities(inp: QualificationInput, config: QualificationConfig) -> list[str]:
|
||||||
|
"""Combine default modalities with any LLM suggestions (stub for now)."""
|
||||||
|
llm_suggestions = []
|
||||||
|
if inp.context_description:
|
||||||
|
llm_suggestions = suggest_modalities_from_context(inp.context_description, config)
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for m in llm_suggestions + config.default_modalities:
|
||||||
|
if m not in seen:
|
||||||
|
seen.add(m)
|
||||||
|
result.append(m)
|
||||||
|
return result
|
||||||
+28
-11
@@ -13,6 +13,9 @@ from app.middleware.rate_limiter import RateLimiterMiddleware
|
|||||||
from app.middleware.security_headers import SecurityHeadersMiddleware
|
from app.middleware.security_headers import SecurityHeadersMiddleware
|
||||||
from app.routers import auth, documents, decisions, votes, mandates, protocols, sanctuary, websocket
|
from app.routers import auth, documents, decisions, votes, mandates, protocols, sanctuary, websocket
|
||||||
from app.routers import public
|
from app.routers import public
|
||||||
|
from app.routers import organizations
|
||||||
|
from app.routers import qualify
|
||||||
|
from app.routers import groups
|
||||||
|
|
||||||
|
|
||||||
# ── Structured logging setup ───────────────────────────────────────────────
|
# ── Structured logging setup ───────────────────────────────────────────────
|
||||||
@@ -85,8 +88,28 @@ app = FastAPI(
|
|||||||
|
|
||||||
|
|
||||||
# ── Middleware stack ──────────────────────────────────────────────────────
|
# ── Middleware stack ──────────────────────────────────────────────────────
|
||||||
# Middleware is applied in reverse order: last added = first executed.
|
# add_middleware is LIFO: last added = outermost = first to execute on request,
|
||||||
# Order: SecurityHeaders -> RateLimiter -> CORS -> Application
|
# last to execute on response (wraps everything inside it).
|
||||||
|
#
|
||||||
|
# Required order so CORS headers appear on ALL responses including 429:
|
||||||
|
# CORS (outermost) → RateLimiter → SecurityHeaders → Application
|
||||||
|
#
|
||||||
|
# If RateLimiter were outside CORS, its 429 responses would have no CORS
|
||||||
|
# headers and the browser would silently discard them as network errors.
|
||||||
|
|
||||||
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
|
||||||
|
# Prototype mode: use RATE_LIMIT_DEFAULT for auth so demos/testing don't hit
|
||||||
|
# the stricter RATE_LIMIT_AUTH (10/min). Set RATE_LIMIT_AUTH >= RATE_LIMIT_DEFAULT
|
||||||
|
# in .env only when going to real production.
|
||||||
|
_auth_rate_limit = settings.RATE_LIMIT_DEFAULT
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
RateLimiterMiddleware,
|
||||||
|
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
|
||||||
|
rate_limit_auth=_auth_rate_limit,
|
||||||
|
rate_limit_vote=settings.RATE_LIMIT_VOTE,
|
||||||
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -96,15 +119,6 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
|
||||||
RateLimiterMiddleware,
|
|
||||||
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
|
|
||||||
rate_limit_auth=settings.RATE_LIMIT_AUTH,
|
|
||||||
rate_limit_vote=settings.RATE_LIMIT_VOTE,
|
|
||||||
)
|
|
||||||
|
|
||||||
app.add_middleware(SecurityHeadersMiddleware)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Routers ──────────────────────────────────────────────────────────────
|
# ── Routers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -117,6 +131,9 @@ app.include_router(protocols.router, prefix="/api/v1/protocols", tags=["protocol
|
|||||||
app.include_router(sanctuary.router, prefix="/api/v1/sanctuary", tags=["sanctuary"])
|
app.include_router(sanctuary.router, prefix="/api/v1/sanctuary", tags=["sanctuary"])
|
||||||
app.include_router(websocket.router, prefix="/api/v1/ws", tags=["websocket"])
|
app.include_router(websocket.router, prefix="/api/v1/ws", tags=["websocket"])
|
||||||
app.include_router(public.router, prefix="/api/v1/public", tags=["public"])
|
app.include_router(public.router, prefix="/api/v1/public", tags=["public"])
|
||||||
|
app.include_router(organizations.router, prefix="/api/v1/organizations", tags=["organizations"])
|
||||||
|
app.include_router(qualify.router, prefix="/api/v1/qualify", tags=["qualify"])
|
||||||
|
app.include_router(groups.router, prefix="/api/v1/groups", tags=["groups"])
|
||||||
|
|
||||||
|
|
||||||
# ── Health check ─────────────────────────────────────────────────────────
|
# ── Health check ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -64,14 +64,6 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
|||||||
self._last_cleanup: float = time.time()
|
self._last_cleanup: float = time.time()
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
def _get_limit_for_path(self, path: str) -> int:
|
|
||||||
"""Return the rate limit applicable to the given request path."""
|
|
||||||
if "/auth" in path:
|
|
||||||
return self.rate_limit_auth
|
|
||||||
if "/vote" in path:
|
|
||||||
return self.rate_limit_vote
|
|
||||||
return self.rate_limit_default
|
|
||||||
|
|
||||||
def _get_client_ip(self, request: Request) -> str:
|
def _get_client_ip(self, request: Request) -> str:
|
||||||
"""Extract the client IP from the request, respecting X-Forwarded-For."""
|
"""Extract the client IP from the request, respecting X-Forwarded-For."""
|
||||||
forwarded = request.headers.get("x-forwarded-for")
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
@@ -101,6 +93,22 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
|||||||
if ips_to_delete:
|
if ips_to_delete:
|
||||||
logger.debug("Nettoyage rate limiter: %d IPs supprimees", len(ips_to_delete))
|
logger.debug("Nettoyage rate limiter: %d IPs supprimees", len(ips_to_delete))
|
||||||
|
|
||||||
|
def _get_limit_for_request(self, request: Request) -> int:
|
||||||
|
"""Return the rate limit applicable to the given request.
|
||||||
|
|
||||||
|
CORS preflight (OPTIONS) requests are never rate-limited — blocking them
|
||||||
|
breaks authenticated cross-origin requests in browsers.
|
||||||
|
Strict auth limit applies only to POST (login flows), not to GET /auth/me.
|
||||||
|
"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return 10_000 # effectively unlimited for preflights
|
||||||
|
path = request.url.path
|
||||||
|
if request.method == "POST" and "/auth" in path:
|
||||||
|
return self.rate_limit_auth
|
||||||
|
if "/vote" in path:
|
||||||
|
return self.rate_limit_vote
|
||||||
|
return self.rate_limit_default
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next) -> Response:
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
"""Check rate limit and either allow the request or return 429."""
|
"""Check rate limit and either allow the request or return 429."""
|
||||||
# Skip rate limiting for WebSocket upgrades
|
# Skip rate limiting for WebSocket upgrades
|
||||||
@@ -111,8 +119,7 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
|||||||
await self._cleanup_old_entries()
|
await self._cleanup_old_entries()
|
||||||
|
|
||||||
client_ip = self._get_client_ip(request)
|
client_ip = self._get_client_ip(request)
|
||||||
path = request.url.path
|
limit = self._get_limit_for_request(request)
|
||||||
limit = self._get_limit_for_path(path)
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
window_start = now - 60
|
window_start = now - 60
|
||||||
|
|
||||||
@@ -133,7 +140,7 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Rate limit depasse pour %s sur %s (%d/%d)",
|
"Rate limit depasse pour %s sur %s (%d/%d)",
|
||||||
client_ip, path, request_count, limit,
|
client_ip, request.url.path, request_count, limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
from app.models.user import DuniterIdentity, Session
|
from app.models.user import DuniterIdentity, Session
|
||||||
|
from app.models.organization import Organization, OrgMember
|
||||||
from app.models.document import Document, DocumentItem, ItemVersion
|
from app.models.document import Document, DocumentItem, ItemVersion
|
||||||
from app.models.decision import Decision, DecisionStep
|
from app.models.decision import Decision, DecisionStep
|
||||||
from app.models.vote import VoteSession, Vote
|
from app.models.vote import VoteSession, Vote
|
||||||
from app.models.mandate import Mandate, MandateStep
|
from app.models.mandate import Mandate, MandateStep
|
||||||
from app.models.protocol import VotingProtocol, FormulaConfig
|
from app.models.protocol import VotingProtocol, FormulaConfig
|
||||||
|
from app.models.qualification import QualificationProtocol
|
||||||
|
from app.models.group import Group, GroupMember
|
||||||
from app.models.sanctuary import SanctuaryEntry
|
from app.models.sanctuary import SanctuaryEntry
|
||||||
from app.models.cache import BlockchainCache
|
from app.models.cache import BlockchainCache
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DuniterIdentity", "Session",
|
"DuniterIdentity", "Session",
|
||||||
|
"Organization", "OrgMember",
|
||||||
"Document", "DocumentItem", "ItemVersion",
|
"Document", "DocumentItem", "ItemVersion",
|
||||||
"Decision", "DecisionStep",
|
"Decision", "DecisionStep",
|
||||||
"VoteSession", "Vote",
|
"VoteSession", "Vote",
|
||||||
"Mandate", "MandateStep",
|
"Mandate", "MandateStep",
|
||||||
"VotingProtocol", "FormulaConfig",
|
"VotingProtocol", "FormulaConfig",
|
||||||
|
"QualificationProtocol",
|
||||||
|
"Group", "GroupMember",
|
||||||
"SanctuaryEntry",
|
"SanctuaryEntry",
|
||||||
"BlockchainCache",
|
"BlockchainCache",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Decision(Base):
|
|||||||
context: Mapped[str | None] = mapped_column(Text)
|
context: Mapped[str | None] = mapped_column(Text)
|
||||||
decision_type: Mapped[str] = mapped_column(String(64), nullable=False) # runtime_upgrade, document_change, mandate_vote, custom
|
decision_type: Mapped[str] = mapped_column(String(64), nullable=False) # runtime_upgrade, document_change, mandate_vote, custom
|
||||||
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, qualification, review, voting, executed, closed
|
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, qualification, review, voting, executed, closed
|
||||||
|
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
|
||||||
voting_protocol_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("voting_protocols.id"))
|
voting_protocol_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("voting_protocols.id"))
|
||||||
created_by_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
|
created_by_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class Document(Base):
|
|||||||
version: Mapped[str] = mapped_column(String(32), default="0.1.0")
|
version: Mapped[str] = mapped_column(String(32), default="0.1.0")
|
||||||
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, active, archived
|
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, active, archived
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
|
||||||
ipfs_cid: Mapped[str | None] = mapped_column(String(128))
|
ipfs_cid: Mapped[str | None] = mapped_column(String(128))
|
||||||
chain_anchor: Mapped[str | None] = mapped_column(String(128))
|
chain_anchor: Mapped[str | None] = mapped_column(String(128))
|
||||||
genesis_json: Mapped[str | None] = mapped_column(Text) # JSON: source files, repos, forum URLs, formula trigger
|
genesis_json: Mapped[str | None] = mapped_column(Text) # JSON: source files, repos, forum URLs, formula trigger
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, DateTime, ForeignKey, Uuid, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Group(Base):
|
||||||
|
__tablename__ = "groups"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
organization_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("organizations.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
|
||||||
|
members: Mapped[list["GroupMember"]] = relationship(
|
||||||
|
back_populates="group", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMember(Base):
|
||||||
|
__tablename__ = "group_members"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
group_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groups.id"), nullable=False, index=True)
|
||||||
|
# FK to duniter_identities when the member is a known WoT member; nullable for free-text entries
|
||||||
|
identity_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("duniter_identities.id"), nullable=True
|
||||||
|
)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
group: Mapped["Group"] = relationship(back_populates="members")
|
||||||
@@ -12,9 +12,11 @@ class Mandate(Base):
|
|||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||||
|
origin_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"), nullable=True, index=True)
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) # techcomm, smith, custom
|
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, candidacy, voting, active, reporting, completed, revoked
|
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"))
|
mandatee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
|
||||||
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
|
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
|
||||||
starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
@@ -22,7 +24,27 @@ class Mandate(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
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())
|
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):
|
class MandateStep(Base):
|
||||||
@@ -31,12 +53,16 @@ class MandateStep(Base):
|
|||||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
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)
|
mandate_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("mandates.id"), nullable=False)
|
||||||
step_order: Mapped[int] = mapped_column(Integer, 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))
|
title: Mapped[str | None] = mapped_column(String(256))
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
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"))
|
vote_session_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("vote_sessions.id"))
|
||||||
outcome: Mapped[str | None] = mapped_column(Text)
|
outcome: Mapped[str | None] = mapped_column(Text)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
mandate: Mapped["Mandate"] = relationship(back_populates="steps")
|
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
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey, Uuid, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Organization(Base):
|
||||||
|
__tablename__ = "organizations"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
slug: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||||
|
# commune, enterprise, association, collective, basin, intercommunality, community
|
||||||
|
org_type: Mapped[str] = mapped_column(String(64), default="community")
|
||||||
|
# True = all authenticated users see & interact with content (Duniter G1, Axiom Team)
|
||||||
|
# False = membership required
|
||||||
|
is_transparent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
color: Mapped[str | None] = mapped_column(String(32)) # CSS color or mood token
|
||||||
|
icon: Mapped[str | None] = mapped_column(String(64)) # lucide icon name
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
members: Mapped[list["OrgMember"]] = relationship(
|
||||||
|
back_populates="organization", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrgMember(Base):
|
||||||
|
__tablename__ = "org_members"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
org_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("organizations.id"), nullable=False)
|
||||||
|
identity_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("duniter_identities.id"), nullable=False
|
||||||
|
)
|
||||||
|
role: Mapped[str] = mapped_column(String(32), default="member") # admin, member, observer
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
organization: Mapped["Organization"] = relationship(back_populates="members")
|
||||||
@@ -44,6 +44,7 @@ class VotingProtocol(Base):
|
|||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
vote_type: Mapped[str] = mapped_column(String(32), nullable=False) # binary, nuanced
|
vote_type: Mapped[str] = mapped_column(String(32), nullable=False) # binary, nuanced
|
||||||
formula_config_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("formula_configs.id"), nullable=False)
|
formula_config_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("formula_configs.id"), nullable=False)
|
||||||
|
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
|
||||||
mode_params: Mapped[str | None] = mapped_column(String(64)) # e.g. "D30M50B.1G.2T.1"
|
mode_params: Mapped[str | None] = mapped_column(String(64)) # e.g. "D30M50B.1G.2T.1"
|
||||||
is_meta_governed: Mapped[bool] = mapped_column(Boolean, default=False)
|
is_meta_governed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Integer, String, Text, Uuid, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class QualificationProtocol(Base):
|
||||||
|
"""Active configuration for the decision qualification engine.
|
||||||
|
|
||||||
|
Thresholds stored here override the engine defaults and can be updated
|
||||||
|
through the admin interface (meta-governance).
|
||||||
|
Only one record should be active at a time (is_active=True).
|
||||||
|
"""
|
||||||
|
__tablename__ = "qualification_protocols"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
small_group_max: Mapped[int] = mapped_column(Integer, default=5)
|
||||||
|
collective_wot_min: Mapped[int] = mapped_column(Integer, default=50)
|
||||||
|
|
||||||
|
# JSON array of modality slugs, e.g. '["vote_wot","vote_smith","election"]'
|
||||||
|
default_modalities_json: Mapped[str] = mapped_column(
|
||||||
|
Text,
|
||||||
|
default='["vote_wot","vote_smith","consultation_avis","election"]',
|
||||||
|
)
|
||||||
|
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_modalities(self) -> list[str]:
|
||||||
|
return json.loads(self.default_modalities_json)
|
||||||
@@ -5,7 +5,8 @@ from __future__ import annotations
|
|||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta, timezone
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -132,9 +133,13 @@ async def verify_challenge(
|
|||||||
detail="Challenge invalide",
|
detail="Challenge invalide",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. Verify signature (bypass for demo profiles in 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}
|
_demo_addresses = {p["address"] for p in DEV_PROFILES}
|
||||||
is_demo_bypass = settings.DEMO_MODE and payload.address in _demo_addresses
|
is_demo_bypass = (settings.DEMO_MODE or settings.ENVIRONMENT == "development") and payload.address in _demo_addresses
|
||||||
|
|
||||||
if not is_demo_bypass:
|
if not is_demo_bypass:
|
||||||
# polkadot.js / Cesium2 signRaw(type='bytes') wraps: <Bytes>{challenge}</Bytes>
|
# polkadot.js / Cesium2 signRaw(type='bytes') wraps: <Bytes>{challenge}</Bytes>
|
||||||
@@ -228,3 +233,24 @@ async def logout(
|
|||||||
for session in sessions:
|
for session in sessions:
|
||||||
await db.delete(session)
|
await db.delete(session)
|
||||||
await db.commit()
|
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()]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.schemas.decision import (
|
|||||||
DecisionUpdate,
|
DecisionUpdate,
|
||||||
)
|
)
|
||||||
from app.schemas.vote import VoteSessionOut
|
from app.schemas.vote import VoteSessionOut
|
||||||
|
from app.dependencies.org import get_active_org_id
|
||||||
from app.services.auth_service import get_current_identity
|
from app.services.auth_service import get_current_identity
|
||||||
from app.services.decision_service import advance_decision, create_vote_session_for_step
|
from app.services.decision_service import advance_decision, create_vote_session_for_step
|
||||||
|
|
||||||
@@ -30,13 +31,20 @@ router = APIRouter()
|
|||||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def _get_decision(db: AsyncSession, decision_id: uuid.UUID) -> Decision:
|
async def _get_decision(
|
||||||
"""Fetch a decision by ID with its steps eagerly loaded, or raise 404."""
|
db: AsyncSession, decision_id: uuid.UUID, org_id: uuid.UUID | None = None
|
||||||
result = await db.execute(
|
) -> Decision:
|
||||||
|
"""Fetch a decision by ID within the active org scope, or raise 404."""
|
||||||
|
stmt = (
|
||||||
select(Decision)
|
select(Decision)
|
||||||
.options(selectinload(Decision.steps))
|
.options(selectinload(Decision.steps))
|
||||||
.where(Decision.id == decision_id)
|
.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()
|
decision = result.scalar_one_or_none()
|
||||||
if decision is None:
|
if decision is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Decision introuvable")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Decision introuvable")
|
||||||
@@ -49,6 +57,7 @@ async def _get_decision(db: AsyncSession, decision_id: uuid.UUID) -> Decision:
|
|||||||
@router.get("/", response_model=list[DecisionOut])
|
@router.get("/", response_model=list[DecisionOut])
|
||||||
async def list_decisions(
|
async def list_decisions(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
decision_type: str | None = Query(default=None, description="Filtrer par type de decision"),
|
decision_type: str | None = Query(default=None, description="Filtrer par type de decision"),
|
||||||
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||||
skip: int = Query(default=0, ge=0),
|
skip: int = Query(default=0, ge=0),
|
||||||
@@ -57,6 +66,10 @@ async def list_decisions(
|
|||||||
"""List all decisions with optional filters."""
|
"""List all decisions with optional filters."""
|
||||||
stmt = select(Decision).options(selectinload(Decision.steps))
|
stmt = select(Decision).options(selectinload(Decision.steps))
|
||||||
|
|
||||||
|
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:
|
if decision_type is not None:
|
||||||
stmt = stmt.where(Decision.decision_type == decision_type)
|
stmt = stmt.where(Decision.decision_type == decision_type)
|
||||||
if status_filter is not None:
|
if status_filter is not None:
|
||||||
@@ -74,18 +87,20 @@ async def create_decision(
|
|||||||
payload: DecisionCreate,
|
payload: DecisionCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> DecisionOut:
|
) -> DecisionOut:
|
||||||
"""Create a new decision process."""
|
"""Create a new decision process."""
|
||||||
decision = Decision(
|
decision = Decision(
|
||||||
**payload.model_dump(),
|
**payload.model_dump(),
|
||||||
created_by_id=identity.id,
|
created_by_id=identity.id,
|
||||||
|
organization_id=org_id,
|
||||||
)
|
)
|
||||||
db.add(decision)
|
db.add(decision)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(decision)
|
await db.refresh(decision)
|
||||||
|
|
||||||
# Reload with steps (empty at creation)
|
# 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)
|
return DecisionOut.model_validate(decision)
|
||||||
|
|
||||||
|
|
||||||
@@ -93,9 +108,10 @@ async def create_decision(
|
|||||||
async def get_decision(
|
async def get_decision(
|
||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> DecisionOut:
|
) -> DecisionOut:
|
||||||
"""Get a single decision with all its steps."""
|
"""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)
|
return DecisionOut.model_validate(decision)
|
||||||
|
|
||||||
|
|
||||||
@@ -105,9 +121,10 @@ async def update_decision(
|
|||||||
payload: DecisionUpdate,
|
payload: DecisionUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> DecisionOut:
|
) -> DecisionOut:
|
||||||
"""Update a decision's metadata (title, description, status, protocol)."""
|
"""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)
|
update_data = payload.model_dump(exclude_unset=True)
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
@@ -117,7 +134,7 @@ async def update_decision(
|
|||||||
await db.refresh(decision)
|
await db.refresh(decision)
|
||||||
|
|
||||||
# Reload with steps
|
# Reload with steps
|
||||||
decision = await _get_decision(db, decision.id)
|
decision = await _get_decision(db, decision.id, org_id)
|
||||||
return DecisionOut.model_validate(decision)
|
return DecisionOut.model_validate(decision)
|
||||||
|
|
||||||
|
|
||||||
@@ -130,10 +147,11 @@ async def add_step(
|
|||||||
payload: DecisionStepCreate,
|
payload: DecisionStepCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> DecisionStepOut:
|
) -> DecisionStepOut:
|
||||||
"""Add a step to a decision process."""
|
"""Add a step to a decision process."""
|
||||||
# Verify decision exists
|
# Verify decision exists
|
||||||
decision = await _get_decision(db, id)
|
decision = await _get_decision(db, id, org_id)
|
||||||
|
|
||||||
step = DecisionStep(
|
step = DecisionStep(
|
||||||
decision_id=decision.id,
|
decision_id=decision.id,
|
||||||
@@ -154,6 +172,7 @@ async def advance_decision_endpoint(
|
|||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> DecisionAdvanceOut:
|
) -> DecisionAdvanceOut:
|
||||||
"""Advance a decision to its next step or status."""
|
"""Advance a decision to its next step or status."""
|
||||||
try:
|
try:
|
||||||
@@ -162,7 +181,7 @@ async def advance_decision_endpoint(
|
|||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
|
||||||
# Reload with steps for complete output
|
# 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 = DecisionOut.model_validate(decision).model_dump()
|
||||||
data["message"] = f"Decision avancee au statut : {decision.status}"
|
data["message"] = f"Decision avancee au statut : {decision.status}"
|
||||||
return DecisionAdvanceOut(**data)
|
return DecisionAdvanceOut(**data)
|
||||||
@@ -178,6 +197,7 @@ async def create_vote_session_for_step_endpoint(
|
|||||||
step_id: uuid.UUID,
|
step_id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> VoteSessionOut:
|
) -> VoteSessionOut:
|
||||||
"""Create a vote session linked to a decision step."""
|
"""Create a vote session linked to a decision step."""
|
||||||
try:
|
try:
|
||||||
@@ -193,9 +213,10 @@ async def delete_decision(
|
|||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete a decision (only if in draft status)."""
|
"""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":
|
if decision.status != "draft":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from app.schemas.document import (
|
|||||||
ItemVersionCreate,
|
ItemVersionCreate,
|
||||||
ItemVersionOut,
|
ItemVersionOut,
|
||||||
)
|
)
|
||||||
|
from app.dependencies.org import get_active_org_id
|
||||||
from app.services import document_service
|
from app.services import document_service
|
||||||
from app.services.auth_service import get_current_identity
|
from app.services.auth_service import get_current_identity
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ async def _get_item(db: AsyncSession, document_id: uuid.UUID, item_id: uuid.UUID
|
|||||||
@router.get("/", response_model=list[DocumentOut])
|
@router.get("/", response_model=list[DocumentOut])
|
||||||
async def list_documents(
|
async def list_documents(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
doc_type: str | None = Query(default=None, description="Filtrer par type de document"),
|
doc_type: str | None = Query(default=None, description="Filtrer par type de document"),
|
||||||
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||||
skip: int = Query(default=0, ge=0),
|
skip: int = Query(default=0, ge=0),
|
||||||
@@ -73,6 +75,10 @@ async def list_documents(
|
|||||||
"""List all reference documents, with optional filters."""
|
"""List all reference documents, with optional filters."""
|
||||||
stmt = select(Document)
|
stmt = select(Document)
|
||||||
|
|
||||||
|
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:
|
if doc_type is not None:
|
||||||
stmt = stmt.where(Document.doc_type == doc_type)
|
stmt = stmt.where(Document.doc_type == doc_type)
|
||||||
if status_filter is not None:
|
if status_filter is not None:
|
||||||
@@ -101,6 +107,7 @@ async def create_document(
|
|||||||
payload: DocumentCreate,
|
payload: DocumentCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> DocumentOut:
|
) -> DocumentOut:
|
||||||
"""Create a new reference document."""
|
"""Create a new reference document."""
|
||||||
# Check slug uniqueness
|
# Check slug uniqueness
|
||||||
@@ -111,7 +118,7 @@ async def create_document(
|
|||||||
detail="Un document avec ce slug existe deja",
|
detail="Un document avec ce slug existe deja",
|
||||||
)
|
)
|
||||||
|
|
||||||
doc = Document(**payload.model_dump())
|
doc = Document(**payload.model_dump(), organization_id=org_id)
|
||||||
db.add(doc)
|
db.add(doc)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(doc)
|
await db.refresh(doc)
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Groups router — predefined sets of Duniter identities used in decision circles."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy import select
|
||||||
|
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
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@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 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(
|
||||||
|
id=g.id,
|
||||||
|
name=g.name,
|
||||||
|
description=g.description,
|
||||||
|
organization_id=g.organization_id,
|
||||||
|
member_count=len(g.members),
|
||||||
|
)
|
||||||
|
for g in groups
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=GroupOut, status_code=201)
|
||||||
|
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, organization_id=org_id)
|
||||||
|
db.add(group)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(group)
|
||||||
|
await db.execute(select(Group).where(Group.id == group.id).options(selectinload(Group.members)))
|
||||||
|
return GroupOut.model_validate(group)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{group_id}", response_model=GroupOut)
|
||||||
|
async def get_group(group_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> GroupOut:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Group).where(Group.id == group_id).options(selectinload(Group.members))
|
||||||
|
)
|
||||||
|
group = result.scalar_one_or_none()
|
||||||
|
if group is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Group not found")
|
||||||
|
return GroupOut.model_validate(group)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{group_id}", status_code=204, response_class=Response, response_model=None)
|
||||||
|
async def delete_group(
|
||||||
|
group_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_identity=Depends(get_current_identity),
|
||||||
|
) -> None:
|
||||||
|
result = await db.execute(select(Group).where(Group.id == group_id))
|
||||||
|
group = result.scalar_one_or_none()
|
||||||
|
if group is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Group not found")
|
||||||
|
await db.delete(group)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{group_id}/members", response_model=GroupMemberOut, status_code=201)
|
||||||
|
async def add_member(
|
||||||
|
group_id: uuid.UUID,
|
||||||
|
payload: GroupMemberCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_identity=Depends(get_current_identity),
|
||||||
|
) -> GroupMemberOut:
|
||||||
|
result = await db.execute(select(Group).where(Group.id == group_id))
|
||||||
|
if result.scalar_one_or_none() is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Group not found")
|
||||||
|
member = GroupMember(
|
||||||
|
group_id=group_id,
|
||||||
|
display_name=payload.display_name,
|
||||||
|
identity_id=payload.identity_id,
|
||||||
|
)
|
||||||
|
db.add(member)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(member)
|
||||||
|
return GroupMemberOut.model_validate(member)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{group_id}/members/{member_id}", status_code=204, response_class=Response, response_model=None)
|
||||||
|
async def remove_member(
|
||||||
|
group_id: uuid.UUID,
|
||||||
|
member_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_identity=Depends(get_current_identity),
|
||||||
|
) -> None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(GroupMember).where(GroupMember.id == member_id, GroupMember.group_id == group_id)
|
||||||
|
)
|
||||||
|
member = result.scalar_one_or_none()
|
||||||
|
if member is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Member not found")
|
||||||
|
await db.delete(member)
|
||||||
|
await db.commit()
|
||||||
@@ -22,6 +22,7 @@ from app.schemas.mandate import (
|
|||||||
MandateUpdate,
|
MandateUpdate,
|
||||||
)
|
)
|
||||||
from app.schemas.vote import VoteSessionOut
|
from app.schemas.vote import VoteSessionOut
|
||||||
|
from app.dependencies.org import get_active_org_id
|
||||||
from app.services.auth_service import get_current_identity
|
from app.services.auth_service import get_current_identity
|
||||||
from app.services.mandate_service import (
|
from app.services.mandate_service import (
|
||||||
advance_mandate,
|
advance_mandate,
|
||||||
@@ -36,33 +37,58 @@ router = APIRouter()
|
|||||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
|
async def _get_mandate(
|
||||||
"""Fetch a mandate by ID with its steps eagerly loaded, or raise 404."""
|
db: AsyncSession, mandate_id: uuid.UUID, org_id: uuid.UUID | None = None
|
||||||
result = await db.execute(
|
) -> Mandate:
|
||||||
|
stmt = (
|
||||||
select(Mandate)
|
select(Mandate)
|
||||||
.options(selectinload(Mandate.steps))
|
.options(
|
||||||
|
selectinload(Mandate.steps),
|
||||||
|
selectinload(Mandate.origin_identity),
|
||||||
|
selectinload(Mandate.mandatee),
|
||||||
|
)
|
||||||
.where(Mandate.id == mandate_id)
|
.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()
|
mandate = result.scalar_one_or_none()
|
||||||
if mandate is None:
|
if mandate is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mandat introuvable")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mandat introuvable")
|
||||||
return mandate
|
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 ──────────────────────────────────────────────────────────
|
# ── Mandate routes ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[MandateOut])
|
@router.get("/", response_model=list[MandateOut])
|
||||||
async def list_mandates(
|
async def list_mandates(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
mandate_type: str | None = Query(default=None, description="Filtrer par type de mandat"),
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
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),
|
skip: int = Query(default=0, ge=0),
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
) -> list[MandateOut]:
|
) -> list[MandateOut]:
|
||||||
"""List all mandates with optional filters."""
|
stmt = select(Mandate).options(
|
||||||
stmt = select(Mandate).options(selectinload(Mandate.steps))
|
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:
|
if mandate_type is not None:
|
||||||
stmt = stmt.where(Mandate.mandate_type == mandate_type)
|
stmt = stmt.where(Mandate.mandate_type == mandate_type)
|
||||||
if status_filter is not None:
|
if status_filter is not None:
|
||||||
@@ -72,7 +98,7 @@ async def list_mandates(
|
|||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
mandates = result.scalars().unique().all()
|
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)
|
@router.post("/", response_model=MandateOut, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -80,26 +106,32 @@ async def create_mandate(
|
|||||||
payload: MandateCreate,
|
payload: MandateCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> MandateOut:
|
) -> MandateOut:
|
||||||
"""Create a new mandate."""
|
data = payload.model_dump()
|
||||||
mandate = Mandate(**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)
|
db.add(mandate)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(mandate)
|
await db.refresh(mandate)
|
||||||
|
|
||||||
# Reload with steps (empty at creation)
|
mandate = await _get_mandate(db, mandate.id, org_id)
|
||||||
mandate = await _get_mandate(db, mandate.id)
|
return _mandate_out(mandate)
|
||||||
return MandateOut.model_validate(mandate)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{id}", response_model=MandateOut)
|
@router.get("/{id}", response_model=MandateOut)
|
||||||
async def get_mandate(
|
async def get_mandate(
|
||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> MandateOut:
|
) -> MandateOut:
|
||||||
"""Get a single mandate with all its steps."""
|
mandate = await _get_mandate(db, id, org_id)
|
||||||
mandate = await _get_mandate(db, id)
|
return _mandate_out(mandate)
|
||||||
return MandateOut.model_validate(mandate)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{id}", response_model=MandateOut)
|
@router.put("/{id}", response_model=MandateOut)
|
||||||
@@ -108,20 +140,16 @@ async def update_mandate(
|
|||||||
payload: MandateUpdate,
|
payload: MandateUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> MandateOut:
|
) -> MandateOut:
|
||||||
"""Update a mandate's metadata."""
|
mandate = await _get_mandate(db, id, org_id)
|
||||||
mandate = await _get_mandate(db, id)
|
|
||||||
|
|
||||||
update_data = payload.model_dump(exclude_unset=True)
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
for field, value in update_data.items():
|
|
||||||
setattr(mandate, field, value)
|
setattr(mandate, field, value)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(mandate)
|
mandate = await _get_mandate(db, mandate.id, org_id)
|
||||||
|
return _mandate_out(mandate)
|
||||||
# Reload with steps
|
|
||||||
mandate = await _get_mandate(db, mandate.id)
|
|
||||||
return MandateOut.model_validate(mandate)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
|
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
|
||||||
@@ -129,9 +157,9 @@ async def delete_mandate(
|
|||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete a mandate (only if in draft status)."""
|
mandate = await _get_mandate(db, id, org_id)
|
||||||
mandate = await _get_mandate(db, id)
|
|
||||||
|
|
||||||
if mandate.status != "draft":
|
if mandate.status != "draft":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -152,14 +180,11 @@ async def add_step(
|
|||||||
payload: MandateStepCreate,
|
payload: MandateStepCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> MandateStepOut:
|
) -> MandateStepOut:
|
||||||
"""Add a step to a mandate process."""
|
mandate = await _get_mandate(db, id, org_id)
|
||||||
mandate = await _get_mandate(db, id)
|
|
||||||
|
|
||||||
step = MandateStep(
|
step = MandateStep(mandate_id=mandate.id, **payload.model_dump())
|
||||||
mandate_id=mandate.id,
|
|
||||||
**payload.model_dump(),
|
|
||||||
)
|
|
||||||
db.add(step)
|
db.add(step)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(step)
|
await db.refresh(step)
|
||||||
@@ -171,9 +196,9 @@ async def add_step(
|
|||||||
async def list_steps(
|
async def list_steps(
|
||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> list[MandateStepOut]:
|
) -> list[MandateStepOut]:
|
||||||
"""List all steps for a mandate, ordered by step_order."""
|
mandate = await _get_mandate(db, id, org_id)
|
||||||
mandate = await _get_mandate(db, id)
|
|
||||||
return [MandateStepOut.model_validate(s) for s in mandate.steps]
|
return [MandateStepOut.model_validate(s) for s in mandate.steps]
|
||||||
|
|
||||||
|
|
||||||
@@ -185,18 +210,19 @@ async def advance_mandate_endpoint(
|
|||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> MandateAdvanceOut:
|
) -> MandateAdvanceOut:
|
||||||
"""Advance a mandate to its next step or status."""
|
|
||||||
try:
|
try:
|
||||||
mandate = await advance_mandate(id, db)
|
mandate = await advance_mandate(id, db)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(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, org_id)
|
||||||
mandate = await _get_mandate(db, mandate.id)
|
out = _mandate_out(mandate)
|
||||||
data = MandateOut.model_validate(mandate).model_dump()
|
return MandateAdvanceOut(
|
||||||
data["message"] = f"Mandat avance au statut : {mandate.status}"
|
**out.model_dump(),
|
||||||
return MandateAdvanceOut(**data)
|
message=f"Mandat avance au statut : {mandate.status}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{id}/assign", response_model=MandateOut)
|
@router.post("/{id}/assign", response_model=MandateOut)
|
||||||
@@ -205,16 +231,15 @@ async def assign_mandatee_endpoint(
|
|||||||
payload: MandateAssignRequest,
|
payload: MandateAssignRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> MandateOut:
|
) -> MandateOut:
|
||||||
"""Assign a mandatee to a mandate."""
|
|
||||||
try:
|
try:
|
||||||
mandate = await assign_mandatee(id, payload.mandatee_id, db)
|
mandate = await assign_mandatee(id, payload.mandatee_id, db)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
|
||||||
# Reload with steps
|
mandate = await _get_mandate(db, mandate.id, org_id)
|
||||||
mandate = await _get_mandate(db, mandate.id)
|
return _mandate_out(mandate)
|
||||||
return MandateOut.model_validate(mandate)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{id}/revoke", response_model=MandateOut)
|
@router.post("/{id}/revoke", response_model=MandateOut)
|
||||||
@@ -222,16 +247,15 @@ async def revoke_mandate_endpoint(
|
|||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> MandateOut:
|
) -> MandateOut:
|
||||||
"""Revoke an active mandate."""
|
|
||||||
try:
|
try:
|
||||||
mandate = await revoke_mandate(id, db)
|
mandate = await revoke_mandate(id, db)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
|
||||||
# Reload with steps
|
mandate = await _get_mandate(db, mandate.id, org_id)
|
||||||
mandate = await _get_mandate(db, mandate.id)
|
return _mandate_out(mandate)
|
||||||
return MandateOut.model_validate(mandate)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -244,8 +268,8 @@ async def create_vote_session_for_step_endpoint(
|
|||||||
step_id: uuid.UUID,
|
step_id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> VoteSessionOut:
|
) -> VoteSessionOut:
|
||||||
"""Create a vote session linked to a mandate step."""
|
|
||||||
try:
|
try:
|
||||||
session = await create_vote_session_for_step(id, step_id, db)
|
session = await create_vote_session_for_step(id, step_id, db)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Organizations router: list, create, membership."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import DuniterIdentity
|
||||||
|
from app.schemas.organization import OrgMemberOut, OrganizationCreate, OrganizationOut
|
||||||
|
from app.services.auth_service import get_current_identity
|
||||||
|
from app.services.org_service import (
|
||||||
|
add_member,
|
||||||
|
create_organization,
|
||||||
|
get_organization,
|
||||||
|
get_organization_by_slug,
|
||||||
|
is_member,
|
||||||
|
list_members,
|
||||||
|
list_organizations,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[OrganizationOut])
|
||||||
|
async def get_organizations(db: AsyncSession = Depends(get_db)) -> list[OrganizationOut]:
|
||||||
|
"""List all organizations (public — transparent ones need no auth)."""
|
||||||
|
orgs = await list_organizations(db)
|
||||||
|
return [OrganizationOut.model_validate(o) for o in orgs]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=OrganizationOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def post_organization(
|
||||||
|
payload: OrganizationCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
) -> OrganizationOut:
|
||||||
|
"""Create a new organization (authenticated users only)."""
|
||||||
|
existing = await get_organization_by_slug(db, payload.slug)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Slug '{payload.slug}' déjà utilisé",
|
||||||
|
)
|
||||||
|
org = await create_organization(db, **payload.model_dump())
|
||||||
|
# Creator becomes admin
|
||||||
|
await add_member(db, org.id, identity.id, role="admin")
|
||||||
|
return OrganizationOut.model_validate(org)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{org_id}", response_model=OrganizationOut)
|
||||||
|
async def get_organization_detail(
|
||||||
|
org_id: uuid.UUID, db: AsyncSession = Depends(get_db)
|
||||||
|
) -> OrganizationOut:
|
||||||
|
org = await get_organization(db, org_id)
|
||||||
|
if not org:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
|
||||||
|
return OrganizationOut.model_validate(org)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{org_id}/members", response_model=list[OrgMemberOut])
|
||||||
|
async def get_members(
|
||||||
|
org_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
) -> list[OrgMemberOut]:
|
||||||
|
org = await get_organization(db, org_id)
|
||||||
|
if not org:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
|
||||||
|
if not org.is_transparent and not await is_member(db, org_id, identity.id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Accès refusé")
|
||||||
|
members = await list_members(db, org_id)
|
||||||
|
return [OrgMemberOut.model_validate(m) for m in members]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{org_id}/join", response_model=OrgMemberOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def join_organization(
|
||||||
|
org_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
) -> OrgMemberOut:
|
||||||
|
"""Join a transparent organization."""
|
||||||
|
org = await get_organization(db, org_id)
|
||||||
|
if not org:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
|
||||||
|
if not org.is_transparent:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Rejoindre cette organisation nécessite une invitation",
|
||||||
|
)
|
||||||
|
if await is_member(db, org_id, identity.id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Déjà membre")
|
||||||
|
member = await add_member(db, org_id, identity.id)
|
||||||
|
return OrgMemberOut.model_validate(member)
|
||||||
@@ -25,6 +25,7 @@ from app.schemas.protocol import (
|
|||||||
VotingProtocolOut,
|
VotingProtocolOut,
|
||||||
VotingProtocolUpdate,
|
VotingProtocolUpdate,
|
||||||
)
|
)
|
||||||
|
from app.dependencies.org import get_active_org_id
|
||||||
from app.services.auth_service import get_current_identity
|
from app.services.auth_service import get_current_identity
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -33,13 +34,20 @@ router = APIRouter()
|
|||||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def _get_protocol(db: AsyncSession, protocol_id: uuid.UUID) -> VotingProtocol:
|
async def _get_protocol(
|
||||||
"""Fetch a voting protocol by ID with its formula config, or raise 404."""
|
db: AsyncSession, protocol_id: uuid.UUID, org_id: uuid.UUID | None = None
|
||||||
result = await db.execute(
|
) -> VotingProtocol:
|
||||||
|
"""Fetch a voting protocol by ID within the active org scope, or raise 404."""
|
||||||
|
stmt = (
|
||||||
select(VotingProtocol)
|
select(VotingProtocol)
|
||||||
.options(selectinload(VotingProtocol.formula_config))
|
.options(selectinload(VotingProtocol.formula_config))
|
||||||
.where(VotingProtocol.id == protocol_id)
|
.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()
|
protocol = result.scalar_one_or_none()
|
||||||
if protocol is None:
|
if protocol is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Protocole introuvable")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Protocole introuvable")
|
||||||
@@ -63,6 +71,7 @@ async def _get_formula(db: AsyncSession, formula_id: uuid.UUID) -> FormulaConfig
|
|||||||
@router.get("/", response_model=list[VotingProtocolOut])
|
@router.get("/", response_model=list[VotingProtocolOut])
|
||||||
async def list_protocols(
|
async def list_protocols(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
vote_type: str | None = Query(default=None, description="Filtrer par type de vote (binary, nuanced)"),
|
vote_type: str | None = Query(default=None, description="Filtrer par type de vote (binary, nuanced)"),
|
||||||
skip: int = Query(default=0, ge=0),
|
skip: int = Query(default=0, ge=0),
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
@@ -70,6 +79,10 @@ async def list_protocols(
|
|||||||
"""List all voting protocols with their formula configurations."""
|
"""List all voting protocols with their formula configurations."""
|
||||||
stmt = select(VotingProtocol).options(selectinload(VotingProtocol.formula_config))
|
stmt = select(VotingProtocol).options(selectinload(VotingProtocol.formula_config))
|
||||||
|
|
||||||
|
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:
|
if vote_type is not None:
|
||||||
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
|
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
|
||||||
|
|
||||||
@@ -85,6 +98,7 @@ async def create_protocol(
|
|||||||
payload: VotingProtocolCreate,
|
payload: VotingProtocolCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> VotingProtocolOut:
|
) -> VotingProtocolOut:
|
||||||
"""Create a new voting protocol.
|
"""Create a new voting protocol.
|
||||||
|
|
||||||
@@ -100,13 +114,13 @@ async def create_protocol(
|
|||||||
detail="Configuration de formule introuvable",
|
detail="Configuration de formule introuvable",
|
||||||
)
|
)
|
||||||
|
|
||||||
protocol = VotingProtocol(**payload.model_dump())
|
protocol = VotingProtocol(**payload.model_dump(), organization_id=org_id)
|
||||||
db.add(protocol)
|
db.add(protocol)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(protocol)
|
await db.refresh(protocol)
|
||||||
|
|
||||||
# Reload with formula config
|
# 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)
|
return VotingProtocolOut.model_validate(protocol)
|
||||||
|
|
||||||
|
|
||||||
@@ -114,9 +128,10 @@ async def create_protocol(
|
|||||||
async def get_protocol(
|
async def get_protocol(
|
||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> VotingProtocolOut:
|
) -> VotingProtocolOut:
|
||||||
"""Get a single voting protocol with its formula configuration."""
|
"""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)
|
return VotingProtocolOut.model_validate(protocol)
|
||||||
|
|
||||||
|
|
||||||
@@ -126,12 +141,13 @@ async def update_protocol(
|
|||||||
payload: VotingProtocolUpdate,
|
payload: VotingProtocolUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> VotingProtocolOut:
|
) -> VotingProtocolOut:
|
||||||
"""Update a voting protocol (meta-governance).
|
"""Update a voting protocol (meta-governance).
|
||||||
|
|
||||||
Only provided fields will be updated. Requires authentication.
|
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)
|
update_data = payload.model_dump(exclude_unset=True)
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
@@ -140,7 +156,7 @@ async def update_protocol(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Reload with formula config
|
# 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)
|
return VotingProtocolOut.model_validate(protocol)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""Qualify router: decision qualification engine endpoint."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.engine.qualifier import QualificationConfig, QualificationInput, qualify
|
||||||
|
from app.models.qualification import QualificationProtocol
|
||||||
|
from app.schemas.qualification import (
|
||||||
|
QualificationProtocolCreate,
|
||||||
|
QualificationProtocolOut,
|
||||||
|
QualifyRequest,
|
||||||
|
QualifyResponse,
|
||||||
|
)
|
||||||
|
from app.services.auth_service import get_current_identity
|
||||||
|
from app.services.qualify_ai_service import (
|
||||||
|
AIFrameRequest,
|
||||||
|
AIFrameResponse,
|
||||||
|
AIMessage,
|
||||||
|
AIQuestion,
|
||||||
|
AIQualifyResult,
|
||||||
|
ai_frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pydantic wrappers for AI chat (FastAPI needs Pydantic, not dataclasses) ──
|
||||||
|
|
||||||
|
|
||||||
|
class AIMessagePayload(BaseModel):
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class AIChatRequest(BaseModel):
|
||||||
|
within_mandate: bool = False
|
||||||
|
affected_count: int | None = None
|
||||||
|
is_structural: bool = False
|
||||||
|
context: str | None = None
|
||||||
|
messages: list[AIMessagePayload] = []
|
||||||
|
|
||||||
|
|
||||||
|
class AIQuestionOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
text: str
|
||||||
|
options: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class AIQualifyResultOut(BaseModel):
|
||||||
|
decision_type: str
|
||||||
|
process: str
|
||||||
|
recommended_modalities: list[str]
|
||||||
|
recommend_onchain: bool
|
||||||
|
onchain_reason: str | None
|
||||||
|
confidence: str
|
||||||
|
collective_available: bool
|
||||||
|
record_in_observatory: bool
|
||||||
|
reasons: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class AIChatResponse(BaseModel):
|
||||||
|
done: bool
|
||||||
|
questions: list[AIQuestionOut] = []
|
||||||
|
result: AIQualifyResultOut | None = None
|
||||||
|
explanation: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_config(db: AsyncSession) -> QualificationConfig:
|
||||||
|
"""Load the active QualificationProtocol from DB, or fall back to defaults."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(QualificationProtocol)
|
||||||
|
.where(QualificationProtocol.is_active == True) # noqa: E712
|
||||||
|
.order_by(QualificationProtocol.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
proto = result.scalar_one_or_none()
|
||||||
|
if proto is None:
|
||||||
|
return QualificationConfig()
|
||||||
|
return QualificationConfig(
|
||||||
|
small_group_max=proto.small_group_max,
|
||||||
|
collective_wot_min=proto.collective_wot_min,
|
||||||
|
default_modalities=proto.default_modalities,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=QualifyResponse)
|
||||||
|
async def qualify_decision(
|
||||||
|
payload: QualifyRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> QualifyResponse:
|
||||||
|
"""Qualify a decision: determine type, process, and modalities.
|
||||||
|
|
||||||
|
No authentication required — this is an advisory endpoint that helps
|
||||||
|
users understand which decision pathway fits their situation.
|
||||||
|
"""
|
||||||
|
config = await _load_config(db)
|
||||||
|
inp = QualificationInput(
|
||||||
|
within_mandate=payload.within_mandate,
|
||||||
|
affected_count=payload.affected_count,
|
||||||
|
is_structural=payload.is_structural,
|
||||||
|
context_description=payload.context_description,
|
||||||
|
)
|
||||||
|
result = qualify(inp, config)
|
||||||
|
return QualifyResponse(**asdict(result))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ai-chat", response_model=AIChatResponse)
|
||||||
|
async def ai_chat(payload: AIChatRequest) -> AIChatResponse:
|
||||||
|
"""Run one round of AI-assisted qualification framing.
|
||||||
|
|
||||||
|
Round 1 (messages=[]) → returns 2 clarifying questions.
|
||||||
|
Round 2 (messages set) → returns final qualification result.
|
||||||
|
|
||||||
|
No auth required — advisory endpoint.
|
||||||
|
"""
|
||||||
|
req = AIFrameRequest(
|
||||||
|
within_mandate=payload.within_mandate,
|
||||||
|
affected_count=payload.affected_count,
|
||||||
|
is_structural=payload.is_structural,
|
||||||
|
context=payload.context,
|
||||||
|
messages=[AIMessage(role=m.role, content=m.content) for m in payload.messages],
|
||||||
|
)
|
||||||
|
resp = ai_frame(req)
|
||||||
|
|
||||||
|
return AIChatResponse(
|
||||||
|
done=resp.done,
|
||||||
|
questions=[AIQuestionOut(id=q.id, text=q.text, options=q.options) for q in resp.questions],
|
||||||
|
result=AIQualifyResultOut(**asdict(resp.result)) if resp.result else None,
|
||||||
|
explanation=resp.explanation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/protocol", response_model=QualificationProtocolOut | None)
|
||||||
|
async def get_active_protocol(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> QualificationProtocolOut | None:
|
||||||
|
"""Return the currently active qualification protocol (thresholds)."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(QualificationProtocol)
|
||||||
|
.where(QualificationProtocol.is_active == True) # noqa: E712
|
||||||
|
.order_by(QualificationProtocol.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
proto = result.scalar_one_or_none()
|
||||||
|
if proto is None:
|
||||||
|
return None
|
||||||
|
return QualificationProtocolOut.model_validate(proto)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/protocol", response_model=QualificationProtocolOut, status_code=201)
|
||||||
|
async def create_protocol(
|
||||||
|
payload: QualificationProtocolCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_identity=Depends(get_current_identity),
|
||||||
|
) -> QualificationProtocolOut:
|
||||||
|
"""Create a new qualification protocol (requires auth).
|
||||||
|
|
||||||
|
Deactivates the current active protocol before saving the new one.
|
||||||
|
"""
|
||||||
|
# Deactivate current
|
||||||
|
current = await db.execute(
|
||||||
|
select(QualificationProtocol).where(QualificationProtocol.is_active == True) # noqa: E712
|
||||||
|
)
|
||||||
|
for proto in current.scalars().all():
|
||||||
|
proto.is_active = False
|
||||||
|
|
||||||
|
import json
|
||||||
|
proto = QualificationProtocol(
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
small_group_max=payload.small_group_max,
|
||||||
|
collective_wot_min=payload.collective_wot_min,
|
||||||
|
default_modalities_json=json.dumps(payload.default_modalities),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(proto)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(proto)
|
||||||
|
return QualificationProtocolOut.model_validate(proto)
|
||||||
@@ -25,6 +25,7 @@ from app.schemas.vote import (
|
|||||||
VoteSessionListOut,
|
VoteSessionListOut,
|
||||||
VoteSessionOut,
|
VoteSessionOut,
|
||||||
)
|
)
|
||||||
|
from app.dependencies.org import get_active_org_id
|
||||||
from app.services.auth_service import get_current_identity
|
from app.services.auth_service import get_current_identity
|
||||||
from app.services.vote_service import (
|
from app.services.vote_service import (
|
||||||
close_session as svc_close_session,
|
close_session as svc_close_session,
|
||||||
@@ -170,14 +171,22 @@ def _compute_result(
|
|||||||
@router.get("/sessions", response_model=list[VoteSessionListOut])
|
@router.get("/sessions", response_model=list[VoteSessionListOut])
|
||||||
async def list_vote_sessions(
|
async def list_vote_sessions(
|
||||||
db: AsyncSession = Depends(get_db),
|
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)"),
|
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"),
|
decision_id: uuid.UUID | None = Query(default=None, description="Filtrer par decision_id"),
|
||||||
skip: int = Query(default=0, ge=0),
|
skip: int = Query(default=0, ge=0),
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
) -> list[VoteSessionListOut]:
|
) -> list[VoteSessionListOut]:
|
||||||
"""List all vote sessions with optional filters by status and decision_id."""
|
"""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:
|
if session_status is not None:
|
||||||
stmt = stmt.where(VoteSession.status == session_status)
|
stmt = stmt.where(VoteSession.status == session_status)
|
||||||
if decision_id is not None:
|
if decision_id is not None:
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMemberCreate(BaseModel):
|
||||||
|
display_name: str
|
||||||
|
identity_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMemberOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
display_name: str
|
||||||
|
identity_id: uuid.UUID | None
|
||||||
|
added_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class GroupCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GroupOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
organization_id: uuid.UUID | None
|
||||||
|
created_at: datetime
|
||||||
|
members: list[GroupMemberOut] = []
|
||||||
|
|
||||||
|
|
||||||
|
class GroupSummary(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
organization_id: uuid.UUID | None
|
||||||
|
member_count: int = 0
|
||||||
@@ -10,21 +10,13 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
|
|
||||||
|
|
||||||
class MandateStepCreate(BaseModel):
|
class MandateStepCreate(BaseModel):
|
||||||
"""Payload for creating a step within a mandate process."""
|
|
||||||
|
|
||||||
step_order: int = Field(..., ge=0)
|
step_order: int = Field(..., ge=0)
|
||||||
step_type: str = Field(
|
step_type: str = Field(..., max_length=32)
|
||||||
...,
|
|
||||||
max_length=32,
|
|
||||||
description="formulation, candidacy, vote, assignment, reporting, completion, revocation",
|
|
||||||
)
|
|
||||||
title: str | None = Field(default=None, max_length=256)
|
title: str | None = Field(default=None, max_length=256)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class MandateStepOut(BaseModel):
|
class MandateStepOut(BaseModel):
|
||||||
"""Full mandate step representation."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: UUID
|
id: UUID
|
||||||
@@ -43,40 +35,45 @@ class MandateStepOut(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class MandateCreate(BaseModel):
|
class MandateCreate(BaseModel):
|
||||||
"""Payload for creating a new mandate."""
|
|
||||||
|
|
||||||
title: str = Field(..., min_length=1, max_length=256)
|
title: str = Field(..., min_length=1, max_length=256)
|
||||||
|
origin_id: UUID | None = None
|
||||||
description: str | 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
|
decision_id: UUID | None = None
|
||||||
|
starts_at: datetime | None = None
|
||||||
|
ends_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class MandateUpdate(BaseModel):
|
class MandateUpdate(BaseModel):
|
||||||
"""Partial update for a mandate."""
|
|
||||||
|
|
||||||
title: str | None = Field(default=None, max_length=256)
|
title: str | None = Field(default=None, max_length=256)
|
||||||
|
origin_id: UUID | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
mandate_type: str | None = Field(default=None, max_length=64)
|
mandate_type: str | None = Field(default=None, max_length=64)
|
||||||
decision_id: UUID | None = None
|
decision_id: UUID | None = None
|
||||||
|
starts_at: datetime | None = None
|
||||||
|
ends_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class MandateAssignRequest(BaseModel):
|
class MandateAssignRequest(BaseModel):
|
||||||
"""Request body for assigning a mandatee to a mandate."""
|
mandatee_id: UUID = Field(..., description="UUID de l'identite Duniter du mandataire")
|
||||||
|
|
||||||
mandatee_id: UUID = Field(..., description="ID de l'identite Duniter du mandataire")
|
|
||||||
|
|
||||||
|
|
||||||
class MandateOut(BaseModel):
|
class MandateOut(BaseModel):
|
||||||
"""Full mandate representation returned by the API."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: UUID
|
id: UUID
|
||||||
title: str
|
title: str
|
||||||
|
origin_id: UUID | None = None
|
||||||
|
origin_display_name: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
mandate_type: str
|
mandate_type: str
|
||||||
status: str
|
status: str
|
||||||
mandatee_id: UUID | None = None
|
mandatee_id: UUID | None = None
|
||||||
|
mandatee_display_name: str | None = None
|
||||||
decision_id: UUID | None = None
|
decision_id: UUID | None = None
|
||||||
starts_at: datetime | None = None
|
starts_at: datetime | None = None
|
||||||
ends_at: datetime | None = None
|
ends_at: datetime | None = None
|
||||||
@@ -85,21 +82,5 @@ class MandateOut(BaseModel):
|
|||||||
steps: list[MandateStepOut] = Field(default_factory=list)
|
steps: list[MandateStepOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class MandateAdvanceOut(BaseModel):
|
class MandateAdvanceOut(MandateOut):
|
||||||
"""Output after advancing a mandate through its workflow."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: UUID
|
|
||||||
title: str
|
|
||||||
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)
|
|
||||||
message: str = Field(..., description="Message decrivant l'avancement effectue")
|
message: str = Field(..., description="Message decrivant l'avancement effectue")
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Pydantic v2 schemas for organizations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
org_type: str
|
||||||
|
is_transparent: bool
|
||||||
|
color: str | None
|
||||||
|
icon: str | None
|
||||||
|
description: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
org_type: str = "community"
|
||||||
|
is_transparent: bool = False
|
||||||
|
color: str | None = None
|
||||||
|
icon: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrgMemberOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
org_id: uuid.UUID
|
||||||
|
identity_id: uuid.UUID
|
||||||
|
role: str
|
||||||
|
created_at: datetime
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class QualifyRequest(BaseModel):
|
||||||
|
within_mandate: bool = False
|
||||||
|
affected_count: int | None = Field(default=None, ge=2, description="Nombre de personnes concernées (minimum 2)")
|
||||||
|
is_structural: bool = False
|
||||||
|
context_description: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class QualifyResponse(BaseModel):
|
||||||
|
decision_type: str
|
||||||
|
process: str
|
||||||
|
recommended_modalities: list[str]
|
||||||
|
recommend_onchain: bool
|
||||||
|
onchain_reason: str | None
|
||||||
|
confidence: str
|
||||||
|
collective_available: bool
|
||||||
|
record_in_observatory: bool
|
||||||
|
reasons: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class QualificationProtocolOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
small_group_max: int
|
||||||
|
collective_wot_min: int
|
||||||
|
default_modalities: list[str]
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class QualificationProtocolCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=128)
|
||||||
|
description: str | None = None
|
||||||
|
small_group_max: int = Field(default=5, ge=1)
|
||||||
|
collective_wot_min: int = Field(default=50, ge=1)
|
||||||
|
default_modalities: list[str] = Field(
|
||||||
|
default=["vote_wot", "vote_smith", "consultation_avis", "election"]
|
||||||
|
)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Organization service: CRUD + membership helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.organization import OrgMember, Organization
|
||||||
|
|
||||||
|
|
||||||
|
async def list_organizations(db: AsyncSession) -> Sequence[Organization]:
|
||||||
|
result = await db.execute(select(Organization).order_by(Organization.name))
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_organization(db: AsyncSession, org_id: uuid.UUID) -> Organization | None:
|
||||||
|
return await db.get(Organization, org_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_organization_by_slug(db: AsyncSession, slug: str) -> Organization | None:
|
||||||
|
result = await db.execute(select(Organization).where(Organization.slug == slug))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_organization(db: AsyncSession, **kwargs) -> Organization:
|
||||||
|
org = Organization(**kwargs)
|
||||||
|
db.add(org)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(org)
|
||||||
|
return org
|
||||||
|
|
||||||
|
|
||||||
|
async def is_member(db: AsyncSession, org_id: uuid.UUID, identity_id: uuid.UUID) -> bool:
|
||||||
|
result = await db.execute(
|
||||||
|
select(OrgMember).where(
|
||||||
|
OrgMember.org_id == org_id,
|
||||||
|
OrgMember.identity_id == identity_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def add_member(
|
||||||
|
db: AsyncSession, org_id: uuid.UUID, identity_id: uuid.UUID, role: str = "member"
|
||||||
|
) -> OrgMember:
|
||||||
|
member = OrgMember(org_id=org_id, identity_id=identity_id, role=role)
|
||||||
|
db.add(member)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(member)
|
||||||
|
return member
|
||||||
|
|
||||||
|
|
||||||
|
async def list_members(db: AsyncSession, org_id: uuid.UUID) -> Sequence[OrgMember]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(OrgMember).where(OrgMember.org_id == org_id).order_by(OrgMember.created_at)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""AI framing service for decision qualification.
|
||||||
|
|
||||||
|
Orchestrates a 2-round conversation that clarifies reversibility and urgency
|
||||||
|
before producing a final QualificationResult.
|
||||||
|
|
||||||
|
Rule-based stub — Qwen3.6 (MacStudio) calls will replace ai_frame() internals
|
||||||
|
once the local endpoint is available. The interface is stable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schemas (dataclasses — no Pydantic dependency in the engine layer)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AIMessage:
|
||||||
|
role: str # "user" | "assistant"
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AIQuestion:
|
||||||
|
id: str
|
||||||
|
text: str
|
||||||
|
options: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AIQualifyResult:
|
||||||
|
decision_type: str
|
||||||
|
process: str
|
||||||
|
recommended_modalities: list[str]
|
||||||
|
recommend_onchain: bool
|
||||||
|
onchain_reason: str | None
|
||||||
|
confidence: str
|
||||||
|
collective_available: bool
|
||||||
|
record_in_observatory: bool
|
||||||
|
reasons: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AIFrameRequest:
|
||||||
|
within_mandate: bool = False
|
||||||
|
affected_count: int | None = None
|
||||||
|
is_structural: bool = False
|
||||||
|
context: str | None = None
|
||||||
|
messages: list[AIMessage] | None = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.messages is None:
|
||||||
|
self.messages = []
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AIFrameResponse:
|
||||||
|
done: bool
|
||||||
|
questions: list[AIQuestion]
|
||||||
|
result: AIQualifyResult | None
|
||||||
|
explanation: str | None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Standard clarifying questions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_CLARIFYING_QUESTIONS: list[AIQuestion] = [
|
||||||
|
AIQuestion(
|
||||||
|
id="reversibility",
|
||||||
|
text="Si cette décision s'avère inappropriée dans 6 mois, peut-on facilement revenir en arrière ?",
|
||||||
|
options=[
|
||||||
|
"Oui, facilement",
|
||||||
|
"Difficilement",
|
||||||
|
"Non, c'est irréversible",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
AIQuestion(
|
||||||
|
id="urgency",
|
||||||
|
text="Y a-t-il une contrainte temporelle sur cette décision ?",
|
||||||
|
options=[
|
||||||
|
"Urgente (< 1 semaine)",
|
||||||
|
"Délai raisonnable (quelques semaines)",
|
||||||
|
"Pas d'urgence",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core function
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def ai_frame(request: AIFrameRequest) -> AIFrameResponse:
|
||||||
|
"""Run one round of AI framing.
|
||||||
|
|
||||||
|
Round 1 (messages=[]) → return 2 clarifying questions, done=False
|
||||||
|
Round 2 (messages set) → parse answers, qualify, return result, done=True
|
||||||
|
"""
|
||||||
|
messages = request.messages or []
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
return AIFrameResponse(
|
||||||
|
done=False,
|
||||||
|
questions=list(_CLARIFYING_QUESTIONS),
|
||||||
|
result=None,
|
||||||
|
explanation=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
answers = _parse_answers(messages)
|
||||||
|
result = _build_result(request, answers)
|
||||||
|
explanation = _build_explanation(answers)
|
||||||
|
|
||||||
|
return AIFrameResponse(
|
||||||
|
done=True,
|
||||||
|
questions=[],
|
||||||
|
result=result,
|
||||||
|
explanation=explanation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_answers(messages: list[AIMessage]) -> dict[str, str]:
|
||||||
|
"""Extract question answers from the last user message.
|
||||||
|
|
||||||
|
Expected format: "reversibility:<answer>|urgency:<answer>"
|
||||||
|
"""
|
||||||
|
answers: dict[str, str] = {}
|
||||||
|
for msg in reversed(messages):
|
||||||
|
if msg.role == "user" and "|" in msg.content and ":" in msg.content:
|
||||||
|
for part in msg.content.split("|"):
|
||||||
|
if ":" in part:
|
||||||
|
key, _, val = part.partition(":")
|
||||||
|
answers[key.strip()] = val.strip()
|
||||||
|
break
|
||||||
|
return answers
|
||||||
|
|
||||||
|
|
||||||
|
def _build_result(request: AIFrameRequest, answers: dict[str, str]) -> AIQualifyResult:
|
||||||
|
"""Produce a qualification result enriched by the AI answers."""
|
||||||
|
from app.engine.qualifier import (
|
||||||
|
QualificationConfig,
|
||||||
|
QualificationInput,
|
||||||
|
qualify,
|
||||||
|
)
|
||||||
|
|
||||||
|
config = QualificationConfig()
|
||||||
|
inp = QualificationInput(
|
||||||
|
within_mandate=request.within_mandate,
|
||||||
|
affected_count=request.affected_count,
|
||||||
|
is_structural=request.is_structural,
|
||||||
|
context_description=request.context,
|
||||||
|
)
|
||||||
|
base = qualify(inp, config)
|
||||||
|
|
||||||
|
reasons = list(base.reasons)
|
||||||
|
|
||||||
|
reversibility = answers.get("reversibility", "")
|
||||||
|
if "irréversible" in reversibility.lower():
|
||||||
|
reasons.append("Décision irréversible : consensus élevé recommandé.")
|
||||||
|
|
||||||
|
urgency = answers.get("urgency", "")
|
||||||
|
if "urgente" in urgency.lower() or "< 1" in urgency:
|
||||||
|
reasons.append("Urgence signalée : privilégier un protocole à délai court.")
|
||||||
|
|
||||||
|
return AIQualifyResult(
|
||||||
|
decision_type=base.decision_type.value,
|
||||||
|
process=base.process,
|
||||||
|
recommended_modalities=base.recommended_modalities,
|
||||||
|
recommend_onchain=base.recommend_onchain,
|
||||||
|
onchain_reason=base.onchain_reason,
|
||||||
|
confidence=base.confidence,
|
||||||
|
collective_available=base.collective_available,
|
||||||
|
record_in_observatory=base.record_in_observatory,
|
||||||
|
reasons=reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_explanation(answers: dict[str, str]) -> str:
|
||||||
|
parts = []
|
||||||
|
rev = answers.get("reversibility", "")
|
||||||
|
urg = answers.get("urgency", "")
|
||||||
|
if rev:
|
||||||
|
parts.append(f"Réversibilité : {rev}.")
|
||||||
|
if urg:
|
||||||
|
parts.append(f"Urgence : {urg}.")
|
||||||
|
return " ".join(parts) if parts else "Qualification basée sur les éléments fournis."
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""Tests for middleware stack: CORS headers, rate limiting, dev auth flow.
|
||||||
|
|
||||||
|
Critical invariants:
|
||||||
|
- ALL responses (including 429) must carry CORS headers when origin is allowed
|
||||||
|
- Dev login flow must survive repeated logins without hitting rate limit
|
||||||
|
- OPTIONS preflight must never be rate-limited
|
||||||
|
|
||||||
|
Note: each test uses a unique X-Forwarded-For IP to isolate rate limit counters,
|
||||||
|
since the rate limiter is in-memory and shared across the app instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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"
|
||||||
|
ME_URL = "/api/v1/auth/me"
|
||||||
|
|
||||||
|
DEV_ADDRESS = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# INVARIANT 1: 429 responses must include CORS headers
|
||||||
|
# Without this, the browser sees "Failed to fetch" instead of "Too Many Requests"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_rate_limited_response_has_cors_headers():
|
||||||
|
"""A 429 from the rate limiter must still carry Access-Control-Allow-Origin.
|
||||||
|
|
||||||
|
Root cause of the "no response / Failed to fetch" bug: the rate limiter
|
||||||
|
sits outside CORS in the middleware stack, so its 429 responses have no
|
||||||
|
CORS headers and the browser discards them as network errors.
|
||||||
|
"""
|
||||||
|
# dev auth limit = 60/min (RATE_LIMIT_DEFAULT), prod = 10/min (RATE_LIMIT_AUTH)
|
||||||
|
# Send 65 requests to guarantee 429 regardless of environment.
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
last_response = None
|
||||||
|
for _ in range(65):
|
||||||
|
resp = await client.post(
|
||||||
|
CHALLENGE_URL,
|
||||||
|
json={"address": DEV_ADDRESS},
|
||||||
|
headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.1.1"},
|
||||||
|
)
|
||||||
|
last_response = resp
|
||||||
|
if resp.status_code == 429:
|
||||||
|
break
|
||||||
|
|
||||||
|
assert last_response is not None
|
||||||
|
assert last_response.status_code == 429, (
|
||||||
|
"Expected 429 after exceeding auth rate limit"
|
||||||
|
)
|
||||||
|
assert "access-control-allow-origin" in last_response.headers, (
|
||||||
|
"429 response must include CORS headers so the browser can read the error"
|
||||||
|
)
|
||||||
|
assert last_response.headers["access-control-allow-origin"] == ORIGIN
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# INVARIANT 2: OPTIONS preflight must never be rate-limited
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_options_preflight_never_rate_limited():
|
||||||
|
"""OPTIONS requests must pass through regardless of request count.
|
||||||
|
|
||||||
|
Browsers send a preflight before every cross-origin POST with custom headers.
|
||||||
|
A 429 on OPTIONS prevents the real request from ever being sent.
|
||||||
|
"""
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
for i in range(20):
|
||||||
|
resp = await client.options(
|
||||||
|
CHALLENGE_URL,
|
||||||
|
headers={
|
||||||
|
"Origin": ORIGIN,
|
||||||
|
"Access-Control-Request-Method": "POST",
|
||||||
|
"Access-Control-Request-Headers": "content-type",
|
||||||
|
"X-Forwarded-For": "10.0.2.1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code != 429, (
|
||||||
|
f"OPTIONS request #{i + 1} was rate-limited (429) — preflights must never be blocked"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# INVARIANT 3: Dev login flow must survive ≥ 10 consecutive logins
|
||||||
|
# (challenge + verify cycle, dev profile bypass)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_dev_login_survives_repeated_cycles():
|
||||||
|
"""Complete login cycle (challenge → verify) must work ≥ 10 times in a row.
|
||||||
|
|
||||||
|
In dev mode, the developer disconnects and reconnects frequently.
|
||||||
|
With auth rate limit = 10/min, the 6th challenge request would be blocked.
|
||||||
|
Dev mode must use a higher limit (≥ 60/min) to prevent login lockout.
|
||||||
|
"""
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
tokens = []
|
||||||
|
for i in range(10):
|
||||||
|
# Step 1: get challenge
|
||||||
|
ch_resp = await client.post(
|
||||||
|
CHALLENGE_URL,
|
||||||
|
json={"address": DEV_ADDRESS},
|
||||||
|
headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.3.1"},
|
||||||
|
)
|
||||||
|
assert ch_resp.status_code == 200, (
|
||||||
|
f"Login cycle #{i + 1}: challenge returned {ch_resp.status_code} — "
|
||||||
|
f"rate limit likely hit. Dev mode requires RATE_LIMIT_AUTH ≥ 60/min."
|
||||||
|
)
|
||||||
|
challenge = ch_resp.json()["challenge"]
|
||||||
|
|
||||||
|
# Step 2: verify (dev bypass — any signature accepted for dev addresses)
|
||||||
|
v_resp = await client.post(
|
||||||
|
VERIFY_URL,
|
||||||
|
json={
|
||||||
|
"address": DEV_ADDRESS,
|
||||||
|
"challenge": challenge,
|
||||||
|
"signature": "0x" + "ab" * 64,
|
||||||
|
},
|
||||||
|
headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.3.1"},
|
||||||
|
)
|
||||||
|
assert v_resp.status_code == 200, (
|
||||||
|
f"Login cycle #{i + 1}: verify returned {v_resp.status_code}"
|
||||||
|
)
|
||||||
|
tokens.append(v_resp.json()["token"])
|
||||||
|
|
||||||
|
assert len(tokens) == 10, "Expected 10 successful login cycles"
|
||||||
|
assert len(set(tokens)) == 10, "Each login cycle must produce a unique token"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# INVARIANT 4: /auth/me OPTIONS preflight must return CORS headers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_auth_me_options_preflight_returns_cors():
|
||||||
|
"""OPTIONS preflight for /auth/me must return 200 with CORS headers.
|
||||||
|
|
||||||
|
This was the root cause of the session-lost-on-reload bug:
|
||||||
|
repeated /auth/me calls would exhaust the auth rate limit,
|
||||||
|
the 429 OPTIONS response had no CORS headers,
|
||||||
|
and the browser threw 'Failed to fetch'.
|
||||||
|
"""
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.options(
|
||||||
|
ME_URL,
|
||||||
|
headers={
|
||||||
|
"Origin": ORIGIN,
|
||||||
|
"Access-Control-Request-Method": "GET",
|
||||||
|
"Access-Control-Request-Headers": "authorization",
|
||||||
|
"X-Forwarded-For": "10.0.4.1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code in (200, 204), (
|
||||||
|
f"OPTIONS /auth/me returned {resp.status_code}"
|
||||||
|
)
|
||||||
|
assert "access-control-allow-origin" in resp.headers
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""TDD — Moteur de qualification des décisions.
|
||||||
|
|
||||||
|
Source de vérité exécutable des règles métier du tunnel "Décider".
|
||||||
|
|
||||||
|
Règles testées :
|
||||||
|
R1 within_mandate → individual + consultation_avis
|
||||||
|
R2 within_mandate → aucune modalité de vote + consignation Observatoire
|
||||||
|
R4 2 ≤ affected_count ≤ small_group_max → individual recommandé, collectif disponible
|
||||||
|
R5 small_group_max < affected_count ≤ collective_wot_min → collective recommandé
|
||||||
|
R6 affected_count > collective_wot_min → collective recommandé (WoT applicable, non obligatoire)
|
||||||
|
R7 is_structural → recommend_onchain + raison explicite
|
||||||
|
R8 is_structural=False → recommend_onchain=False
|
||||||
|
|
||||||
|
GARDE-FOUS (invariants internes qui ne doivent jamais régresser) :
|
||||||
|
G1 decision_type est toujours dans l'enum autorisé
|
||||||
|
G2 individual n'expose jamais de modalités de vote
|
||||||
|
G3 collective expose au moins une modalité
|
||||||
|
G4 les seuils sont lus depuis QualificationConfig (configurables)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.qualifier import (
|
||||||
|
DecisionType,
|
||||||
|
QualificationConfig,
|
||||||
|
QualificationInput,
|
||||||
|
QualificationResult,
|
||||||
|
qualify,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = QualificationConfig()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# R1 — within_mandate → individual + consultation_avis
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_r1_within_mandate_gives_individual():
|
||||||
|
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
|
||||||
|
assert result.decision_type == DecisionType.INDIVIDUAL
|
||||||
|
|
||||||
|
|
||||||
|
def test_r1_within_mandate_gives_consultation_avis():
|
||||||
|
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
|
||||||
|
assert result.process == "consultation_avis"
|
||||||
|
|
||||||
|
|
||||||
|
def test_r1_within_mandate_overrides_large_affected_count():
|
||||||
|
"""Même si de nombreuses personnes sont concernées, un mandat impose individual."""
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=True, affected_count=500),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.decision_type == DecisionType.INDIVIDUAL
|
||||||
|
assert result.process == "consultation_avis"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# R2 — within_mandate → aucune modalité de vote + consignation Observatoire
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_r2_within_mandate_no_vote_modalities():
|
||||||
|
"""Le mandataire décide seul après consultation — pas de vote collectif."""
|
||||||
|
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
|
||||||
|
assert result.recommended_modalities == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_r2_within_mandate_records_in_observatory():
|
||||||
|
"""Une décision dans un mandat doit être consignée dans l'Observatoire."""
|
||||||
|
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
|
||||||
|
assert result.record_in_observatory is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_r2_out_of_mandate_does_not_force_observatory():
|
||||||
|
"""Hors mandat, la consignation dans l'Observatoire n'est pas imposée."""
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=10),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.record_in_observatory is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# R4 — 2 ≤ affected_count ≤ small_group_max → individual recommandé
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_r4_small_group_recommends_individual():
|
||||||
|
for count in range(2, DEFAULT_CONFIG.small_group_max + 1):
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=count),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.decision_type == DecisionType.INDIVIDUAL, (
|
||||||
|
f"affected_count={count} devrait recommander individual"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_r4_small_group_collective_is_available():
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=3),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.collective_available is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_r4_small_group_confidence_is_recommended():
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=3),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.confidence == "recommended"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# R5 — small_group_max < affected_count ≤ collective_wot_min → collective recommandé
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_r5_medium_group_recommends_collective():
|
||||||
|
mid = DEFAULT_CONFIG.small_group_max + 1
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=mid),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.decision_type == DecisionType.COLLECTIVE
|
||||||
|
|
||||||
|
|
||||||
|
def test_r5_medium_group_confidence_is_recommended():
|
||||||
|
mid = DEFAULT_CONFIG.small_group_max + 1
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=mid),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.confidence == "recommended"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# R6 — affected_count > collective_wot_min → collective recommandé (pas obligatoire)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_r6_large_group_recommends_collective():
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.decision_type == DecisionType.COLLECTIVE
|
||||||
|
|
||||||
|
|
||||||
|
def test_r6_large_group_confidence_is_recommended_not_required():
|
||||||
|
"""Au-delà du seuil WoT, le vote collectif est recommandé — pas imposé."""
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.confidence == "recommended"
|
||||||
|
|
||||||
|
|
||||||
|
def test_r6_large_group_includes_vote_wot_modality():
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert "vote_wot" in result.recommended_modalities
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# R7 — is_structural=True → recommend_onchain + raison
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_r7_structural_recommends_onchain():
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=100, is_structural=True),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.recommend_onchain is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_r7_structural_provides_onchain_reason():
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=100, is_structural=True),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.onchain_reason is not None and len(result.onchain_reason) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_r7_structural_within_mandate_can_also_recommend_onchain():
|
||||||
|
"""Même une décision dans un mandat peut être gravée si structurante."""
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=True, is_structural=True),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.recommend_onchain is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# R8 — is_structural=False → recommend_onchain=False
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_r8_non_structural_never_recommends_onchain():
|
||||||
|
for count in [2, 3, 10, 100]:
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=count, is_structural=False),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.recommend_onchain is False, (
|
||||||
|
f"affected_count={count} non structurant : on-chain ne doit pas être proposé"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GARDE-FOUS internes (régressions silencieuses)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_g1_decision_type_always_valid():
|
||||||
|
valid_types = set(DecisionType)
|
||||||
|
for inp in [
|
||||||
|
QualificationInput(within_mandate=True),
|
||||||
|
QualificationInput(within_mandate=False, affected_count=2),
|
||||||
|
QualificationInput(within_mandate=False, affected_count=10),
|
||||||
|
QualificationInput(within_mandate=False, affected_count=100),
|
||||||
|
]:
|
||||||
|
result = qualify(inp, DEFAULT_CONFIG)
|
||||||
|
assert result.decision_type in valid_types
|
||||||
|
|
||||||
|
|
||||||
|
def test_g2_individual_never_has_vote_modalities():
|
||||||
|
for inp in [
|
||||||
|
QualificationInput(within_mandate=True),
|
||||||
|
QualificationInput(within_mandate=False, affected_count=2),
|
||||||
|
QualificationInput(within_mandate=False, affected_count=3),
|
||||||
|
]:
|
||||||
|
result = qualify(inp, DEFAULT_CONFIG)
|
||||||
|
if result.decision_type == DecisionType.INDIVIDUAL:
|
||||||
|
assert result.recommended_modalities == [], (
|
||||||
|
f"Individual ne doit pas exposer de modalités : {inp}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_g3_collective_has_at_least_one_modality():
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=20),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.decision_type == DecisionType.COLLECTIVE
|
||||||
|
assert len(result.recommended_modalities) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_g4_custom_config_overrides_thresholds():
|
||||||
|
"""Les seuils viennent de QualificationConfig — pas de constantes hardcodées."""
|
||||||
|
custom = QualificationConfig(small_group_max=2, collective_wot_min=10)
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=3),
|
||||||
|
custom,
|
||||||
|
)
|
||||||
|
assert result.decision_type == DecisionType.COLLECTIVE
|
||||||
|
|
||||||
|
|
||||||
|
def test_g4_default_thresholds_are_stable():
|
||||||
|
cfg = QualificationConfig()
|
||||||
|
assert cfg.small_group_max == 5
|
||||||
|
assert cfg.collective_wot_min == 50
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""TDD — Service AI de cadrage des décisions (qualify/ai-chat).
|
||||||
|
|
||||||
|
Invariants testés :
|
||||||
|
A1 Premier appel (messages=[]) → retourne toujours 2 questions, done=False
|
||||||
|
A2 Les 2 questions couvrent réversibilité et urgence (ids stables)
|
||||||
|
A3 Deuxième appel (messages=[q+réponse]) → done=True, résultat qualifié
|
||||||
|
A4 Réponse "irréversible" → recommend_onchain conservé si is_structural
|
||||||
|
A5 Réponse "urgente" → raison "urgence" présente dans le résultat
|
||||||
|
A6 La qualification finale respecte les règles du moteur (R1/R2/R4/R5/R6)
|
||||||
|
A7 Sans contexte, les questions restent les mêmes (stub ne dépend pas du LLM)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.qualify_ai_service import (
|
||||||
|
AIFrameRequest,
|
||||||
|
AIMessage,
|
||||||
|
ai_frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_REQUEST = AIFrameRequest(
|
||||||
|
context="Révision du règlement intérieur de l'association",
|
||||||
|
within_mandate=False,
|
||||||
|
affected_count=20,
|
||||||
|
is_structural=False,
|
||||||
|
messages=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A1 — Premier appel → 2 questions, done=False
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a1_first_call_returns_questions():
|
||||||
|
resp = ai_frame(DEFAULT_REQUEST)
|
||||||
|
assert resp.done is False
|
||||||
|
assert len(resp.questions) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_a1_first_call_result_is_none():
|
||||||
|
resp = ai_frame(DEFAULT_REQUEST)
|
||||||
|
assert resp.result is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A2 — Questions couvrent réversibilité et urgence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a2_questions_have_stable_ids():
|
||||||
|
resp = ai_frame(DEFAULT_REQUEST)
|
||||||
|
ids = {q.id for q in resp.questions}
|
||||||
|
assert "reversibility" in ids
|
||||||
|
assert "urgency" in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_a2_questions_have_options():
|
||||||
|
resp = ai_frame(DEFAULT_REQUEST)
|
||||||
|
for q in resp.questions:
|
||||||
|
assert len(q.options) >= 2, f"Question '{q.id}' doit avoir au moins 2 options"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A3 — Deuxième appel (avec réponses) → done=True + résultat
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_second_request(reversibility_ans: str, urgency_ans: str, **kwargs) -> AIFrameRequest:
|
||||||
|
questions = ai_frame(DEFAULT_REQUEST).questions
|
||||||
|
messages = []
|
||||||
|
for q in questions:
|
||||||
|
messages.append(AIMessage(role="assistant", content=q.text))
|
||||||
|
# One user message bundling all answers
|
||||||
|
messages.append(AIMessage(
|
||||||
|
role="user",
|
||||||
|
content=f"reversibility:{reversibility_ans}|urgency:{urgency_ans}",
|
||||||
|
))
|
||||||
|
return AIFrameRequest(
|
||||||
|
**{**vars(DEFAULT_REQUEST), "messages": messages, **kwargs}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a3_second_call_is_done():
|
||||||
|
req = _make_second_request("Difficilement", "Pas d'urgence")
|
||||||
|
resp = ai_frame(req)
|
||||||
|
assert resp.done is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_a3_second_call_has_result():
|
||||||
|
req = _make_second_request("Difficilement", "Pas d'urgence")
|
||||||
|
resp = ai_frame(req)
|
||||||
|
assert resp.result is not None
|
||||||
|
assert resp.result.decision_type in ("individual", "collective")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A4 — Irréversible + structurant → recommend_onchain
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a4_irreversible_structural_recommends_onchain():
|
||||||
|
req = _make_second_request(
|
||||||
|
"Non, c'est irréversible",
|
||||||
|
"Pas d'urgence",
|
||||||
|
is_structural=True,
|
||||||
|
)
|
||||||
|
resp = ai_frame(req)
|
||||||
|
assert resp.result is not None
|
||||||
|
assert resp.result.recommend_onchain is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A5 — Urgence → raison présente
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a5_urgent_adds_urgency_reason():
|
||||||
|
req = _make_second_request("Oui, facilement", "Urgente (< 1 semaine)")
|
||||||
|
resp = ai_frame(req)
|
||||||
|
assert resp.result is not None
|
||||||
|
reasons_text = " ".join(resp.result.reasons).lower()
|
||||||
|
assert "urgence" in reasons_text or "urgent" in reasons_text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A6 — Résultat respecte les règles du moteur
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a6_within_mandate_gives_individual():
|
||||||
|
req = AIFrameRequest(
|
||||||
|
within_mandate=True,
|
||||||
|
affected_count=None,
|
||||||
|
messages=[
|
||||||
|
AIMessage(role="assistant", content="q"),
|
||||||
|
AIMessage(role="user", content="reversibility:Facilement|urgency:Pas d'urgence"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
resp = ai_frame(req)
|
||||||
|
assert resp.done is True
|
||||||
|
assert resp.result is not None
|
||||||
|
assert resp.result.decision_type == "individual"
|
||||||
|
assert resp.result.process == "consultation_avis"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a6_large_group_gives_collective():
|
||||||
|
req = _make_second_request("Difficilement", "Pas d'urgence", affected_count=100)
|
||||||
|
resp = ai_frame(req)
|
||||||
|
assert resp.result is not None
|
||||||
|
assert resp.result.decision_type == "collective"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A7 — Sans contexte, mêmes questions (stub ne dépend pas du LLM)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a7_no_context_same_question_ids():
|
||||||
|
req_with = DEFAULT_REQUEST
|
||||||
|
req_without = AIFrameRequest(
|
||||||
|
context=None,
|
||||||
|
within_mandate=False,
|
||||||
|
affected_count=20,
|
||||||
|
messages=[],
|
||||||
|
)
|
||||||
|
ids_with = {q.id for q in ai_frame(req_with).questions}
|
||||||
|
ids_without = {q.id for q in ai_frame(req_without).questions}
|
||||||
|
assert ids_with == ids_without
|
||||||
File diff suppressed because it is too large
Load Diff
+95
-9
@@ -32,6 +32,8 @@ from app.models.protocol import FormulaConfig, VotingProtocol
|
|||||||
from app.models.document import Document, DocumentItem
|
from app.models.document import Document, DocumentItem
|
||||||
from app.models.decision import Decision, DecisionStep
|
from app.models.decision import Decision, DecisionStep
|
||||||
from app.models.mandate import Mandate, MandateStep
|
from app.models.mandate import Mandate, MandateStep
|
||||||
|
from app.models.organization import Organization
|
||||||
|
from app.models.qualification import QualificationProtocol
|
||||||
from app.models.user import DuniterIdentity
|
from app.models.user import DuniterIdentity
|
||||||
from app.models.vote import VoteSession, Vote
|
from app.models.vote import VoteSession, Vote
|
||||||
|
|
||||||
@@ -161,6 +163,7 @@ async def seed_formula_configs(session: AsyncSession) -> dict[str, FormulaConfig
|
|||||||
async def seed_voting_protocols(
|
async def seed_voting_protocols(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
formulas: dict[str, FormulaConfig],
|
formulas: dict[str, FormulaConfig],
|
||||||
|
org_id: uuid.UUID | None = None,
|
||||||
) -> dict[str, VotingProtocol]:
|
) -> dict[str, VotingProtocol]:
|
||||||
protocols: dict[str, dict] = {
|
protocols: dict[str, dict] = {
|
||||||
"Vote WoT standard": {
|
"Vote WoT standard": {
|
||||||
@@ -206,6 +209,7 @@ async def seed_voting_protocols(
|
|||||||
instance, created = await get_or_create(
|
instance, created = await get_or_create(
|
||||||
session, VotingProtocol, "name", name, **params,
|
session, VotingProtocol, "name", name, **params,
|
||||||
)
|
)
|
||||||
|
instance.organization_id = org_id
|
||||||
status = "created" if created else "exists"
|
status = "created" if created else "exists"
|
||||||
print(f" VotingProtocol '{name}': {status}")
|
print(f" VotingProtocol '{name}': {status}")
|
||||||
result[name] = instance
|
result[name] = instance
|
||||||
@@ -829,6 +833,7 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
|
|||||||
async def seed_document_engagement_certification(
|
async def seed_document_engagement_certification(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
protocols: dict[str, VotingProtocol],
|
protocols: dict[str, VotingProtocol],
|
||||||
|
org_id: uuid.UUID | None = None,
|
||||||
) -> Document:
|
) -> Document:
|
||||||
genesis = json.dumps(GENESIS_CERTIFICATION, ensure_ascii=False, indent=2)
|
genesis = json.dumps(GENESIS_CERTIFICATION, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
@@ -850,6 +855,7 @@ async def seed_document_engagement_certification(
|
|||||||
),
|
),
|
||||||
genesis_json=genesis,
|
genesis_json=genesis,
|
||||||
)
|
)
|
||||||
|
doc.organization_id = org_id
|
||||||
print(f" Document 'Acte d'engagement Certification': {'created' if created else 'exists'}")
|
print(f" Document 'Acte d'engagement Certification': {'created' if created else 'exists'}")
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
@@ -1893,6 +1899,7 @@ ENGAGEMENT_FORGERON_ITEMS: list[dict] = [
|
|||||||
async def seed_document_engagement_forgeron(
|
async def seed_document_engagement_forgeron(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
protocols: dict[str, VotingProtocol],
|
protocols: dict[str, VotingProtocol],
|
||||||
|
org_id: uuid.UUID | None = None,
|
||||||
) -> Document:
|
) -> Document:
|
||||||
genesis = json.dumps(GENESIS_FORGERON, ensure_ascii=False, indent=2)
|
genesis = json.dumps(GENESIS_FORGERON, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
@@ -1916,6 +1923,7 @@ async def seed_document_engagement_forgeron(
|
|||||||
),
|
),
|
||||||
genesis_json=genesis,
|
genesis_json=genesis,
|
||||||
)
|
)
|
||||||
|
doc.organization_id = org_id
|
||||||
print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}")
|
print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}")
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
@@ -1988,7 +1996,7 @@ RUNTIME_UPGRADE_STEPS: list[dict] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
|
async def seed_decision_runtime_upgrade(session: AsyncSession, org_id: uuid.UUID | None = None) -> Decision:
|
||||||
decision, created = await get_or_create(
|
decision, created = await get_or_create(
|
||||||
session,
|
session,
|
||||||
Decision,
|
Decision,
|
||||||
@@ -2009,6 +2017,7 @@ async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
|
|||||||
decision_type="runtime_upgrade",
|
decision_type="runtime_upgrade",
|
||||||
status="draft",
|
status="draft",
|
||||||
)
|
)
|
||||||
|
decision.organization_id = org_id
|
||||||
print(f" Decision 'Runtime Upgrade': {'created' if created else 'exists'}")
|
print(f" Decision 'Runtime Upgrade': {'created' if created else 'exists'}")
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
@@ -2148,7 +2157,7 @@ async def seed_votes_on_items(
|
|||||||
# Seed: Additional decisions (demo content)
|
# Seed: Additional decisions (demo content)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def seed_decision_licence_evolution(session: AsyncSession) -> Decision:
|
async def seed_decision_licence_evolution(session: AsyncSession, org_id: uuid.UUID | None = None) -> Decision:
|
||||||
"""Seed a community decision: evolution of the G1 monetary license."""
|
"""Seed a community decision: evolution of the G1 monetary license."""
|
||||||
decision, created = await get_or_create(
|
decision, created = await get_or_create(
|
||||||
session,
|
session,
|
||||||
@@ -2170,6 +2179,7 @@ async def seed_decision_licence_evolution(session: AsyncSession) -> Decision:
|
|||||||
decision_type="community",
|
decision_type="community",
|
||||||
status="draft",
|
status="draft",
|
||||||
)
|
)
|
||||||
|
decision.organization_id = org_id
|
||||||
print(f" Decision 'Évolution Licence G1 v0.4.0': {'created' if created else 'exists'}")
|
print(f" Decision 'Évolution Licence G1 v0.4.0': {'created' if created else 'exists'}")
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
@@ -2225,7 +2235,7 @@ async def seed_decision_licence_evolution(session: AsyncSession) -> Decision:
|
|||||||
# Seed: Mandates (Comité Technique + Admin Forgerons)
|
# Seed: Mandates (Comité Technique + Admin Forgerons)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity]) -> None:
|
async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity], org_id: uuid.UUID | None = None) -> None:
|
||||||
"""Seed example mandates: TechComm and Smith Admin."""
|
"""Seed example mandates: TechComm and Smith Admin."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
@@ -2397,6 +2407,7 @@ async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity]) ->
|
|||||||
m_data["title"],
|
m_data["title"],
|
||||||
**{k: v for k, v in m_data.items() if k != "title"},
|
**{k: v for k, v in m_data.items() if k != "title"},
|
||||||
)
|
)
|
||||||
|
mandate.organization_id = org_id
|
||||||
status_str = "created" if created else "exists"
|
status_str = "created" if created else "exists"
|
||||||
print(f" Mandate '{mandate.title[:50]}': {status_str}")
|
print(f" Mandate '{mandate.title[:50]}': {status_str}")
|
||||||
|
|
||||||
@@ -2408,10 +2419,78 @@ async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity]) ->
|
|||||||
print(f" -> {len(steps_data)} steps created")
|
print(f" -> {len(steps_data)} steps created")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Seed: Organizations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def seed_organizations(session: AsyncSession) -> dict[str, Organization]:
|
||||||
|
"""Create the two base transparent organizations (idempotent)."""
|
||||||
|
orgs_data = [
|
||||||
|
{
|
||||||
|
"slug": "duniter-g1",
|
||||||
|
"name": "Duniter G1",
|
||||||
|
"org_type": "community",
|
||||||
|
"is_transparent": True,
|
||||||
|
"color": "#22c55e",
|
||||||
|
"icon": "i-lucide-globe",
|
||||||
|
"description": "Communauté Duniter — monnaie libre G1. Accessible à tous les membres authentifiés.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "axiom-team",
|
||||||
|
"name": "Axiom Team",
|
||||||
|
"org_type": "collective",
|
||||||
|
"is_transparent": True,
|
||||||
|
"color": "#3b82f6",
|
||||||
|
"icon": "i-lucide-users",
|
||||||
|
"description": "Équipe Axiom — développement et gouvernance des outils communs.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
orgs: dict[str, Organization] = {}
|
||||||
|
for data in orgs_data:
|
||||||
|
org, created = await get_or_create(session, Organization, "slug", data["slug"], **{k: v for k, v in data.items() if k != "slug"})
|
||||||
|
status_str = "created" if created else "exists"
|
||||||
|
print(f" Organisation '{org.name}': {status_str}")
|
||||||
|
orgs[org.slug] = org
|
||||||
|
|
||||||
|
return orgs
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Main seed runner
|
# Main seed runner
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def seed_qualification_protocol(session: AsyncSession) -> QualificationProtocol:
|
||||||
|
"""Seed the default qualification protocol (thresholds for the Décider tunnel)."""
|
||||||
|
stmt = select(QualificationProtocol).where(QualificationProtocol.is_active == True) # noqa: E712
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
print(" [skip] Protocole de qualification déjà présent")
|
||||||
|
return existing
|
||||||
|
|
||||||
|
proto = QualificationProtocol(
|
||||||
|
name="Protocole de qualification par défaut",
|
||||||
|
description=(
|
||||||
|
"Seuils utilisés par le tunnel Décider pour router vers "
|
||||||
|
"individual/collective et proposer les modalités de vote."
|
||||||
|
),
|
||||||
|
small_group_max=5,
|
||||||
|
collective_wot_min=50,
|
||||||
|
default_modalities_json=json.dumps([
|
||||||
|
"vote_wot",
|
||||||
|
"vote_smith",
|
||||||
|
"consultation_avis",
|
||||||
|
"election",
|
||||||
|
]),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
session.add(proto)
|
||||||
|
await session.flush()
|
||||||
|
print(" [ok] Protocole de qualification créé")
|
||||||
|
return proto
|
||||||
|
|
||||||
|
|
||||||
async def run_seed():
|
async def run_seed():
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("libreDecision - Seed Database")
|
print("libreDecision - Seed Database")
|
||||||
@@ -2423,23 +2502,30 @@ async def run_seed():
|
|||||||
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
async with session.begin():
|
async with session.begin():
|
||||||
|
print("\n[0/10] Organizations...")
|
||||||
|
orgs = await seed_organizations(session)
|
||||||
|
duniter_g1_id = orgs["duniter-g1"].id
|
||||||
|
|
||||||
|
print("\n[0b] Protocole de qualification...")
|
||||||
|
await seed_qualification_protocol(session)
|
||||||
|
|
||||||
print("\n[1/10] Formula Configs...")
|
print("\n[1/10] Formula Configs...")
|
||||||
formulas = await seed_formula_configs(session)
|
formulas = await seed_formula_configs(session)
|
||||||
|
|
||||||
print("\n[2/10] Voting Protocols...")
|
print("\n[2/10] Voting Protocols...")
|
||||||
protocols = await seed_voting_protocols(session, formulas)
|
protocols = await seed_voting_protocols(session, formulas, org_id=duniter_g1_id)
|
||||||
|
|
||||||
print("\n[3/10] Document: Acte d'engagement Certification...")
|
print("\n[3/10] Document: Acte d'engagement Certification...")
|
||||||
await seed_document_engagement_certification(session, protocols)
|
await seed_document_engagement_certification(session, protocols, org_id=duniter_g1_id)
|
||||||
|
|
||||||
print("\n[4/10] Document: Acte d'engagement forgeron v2.0.0...")
|
print("\n[4/10] Document: Acte d'engagement forgeron v2.0.0...")
|
||||||
doc_forgeron = await seed_document_engagement_forgeron(session, protocols)
|
doc_forgeron = await seed_document_engagement_forgeron(session, protocols, org_id=duniter_g1_id)
|
||||||
|
|
||||||
print("\n[5/10] Decision: Runtime Upgrade...")
|
print("\n[5/10] Decision: Runtime Upgrade...")
|
||||||
await seed_decision_runtime_upgrade(session)
|
await seed_decision_runtime_upgrade(session, org_id=duniter_g1_id)
|
||||||
|
|
||||||
print("\n[6/10] Decision: Évolution Licence G1 v0.4.0...")
|
print("\n[6/10] Decision: Évolution Licence G1 v0.4.0...")
|
||||||
await seed_decision_licence_evolution(session)
|
await seed_decision_licence_evolution(session, org_id=duniter_g1_id)
|
||||||
|
|
||||||
print("\n[7/10] Simulated voters...")
|
print("\n[7/10] Simulated voters...")
|
||||||
voters = await seed_voters(session)
|
voters = await seed_voters(session)
|
||||||
@@ -2453,7 +2539,7 @@ async def run_seed():
|
|||||||
)
|
)
|
||||||
|
|
||||||
print("\n[9/10] Mandates...")
|
print("\n[9/10] Mandates...")
|
||||||
await seed_mandates(session, voters)
|
await seed_mandates(session, voters, org_id=duniter_g1_id)
|
||||||
|
|
||||||
print("\n[10/10] Done.")
|
print("\n[10/10] Done.")
|
||||||
|
|
||||||
|
|||||||
@@ -41,4 +41,4 @@ COPY backend/requirements.txt .
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
WORKDIR /app
|
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
|
WORKDIR /src
|
||||||
|
|
||||||
RUN apt-get update && \
|
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/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# ── Build ─────────────────────────────────────────────────────────────────────
|
# ── 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
@@ -0,0 +1,144 @@
|
|||||||
|
# Méthode de travail TDD — libreDecision
|
||||||
|
|
||||||
|
## Principe fondamental
|
||||||
|
|
||||||
|
**Tu décris la règle métier. Claude traduit en test. Tu valides. Claude implémente.**
|
||||||
|
|
||||||
|
Jamais l'inverse. Le test est la source de vérité ; l'implémentation n'est que le moyen de le faire passer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow par itération
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Tu décris une règle en français naturel
|
||||||
|
→ "Si scope=personal, la décision est toujours individuelle"
|
||||||
|
|
||||||
|
2. Claude écrit le(s) test(s) — RED (le test échoue avant l'implémentation)
|
||||||
|
→ Tu valides que le test capture bien l'intention
|
||||||
|
|
||||||
|
3. Claude implémente le minimum pour que le test passe — GREEN
|
||||||
|
→ Rien de plus que ce que le test exige
|
||||||
|
|
||||||
|
4. Claude refactorise si nécessaire — REFACTOR
|
||||||
|
→ Sans casser les tests existants
|
||||||
|
|
||||||
|
5. Répétition avec la règle suivante
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commandes de prompt
|
||||||
|
|
||||||
|
| Commande | Action |
|
||||||
|
|---|---|
|
||||||
|
| `+test` | Écrire le(s) test(s) sans implémenter |
|
||||||
|
| `+impl` | Implémenter pour faire passer les tests en attente |
|
||||||
|
| `+test+impl` | Test + implémentation d'un coup (règle simple) |
|
||||||
|
| `+règle` | Ajouter une règle au moteur existant |
|
||||||
|
| `+règle remplace` | Une nouvelle règle remplace une précédente (précise laquelle) |
|
||||||
|
| `+régression` | Vérifier qu'aucun test existant n'est cassé après un changement |
|
||||||
|
| `+résumé` | Afficher l'état des règles implémentées et en attente |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Format d'une règle métier
|
||||||
|
|
||||||
|
Pour être efficace, une règle doit préciser :
|
||||||
|
|
||||||
|
```
|
||||||
|
ENTRÉES : les variables concernées et leurs valeurs
|
||||||
|
RÉSULTAT : ce que le système doit retourner ou faire
|
||||||
|
EXCEPTIONS : cas qui brisent la règle générale (si aucune, dire "aucune")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exemple :**
|
||||||
|
```
|
||||||
|
ENTRÉES : scope = "personal"
|
||||||
|
RÉSULTAT : decision_type = "individual", recommend_onchain = False
|
||||||
|
EXCEPTIONS : aucune — même si stakes = "critical", une décision personnelle reste individuelle
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structure des tests dans ce projet
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/app/tests/
|
||||||
|
test_qualifier.py ← moteur de qualification (tunnel Décider)
|
||||||
|
test_middleware.py ← rate limiter, CORS, headers
|
||||||
|
test_threshold.py ← formules WoT existantes
|
||||||
|
test_votes.py ← logique de vote
|
||||||
|
test_decisions.py ← service décisions
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque fichier de test correspond à un module ou un bloc fonctionnel.
|
||||||
|
Les tests d'intégration (qui touchent la DB) sont marqués `@pytest.mark.integration`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Les 4 blocs algorithmiques
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ TUNNEL "DÉCIDER" │
|
||||||
|
│ │
|
||||||
|
│ 1. QUALIFIER → nature / enjeu / réversibilité │
|
||||||
|
│ ↓ │
|
||||||
|
│ 2. ROUTEUR → individual / collective / delegated │
|
||||||
|
│ ↓ ↓ │
|
||||||
|
│ 3. PROTOCOLE → sélection formule WoT + paramètres │
|
||||||
|
│ ↓ │
|
||||||
|
│ 4. GRAVURE → recommandation on-chain (IPFS+remark) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Les blocs sont testés indépendamment puis en intégration.
|
||||||
|
Un changement dans le bloc 1 ne doit jamais casser silencieusement le bloc 4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Invariants fondamentaux (ne jamais casser)
|
||||||
|
|
||||||
|
Ces règles doivent avoir un test dédié et rester vertes en permanence :
|
||||||
|
|
||||||
|
1. Une décision `individual` ne génère jamais de session de vote
|
||||||
|
2. Une décision `on_chain` implique toujours `recommend_onchain = True`
|
||||||
|
3. `recommend_onchain = True` requiert `reversibility = "impossible"` **ou** `stakes = "critical"`
|
||||||
|
4. Le qualificateur ne retourne jamais un type inconnu (enum strict)
|
||||||
|
5. Un protocole WoT sélectionné doit exister en base (slug valide)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Règles de régression
|
||||||
|
|
||||||
|
- Après chaque implémentation : `pytest backend/app/tests/ -v --tb=short`
|
||||||
|
- Avant tout commit : zéro test rouge
|
||||||
|
- Si un test existant casse après un nouveau changement → **stop, analyser, ne pas contourner**
|
||||||
|
- `RATE_LIMIT_AUTH` en dev = 60/min minimum (pas de blocage en développement)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Où sont les règles métier documentées
|
||||||
|
|
||||||
|
| Source | Contenu |
|
||||||
|
|---|---|
|
||||||
|
| `docs/dev/tdd-methode.md` | Cette méthode |
|
||||||
|
| `docs/dev/qualifier-rules.md` | Règles du moteur de qualification (créé au fil des itérations) |
|
||||||
|
| `backend/app/engine/qualifier.py` | Implémentation du qualificateur |
|
||||||
|
| `backend/app/tests/test_qualifier.py` | Tests — source de vérité exécutable |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## À propos de la mémoire de contexte
|
||||||
|
|
||||||
|
Entre les sessions, Claude peut perdre le contexte des règles en cours.
|
||||||
|
Pour reprendre efficacement :
|
||||||
|
|
||||||
|
```
|
||||||
|
"Résume les règles du qualificateur implémentées jusqu'ici"
|
||||||
|
→ Claude lit test_qualifier.py et qualifier.py et synthétise
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tests sont leur propre documentation. Ne pas dupliquer les règles en commentaires.
|
||||||
+10
-708
@@ -1,719 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const auth = useAuthStore()
|
// Shell v2 — app.vue MINCE : ambiance + hydratation du collectif, rien d'autre.
|
||||||
const route = useRoute()
|
// Toute la structure (header, sidebar, drawer, sceau) vit dans layouts/default.vue.
|
||||||
const { initMood } = useMood()
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
|
||||||
const navigationItems = [
|
const { initMood } = useLibreMood()
|
||||||
{
|
|
||||||
label: 'Boîte à outils',
|
|
||||||
icon: 'i-lucide-wrench',
|
|
||||||
to: '/tools',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Documents',
|
|
||||||
icon: 'i-lucide-book-open',
|
|
||||||
to: '/documents',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Decisions',
|
|
||||||
icon: 'i-lucide-scale',
|
|
||||||
to: '/decisions',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Mandats',
|
|
||||||
icon: 'i-lucide-user-check',
|
|
||||||
to: '/mandates',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Protocoles',
|
|
||||||
icon: 'i-lucide-settings',
|
|
||||||
to: '/protocols',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Sanctuaire',
|
|
||||||
icon: 'i-lucide-archive',
|
|
||||||
to: '/sanctuary',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
/** Mobile drawer state. */
|
onMounted(() => {
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
/** WebSocket connection and notifications. */
|
|
||||||
const ws = useWebSocket()
|
|
||||||
const { setupWsNotifications } = useNotifications()
|
|
||||||
|
|
||||||
watch(sidebarCollapsed, (val) => {
|
|
||||||
localStorage.setItem('libred-sidebar-collapsed', String(val))
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
initMood()
|
initMood()
|
||||||
const savedCollapsed = localStorage.getItem('libred-sidebar-collapsed')
|
useCollectiveStore().init?.()
|
||||||
if (savedCollapsed !== null) sidebarCollapsed.value = savedCollapsed === 'true'
|
|
||||||
auth.hydrateFromStorage()
|
|
||||||
if (auth.token) {
|
|
||||||
try {
|
|
||||||
await auth.fetchMe()
|
|
||||||
} catch {
|
|
||||||
auth.logout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ws.connect()
|
|
||||||
setupWsNotifications(ws)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
ws.disconnect()
|
|
||||||
})
|
|
||||||
|
|
||||||
function isActive(to: string) {
|
|
||||||
return route.path === to || route.path.startsWith(to + '/')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
<UApp>
|
<UApp>
|
||||||
<OfflineBanner />
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
<div
|
</NuxtLayout>
|
||||||
class="app-shell"
|
|
||||||
:style="{
|
|
||||||
backgroundColor: 'var(--mood-bg)',
|
|
||||||
color: 'var(--mood-text)',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<!-- Header -->
|
|
||||||
<header class="app-header">
|
|
||||||
<div class="app-header__inner">
|
|
||||||
<!-- Left: Hamburger (mobile) + Logo -->
|
|
||||||
<div class="app-header__left">
|
|
||||||
<button
|
|
||||||
class="app-header__menu-btn"
|
|
||||||
aria-label="Ouvrir le menu"
|
|
||||||
@click="mobileMenuOpen = true"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-menu" class="text-xl" />
|
|
||||||
</button>
|
|
||||||
<NuxtLink to="/" class="app-header__logo">
|
|
||||||
<span class="app-header__logo-stamp">
|
|
||||||
<UIcon name="i-lucide-gavel" class="app-header__logo-icon" />
|
|
||||||
</span>
|
|
||||||
<span class="app-header__logo-text">
|
|
||||||
<span class="app-header__logo-libre">libre</span><span class="app-header__logo-decision">Decision</span>
|
|
||||||
</span>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Center: Workspace selector + Mood switcher (desktop) -->
|
|
||||||
<div class="app-header__center">
|
|
||||||
<WorkspaceSelector class="hidden sm:flex" />
|
|
||||||
<MoodSwitcher class="hidden sm:flex" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: Auth -->
|
|
||||||
<div class="app-header__right">
|
|
||||||
<template v-if="auth.isAuthenticated">
|
|
||||||
<span class="app-header__identity">
|
|
||||||
{{ auth.identity?.display_name || auth.identity?.address?.slice(0, 10) + '...' }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="auth.identity?.is_smith"
|
|
||||||
class="app-header__role app-header__role--smith"
|
|
||||||
>
|
|
||||||
Forgeron
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="auth.identity?.is_techcomm"
|
|
||||||
class="app-header__role app-header__role--tech"
|
|
||||||
>
|
|
||||||
CoTec
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
class="app-header__icon-btn"
|
|
||||||
aria-label="Se deconnecter"
|
|
||||||
@click="auth.logout()"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-log-out" />
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<NuxtLink to="/login" class="app-header__connect-btn">
|
|
||||||
<UIcon name="i-lucide-log-in" />
|
|
||||||
<span>Connexion</span>
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Mobile navigation drawer -->
|
|
||||||
<USlideover
|
|
||||||
v-model:open="mobileMenuOpen"
|
|
||||||
side="left"
|
|
||||||
title="Navigation"
|
|
||||||
:ui="{ width: 'max-w-xs' }"
|
|
||||||
>
|
|
||||||
<template #body>
|
|
||||||
<nav class="app-mobile-nav">
|
|
||||||
<NuxtLink
|
|
||||||
v-for="item in navigationItems"
|
|
||||||
:key="item.to"
|
|
||||||
:to="item.to"
|
|
||||||
class="app-mobile-nav__link"
|
|
||||||
:class="{ 'app-mobile-nav__link--active': isActive(item.to) }"
|
|
||||||
@click="mobileMenuOpen = false"
|
|
||||||
>
|
|
||||||
<UIcon :name="item.icon" class="text-lg" />
|
|
||||||
<span>{{ item.label }}</span>
|
|
||||||
</NuxtLink>
|
|
||||||
</nav>
|
|
||||||
<!-- Workspace + Mood in mobile drawer -->
|
|
||||||
<div class="app-mobile-mood">
|
|
||||||
<span class="app-mobile-mood__label">Espace</span>
|
|
||||||
<WorkspaceSelector />
|
|
||||||
</div>
|
|
||||||
<div class="app-mobile-mood">
|
|
||||||
<span class="app-mobile-mood__label">Ambiance</span>
|
|
||||||
<MoodSwitcher />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</USlideover>
|
|
||||||
|
|
||||||
<!-- Main content with sidebar -->
|
|
||||||
<div class="app-body">
|
|
||||||
<!-- Desktop sidebar -->
|
|
||||||
<aside class="app-sidebar" :class="{ 'app-sidebar--collapsed': sidebarCollapsed }">
|
|
||||||
<nav class="app-sidebar__nav">
|
|
||||||
<NuxtLink
|
|
||||||
v-for="item in navigationItems"
|
|
||||||
:key="item.to"
|
|
||||||
:to="item.to"
|
|
||||||
class="app-sidebar__link"
|
|
||||||
:class="{ 'app-sidebar__link--active': isActive(item.to) }"
|
|
||||||
>
|
|
||||||
<UIcon :name="item.icon" class="text-lg flex-shrink-0" />
|
|
||||||
<span class="app-sidebar__link-label">{{ item.label }}</span>
|
|
||||||
</NuxtLink>
|
|
||||||
<div class="app-sidebar__divider" />
|
|
||||||
<button
|
|
||||||
class="app-sidebar__toggle"
|
|
||||||
:title="sidebarCollapsed ? 'Déplier le menu' : 'Replier le menu'"
|
|
||||||
@click="sidebarCollapsed = !sidebarCollapsed"
|
|
||||||
>
|
|
||||||
<UIcon
|
|
||||||
:name="sidebarCollapsed ? 'i-lucide-panel-left-open' : 'i-lucide-panel-left-close'"
|
|
||||||
class="text-base flex-shrink-0"
|
|
||||||
/>
|
|
||||||
<span class="app-sidebar__link-label">Replier</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Page content -->
|
|
||||||
<main class="app-main">
|
|
||||||
<ErrorBoundary>
|
|
||||||
<NuxtPage />
|
|
||||||
</ErrorBoundary>
|
|
||||||
|
|
||||||
<!-- 井 Tsing — sceau (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>
|
|
||||||
</UApp>
|
</UApp>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* === Shell === */
|
|
||||||
.app-shell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Header === */
|
|
||||||
.app-header {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 30;
|
|
||||||
background: var(--mood-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__inner {
|
|
||||||
max-width: 80rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
height: 3.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__center {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
flex: 1;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__menu-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2.25rem;
|
|
||||||
height: 2.25rem;
|
|
||||||
background: none;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
.app-header__menu-btn:hover {
|
|
||||||
color: var(--mood-text);
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.app-header__menu-btn {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo {
|
|
||||||
text-decoration: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo-stamp {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
background: var(--mood-accent);
|
|
||||||
color: var(--mood-accent-text);
|
|
||||||
border-radius: 8px;
|
|
||||||
transform: rotate(-10deg);
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo:hover .app-header__logo-stamp {
|
|
||||||
transform: rotate(-16deg) scale(1.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo-icon {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
transform: scaleX(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo-text {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo-libre {
|
|
||||||
font-size: 1.0625rem;
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo-decision {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__identity {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text);
|
|
||||||
max-width: 6rem;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.app-header__identity {
|
|
||||||
max-width: 12rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__role {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 20px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.app-header__role {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__role--smith {
|
|
||||||
background: rgba(24, 132, 59, 0.15);
|
|
||||||
color: var(--mood-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__role--tech {
|
|
||||||
background: rgba(24, 86, 168, 0.15);
|
|
||||||
color: var(--mood-status-vote, #1856a8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__icon-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2.25rem;
|
|
||||||
height: 2.25rem;
|
|
||||||
background: none;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
.app-header__icon-btn:hover {
|
|
||||||
color: var(--mood-text);
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__connect-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.5rem 0.875rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-accent-text);
|
|
||||||
background: var(--mood-accent);
|
|
||||||
border-radius: 24px;
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.app-header__connect-btn {
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 1.25rem;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.app-header__connect-btn:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px var(--mood-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Sidebar === */
|
|
||||||
.app-body {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar {
|
|
||||||
width: 14rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--mood-surface);
|
|
||||||
display: none;
|
|
||||||
transition: width 0.22s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar--collapsed {
|
|
||||||
width: 3.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.app-sidebar {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__nav {
|
|
||||||
position: sticky;
|
|
||||||
top: 3.5rem;
|
|
||||||
padding: 1rem 0.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
padding: 0.625rem 0.75rem;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
transition: all 0.12s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__link:hover {
|
|
||||||
color: var(--mood-text);
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__link--active {
|
|
||||||
color: var(--mood-accent);
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__link-label {
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: opacity 0.18s ease, max-width 0.22s ease;
|
|
||||||
max-width: 10rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar--collapsed .app-sidebar__link-label {
|
|
||||||
opacity: 0;
|
|
||||||
max-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar--collapsed .app-sidebar__link {
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__divider {
|
|
||||||
height: 1px;
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
|
|
||||||
margin: 0.375rem 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 12px;
|
|
||||||
width: 100%;
|
|
||||||
transition: all 0.12s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__toggle:hover {
|
|
||||||
color: var(--mood-text);
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar--collapsed .app-sidebar__toggle {
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Mobile nav === */
|
|
||||||
.app-mobile-nav {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-mobile-nav__link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.875rem;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
font-size: 1.0625rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 14px;
|
|
||||||
min-height: 3rem;
|
|
||||||
}
|
|
||||||
.app-mobile-nav__link:hover,
|
|
||||||
.app-mobile-nav__link:active {
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
color: var(--mood-text);
|
|
||||||
}
|
|
||||||
.app-mobile-nav__link--active {
|
|
||||||
color: var(--mood-accent);
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-mobile-mood {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
border-top: 1px solid var(--mood-accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-mobile-mood__label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Main === */
|
|
||||||
.app-main {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 1.5rem 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.app-main {
|
|
||||||
padding: 2rem 1.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.app-main {
|
|
||||||
padding: 2rem 2.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === WS Banner === */
|
|
||||||
.app-ws-banner {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 40;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
padding: 0.625rem 1rem;
|
|
||||||
background: var(--mood-error);
|
|
||||||
color: white;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-ws-banner__btn {
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
background: rgba(255,255,255,0.2);
|
|
||||||
border-radius: 20px;
|
|
||||||
color: white;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.app-ws-banner__btn:hover {
|
|
||||||
background: rgba(255,255,255,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Footer === */
|
|
||||||
.app-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-footer__sep {
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Seal — 井 Tsing === */
|
|
||||||
.app-seal {
|
|
||||||
display: block;
|
|
||||||
width: 44px;
|
|
||||||
margin: 1.5rem 0 0.5rem auto;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
opacity: 0.28;
|
|
||||||
filter: drop-shadow(1px 1px 0.5px rgba(0,0,0,0.25))
|
|
||||||
drop-shadow(-0.5px -0.5px 0.5px rgba(255,255,255,0.15));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Transitions === */
|
|
||||||
.slide-up-enter-active,
|
|
||||||
.slide-up-leave-active {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
.slide-up-enter-from,
|
|
||||||
.slide-up-leave-to {
|
|
||||||
transform: translateY(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
+284
-181
@@ -1,158 +1,196 @@
|
|||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
libreDecision — Mood / Ambiance System
|
libreDecision v2 — Ambiances (champ lexical du puits 井)
|
||||||
Palettes harmoniques variees, colores en lite, lumineux en dark.
|
Source (light, eau claire) · Margelle (light, pierre chaude)
|
||||||
|
Nappe (dark, eau profonde) · Minuit (dark, encre & lanterne)
|
||||||
|
Borderless : profondeur par background + box-shadow, jamais de bordures.
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
Peps — Chaud, colore, tonique (Light)
|
Source — l'eau vive au grand jour (Light)
|
||||||
Palette: corail vif / ocre / indigo / vert franc
|
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
.mood-peps {
|
.mood-source {
|
||||||
--mood-bg: #faf8f5;
|
--mood-bg: #f0f5f6;
|
||||||
--mood-surface: #ffffff;
|
--mood-surface: #fcfefe;
|
||||||
--mood-surface-hover: #fdf2ec;
|
--mood-surface-hover: #e6eff2;
|
||||||
--mood-text: #1e1410;
|
--mood-text: #102126;
|
||||||
--mood-text-muted: #7a5e48;
|
--mood-text-muted: #52707a;
|
||||||
--mood-accent: #d44a10;
|
--mood-accent: #0f7fa8;
|
||||||
--mood-accent-soft: rgba(212, 74, 16, 0.08);
|
--mood-accent-soft: rgba(15, 127, 168, 0.09);
|
||||||
--mood-accent-text: #ffffff;
|
--mood-accent-text: #ffffff;
|
||||||
--mood-border: #e8d8c8;
|
--mood-secondary: #a8742c; /* laiton — le mandat, le pouvoir confié */
|
||||||
--mood-secondary: #4a5ec0;
|
--mood-tertiary: #2e8a72; /* eau végétale — l'avis, le dialogue */
|
||||||
--mood-tertiary: #c07820;
|
--mood-success: #1d8a4a;
|
||||||
--mood-success: #1a8c3e;
|
--mood-warning: #b07c14;
|
||||||
--mood-warning: #c47d0a;
|
--mood-error: #bc3d33;
|
||||||
--mood-error: #c23028;
|
--mood-gradient: linear-gradient(150deg, #eaf3f5 0%, #fcfefe 45%, #eef2ee 100%);
|
||||||
--mood-gradient: linear-gradient(145deg, #fdf8f0 0%, #ffffff 40%, #f0f4fb 100%);
|
--mood-shadow: rgba(20, 90, 120, 0.08);
|
||||||
--mood-shadow: rgba(180, 80, 20, 0.07);
|
--mood-input-bg: #fcfefe;
|
||||||
--mood-input-bg: #ffffff;
|
--mood-input-border: #b8ccd2;
|
||||||
--mood-input-border: #d4c0aa;
|
--mood-input-focus: #0f7fa8;
|
||||||
--mood-input-focus: #d44a10;
|
|
||||||
|
|
||||||
--mood-status-prepa: #b8600e;
|
--mood-status-prepa: #8a7040;
|
||||||
--mood-status-prepa-bg: rgba(184, 96, 14, 0.12);
|
--mood-status-prepa-bg: rgba(138, 112, 64, 0.10);
|
||||||
--mood-status-vote: #4a5ec0;
|
--mood-status-fenetre: #b07c14;
|
||||||
--mood-status-vote-bg: rgba(74, 94, 192, 0.10);
|
--mood-status-fenetre-bg: rgba(176, 124, 20, 0.11);
|
||||||
--mood-status-vigueur: #1a8c3e;
|
--mood-status-vote: #2661b8;
|
||||||
--mood-status-vigueur-bg: rgba(26, 140, 62, 0.10);
|
--mood-status-vote-bg: rgba(38, 97, 184, 0.10);
|
||||||
--mood-status-clos: #6e5844;
|
--mood-status-fige: #58708c;
|
||||||
--mood-status-clos-bg: rgba(110, 88, 68, 0.08);
|
--mood-status-fige-bg: rgba(88, 112, 140, 0.12);
|
||||||
|
--mood-status-vigueur: #1d8a4a;
|
||||||
|
--mood-status-vigueur-bg: rgba(29, 138, 74, 0.10);
|
||||||
|
--mood-status-revoque: #a4553a;
|
||||||
|
--mood-status-revoque-bg: rgba(164, 85, 58, 0.10);
|
||||||
|
--mood-status-clos: #5f6f74;
|
||||||
|
--mood-status-clos-bg: rgba(95, 111, 116, 0.09);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
Zen — Vegetal lumineux, nature vivante (Light)
|
Margelle — la pierre du puits, calcaire et bronze (Light)
|
||||||
Palette: vert franc / terre sienne / bleu canard / bois chaud
|
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
.mood-zen {
|
.mood-margelle {
|
||||||
--mood-bg: #f4f6f0;
|
--mood-bg: #f6f3ec;
|
||||||
--mood-surface: #fbfcf8;
|
--mood-surface: #fdfbf6;
|
||||||
--mood-surface-hover: #eaf0e4;
|
--mood-surface-hover: #efe9dc;
|
||||||
--mood-text: #1a2418;
|
--mood-text: #221c10;
|
||||||
--mood-text-muted: #5a7050;
|
--mood-text-muted: #77684c;
|
||||||
--mood-accent: #2e8b48;
|
--mood-accent: #96682a;
|
||||||
--mood-accent-soft: rgba(46, 139, 72, 0.09);
|
--mood-accent-soft: rgba(150, 104, 42, 0.10);
|
||||||
--mood-accent-text: #ffffff;
|
--mood-accent-text: #ffffff;
|
||||||
--mood-border: #b8d0a8;
|
--mood-secondary: #285e88; /* le ciel dans l'eau du seau */
|
||||||
--mood-secondary: #8a6030;
|
--mood-tertiary: #4f7a3c; /* mousse entre les pierres */
|
||||||
--mood-tertiary: #2a7888;
|
--mood-success: #3d7e34;
|
||||||
--mood-success: #268040;
|
--mood-warning: #a8720e;
|
||||||
--mood-warning: #a87818;
|
--mood-error: #b23f30;
|
||||||
--mood-error: #b83838;
|
--mood-gradient: linear-gradient(150deg, #f3eee2 0%, #fdfbf6 45%, #f0eee6 100%);
|
||||||
--mood-gradient: linear-gradient(145deg, #f0f5ea 0%, #fbfcf8 40%, #f4efe6 100%);
|
--mood-shadow: rgba(120, 90, 40, 0.09);
|
||||||
--mood-shadow: rgba(40, 100, 50, 0.07);
|
--mood-input-bg: #fdfbf6;
|
||||||
--mood-input-bg: #fbfcf8;
|
--mood-input-border: #cfc2a6;
|
||||||
--mood-input-border: #a8c898;
|
--mood-input-focus: #96682a;
|
||||||
--mood-input-focus: #2e8b48;
|
|
||||||
|
|
||||||
--mood-status-prepa: #a87818;
|
--mood-status-prepa: #7c6a48;
|
||||||
--mood-status-prepa-bg: rgba(168, 120, 24, 0.11);
|
--mood-status-prepa-bg: rgba(124, 106, 72, 0.10);
|
||||||
--mood-status-vote: #2a7888;
|
--mood-status-fenetre: #a8720e;
|
||||||
--mood-status-vote-bg: rgba(42, 120, 136, 0.10);
|
--mood-status-fenetre-bg: rgba(168, 114, 14, 0.11);
|
||||||
--mood-status-vigueur: #268040;
|
--mood-status-vote: #285e88;
|
||||||
--mood-status-vigueur-bg: rgba(38, 128, 64, 0.10);
|
--mood-status-vote-bg: rgba(40, 94, 136, 0.10);
|
||||||
--mood-status-clos: #607858;
|
--mood-status-fige: #6e6a58;
|
||||||
--mood-status-clos-bg: rgba(96, 120, 88, 0.08);
|
--mood-status-fige-bg: rgba(110, 106, 88, 0.12);
|
||||||
|
--mood-status-vigueur: #3d7e34;
|
||||||
|
--mood-status-vigueur-bg: rgba(61, 126, 52, 0.10);
|
||||||
|
--mood-status-revoque: #9c5236;
|
||||||
|
--mood-status-revoque-bg: rgba(156, 82, 54, 0.10);
|
||||||
|
--mood-status-clos: #6f6a5c;
|
||||||
|
--mood-status-clos-bg: rgba(111, 106, 92, 0.09);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
Chagrine — Nuit profonde, reflets varies (Dark)
|
Nappe — l'eau profonde sous la ville (Dark)
|
||||||
Palette: bleu lavande / ambre chaud / rose ancien / vert d'eau
|
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
.mood-chagrine {
|
.mood-nappe {
|
||||||
--mood-bg: #0e1018;
|
--mood-bg: #0a141b;
|
||||||
--mood-surface: #151a28;
|
--mood-surface: #101f2a;
|
||||||
--mood-surface-hover: #1c2640;
|
--mood-surface-hover: #16293a;
|
||||||
--mood-text: #dce0ec;
|
--mood-text: #d6e3ea;
|
||||||
--mood-text-muted: #8898b8;
|
--mood-text-muted: #7e98a8;
|
||||||
--mood-accent: #6488d8;
|
--mood-accent: #3fa9cc;
|
||||||
--mood-accent-soft: rgba(100, 136, 216, 0.12);
|
--mood-accent-soft: rgba(63, 169, 204, 0.13);
|
||||||
--mood-accent-text: #ffffff;
|
--mood-accent-text: #06141c;
|
||||||
--mood-border: #222e48;
|
--mood-secondary: #cfa14a; /* laiton à la lueur de la lampe */
|
||||||
--mood-secondary: #d0a040;
|
--mood-tertiary: #52b894;
|
||||||
--mood-tertiary: #c87090;
|
--mood-success: #46bf7c;
|
||||||
--mood-success: #48c87a;
|
--mood-warning: #d2a13c;
|
||||||
--mood-warning: #d8a838;
|
--mood-error: #d86458;
|
||||||
--mood-error: #d86060;
|
--mood-gradient: linear-gradient(150deg, #0a141b 0%, #101f2a 45%, #0d1a24 100%);
|
||||||
--mood-gradient: linear-gradient(145deg, #0e1018 0%, #151a28 40%, #101420 100%);
|
--mood-shadow: rgba(30, 120, 160, 0.14);
|
||||||
--mood-shadow: rgba(60, 100, 180, 0.12);
|
--mood-input-bg: #101f2a;
|
||||||
--mood-input-bg: #151a28;
|
--mood-input-border: #24425a;
|
||||||
--mood-input-border: #2a3858;
|
--mood-input-focus: #3fa9cc;
|
||||||
--mood-input-focus: #6488d8;
|
|
||||||
|
|
||||||
--mood-status-prepa: #d8a838;
|
--mood-status-prepa: #a5987c;
|
||||||
--mood-status-prepa-bg: rgba(216, 168, 56, 0.14);
|
--mood-status-prepa-bg: rgba(165, 152, 124, 0.12);
|
||||||
--mood-status-vote: #6488d8;
|
--mood-status-fenetre: #d2a13c;
|
||||||
--mood-status-vote-bg: rgba(100, 136, 216, 0.14);
|
--mood-status-fenetre-bg: rgba(210, 161, 60, 0.14);
|
||||||
--mood-status-vigueur: #48c87a;
|
--mood-status-vote: #5f97e0;
|
||||||
--mood-status-vigueur-bg: rgba(72, 200, 122, 0.14);
|
--mood-status-vote-bg: rgba(95, 151, 224, 0.14);
|
||||||
--mood-status-clos: #7888a8;
|
--mood-status-fige: #7d97b5;
|
||||||
--mood-status-clos-bg: rgba(120, 136, 168, 0.10);
|
--mood-status-fige-bg: rgba(125, 151, 181, 0.14);
|
||||||
|
--mood-status-vigueur: #46bf7c;
|
||||||
|
--mood-status-vigueur-bg: rgba(70, 191, 124, 0.13);
|
||||||
|
--mood-status-revoque: #cd7a56;
|
||||||
|
--mood-status-revoque-bg: rgba(205, 122, 86, 0.13);
|
||||||
|
--mood-status-clos: #7d8fa0;
|
||||||
|
--mood-status-clos-bg: rgba(125, 143, 160, 0.11);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
Grave — Ambre mineral, ocre lumineux (Dark)
|
Minuit — l'encre, et la lanterne posée sur la margelle (Dark)
|
||||||
Palette: ambre dore / ocre rouge / bleu ardoise / vert mousse
|
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
.mood-grave {
|
.mood-minuit {
|
||||||
--mood-bg: #131210;
|
--mood-bg: #101014;
|
||||||
--mood-surface: #1c1a16;
|
--mood-surface: #17171e;
|
||||||
--mood-surface-hover: #28241c;
|
--mood-surface-hover: #202029;
|
||||||
--mood-text: #ece4d4;
|
--mood-text: #e2e2e8;
|
||||||
--mood-text-muted: #a89870;
|
--mood-text-muted: #8e8e9e;
|
||||||
--mood-accent: #d8a030;
|
--mood-accent: #cf9c3e;
|
||||||
--mood-accent-soft: rgba(216, 160, 48, 0.11);
|
--mood-accent-soft: rgba(207, 156, 62, 0.12);
|
||||||
--mood-accent-text: #131210;
|
--mood-accent-text: #131309;
|
||||||
--mood-border: #342e24;
|
--mood-secondary: #6c94d8;
|
||||||
--mood-secondary: #6898b8;
|
--mood-tertiary: #58ae8e;
|
||||||
--mood-tertiary: #c07040;
|
--mood-success: #52bd7e;
|
||||||
--mood-success: #58c870;
|
--mood-warning: #cfa03a;
|
||||||
--mood-warning: #d0a030;
|
--mood-error: #d25f55;
|
||||||
--mood-error: #d05050;
|
--mood-gradient: linear-gradient(150deg, #101014 0%, #17171e 45%, #131318 100%);
|
||||||
--mood-gradient: linear-gradient(145deg, #131210 0%, #1c1a16 40%, #16181c 100%);
|
--mood-shadow: rgba(190, 140, 50, 0.11);
|
||||||
--mood-shadow: rgba(180, 130, 40, 0.10);
|
--mood-input-bg: #17171e;
|
||||||
--mood-input-bg: #1c1a16;
|
--mood-input-border: #33333f;
|
||||||
--mood-input-border: #3c3428;
|
--mood-input-focus: #cf9c3e;
|
||||||
--mood-input-focus: #d8a030;
|
|
||||||
|
|
||||||
--mood-status-prepa: #c07040;
|
--mood-status-prepa: #9e9584;
|
||||||
--mood-status-prepa-bg: rgba(192, 112, 64, 0.15);
|
--mood-status-prepa-bg: rgba(158, 149, 132, 0.12);
|
||||||
--mood-status-vote: #6898b8;
|
--mood-status-fenetre: #cfa03a;
|
||||||
--mood-status-vote-bg: rgba(104, 152, 184, 0.14);
|
--mood-status-fenetre-bg: rgba(207, 160, 58, 0.14);
|
||||||
--mood-status-vigueur: #58c870;
|
--mood-status-vote: #6c94d8;
|
||||||
--mood-status-vigueur-bg: rgba(88, 200, 112, 0.14);
|
--mood-status-vote-bg: rgba(108, 148, 216, 0.14);
|
||||||
--mood-status-clos: #807860;
|
--mood-status-fige: #8b8ba0;
|
||||||
--mood-status-clos-bg: rgba(128, 120, 96, 0.12);
|
--mood-status-fige-bg: rgba(139, 139, 160, 0.14);
|
||||||
|
--mood-status-vigueur: #52bd7e;
|
||||||
|
--mood-status-vigueur-bg: rgba(82, 189, 126, 0.13);
|
||||||
|
--mood-status-revoque: #c97a5c;
|
||||||
|
--mood-status-revoque-bg: rgba(201, 122, 92, 0.13);
|
||||||
|
--mood-status-clos: #85859a;
|
||||||
|
--mood-status-clos-bg: rgba(133, 133, 154, 0.11);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
Global design tokens — Plus Jakarta Sans, rounded, borderless
|
Tokens dérivés — routes, tailles, ombres (identiques dans toutes les ambiances)
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
.mood-source, .mood-margelle, .mood-nappe, .mood-minuit {
|
||||||
|
/* Routes du chemin — vocabulaire visuel fixe */
|
||||||
|
--route-solo: var(--mood-accent);
|
||||||
|
--route-mandate: var(--mood-secondary);
|
||||||
|
--route-transmit: var(--mood-text-muted);
|
||||||
|
--route-advice: var(--mood-tertiary);
|
||||||
|
--route-collective: var(--mood-status-vote);
|
||||||
|
--route-record: var(--mood-status-prepa);
|
||||||
|
--route-urgent: var(--mood-error);
|
||||||
|
|
||||||
*,
|
/* Rayons (consignes) */
|
||||||
*::before,
|
--r-card: 16px;
|
||||||
*::after {
|
--r-input: 12px;
|
||||||
border-color: transparent;
|
--r-pill: 20px;
|
||||||
|
--r-icon: 14px;
|
||||||
|
|
||||||
|
/* Ombres borderless — deux couches, jamais de bordure */
|
||||||
|
--shadow-card: 0 1px 2px var(--mood-shadow), 0 5px 18px var(--mood-shadow);
|
||||||
|
--shadow-card-hover: 0 2px 4px var(--mood-shadow), 0 10px 28px var(--mood-shadow);
|
||||||
|
--shadow-raised: 0 1px 3px var(--mood-shadow), 0 8px 24px var(--mood-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Socle global — Plus Jakarta Sans, borderless, gestes
|
||||||
|
========================================================================== */
|
||||||
|
*, *::before, *::after { border-color: transparent; }
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
|
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -160,89 +198,154 @@ html {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
|
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--mood-bg);
|
||||||
|
color: var(--mood-text);
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- No borders anywhere --- */
|
|
||||||
button, input, select, textarea {
|
button, input, select, textarea {
|
||||||
border: none !important;
|
border: none !important;
|
||||||
outline: none;
|
outline: none;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus, select:focus, textarea:focus {
|
input, select, textarea {
|
||||||
|
background: var(--mood-input-bg);
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus-visible, select:focus-visible, textarea:focus-visible,
|
||||||
|
button:focus-visible, a:focus-visible, [tabindex]:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 2.5px var(--mood-accent-soft);
|
box-shadow: 0 0 0 2.5px var(--mood-accent-soft), 0 0 0 1.5px var(--mood-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Status pills --- */
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Cartes --- */
|
||||||
|
.ld-card {
|
||||||
|
background: var(--mood-surface);
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
.ld-card--hover:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: var(--shadow-card-hover);
|
||||||
|
}
|
||||||
|
.ld-card--hover:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
/* --- Boutons pill --- */
|
||||||
|
.ld-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--mood-accent);
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
transition: transform 0.1s ease, box-shadow 0.1s ease, filter 0.1s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.ld-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px var(--mood-shadow); }
|
||||||
|
.ld-btn:active { transform: translateY(0); }
|
||||||
|
.ld-btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||||
|
.ld-btn--ghost {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
}
|
||||||
|
.ld-btn--quiet {
|
||||||
|
background: none;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
.ld-btn--quiet:hover { background: var(--mood-accent-soft); color: var(--mood-text); box-shadow: none; }
|
||||||
|
|
||||||
|
/* --- Pills d'état (mapping unique état→couleur) --- */
|
||||||
.status-pill {
|
.status-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 5px 14px;
|
gap: 0.3rem;
|
||||||
border-radius: 20px;
|
padding: 4px 13px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
border: none !important;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
.status-pill:hover {
|
.status-pill.is-clickable { cursor: pointer; }
|
||||||
filter: brightness(1.08);
|
.status-pill.is-clickable:hover { filter: brightness(1.08); transform: translateY(-1px); }
|
||||||
transform: translateY(-1px);
|
.status-pill.active { box-shadow: 0 0 0 2.5px var(--mood-accent); }
|
||||||
}
|
|
||||||
.status-pill.active {
|
.status-draft { background: var(--mood-status-prepa-bg); color: var(--mood-status-prepa); }
|
||||||
box-shadow: 0 0 0 2.5px var(--mood-accent);
|
.status-advice,
|
||||||
|
.status-objection { background: var(--mood-status-fenetre-bg); color: var(--mood-status-fenetre); }
|
||||||
|
.status-framing { background: var(--mood-status-prepa-bg); color: var(--mood-status-prepa); }
|
||||||
|
.status-voting { background: var(--mood-status-vote-bg); color: var(--mood-status-vote); }
|
||||||
|
.status-frozen { background: var(--mood-status-fige-bg); color: var(--mood-status-fige); }
|
||||||
|
.status-adopted { background: var(--mood-status-vigueur-bg); color: var(--mood-status-vigueur); }
|
||||||
|
.status-rejected { background: var(--mood-status-clos-bg); color: var(--mood-status-clos); text-decoration: line-through; }
|
||||||
|
.status-revoked { background: var(--mood-status-revoque-bg); color: var(--mood-status-revoque); }
|
||||||
|
.status-transmitted { background: var(--mood-status-fige-bg); color: var(--mood-status-fige); }
|
||||||
|
.status-closed { background: var(--mood-status-clos-bg); color: var(--mood-status-clos); }
|
||||||
|
|
||||||
|
/* Suspension (frontière contestée) : fond strié */
|
||||||
|
.status-suspended {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
-45deg,
|
||||||
|
var(--mood-status-fenetre-bg),
|
||||||
|
var(--mood-status-fenetre-bg) 6px,
|
||||||
|
transparent 6px,
|
||||||
|
transparent 10px
|
||||||
|
);
|
||||||
|
color: var(--mood-status-fenetre);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-prepa {
|
/* --- Le tampon 井 (signature) --- */
|
||||||
background: var(--mood-status-prepa-bg);
|
.ld-stamp {
|
||||||
color: var(--mood-status-prepa);
|
display: inline-flex;
|
||||||
}
|
align-items: center;
|
||||||
.status-vote {
|
gap: 0.4rem;
|
||||||
background: var(--mood-status-vote-bg);
|
padding: 0.35rem 0.85rem;
|
||||||
color: var(--mood-status-vote);
|
border-radius: 10px;
|
||||||
}
|
transform: rotate(-10deg);
|
||||||
.status-vigueur {
|
font-weight: 800;
|
||||||
background: var(--mood-status-vigueur-bg);
|
font-size: 0.8125rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
color: var(--mood-status-vigueur);
|
color: var(--mood-status-vigueur);
|
||||||
}
|
box-shadow: inset 0 0 0 2px var(--mood-status-vigueur);
|
||||||
.status-clos {
|
background: var(--mood-status-vigueur-bg);
|
||||||
background: var(--mood-status-clos-bg);
|
user-select: none;
|
||||||
color: var(--mood-status-clos);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================================================
|
/* --- Sceau hexagramme #48 Tsing --- */
|
||||||
Nuxt UI overrides — borderless everything
|
.app-seal {
|
||||||
========================================================================== */
|
display: block;
|
||||||
|
width: 44px;
|
||||||
:root .mood-peps,
|
color: var(--mood-accent);
|
||||||
:root .mood-zen,
|
opacity: 0.28;
|
||||||
:root .mood-chagrine,
|
filter: drop-shadow(1px 1px 0.5px rgba(0, 0, 0, 0.25))
|
||||||
:root .mood-grave {
|
drop-shadow(-0.5px -0.5px 0.5px rgba(255, 255, 255, 0.15));
|
||||||
--ui-border: transparent;
|
|
||||||
--ui-bg: var(--mood-input-bg);
|
|
||||||
--ui-text-highlighted: var(--mood-accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root button,
|
/* --- Impression A4 (PV, affiches de session) --- */
|
||||||
:root [class*="UButton"],
|
@media print {
|
||||||
:root [class*="u-button"],
|
body { background: #ffffff !important; color: #111111 !important; }
|
||||||
:root [data-variant] {
|
.app-header, .app-sidebar, .app-fab, .app-seal, .no-print { display: none !important; }
|
||||||
border: none !important;
|
.ld-card { box-shadow: none !important; }
|
||||||
font-family: 'Plus Jakarta Sans', system-ui, sans-serif !important;
|
.print-only { display: block !important; }
|
||||||
}
|
|
||||||
|
|
||||||
:root input,
|
|
||||||
:root textarea,
|
|
||||||
:root select,
|
|
||||||
:root [class*="UInput"],
|
|
||||||
:root [class*="USelect"],
|
|
||||||
:root [class*="UTextarea"] {
|
|
||||||
border: none !important;
|
|
||||||
font-family: 'Plus Jakarta Sans', system-ui, sans-serif !important;
|
|
||||||
}
|
}
|
||||||
|
.print-only { display: none; }
|
||||||
|
|||||||
@@ -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,272 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* WorkspaceSelector — Sélecteur de collectif / espace de travail.
|
|
||||||
* Compartimentage multi-collectifs, multi-sites.
|
|
||||||
* UI-only pour l'instant, prêt pour le backend (collective_id sur toutes les entités).
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Workspace {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
icon: string
|
|
||||||
role?: string
|
|
||||||
color?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock data — sera remplacé par le store collectifs
|
|
||||||
const workspaces: Workspace[] = [
|
|
||||||
{
|
|
||||||
id: 'g1-main',
|
|
||||||
name: 'Duniter G1',
|
|
||||||
slug: 'duniter-g1',
|
|
||||||
icon: 'i-lucide-coins',
|
|
||||||
role: 'Membre',
|
|
||||||
color: 'accent',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'axiom',
|
|
||||||
name: 'Axiom Team',
|
|
||||||
slug: 'axiom-team',
|
|
||||||
icon: 'i-lucide-layers',
|
|
||||||
role: 'Admin',
|
|
||||||
color: 'secondary',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const activeId = ref('g1-main')
|
|
||||||
const isOpen = ref(false)
|
|
||||||
|
|
||||||
const active = computed(() => workspaces.find(w => w.id === activeId.value) ?? workspaces[0])
|
|
||||||
|
|
||||||
function selectWorkspace(id: string) {
|
|
||||||
activeId.value = id
|
|
||||||
isOpen.value = false
|
|
||||||
// TODO: store.setActiveCollective(id) + refetch all data
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close on outside click
|
|
||||||
const containerRef = ref<HTMLElement | null>(null)
|
|
||||||
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 }" @click="isOpen = !isOpen">
|
|
||||||
<div class="ws__icon" :class="`ws__icon--${active.color}`">
|
|
||||||
<UIcon :name="active.icon" />
|
|
||||||
</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" class="ws__dropdown">
|
|
||||||
<div class="ws__dropdown-header">
|
|
||||||
Espace de travail
|
|
||||||
</div>
|
|
||||||
<div class="ws__items">
|
|
||||||
<button
|
|
||||||
v-for="ws in workspaces"
|
|
||||||
:key="ws.id"
|
|
||||||
class="ws__item"
|
|
||||||
:class="{ 'ws__item--active': ws.id === activeId }"
|
|
||||||
@click="selectWorkspace(ws.id)"
|
|
||||||
>
|
|
||||||
<div class="ws__item-icon" :class="`ws__icon--${ws.color}`">
|
|
||||||
<UIcon :name="ws.icon" />
|
|
||||||
</div>
|
|
||||||
<div class="ws__item-info">
|
|
||||||
<span class="ws__item-name">{{ ws.name }}</span>
|
|
||||||
<span v-if="ws.role" class="ws__item-role">{{ ws.role }}</span>
|
|
||||||
</div>
|
|
||||||
<UIcon v-if="ws.id === activeId" 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 {
|
|
||||||
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__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--accent { background: var(--mood-accent); color: var(--mood-accent-text); }
|
|
||||||
.ws__icon--secondary {
|
|
||||||
background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 20%, transparent);
|
|
||||||
color: var(--mood-secondary, var(--mood-accent));
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user