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:
Yvv
2026-04-23 20:25:44 +02:00
parent e2ae8b196e
commit 9a8f10efdf
9 changed files with 879 additions and 20 deletions

View File

@@ -4,6 +4,7 @@ import type { DecisionCreate } from '~/stores/decisions'
const decisions = useDecisionsStore()
const protocols = useProtocolsStore()
const mandates = useMandatesStore()
const groupsStore = useGroupsStore()
const { $api } = useApi()
// ── Wizard steps ──────────────────────────────────────────────────────────────
@@ -16,16 +17,57 @@ const isStructural = ref(false)
const contextDescription = ref('')
// Cercles de personnes concernées
const circle1 = ref('') // 1er cercle — nommés explicitement (requis hors mandat)
const circle2 = ref('') // 2e cercle — optionnel
const circle3Open = ref(true) // 3e cercle — toutes personnes se sentant concernées
// Chaque cercle peut être en mode texte libre ou groupe prédéfini
type CircleMode = 'text' | 'group'
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
const affectedCountFromCircles = computed(() => {
const c1 = circle1.value.split(/[,;\n]/).map(s => s.trim()).filter(Boolean).length
const c2 = circle2.value.split(/[,;\n]/).map(s => s.trim()).filter(Boolean).length
const base = c1 + c2
return base >= 2 ? base : (base === 1 ? 2 : null) // minimum 2
const c1names = circleNames(circle1Mode.value, circle1Text.value, circle1GroupId.value)
const c2names = circleNames(circle2Mode.value, circle2Text.value, circle2GroupId.value)
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 ───────────────────────────────────────────────────────────
@@ -75,6 +117,7 @@ const activeMandates = computed(() =>
onMounted(() => {
mandates.fetchAll()
groupsStore.fetchAll()
})
// ── Modality metadata ─────────────────────────────────────────────────────────
@@ -112,9 +155,8 @@ function modalityMeta(slug: string) {
const canQualify = computed(() => {
if (withinMandate.value === null) return false
if (withinMandate.value === false) {
// Need at least one name in circle 1
const c1 = circle1.value.trim()
return c1.length > 0
if (circle1Mode.value === 'text') return circle1Text.value.trim().length > 0
return circle1GroupId.value !== null
}
return true
})
@@ -206,9 +248,10 @@ async function onSubmit() {
if (!formData.value.title.trim()) return
submitting.value = true
try {
const { c1, c2 } = circlesSummary.value
const circlesContext = [
circle1.value.trim() ? `Cercle 1 (directement concernés) : ${circle1.value.trim()}` : '',
circle2.value.trim() ? `Cercle 2 (indirectement concernés) : ${circle2.value.trim()}` : '',
c1 ? `Cercle 1 (directement concernés) : ${c1}` : '',
c2 ? `Cercle 2 (indirectement concernés) : ${c2}` : '',
circle3Open.value ? 'Cercle 3 : ouvert à toute personne se sentant concernée.' : '',
].filter(Boolean).join('\n')
@@ -326,13 +369,35 @@ const confidenceLabel: Record<string, string> = {
<div class="circle-header">
<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>
<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>
<textarea
v-model="circle1"
v-if="circle1Mode === 'text'"
v-model="circle1Text"
class="qualify-textarea"
rows="2"
lang="fr"
spellcheck="true"
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>
</div>
@@ -341,13 +406,32 @@ const confidenceLabel: Record<string, string> = {
<div class="circle-header">
<span class="circle-badge circle-badge--2">2</span>
<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>
<textarea
v-model="circle2"
v-if="circle2Mode === 'text'"
v-model="circle2Text"
class="qualify-textarea"
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>
<!-- Cercle 3 -->
@@ -396,7 +480,7 @@ const confidenceLabel: Record<string, string> = {
<!-- Contexte -->
<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>
<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>
<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">
<p class="qresult__circles-title">Cercles concernés</p>
<div class="qresult__circles-list">
<span v-if="circle1.trim()" class="qresult__circle">
<span class="circle-badge circle-badge--1 circle-badge--sm">1</span> {{ circle1.trim() }}
<span v-if="circlesSummary.c1" class="qresult__circle">
<span class="circle-badge circle-badge--1 circle-badge--sm">1</span> {{ circlesSummary.c1 }}
</span>
<span v-if="circle2.trim()" class="qresult__circle">
<span class="circle-badge circle-badge--2 circle-badge--sm">2</span> {{ circle2.trim() }}
<span v-if="circlesSummary.c2" class="qresult__circle">
<span class="circle-badge circle-badge--2 circle-badge--sm">2</span> {{ circlesSummary.c2 }}
</span>
<span class="qresult__circle">
<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-from, .slide-down-leave-to { opacity: 0; max-height: 0; }
.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>

View File

@@ -24,6 +24,7 @@ onMounted(async () => {
await Promise.all([
protocols.fetchProtocols(),
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. */
const n8nWorkflows = [
{
@@ -330,6 +398,79 @@ const n8nWorkflows = [
</div>
</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) -->
<div class="proto-ops">
<h3 class="proto-ops__title">
@@ -506,6 +647,51 @@ const n8nWorkflows = [
</div>
</template>
</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>
<style scoped>
@@ -1185,4 +1371,175 @@ const n8nWorkflows = [
box-shadow: 0 4px 12px var(--mood-shadow);
}
.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>