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>
110 lines
5.8 KiB
Vue
110 lines
5.8 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</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" :class="{ 'drop-hover': dropStage === st }"
|
|
@dragover.prevent="dropStage = st" @dragleave="dropStage === st && (dropStage = null)" @drop="onDrop(st)">
|
|
<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>
|
|
</div>
|
|
<div class="pipe-col-body">
|
|
<div v-for="c in (columns[st] || [])" :key="c.token" class="pipe-card" draggable="true"
|
|
@dragstart="onDragStart(c)" @dragend="dragCard = null" @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.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>
|
|
<div v-if="!(columns[st] || []).length" class="pipe-empty">—</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useQuasar } from 'quasar'
|
|
import { useConversations } from 'src/composables/useConversations'
|
|
import { shortAgent } 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 dragCard = ref(null)
|
|
const dropStage = ref(null)
|
|
|
|
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))
|
|
|
|
// 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; columns.value = b.columns || {} } catch (e) { /* */ } finally { loading.value = false }
|
|
}
|
|
|
|
function onDragStart (c) { dragCard.value = c }
|
|
async function onDrop (stage) {
|
|
dropStage.value = null
|
|
const c = dragCard.value; dragCard.value = null
|
|
if (!c || !stage) return
|
|
// retire de l'ancienne colonne, ajoute à la nouvelle (optimiste)
|
|
for (const st in columns.value) columns.value[st] = columns.value[st].filter(x => x.token !== c.token)
|
|
columns.value[stage] = [{ ...c }, ...(columns.value[stage] || [])]
|
|
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-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-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>
|