gigafibre-fsm/apps/ops/src/components/shared/InterveneDialog.vue
louispaulb 0f65c02d83 feat(fsm): platform build — comms UI, F→ERPNext sync/billing, roster, campaigns, network, reports
Accumulated work on the dispatch/legacy-writeback branch:
- Communications UI: CommunicationsPage, ConversationFullPage, DepartmentBoard,
  PipelineBoard, ReaderStack, Orchestrator/NewTicket/ServiceStatus/Outbox dialogs;
  hub gmail.js, ticket-collab.js, outbox.js, coupon-triage.js, client-diag.js.
- Billing/sync mirror (F→ERPNext): legacy-payments.js, legacy-sync.js,
  sync-orchestrator.js, supplier-invoices.js, municipality.js + incremental
  migration scripts; LegacySyncPage, SupplierInvoices + negative-billing /
  terminated-active reports.
- Roster/campaigns/network/voice: roster + roster-assistant, campaigns, giftbit,
  olt-snmp, traccar, twilio, vision, tech-absence-sms, ai/agent/config/helpers,
  legacy-dispatch-sync; ops PlanificationPage, RapportsPage, Settings, Tickets,
  ClientDetail updates.
- docs/ PLATFORM_GUIDE + UI_AND_OPTIMIZATION; .gitignore __pycache__.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:12:12 -04:00

152 lines
8.5 KiB
Vue

