gigafibre-fsm/apps/ops/src/components/shared/PipelineBoard.vue
louispaulb 512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).

Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:49:35 -04:00

125 lines
6.9 KiB
Vue

<template>
<div class="pipe-wrap">
<div class="pipe-head">
<div v-if="!embedded" class="text-h6 text-weight-bold">Pipeline de ventes</div>
<div v-else class="text-subtitle2 text-weight-bold text-grey-8">Leads</div>
<q-space />
<div class="text-caption text-grey-7 q-mr-md">{{ totalLeads }} lead(s) · {{ openLeads }} en cours<span v-if="pipelineValue > 0"> · <b class="text-primary">{{ formatMoney(pipelineValue) }}</b> en pipeline</span></div>
<q-btn flat dense round icon="refresh" :loading="loading" @click="load" />
</div>
<div class="pipe-board">
<div v-for="st in stages" :key="st" class="pipe-col">
<div class="pipe-col-hd" :class="stageClass(st)">
<span>{{ st }}</span>
<q-badge color="white" text-color="grey-9" class="q-ml-xs">{{ (columns[st] || []).length }}</q-badge>
<q-space />
<span v-if="colTotal(st) > 0" style="font-size:.72rem;opacity:.92;font-weight:600">{{ formatMoney(colTotal(st)) }}</span>
</div>
<!-- vuedraggable (SortableJS) : glisser-déposer qui marche AU DOIGT — appui long 160ms sur tactile
(delay-on-touch-only → le défilement reste fluide), instantané à la souris sur desktop. -->
<draggable :list="columns[st]" group="pipeline" item-key="token" class="pipe-col-body"
:animation="150" :delay="160" :delay-on-touch-only="true" :touch-start-threshold="6" ghost-class="pipe-ghost"
@change="(e) => onDragChange(e, st)">
<template #item="{ element: c }">
<div class="pipe-card" @click="openLead(c)">
<div class="pipe-card-t">
<q-icon :name="c.channel === 'email' ? 'mail' : 'sms'" size="13px" class="q-mr-xs" :color="c.channel === 'email' ? 'red-5' : 'teal-6'" />
{{ c.customerName || c.contact || 'Lead' }}
<q-badge v-if="c.is_customer" color="green-2" text-color="green-9" class="q-ml-xs" style="font-size:.6rem">client</q-badge>
</div>
<div class="pipe-card-s ellipsis">{{ c.subject || '—' }}</div>
<div class="pipe-card-m">
<span v-if="c.value > 0" class="pipe-val">{{ formatMoney(c.value) }}</span>
<span v-else-if="c.contact" class="text-grey-6 ellipsis" style="max-width:130px">{{ c.contact }}</span>
<q-space />
<span v-if="c.assignee" class="pipe-assignee"><q-icon name="pan_tool" size="11px" /> {{ shortAgent(c.assignee) }}</span>
</div>
</div>
</template>
<template #footer>
<div v-if="!(columns[st] || []).length" class="pipe-empty">Glissez un lead ici</div>
</template>
</draggable>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import draggable from 'vuedraggable'
import { useConversations } from 'src/composables/useConversations'
import { shortAgent, formatMoney } from 'src/composables/useFormatters'
defineProps({ embedded: Boolean })
const $q = useQuasar()
const { pipelineBoard, setPipeline, fetchList, discussions, openDiscussion, panelOpen } = useConversations()
const stages = ref(['Nouveau', 'Qualifié', 'Devis', 'Gagné', 'Perdu'])
const columns = ref({})
const loading = ref(false)
const totalLeads = computed(() => Object.values(columns.value).reduce((s, a) => s + a.length, 0))
const openLeads = computed(() => ['Nouveau', 'Qualifié', 'Devis'].reduce((s, st) => s + ((columns.value[st] || []).length), 0))
function colTotal (st) { return (columns.value[st] || []).reduce((s, c) => s + (c.value || 0), 0) }
const pipelineValue = computed(() => ['Nouveau', 'Qualifié', 'Devis'].reduce((s, st) => s + colTotal(st), 0)) // valeur ouverte (hors Gagné/Perdu)
// shortAgent vient de useFormatters (consolidation)
function stageClass (st) { return 'st-' + st.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() }
async function load () {
loading.value = true
try {
const b = await pipelineBoard()
if (b.stages && b.stages.length) stages.value = b.stages
const cols = b.columns || {}
for (const st of stages.value) if (!Array.isArray(cols[st])) cols[st] = [] // chaque étape = vrai tableau (requis par vuedraggable :list)
columns.value = cols
} catch (e) { /* */ } finally { loading.value = false }
}
// vuedraggable a DÉJÀ déplacé la carte entre les colonnes (optimiste) ; on persiste seulement à l'arrivée (added).
async function onDragChange (evt, stage) {
const c = evt && evt.added && evt.added.element
if (!c || !stage) return
try { await setPipeline(c.token, stage); $q.notify({ type: 'positive', message: `${c.customerName || 'Lead'}${stage}`, timeout: 1500 }) }
catch (e) { $q.notify({ type: 'negative', message: 'Échec déplacement' }); load() }
}
async function openLead (c) {
panelOpen.value = true
let disc = discussions.value.find(d => d.token === c.token || (d.conversations || []).some(x => x.token === c.token))
if (!disc) { await fetchList(); disc = discussions.value.find(d => d.token === c.token || (d.conversations || []).some(x => x.token === c.token)) }
if (disc) openDiscussion(disc)
}
onMounted(load)
</script>
<style scoped>
.pipe-wrap { padding: 12px 16px; }
.pipe-head { display: flex; align-items: center; margin-bottom: 12px; }
.pipe-board { display: flex; gap: 12px; overflow-x: auto; align-items: flex-start; padding-bottom: 8px; }
.pipe-col { flex: 0 0 250px; background: #f1f5f9; border-radius: 10px; display: flex; flex-direction: column; max-height: calc(100vh - 220px); }
.pipe-col.drop-hover { outline: 2px dashed #6366f1; outline-offset: -2px; }
.pipe-col-hd { display: flex; align-items: center; font-weight: 700; font-size: .8rem; color: #fff; padding: 8px 10px; border-radius: 10px 10px 0 0; }
.st-nouveau { background: #64748b; } .st-qualifie { background: #6366f1; } .st-devis { background: #d97706; } .st-gagne { background: #16a34a; } .st-perdu { background: #dc2626; }
.pipe-col-body { padding: 8px; overflow-y: auto; display: flex; flex-direction: column; gap: 7px; }
.pipe-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px 10px; cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
.pipe-card:hover { border-color: #6366f1; }
.pipe-ghost { opacity: .55; background: #eef2ff; border: 1px dashed #6366f1; }
.pipe-card-t { font-weight: 600; font-size: .82rem; display: flex; align-items: center; }
.pipe-card-s { font-size: .75rem; color: #475569; margin-top: 2px; }
.pipe-card-m { display: flex; align-items: center; font-size: .68rem; margin-top: 5px; }
.pipe-assignee { color: #b45309; font-weight: 600; }
.pipe-val { color: #4f46e5; font-weight: 700; }
.pipe-empty { text-align: center; color: #cbd5e1; padding: 10px; font-size: .8rem; }
@media (max-width: 640px) {
.pipe-board { scroll-snap-type: x mandatory; }
.pipe-col { flex-basis: 86vw; scroll-snap-align: start; }
}
</style>