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>
125 lines
6.9 KiB
Vue
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>
|