<template>
<q-dialog v-model="open" @show="onShow">
<q-card style="min-width:480px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="forum" color="indigo-6" size="22px" class="q-mr-sm" />
<div>
<div class="text-subtitle1 text-weight-bold">Intervenir{{ title ? ' — ' + title : '' }}</div>
<div class="text-caption text-grey-6">Collaboration d'équipe · <b>n'affecte pas l'assignation</b></div>
</div>
<q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<!-- Écran 1 : options principales -->
<div v-if="step === 'menu'" class="row q-col-gutter-sm">
<div class="col-6" v-for="a in actions" :key="a.key">
<div class="iv-tile" :class="{ soon: a.soon }" @click="!a.soon && (step = a.key)">
<q-icon :name="a.icon" size="26px" :color="a.soon ? 'grey-5' : a.color" />
<div class="text-weight-medium q-mt-xs">{{ a.label }}</div>
<div class="text-caption text-grey-6">{{ a.hint }}</div>
<q-badge v-if="a.soon" label="bientôt" color="grey-3" text-color="grey-6" class="q-mt-xs" />
</div>
</div>
</div>
<!-- Écran 2a : note / indice -->
<div v-else-if="step === 'note'">
<q-btn flat dense size="sm" icon="arrow_back" label="Retour" no-caps class="q-mb-xs" @click="step = 'menu'" />
<q-input v-model="text" type="textarea" autogrow :rows="3" outlined autofocus
label="Indice, note, contexte… (visible par l'équipe sur le ticket)" />
<div class="row justify-end q-mt-sm">
<q-btn unelevated color="indigo-6" icon="send" label="Publier la note" no-caps :disable="!text.trim() || busy" :loading="busy" @click="submit([])" />
</div>
</div>
<!-- Écran 2b : mentionner un collègue -->
<div v-else-if="step === 'mention'">
<q-btn flat dense size="sm" icon="arrow_back" label="Retour" no-caps class="q-mb-xs" @click="step = 'menu'" />
<div class="row items-center q-gutter-xs q-mb-xs" v-if="picked.length">
<q-chip v-for="m in picked" :key="m" dense removable color="indigo-1" text-color="indigo-8" @remove="picked = picked.filter(x => x !== m)">{{ shortName(m) }}</q-chip>
</div>
<div style="position:relative">
<q-input v-model="search" dense outlined label="Mentionner un collègue…" autofocus autocomplete="off" @update:model-value="onSearch">
<template #append><q-spinner v-if="searching" size="16px" color="indigo-6" /></template>
</q-input>
<div v-if="results.length" class="iv-dd">
<div v-for="u in results" :key="u.email" class="iv-dd-item" @click="addPick(u.email)">
<q-avatar size="22px" color="indigo-1" text-color="indigo-8" class="q-mr-sm" style="font-size:.6rem">{{ (u.name || u.email).charAt(0).toUpperCase() }}</q-avatar>
{{ u.name || u.email }}<span class="text-caption text-grey-6 q-ml-xs">{{ u.email }}</span>
</div>
</div>
</div>
<q-input v-model="text" type="textarea" autogrow :rows="2" outlined class="q-mt-sm" label="Message au(x) collègue(s)…" />
<div class="row justify-end q-mt-sm">
<q-btn unelevated color="indigo-6" icon="send" :label="`Notifier ${picked.length || ''}`" no-caps :disable="!picked.length || !text.trim() || busy" :loading="busy" @click="submit(picked)" />
</div>
</div>
<!-- Fil récent -->
<div v-if="activity.length" class="iv-feed q-mt-md">
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">Activité récente</div>
<div v-for="(c, i) in activity" :key="i" class="iv-msg">
<b>{{ shortName(c.comment_by || c.comment_email) }}</b>
<span class="text-grey-5 q-ml-xs" style="font-size:.7rem">{{ fmt(c.creation) }}</span>
<div class="iv-msg-txt">{{ strip(c.content) }}</div>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
</template>
<script setup>
import { ref } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
import { shortAgent as shortName, formatDateTimeShort as fmt } from 'src/composables/useFormatters'
const props = defineProps({ modelValue: Boolean, doctype: { type: String, default: 'Issue' }, name: String, title: String })
const emit = defineEmits(['update:modelValue', 'posted'])
const $q = useQuasar()
const open = ref(false)
const step = ref('menu')
const text = ref('')
const picked = ref([])
const search = ref('')
const results = ref([])
const searching = ref(false)
const busy = ref(false)
const activity = ref([])
let _t = null
const actions = [
{ key: 'note', icon: 'lightbulb', color: 'amber-7', label: 'Indice / note', hint: 'Partager une connaissance' },
{ key: 'mention', icon: 'alternate_email', color: 'indigo-6', label: 'Mentionner', hint: 'Demander à un collègue' },
{ key: 'link', icon: 'link', color: 'teal-6', label: 'Lier un élément', hint: 'Ticket, client…', soon: true },
{ key: 'follow', icon: 'visibility', color: 'blue-grey-6', label: 'Suivre', hint: 'Recevoir les suites', soon: true },
]
// sync v-model
import { watch } from 'vue'
watch(() => props.modelValue, v => { open.value = v })
watch(open, v => { emit('update:modelValue', v); if (!v) reset() })
function reset () { step.value = 'menu'; text.value = ''; picked.value = []; search.value = ''; results.value = [] }
// shortName = shortAgent, fmt = formatDateTimeShort (useFormatters, consolidation)
function strip (h) { return String(h || '').replace(/<[^>]+>/g, ' ').replace(/&lt;/g, '<').replace(/&amp;/g, '&').replace(/\s+/g, ' ').trim() }
async function onShow () { reset(); await loadActivity() }
async function loadActivity () {
if (!props.name) return
try { const r = await fetch(`${HUB_URL}/collab/activity?doctype=${encodeURIComponent(props.doctype)}&name=${encodeURIComponent(props.name)}`); if (r.ok) { const d = await r.json(); activity.value = d.comments || [] } } catch (e) { /* */ }
}
function onSearch (v) {
clearTimeout(_t); const q = String(v || '').trim()
if (q.length < 2) { results.value = []; return }
_t = setTimeout(async () => {
searching.value = true
try { const r = await fetch(`${HUB_URL}/auth/users?search=${encodeURIComponent(q)}`); if (r.ok) { const d = await r.json(); results.value = (d.users || d || []).filter(u => u && u.email && !picked.value.includes(u.email)).slice(0, 8) } } catch (e) { results.value = [] } finally { searching.value = false }
}, 250)
}
function addPick (email) { if (email && !picked.value.includes(email)) picked.value.push(email); search.value = ''; results.value = [] }
async function submit (mentions) {
if (!props.name || !text.value.trim()) return
busy.value = true
try {
const r = await fetch(`${HUB_URL}/collab/comment`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ doctype: props.doctype, name: props.name, text: text.value.trim(), mentions }) })
const d = await r.json()
if (d.ok) {
$q.notify({ type: 'positive', message: mentions.length ? `Note publiée + ${mentions.length} collègue(s) notifié(s)` : 'Note publiée sur le ticket' })
text.value = ''; picked.value = []; step.value = 'menu'; await loadActivity(); emit('posted')
} else $q.notify({ type: 'negative', message: d.error || 'Échec' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { busy.value = false }
}
</script>
<style scoped>
.iv-tile { border: 1px solid #e2e8f0; border-radius: 10px; padding: 14px; text-align: center; cursor: pointer; transition: all .12s; height: 100%; }
.iv-tile:hover { border-color: #6366f1; background: #eef2ff; }
.iv-tile.soon { cursor: default; opacity: .6; }
.iv-tile.soon:hover { border-color: #e2e8f0; background: transparent; }
.iv-dd { position: absolute; z-index: 20; left: 0; right: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.12); margin-top: 2px; max-height: 220px; overflow-y: auto; }
.iv-dd-item { display: flex; align-items: center; padding: 7px 10px; cursor: pointer; font-size: .85rem; }
.iv-dd-item:hover { background: #f1f5f9; }
.iv-feed { border-top: 1px solid #eef2f7; padding-top: 8px; max-height: 200px; overflow-y: auto; }
.iv-msg { padding: 5px 0; border-bottom: 1px solid #f5f7fa; font-size: .8rem; }
.iv-msg-txt { color: #334155; white-space: pre-wrap; }
</style>