Groupes d'identités : modèle DB, router, store, UI cercles + Protocoles
- Group + GroupMember : modèle SQLAlchemy + migration + router CRUD - /api/v1/groups : liste, création, suppression, membres (add/remove) - groups.ts : store Pinia (fetchAll, getGroup, create, remove, addMember, removeMember) - decisions/new.vue : cercles 1 & 2 en mode texte libre OU groupe prédéfini (affected_count calculé depuis le member_count du groupe) - protocols/index.vue : section Groupes avec expand/collapse, ajout/suppression membres - lang="fr" + spellcheck sur tous les textareas ; placeholder cercle 2 corrigé - n8n channels : prévu sprint futur (texte libre → webhook appel à contribution) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
"""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.create_table(
|
||||||
|
"groups",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(128), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("organization_id", sa.Uuid(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_groups_organization_id", "groups", ["organization_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"group_members",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("group_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("identity_id", sa.Uuid(), nullable=True),
|
||||||
|
sa.Column("display_name", sa.String(128), nullable=False),
|
||||||
|
sa.Column("added_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["group_id"], ["groups.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["identity_id"], ["duniter_identities.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_group_members_group_id", "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")
|
||||||
@@ -15,6 +15,7 @@ from app.routers import auth, documents, decisions, votes, mandates, protocols,
|
|||||||
from app.routers import public
|
from app.routers import public
|
||||||
from app.routers import organizations
|
from app.routers import organizations
|
||||||
from app.routers import qualify
|
from app.routers import qualify
|
||||||
|
from app.routers import groups
|
||||||
|
|
||||||
|
|
||||||
# ── Structured logging setup ───────────────────────────────────────────────
|
# ── Structured logging setup ───────────────────────────────────────────────
|
||||||
@@ -135,6 +136,7 @@ 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(organizations.router, prefix="/api/v1/organizations", tags=["organizations"])
|
||||||
app.include_router(qualify.router, prefix="/api/v1/qualify", tags=["qualify"])
|
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 ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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.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
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ __all__ = [
|
|||||||
"Mandate", "MandateStep",
|
"Mandate", "MandateStep",
|
||||||
"VotingProtocol", "FormulaConfig",
|
"VotingProtocol", "FormulaConfig",
|
||||||
"QualificationProtocol",
|
"QualificationProtocol",
|
||||||
|
"Group", "GroupMember",
|
||||||
"SanctuaryEntry",
|
"SanctuaryEntry",
|
||||||
"BlockchainCache",
|
"BlockchainCache",
|
||||||
]
|
]
|
||||||
|
|||||||
41
backend/app/models/group.py
Normal file
41
backend/app/models/group.py
Normal file
@@ -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")
|
||||||
126
backend/app/routers/groups.py
Normal file
126
backend/app/routers/groups.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""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.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()
|
||||||
|
|
||||||
|
|
||||||
|
def _org_id_from_header(request_headers) -> uuid.UUID | None:
|
||||||
|
raw = request_headers.get("x-organization")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return uuid.UUID(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[GroupSummary])
|
||||||
|
async def list_groups(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> list[GroupSummary]:
|
||||||
|
"""List all groups. No auth required — groups are public within the workspace."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Group).options(selectinload(Group.members)).order_by(Group.name)
|
||||||
|
)
|
||||||
|
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),
|
||||||
|
) -> GroupOut:
|
||||||
|
group = Group(name=payload.name, description=payload.description)
|
||||||
|
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()
|
||||||
46
backend/app/schemas/group.py
Normal file
46
backend/app/schemas/group.py
Normal file
@@ -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
|
||||||
@@ -4,6 +4,7 @@ import type { DecisionCreate } from '~/stores/decisions'
|
|||||||
const decisions = useDecisionsStore()
|
const decisions = useDecisionsStore()
|
||||||
const protocols = useProtocolsStore()
|
const protocols = useProtocolsStore()
|
||||||
const mandates = useMandatesStore()
|
const mandates = useMandatesStore()
|
||||||
|
const groupsStore = useGroupsStore()
|
||||||
const { $api } = useApi()
|
const { $api } = useApi()
|
||||||
|
|
||||||
// ── Wizard steps ──────────────────────────────────────────────────────────────
|
// ── Wizard steps ──────────────────────────────────────────────────────────────
|
||||||
@@ -16,16 +17,57 @@ const isStructural = ref(false)
|
|||||||
const contextDescription = ref('')
|
const contextDescription = ref('')
|
||||||
|
|
||||||
// Cercles de personnes concernées
|
// Cercles de personnes concernées
|
||||||
const circle1 = ref('') // 1er cercle — nommés explicitement (requis hors mandat)
|
// Chaque cercle peut être en mode texte libre ou groupe prédéfini
|
||||||
const circle2 = ref('') // 2e cercle — optionnel
|
type CircleMode = 'text' | 'group'
|
||||||
const circle3Open = ref(true) // 3e cercle — toutes personnes se sentant concernées
|
const circle1Mode = ref<CircleMode>('text')
|
||||||
|
const circle1Text = ref('')
|
||||||
|
const circle1GroupId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const circle2Mode = ref<CircleMode>('text')
|
||||||
|
const circle2Text = ref('')
|
||||||
|
const circle2GroupId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const circle3Open = ref(true)
|
||||||
|
|
||||||
|
// Résolution du cercle en liste de noms (pour le count et la sauvegarde)
|
||||||
|
function circleNames(mode: CircleMode, text: string, groupId: string | null): string[] {
|
||||||
|
if (mode === 'group' && groupId) {
|
||||||
|
const g = groupsStore.list.find(g => g.id === groupId)
|
||||||
|
return g ? [`[Groupe: ${g.name}]`] : []
|
||||||
|
}
|
||||||
|
return text.split(/[,;\n]/).map(s => s.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
// Affected count derivé des cercles
|
// Affected count derivé des cercles
|
||||||
const affectedCountFromCircles = computed(() => {
|
const affectedCountFromCircles = computed(() => {
|
||||||
const c1 = circle1.value.split(/[,;\n]/).map(s => s.trim()).filter(Boolean).length
|
const c1names = circleNames(circle1Mode.value, circle1Text.value, circle1GroupId.value)
|
||||||
const c2 = circle2.value.split(/[,;\n]/).map(s => s.trim()).filter(Boolean).length
|
const c2names = circleNames(circle2Mode.value, circle2Text.value, circle2GroupId.value)
|
||||||
const base = c1 + c2
|
|
||||||
return base >= 2 ? base : (base === 1 ? 2 : null) // minimum 2
|
let count = 0
|
||||||
|
if (circle1Mode.value === 'group' && circle1GroupId.value) {
|
||||||
|
const g = groupsStore.list.find(g => g.id === circle1GroupId.value)
|
||||||
|
count += g?.member_count ?? 1
|
||||||
|
} else {
|
||||||
|
count += c1names.length
|
||||||
|
}
|
||||||
|
if (circle2Mode.value === 'group' && circle2GroupId.value) {
|
||||||
|
const g = groupsStore.list.find(g => g.id === circle2GroupId.value)
|
||||||
|
count += g?.member_count ?? 0
|
||||||
|
} else {
|
||||||
|
count += c2names.length
|
||||||
|
}
|
||||||
|
return count >= 2 ? count : (count === 1 ? 2 : null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Résumé lisible des cercles (pour la sauvegarde dans le contexte)
|
||||||
|
const circlesSummary = computed(() => {
|
||||||
|
const c1 = circle1Mode.value === 'group' && circle1GroupId.value
|
||||||
|
? `Groupe: ${groupsStore.list.find(g => g.id === circle1GroupId.value)?.name ?? '?'}`
|
||||||
|
: circle1Text.value.trim()
|
||||||
|
const c2 = circle2Mode.value === 'group' && circle2GroupId.value
|
||||||
|
? `Groupe: ${groupsStore.list.find(g => g.id === circle2GroupId.value)?.name ?? '?'}`
|
||||||
|
: circle2Text.value.trim()
|
||||||
|
return { c1, c2 }
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── AI conversation ───────────────────────────────────────────────────────────
|
// ── AI conversation ───────────────────────────────────────────────────────────
|
||||||
@@ -75,6 +117,7 @@ const activeMandates = computed(() =>
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
mandates.fetchAll()
|
mandates.fetchAll()
|
||||||
|
groupsStore.fetchAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Modality metadata ─────────────────────────────────────────────────────────
|
// ── Modality metadata ─────────────────────────────────────────────────────────
|
||||||
@@ -112,9 +155,8 @@ function modalityMeta(slug: string) {
|
|||||||
const canQualify = computed(() => {
|
const canQualify = computed(() => {
|
||||||
if (withinMandate.value === null) return false
|
if (withinMandate.value === null) return false
|
||||||
if (withinMandate.value === false) {
|
if (withinMandate.value === false) {
|
||||||
// Need at least one name in circle 1
|
if (circle1Mode.value === 'text') return circle1Text.value.trim().length > 0
|
||||||
const c1 = circle1.value.trim()
|
return circle1GroupId.value !== null
|
||||||
return c1.length > 0
|
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -206,9 +248,10 @@ async function onSubmit() {
|
|||||||
if (!formData.value.title.trim()) return
|
if (!formData.value.title.trim()) return
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
|
const { c1, c2 } = circlesSummary.value
|
||||||
const circlesContext = [
|
const circlesContext = [
|
||||||
circle1.value.trim() ? `Cercle 1 (directement concernés) : ${circle1.value.trim()}` : '',
|
c1 ? `Cercle 1 (directement concernés) : ${c1}` : '',
|
||||||
circle2.value.trim() ? `Cercle 2 (indirectement concernés) : ${circle2.value.trim()}` : '',
|
c2 ? `Cercle 2 (indirectement concernés) : ${c2}` : '',
|
||||||
circle3Open.value ? 'Cercle 3 : ouvert à toute personne se sentant concernée.' : '',
|
circle3Open.value ? 'Cercle 3 : ouvert à toute personne se sentant concernée.' : '',
|
||||||
].filter(Boolean).join('\n')
|
].filter(Boolean).join('\n')
|
||||||
|
|
||||||
@@ -326,13 +369,35 @@ const confidenceLabel: Record<string, string> = {
|
|||||||
<div class="circle-header">
|
<div class="circle-header">
|
||||||
<span class="circle-badge circle-badge--1">1</span>
|
<span class="circle-badge circle-badge--1">1</span>
|
||||||
<p class="qualify-label">Premier cercle — personnes directement concernées <span class="qualify-req">*</span></p>
|
<p class="qualify-label">Premier cercle — personnes directement concernées <span class="qualify-req">*</span></p>
|
||||||
|
<div class="circle-mode-toggle">
|
||||||
|
<button class="circle-mode-btn" :class="{ 'circle-mode-btn--active': circle1Mode === 'text' }" @click="circle1Mode = 'text'">
|
||||||
|
<UIcon name="i-lucide-type" class="text-xs" /> Texte libre
|
||||||
|
</button>
|
||||||
|
<button class="circle-mode-btn" :class="{ 'circle-mode-btn--active': circle1Mode === 'group' }" @click="circle1Mode = 'group'">
|
||||||
|
<UIcon name="i-lucide-users-round" class="text-xs" /> Groupe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="circle1"
|
v-if="circle1Mode === 'text'"
|
||||||
|
v-model="circle1Text"
|
||||||
class="qualify-textarea"
|
class="qualify-textarea"
|
||||||
rows="2"
|
rows="2"
|
||||||
|
lang="fr"
|
||||||
|
spellcheck="true"
|
||||||
placeholder="Alice, Bob, Charlie — une par ligne ou séparées par des virgules"
|
placeholder="Alice, Bob, Charlie — une par ligne ou séparées par des virgules"
|
||||||
/>
|
/>
|
||||||
|
<div v-else class="circle-group-select">
|
||||||
|
<select v-model="circle1GroupId" class="qualify-select">
|
||||||
|
<option :value="null">— Sélectionner un groupe —</option>
|
||||||
|
<option v-for="g in groupsStore.list" :key="g.id" :value="g.id">
|
||||||
|
{{ g.name }} ({{ g.member_count }} membre{{ g.member_count > 1 ? 's' : '' }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="groupsStore.list.length === 0" class="qualify-hint qualify-hint--warn">
|
||||||
|
Aucun groupe défini. <NuxtLink to="/protocols" class="qualify-link">Créer un groupe</NuxtLink> dans Protocoles & Fonctionnement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<p class="qualify-hint">Personnes dont la décision modifie directement la situation.</p>
|
<p class="qualify-hint">Personnes dont la décision modifie directement la situation.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -341,13 +406,32 @@ const confidenceLabel: Record<string, string> = {
|
|||||||
<div class="circle-header">
|
<div class="circle-header">
|
||||||
<span class="circle-badge circle-badge--2">2</span>
|
<span class="circle-badge circle-badge--2">2</span>
|
||||||
<p class="qualify-label">Deuxième cercle <span class="qualify-optional">(optionnel)</span></p>
|
<p class="qualify-label">Deuxième cercle <span class="qualify-optional">(optionnel)</span></p>
|
||||||
|
<div class="circle-mode-toggle">
|
||||||
|
<button class="circle-mode-btn" :class="{ 'circle-mode-btn--active': circle2Mode === 'text' }" @click="circle2Mode = 'text'">
|
||||||
|
<UIcon name="i-lucide-type" class="text-xs" /> Texte libre
|
||||||
|
</button>
|
||||||
|
<button class="circle-mode-btn" :class="{ 'circle-mode-btn--active': circle2Mode === 'group' }" @click="circle2Mode = 'group'">
|
||||||
|
<UIcon name="i-lucide-users-round" class="text-xs" /> Groupe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="circle2"
|
v-if="circle2Mode === 'text'"
|
||||||
|
v-model="circle2Text"
|
||||||
class="qualify-textarea"
|
class="qualify-textarea"
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder="Personnes impactées indirectement ou parties prenantes secondaires"
|
lang="fr"
|
||||||
|
spellcheck="true"
|
||||||
|
placeholder="Personnes impactées indirectement ou parties prenantes de second niveau"
|
||||||
/>
|
/>
|
||||||
|
<div v-else class="circle-group-select">
|
||||||
|
<select v-model="circle2GroupId" class="qualify-select">
|
||||||
|
<option :value="null">— Sélectionner un groupe —</option>
|
||||||
|
<option v-for="g in groupsStore.list" :key="g.id" :value="g.id">
|
||||||
|
{{ g.name }} ({{ g.member_count }} membre{{ g.member_count > 1 ? 's' : '' }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cercle 3 -->
|
<!-- Cercle 3 -->
|
||||||
@@ -396,7 +480,7 @@ const confidenceLabel: Record<string, string> = {
|
|||||||
<!-- Contexte -->
|
<!-- Contexte -->
|
||||||
<div v-if="withinMandate !== null" class="qualify-section">
|
<div v-if="withinMandate !== null" class="qualify-section">
|
||||||
<p class="qualify-label">Décrivez brièvement le contexte <span class="qualify-optional">(optionnel — enrichit les propositions de l'IA)</span></p>
|
<p class="qualify-label">Décrivez brièvement le contexte <span class="qualify-optional">(optionnel — enrichit les propositions de l'IA)</span></p>
|
||||||
<textarea v-model="contextDescription" class="qualify-textarea" rows="3" placeholder="En quelques mots : de quoi s'agit-il ? Quels enjeux ? Quelle contrainte ?" />
|
<textarea v-model="contextDescription" class="qualify-textarea" rows="3" lang="fr" spellcheck="true" placeholder="En quelques mots : de quoi s'agit-il ? Quels enjeux ? Quelle contrainte ?" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="qualifyError" class="qualify-error">{{ qualifyError }}</p>
|
<p v-if="qualifyError" class="qualify-error">{{ qualifyError }}</p>
|
||||||
@@ -535,11 +619,11 @@ const confidenceLabel: Record<string, string> = {
|
|||||||
<div v-if="withinMandate === false" class="qresult__circles">
|
<div v-if="withinMandate === false" class="qresult__circles">
|
||||||
<p class="qresult__circles-title">Cercles concernés</p>
|
<p class="qresult__circles-title">Cercles concernés</p>
|
||||||
<div class="qresult__circles-list">
|
<div class="qresult__circles-list">
|
||||||
<span v-if="circle1.trim()" class="qresult__circle">
|
<span v-if="circlesSummary.c1" class="qresult__circle">
|
||||||
<span class="circle-badge circle-badge--1 circle-badge--sm">1</span> {{ circle1.trim() }}
|
<span class="circle-badge circle-badge--1 circle-badge--sm">1</span> {{ circlesSummary.c1 }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="circle2.trim()" class="qresult__circle">
|
<span v-if="circlesSummary.c2" class="qresult__circle">
|
||||||
<span class="circle-badge circle-badge--2 circle-badge--sm">2</span> {{ circle2.trim() }}
|
<span class="circle-badge circle-badge--2 circle-badge--sm">2</span> {{ circlesSummary.c2 }}
|
||||||
</span>
|
</span>
|
||||||
<span class="qresult__circle">
|
<span class="qresult__circle">
|
||||||
<span class="circle-badge circle-badge--3 circle-badge--sm">3</span>
|
<span class="circle-badge circle-badge--3 circle-badge--sm">3</span>
|
||||||
@@ -1016,4 +1100,44 @@ const confidenceLabel: Record<string, string> = {
|
|||||||
.slide-down-enter-active, .slide-down-leave-active { transition: all 0.2s ease; overflow: hidden; }
|
.slide-down-enter-active, .slide-down-leave-active { transition: all 0.2s ease; overflow: hidden; }
|
||||||
.slide-down-enter-from, .slide-down-leave-to { opacity: 0; max-height: 0; }
|
.slide-down-enter-from, .slide-down-leave-to { opacity: 0; max-height: 0; }
|
||||||
.slide-down-enter-to, .slide-down-leave-from { max-height: 600px; }
|
.slide-down-enter-to, .slide-down-leave-from { max-height: 600px; }
|
||||||
|
|
||||||
|
/* Circle mode toggle */
|
||||||
|
.circle-header { flex-wrap: wrap; }
|
||||||
|
.circle-mode-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-left: auto;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.1875rem;
|
||||||
|
}
|
||||||
|
.circle-mode-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-muted);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
.circle-mode-btn--active {
|
||||||
|
background: var(--mood-accent);
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
}
|
||||||
|
.circle-group-select { margin-bottom: 0.25rem; }
|
||||||
|
.qualify-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--mood-text);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 12px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.qualify-hint--warn { color: var(--mood-warning, var(--mood-muted)); }
|
||||||
|
.qualify-link { color: var(--mood-accent); text-decoration: underline; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ onMounted(async () => {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
protocols.fetchProtocols(),
|
protocols.fetchProtocols(),
|
||||||
protocols.fetchFormulas(),
|
protocols.fetchFormulas(),
|
||||||
|
groupsStore.fetchAll(),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -174,6 +175,73 @@ const operationalProtocols: OperationalProtocol[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// ── Groups ─────────────────────────────────────────────────────────────────
|
||||||
|
const groupsStore = useGroupsStore()
|
||||||
|
const showGroupModal = ref(false)
|
||||||
|
const newGroupName = ref('')
|
||||||
|
const newGroupDesc = ref('')
|
||||||
|
const creatingGroup = ref(false)
|
||||||
|
const expandedGroupId = ref<string | null>(null)
|
||||||
|
const expandedGroupDetail = ref<import('~/stores/groups').Group | null>(null)
|
||||||
|
const loadingGroupDetail = ref(false)
|
||||||
|
const newMemberName = ref('')
|
||||||
|
const addingMember = ref(false)
|
||||||
|
|
||||||
|
async function openGroupModal() {
|
||||||
|
newGroupName.value = ''
|
||||||
|
newGroupDesc.value = ''
|
||||||
|
showGroupModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGroup() {
|
||||||
|
if (!newGroupName.value.trim()) return
|
||||||
|
creatingGroup.value = true
|
||||||
|
try {
|
||||||
|
await groupsStore.create({ name: newGroupName.value.trim(), description: newGroupDesc.value.trim() || null })
|
||||||
|
showGroupModal.value = false
|
||||||
|
} finally {
|
||||||
|
creatingGroup.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleGroupDetail(groupId: string) {
|
||||||
|
if (expandedGroupId.value === groupId) {
|
||||||
|
expandedGroupId.value = null
|
||||||
|
expandedGroupDetail.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expandedGroupId.value = groupId
|
||||||
|
loadingGroupDetail.value = true
|
||||||
|
expandedGroupDetail.value = await groupsStore.getGroup(groupId)
|
||||||
|
loadingGroupDetail.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMember(groupId: string) {
|
||||||
|
if (!newMemberName.value.trim()) return
|
||||||
|
addingMember.value = true
|
||||||
|
const member = await groupsStore.addMember(groupId, { display_name: newMemberName.value.trim() })
|
||||||
|
if (member && expandedGroupDetail.value) {
|
||||||
|
expandedGroupDetail.value.members.push(member)
|
||||||
|
newMemberName.value = ''
|
||||||
|
}
|
||||||
|
addingMember.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMember(groupId: string, memberId: string) {
|
||||||
|
const ok = await groupsStore.removeMember(groupId, memberId)
|
||||||
|
if (ok && expandedGroupDetail.value) {
|
||||||
|
expandedGroupDetail.value.members = expandedGroupDetail.value.members.filter(m => m.id !== memberId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGroup(groupId: string) {
|
||||||
|
await groupsStore.remove(groupId)
|
||||||
|
if (expandedGroupId.value === groupId) {
|
||||||
|
expandedGroupId.value = null
|
||||||
|
expandedGroupDetail.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** n8n workflow demo items. */
|
/** n8n workflow demo items. */
|
||||||
const n8nWorkflows = [
|
const n8nWorkflows = [
|
||||||
{
|
{
|
||||||
@@ -330,6 +398,79 @@ const n8nWorkflows = [
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Groups ─────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="proto-groups">
|
||||||
|
<h3 class="proto-groups__title">
|
||||||
|
<UIcon name="i-lucide-users-round" class="text-sm" />
|
||||||
|
Groupes d'identités
|
||||||
|
<span class="proto-groups__count">{{ groupsStore.list.length }}</span>
|
||||||
|
<button v-if="auth.isAuthenticated" class="proto-groups__add-btn" @click="openGroupModal">
|
||||||
|
<UIcon name="i-lucide-plus" class="text-sm" />
|
||||||
|
Nouveau groupe
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div v-if="groupsStore.list.length === 0" class="proto-groups__empty">
|
||||||
|
<UIcon name="i-lucide-users" class="text-lg" />
|
||||||
|
<span>Aucun groupe défini. Les groupes permettent de pré-sélectionner des cercles dans les décisions.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="proto-groups__list">
|
||||||
|
<div v-for="g in groupsStore.list" :key="g.id" class="proto-groups__item">
|
||||||
|
<div class="proto-groups__item-head" @click="toggleGroupDetail(g.id)">
|
||||||
|
<div class="proto-groups__item-info">
|
||||||
|
<UIcon name="i-lucide-users" class="text-sm" />
|
||||||
|
<span class="proto-groups__item-name">{{ g.name }}</span>
|
||||||
|
<span class="proto-groups__item-count">{{ g.member_count }} membre{{ g.member_count > 1 ? 's' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="proto-groups__item-actions">
|
||||||
|
<button v-if="auth.isAuthenticated" class="proto-groups__delete-btn" @click.stop="deleteGroup(g.id)">
|
||||||
|
<UIcon name="i-lucide-trash-2" class="text-xs" />
|
||||||
|
</button>
|
||||||
|
<UIcon :name="expandedGroupId === g.id ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" class="text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="slide-down">
|
||||||
|
<div v-if="expandedGroupId === g.id" class="proto-groups__detail">
|
||||||
|
<p v-if="g.description" class="proto-groups__detail-desc">{{ g.description }}</p>
|
||||||
|
<div v-if="loadingGroupDetail" class="proto-groups__members-loading">
|
||||||
|
<UIcon name="i-lucide-loader-2" class="animate-spin text-sm" />
|
||||||
|
</div>
|
||||||
|
<ul v-else-if="expandedGroupDetail" class="proto-groups__members">
|
||||||
|
<li v-for="m in expandedGroupDetail.members" :key="m.id" class="proto-groups__member">
|
||||||
|
<UIcon name="i-lucide-user" class="text-xs" />
|
||||||
|
<span>{{ m.display_name }}</span>
|
||||||
|
<button v-if="auth.isAuthenticated" class="proto-groups__member-remove" @click="removeMember(g.id, m.id)">
|
||||||
|
<UIcon name="i-lucide-x" class="text-xs" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li v-if="expandedGroupDetail.members.length === 0" class="proto-groups__member proto-groups__member--empty">
|
||||||
|
Aucun membre
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-if="auth.isAuthenticated" class="proto-groups__add-member">
|
||||||
|
<input
|
||||||
|
v-model="newMemberName"
|
||||||
|
type="text"
|
||||||
|
lang="fr"
|
||||||
|
spellcheck="true"
|
||||||
|
class="proto-groups__member-input"
|
||||||
|
placeholder="Nom ou adresse Duniter"
|
||||||
|
@keydown.enter="addMember(g.id)"
|
||||||
|
/>
|
||||||
|
<button class="proto-groups__member-btn" :disabled="addingMember || !newMemberName.trim()" @click="addMember(g.id)">
|
||||||
|
<UIcon v-if="addingMember" name="i-lucide-loader-2" class="animate-spin text-xs" />
|
||||||
|
<UIcon v-else name="i-lucide-user-plus" class="text-xs" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Operational protocols (always visible, frontend-only data) -->
|
<!-- Operational protocols (always visible, frontend-only data) -->
|
||||||
<div class="proto-ops">
|
<div class="proto-ops">
|
||||||
<h3 class="proto-ops__title">
|
<h3 class="proto-ops__title">
|
||||||
@@ -506,6 +647,51 @@ const n8nWorkflows = [
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</UModal>
|
||||||
|
|
||||||
|
<!-- Create group modal -->
|
||||||
|
<UModal v-model:open="showGroupModal">
|
||||||
|
<template #content>
|
||||||
|
<div class="proto-modal">
|
||||||
|
<h3 class="proto-modal__title">Nouveau groupe d'identités</h3>
|
||||||
|
<div class="proto-modal__fields">
|
||||||
|
<div class="proto-modal__field">
|
||||||
|
<label class="proto-modal__label">Nom du groupe</label>
|
||||||
|
<input
|
||||||
|
v-model="newGroupName"
|
||||||
|
type="text"
|
||||||
|
lang="fr"
|
||||||
|
spellcheck="true"
|
||||||
|
class="proto-modal__input"
|
||||||
|
placeholder="Ex: Comité technique, Forgerons actifs…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="proto-modal__field">
|
||||||
|
<label class="proto-modal__label">Description <span class="proto-modal__optional">(optionnel)</span></label>
|
||||||
|
<textarea
|
||||||
|
v-model="newGroupDesc"
|
||||||
|
class="proto-modal__textarea"
|
||||||
|
lang="fr"
|
||||||
|
spellcheck="true"
|
||||||
|
placeholder="Rôle ou périmètre de ce groupe…"
|
||||||
|
rows="2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="proto-modal__actions">
|
||||||
|
<button class="proto-modal__cancel" @click="showGroupModal = false">Annuler</button>
|
||||||
|
<button
|
||||||
|
class="proto-modal__submit"
|
||||||
|
:disabled="!newGroupName.trim() || creatingGroup"
|
||||||
|
@click="createGroup"
|
||||||
|
>
|
||||||
|
<UIcon v-if="creatingGroup" name="i-lucide-loader-2" class="animate-spin" />
|
||||||
|
<UIcon v-else name="i-lucide-users-round" />
|
||||||
|
Créer le groupe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -1185,4 +1371,175 @@ const n8nWorkflows = [
|
|||||||
box-shadow: 0 4px 12px var(--mood-shadow);
|
box-shadow: 0 4px 12px var(--mood-shadow);
|
||||||
}
|
}
|
||||||
.proto-modal__submit:disabled { opacity: 0.4; cursor: not-allowed; }
|
.proto-modal__submit:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.proto-modal__optional { font-size: 0.8125rem; opacity: 0.55; font-weight: 400; }
|
||||||
|
|
||||||
|
/* --- Groups --- */
|
||||||
|
.proto-groups {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.proto-groups__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.proto-groups__count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.375rem;
|
||||||
|
height: 1.375rem;
|
||||||
|
padding: 0 0.375rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
.proto-groups__add-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
.proto-groups__add-btn:hover { transform: translateY(-1px); }
|
||||||
|
.proto-groups__empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
color: var(--mood-muted);
|
||||||
|
background: var(--mood-surface);
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.proto-groups__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.proto-groups__item {
|
||||||
|
background: var(--mood-surface);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.proto-groups__item-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
.proto-groups__item-head:hover { background: var(--mood-hover); }
|
||||||
|
.proto-groups__item-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
.proto-groups__item-name { font-weight: 600; font-size: 0.9375rem; }
|
||||||
|
.proto-groups__item-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-muted);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
}
|
||||||
|
.proto-groups__item-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--mood-muted);
|
||||||
|
}
|
||||||
|
.proto-groups__delete-btn {
|
||||||
|
color: var(--mood-danger, #e53e3e);
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
}
|
||||||
|
.proto-groups__delete-btn:hover { opacity: 1; }
|
||||||
|
.proto-groups__detail {
|
||||||
|
padding: 0.75rem 1rem 1rem;
|
||||||
|
border-top: 1px solid var(--mood-border, rgba(0,0,0,0.06));
|
||||||
|
}
|
||||||
|
.proto-groups__detail-desc {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-muted);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.proto-groups__members-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
color: var(--mood-muted);
|
||||||
|
}
|
||||||
|
.proto-groups__members {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.proto-groups__member {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-text);
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
background: var(--mood-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.proto-groups__member--empty { color: var(--mood-muted); font-style: italic; }
|
||||||
|
.proto-groups__member-remove {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mood-danger, #e53e3e);
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
}
|
||||||
|
.proto-groups__member-remove:hover { opacity: 1; }
|
||||||
|
.proto-groups__add-member {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.proto-groups__member-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.4375rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-text);
|
||||||
|
background: var(--mood-bg);
|
||||||
|
border-radius: 10px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.proto-groups__member-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.4375rem 0.875rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
background: var(--mood-accent);
|
||||||
|
border-radius: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.proto-groups__member-btn:hover:not(:disabled) { transform: translateY(-1px); }
|
||||||
|
.proto-groups__member-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
110
frontend/app/stores/groups.ts
Normal file
110
frontend/app/stores/groups.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
export interface GroupMember {
|
||||||
|
id: string
|
||||||
|
display_name: string
|
||||||
|
identity_id: string | null
|
||||||
|
added_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
organization_id: string | null
|
||||||
|
created_at: string
|
||||||
|
members: GroupMember[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupSummary {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
organization_id: string | null
|
||||||
|
member_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupCreate {
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupMemberCreate {
|
||||||
|
display_name: string
|
||||||
|
identity_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGroupsStore = defineStore('groups', () => {
|
||||||
|
const { $api } = useApi()
|
||||||
|
const orgs = useOrgsStore()
|
||||||
|
|
||||||
|
const list = ref<GroupSummary[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function fetchAll() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
list.value = await $api<GroupSummary[]>('/groups/')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message ?? 'Erreur chargement groupes'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGroup(id: string): Promise<Group | null> {
|
||||||
|
try {
|
||||||
|
return await $api<Group>(`/groups/${id}`)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(payload: GroupCreate): Promise<Group | null> {
|
||||||
|
try {
|
||||||
|
const group = await $api<Group>('/groups/', { method: 'POST', body: payload })
|
||||||
|
await fetchAll()
|
||||||
|
return group
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message ?? 'Erreur création groupe'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await $api(`/groups/${id}`, { method: 'DELETE' })
|
||||||
|
list.value = list.value.filter(g => g.id !== id)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMember(groupId: string, payload: GroupMemberCreate): Promise<GroupMember | null> {
|
||||||
|
try {
|
||||||
|
const member = await $api<GroupMember>(`/groups/${groupId}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
const g = list.value.find(g => g.id === groupId)
|
||||||
|
if (g) g.member_count++
|
||||||
|
return member
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMember(groupId: string, memberId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await $api(`/groups/${groupId}/members/${memberId}`, { method: 'DELETE' })
|
||||||
|
const g = list.value.find(g => g.id === groupId)
|
||||||
|
if (g) g.member_count--
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { list, loading, error, fetchAll, getGroup, create, remove, addMember, removeMember }
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user