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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user