Adds a sortable 'Ville' column (field city) to the report. Quasar's default filter scans all columns, so the existing search box now matches city too. Street address caption drops the now-redundant city (keeps postal code). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
345 lines
15 KiB
Vue
345 lines
15 KiB
Vue
<template>
|
||
<q-page padding>
|
||
<div class="row items-center q-mb-md">
|
||
<q-btn flat dense icon="arrow_back" to="/rapports" class="q-mr-sm" />
|
||
<div class="text-h6 text-weight-bold">Clients qui paient cher — Internet</div>
|
||
<q-space />
|
||
<q-btn flat dense icon="travel_explore" class="q-mr-sm"
|
||
:label="svcBusy ? `Concurrence ${svcProgress.done}/${svcProgress.total}` : 'Concurrence'"
|
||
:loading="svcBusy" :disable="!rows.length" @click="fetchServiceability(rows)">
|
||
<q-tooltip max-width="280px">Vérifie, pour chaque adresse, les fournisseurs Internet disponibles (données ouvertes Québec IHV). Cogeco surligné = alternative existante = risque de churn.</q-tooltip>
|
||
</q-btn>
|
||
<q-btn flat dense icon="download" label="CSV" :href="csvUrl" :disable="!rows.length" />
|
||
</div>
|
||
|
||
<div class="text-caption text-grey-7 q-mb-md" style="max-width:820px">
|
||
Total mensuel net du service Internet (forfait + rabais récurrents, forfaits annuels
|
||
ramenés au mois) par adresse de service, depuis la base legacy.
|
||
<strong>Comptes clients actifs uniquement</strong> — exclut les comptes résiliés,
|
||
prospects, relais/infrastructure et propriétaires-hébergeurs. Exclut aussi TV,
|
||
téléphonie, frais ponctuels (équipement, installation) et crédits promo expirés.
|
||
Sert à repérer les clients qui gagneraient à être déplacés vers un forfait mieux adapté.
|
||
</div>
|
||
|
||
<!-- Filters -->
|
||
<div class="row q-col-gutter-sm q-mb-md items-end">
|
||
<div class="col-auto">
|
||
<q-input v-model.number="threshold" type="number" min="0" step="5" label="Seuil $/mois"
|
||
dense outlined style="width:130px" />
|
||
</div>
|
||
<div class="col-auto">
|
||
<q-select v-model="segment" :options="segmentOptions" emit-value map-options
|
||
label="Segment" dense outlined style="width:170px" />
|
||
</div>
|
||
<div class="col-auto">
|
||
<q-toggle v-model="addons" label="Inclure add-ons (IP fixe, etc.)" />
|
||
</div>
|
||
<div class="col-auto">
|
||
<q-btn color="primary" label="Générer" icon="play_arrow" @click="loadReport" :loading="loading" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Data freshness banner — the legacy copy is a snapshot, not live -->
|
||
<q-banner v-if="loaded && dataAsOf" dense rounded
|
||
:class="staleClass" class="q-mb-md">
|
||
<template #avatar><q-icon :name="isStale ? 'warning' : 'schedule'" /></template>
|
||
Données legacy au <strong>{{ formatDate(dataAsOf) }}</strong>
|
||
<span v-if="isStale"> — copie figée ({{ daysOld }} jours). Les renégociations,
|
||
nouveaux rabais et résiliations récents ne sont pas reflétés.</span>
|
||
</q-banner>
|
||
|
||
<!-- Summary cards -->
|
||
<div v-if="loaded" class="row q-col-gutter-sm q-mb-md">
|
||
<div class="col-auto">
|
||
<div class="ops-card text-center" style="min-width:140px">
|
||
<div class="text-caption text-grey-6">Adresses</div>
|
||
<div class="text-h6 text-weight-bold">{{ rows.length }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<div class="ops-card text-center" style="min-width:160px">
|
||
<div class="text-caption text-grey-6">Total mensuel net</div>
|
||
<div class="text-h6 text-weight-bold text-primary">{{ formatMoney(totalNet) }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<div class="ops-card text-center" style="min-width:140px">
|
||
<div class="text-caption text-grey-6">Moyenne / adresse</div>
|
||
<div class="text-h6 text-weight-bold">{{ formatMoney(avgNet) }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<div class="ops-card text-center" style="min-width:140px">
|
||
<div class="text-caption text-grey-6">Rabais mensuels</div>
|
||
<div class="text-h6 text-weight-bold text-positive">{{ formatMoney(totalDiscounts) }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-auto" v-if="svcProgress.total">
|
||
<div class="ops-card text-center" style="min-width:180px">
|
||
<div class="text-caption text-grey-6">Cogeco disponible</div>
|
||
<div class="text-h6 text-weight-bold text-deep-orange-8">{{ cogecoCount }}</div>
|
||
<div class="text-caption text-grey-6">{{ svcBusy ? `vérifié ${svcProgress.done}/${svcProgress.total}` : 'risque de churn' }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Data table -->
|
||
<q-table
|
||
v-if="loaded"
|
||
:rows="rows"
|
||
:columns="columns"
|
||
row-key="delivery_id"
|
||
flat bordered
|
||
class="ops-table"
|
||
:pagination="{ rowsPerPage: 50, sortBy: 'net_internet', descending: true }"
|
||
dense
|
||
:filter="search"
|
||
>
|
||
<template #top-right>
|
||
<q-input v-model="search" dense outlined placeholder="Filtrer (nom, ville, courriel…)" clearable style="width:240px">
|
||
<template #prepend><q-icon name="search" /></template>
|
||
</q-input>
|
||
</template>
|
||
|
||
<template #body-cell-client="props">
|
||
<q-td :props="props">
|
||
<div class="text-weight-medium">{{ props.row.client_name || '(sans nom)' }}</div>
|
||
<div v-if="props.row.company" class="text-caption text-grey-7">{{ props.row.company }}</div>
|
||
</q-td>
|
||
</template>
|
||
|
||
<template #body-cell-address="props">
|
||
<q-td :props="props">
|
||
<div>{{ props.row.address1 || '—' }}</div>
|
||
<div class="text-caption text-grey-7">{{ props.row.zip }}</div>
|
||
</q-td>
|
||
</template>
|
||
|
||
<template #body-cell-net_internet="props">
|
||
<q-td :props="props">
|
||
<span class="text-weight-bold" :class="props.value >= 110 ? 'text-negative' : 'text-orange-9'">
|
||
{{ formatMoney(props.value) }}
|
||
</span>
|
||
</q-td>
|
||
</template>
|
||
|
||
<template #body-cell-discounts="props">
|
||
<q-td :props="props">
|
||
<span v-if="props.value < 0" class="text-positive">{{ formatMoney(props.value) }}</span>
|
||
<span v-else class="text-grey-5">—</span>
|
||
</q-td>
|
||
</template>
|
||
|
||
<template #body-cell-recent_expired_discount="props">
|
||
<q-td :props="props">
|
||
<span v-if="props.value < 0" class="text-deep-orange-7 text-weight-medium">
|
||
{{ formatMoney(props.value) }}
|
||
<q-icon name="warning" size="14px" class="q-ml-xs" />
|
||
<q-tooltip>Rabais expiré dans les 6 derniers mois — la facture vient d'augmenter d'autant. Candidat à la rétention.</q-tooltip>
|
||
</span>
|
||
<span v-else class="text-grey-5">—</span>
|
||
</q-td>
|
||
</template>
|
||
|
||
<template #body-cell-detail="props">
|
||
<q-td :props="props">
|
||
<span class="text-caption" style="font-family:monospace">{{ shorten(props.value) }}</span>
|
||
<q-tooltip v-if="props.value" max-width="420px" class="text-body2">{{ props.value }}</q-tooltip>
|
||
</q-td>
|
||
</template>
|
||
|
||
<template #body-cell-contact="props">
|
||
<q-td :props="props">
|
||
<a v-if="props.row.email" :href="`mailto:${props.row.email}`" class="text-primary block">{{ props.row.email }}</a>
|
||
<span v-if="props.row.cell || props.row.tel_home" class="text-caption text-grey-7">
|
||
{{ props.row.cell || props.row.tel_home }}
|
||
</span>
|
||
</q-td>
|
||
</template>
|
||
|
||
<template #body-cell-concurrence="props">
|
||
<q-td :props="props" style="min-width:210px">
|
||
<div class="row items-center no-wrap">
|
||
<div class="col">
|
||
<div v-if="svcLoadingOf(props.row)" class="text-grey-5"><q-spinner size="14px" /></div>
|
||
<template v-else-if="svcOf(props.row)">
|
||
<div v-if="hasProviders(props.row)" class="q-gutter-xs">
|
||
<q-chip v-for="p in svcOf(props.row).providers" :key="p.nom" dense size="sm"
|
||
:color="isCogeco(p.nom) ? 'deep-orange-3' : 'blue-grey-2'"
|
||
:text-color="isCogeco(p.nom) ? 'deep-orange-10' : 'blue-grey-9'"
|
||
:icon="isCogeco(p.nom) ? 'priority_high' : undefined"
|
||
:label="p.nom">
|
||
<q-tooltip v-if="p.url">{{ p.url }}</q-tooltip>
|
||
</q-chip>
|
||
</div>
|
||
<span v-else class="text-grey-6 text-caption">{{ svcOf(props.row).etat_label }}</span>
|
||
</template>
|
||
<span v-else class="text-grey-5">—</span>
|
||
</div>
|
||
<q-btn dense flat round size="xs" icon="open_in_new" color="grey-5" @click="checkCogeco(props.row)">
|
||
<q-tooltip>Ouvrir Cogeco pour valider manuellement</q-tooltip>
|
||
</q-btn>
|
||
</div>
|
||
</q-td>
|
||
</template>
|
||
</q-table>
|
||
|
||
<div v-if="loaded && !rows.length" class="text-center q-pa-xl text-grey-7">
|
||
<q-icon name="search_off" size="42px" />
|
||
<div class="q-mt-sm">Aucune adresse au-dessus de {{ formatMoney(threshold) }}/mois pour ce segment.</div>
|
||
</div>
|
||
</q-page>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed } from 'vue'
|
||
import { useQuasar, copyToClipboard } from 'quasar'
|
||
import { fetchOverpricedInternet, overpricedInternetCsvUrl, lookupServiceabilityBatch } from 'src/api/reports'
|
||
|
||
const $q = useQuasar()
|
||
const threshold = ref(90)
|
||
const segment = ref('residential')
|
||
const addons = ref(true)
|
||
const loading = ref(false)
|
||
const loaded = ref(false)
|
||
const rows = ref([])
|
||
const search = ref('')
|
||
const dataAsOf = ref(null)
|
||
|
||
// Serviceability (Québec IHV) state — keyed by delivery_id
|
||
const svc = ref({}) // delivery_id -> { matched, etat_label, providers, cogeco }
|
||
const svcLoading = ref({}) // delivery_id -> bool
|
||
const svcBusy = ref(false)
|
||
const svcProgress = ref({ done: 0, total: 0 })
|
||
|
||
const segmentOptions = [
|
||
{ label: 'Résidentiel', value: 'residential' },
|
||
{ label: 'Commercial', value: 'commercial' },
|
||
{ label: 'Tous', value: 'all' },
|
||
]
|
||
|
||
const columns = [
|
||
{ name: 'client', label: 'Client', field: 'client_name', align: 'left', sortable: true },
|
||
{ name: 'address', label: 'Adresse de service', field: 'address1', align: 'left', sortable: true },
|
||
{ name: 'city', label: 'Ville', field: 'city', align: 'left', sortable: true },
|
||
{ name: 'net_internet', label: 'Net Internet /mois', field: 'net_internet', align: 'right', sortable: true },
|
||
{ name: 'gross', label: 'Brut', field: 'gross', align: 'right', sortable: true, format: v => formatMoney(v) },
|
||
{ name: 'discounts', label: 'Rabais actifs', field: 'discounts', align: 'right', sortable: true },
|
||
{ name: 'recent_expired_discount', label: 'Rabais expirés (6 mois)', field: 'recent_expired_discount', align: 'right', sortable: true },
|
||
{ name: 'n_lines', label: 'Lignes', field: 'n_lines', align: 'center', sortable: true },
|
||
{ name: 'detail', label: 'Détail forfait', field: 'detail', align: 'left' },
|
||
{ name: 'contact', label: 'Contact', field: 'email', align: 'left' },
|
||
{ name: 'concurrence', label: 'Concurrence (FSI)', field: 'delivery_id', align: 'left' },
|
||
]
|
||
|
||
const totalNet = computed(() => rows.value.reduce((s, r) => s + Number(r.net_internet || 0), 0))
|
||
const avgNet = computed(() => rows.value.length ? totalNet.value / rows.value.length : 0)
|
||
const totalDiscounts = computed(() => rows.value.reduce((s, r) => s + Number(r.discounts || 0), 0))
|
||
const cogecoCount = computed(() => Object.values(svc.value).filter(v => v && v.cogeco).length)
|
||
|
||
function isCogeco (nom) { return /cogeco/i.test(nom || '') }
|
||
function svcOf (row) { return svc.value[row.delivery_id] }
|
||
function svcLoadingOf (row) { return !!svcLoading.value[row.delivery_id] }
|
||
function hasProviders (row) { const v = svc.value[row.delivery_id]; return !!(v && v.providers && v.providers.length) }
|
||
|
||
const csvUrl = computed(() => overpricedInternetCsvUrl({
|
||
threshold: threshold.value, segment: segment.value, addons: addons.value,
|
||
}))
|
||
|
||
const daysOld = computed(() => {
|
||
if (!dataAsOf.value) return 0
|
||
return Math.floor((Date.now() - new Date(dataAsOf.value)) / 86400000)
|
||
})
|
||
const isStale = computed(() => daysOld.value > 7)
|
||
const staleClass = computed(() => isStale.value ? 'bg-orange-2 text-orange-10' : 'bg-blue-1 text-blue-9')
|
||
|
||
function formatMoney (v) {
|
||
return new Intl.NumberFormat('fr-CA', { style: 'currency', currency: 'CAD' }).format(Number(v) || 0)
|
||
}
|
||
function formatDate (iso) {
|
||
return iso ? new Date(iso).toLocaleDateString('fr-CA', { day: 'numeric', month: 'long', year: 'numeric' }) : ''
|
||
}
|
||
function shorten (s) {
|
||
if (!s) return ''
|
||
return s.length > 48 ? s.slice(0, 48) + '…' : s
|
||
}
|
||
|
||
// Build a clean single-line address for the competitor's address checker.
|
||
// e.g. "147 Montée Richard, Saint-Bernard-de-Lacolle, QC J0J 1V0"
|
||
function fullAddress (row) {
|
||
const prov = 'QC' + (row.zip ? ' ' + String(row.zip).toUpperCase() : '')
|
||
return [row.address1, row.city, prov].filter(Boolean).join(', ')
|
||
}
|
||
|
||
// Assisted spot-check: Cogeco's checker is gated by reCAPTCHA Enterprise so we
|
||
// can't qualify addresses automatically at volume. Instead, one click copies
|
||
// the address and opens Cogeco's availability page in a new tab — a human
|
||
// pastes + reads the verdict in ~10s, only for the leads that matter.
|
||
const COGECO_URL = 'https://www.cogeco.ca/en/internet/packages'
|
||
async function checkCogeco (row) {
|
||
const addr = fullAddress(row)
|
||
let copied = true
|
||
try { await copyToClipboard(addr) } catch { copied = false }
|
||
window.open(COGECO_URL, '_blank', 'noopener')
|
||
$q.notify({
|
||
color: 'indigo-7',
|
||
icon: 'travel_explore',
|
||
message: copied ? 'Adresse copiée : ' + addr : addr,
|
||
caption: copied
|
||
? 'Dans l’onglet Cogeco : clique « Check Availability », colle (⌘V / Ctrl+V) puis choisis la suggestion.'
|
||
: 'Copie l’adresse ci-dessus, puis dans Cogeco clique « Check Availability » et colle-la.',
|
||
timeout: 7000,
|
||
multiLine: true,
|
||
actions: [{ label: 'OK', color: 'white', round: true }],
|
||
})
|
||
}
|
||
|
||
// Batch-fetch provider availability for the given rows via the hub
|
||
// (Québec IHV open data). Chunked + progressive so the column fills as it goes.
|
||
async function fetchServiceability (rowsList) {
|
||
const items = (rowsList || []).filter(r => r.address1).map(r => ({
|
||
key: String(r.delivery_id), address1: r.address1, city: r.city, zip: r.zip,
|
||
}))
|
||
if (!items.length) return
|
||
svcBusy.value = true
|
||
svc.value = {}
|
||
const ld = {}; items.forEach(it => { ld[it.key] = true }); svcLoading.value = ld
|
||
svcProgress.value = { done: 0, total: items.length }
|
||
const CHUNK = 40
|
||
try {
|
||
for (let i = 0; i < items.length; i += CHUNK) {
|
||
const chunk = items.slice(i, i + CHUNK)
|
||
try {
|
||
const { results } = await lookupServiceabilityBatch(chunk)
|
||
const sv = { ...svc.value }; const ldd = { ...svcLoading.value }
|
||
for (const [k, v] of Object.entries(results)) { sv[k] = v; ldd[k] = false }
|
||
svc.value = sv; svcLoading.value = ldd
|
||
} catch (e) {
|
||
const ldd = { ...svcLoading.value }; chunk.forEach(c => { ldd[c.key] = false }); svcLoading.value = ldd
|
||
$q.notify({ type: 'warning', message: 'Concurrence : lot ignoré (' + e.message + ')', timeout: 3000 })
|
||
}
|
||
svcProgress.value = { done: Math.min(i + CHUNK, items.length), total: items.length }
|
||
}
|
||
} finally {
|
||
svcBusy.value = false
|
||
}
|
||
}
|
||
|
||
async function loadReport () {
|
||
loading.value = true
|
||
svc.value = {}; svcLoading.value = {}; svcProgress.value = { done: 0, total: 0 }
|
||
try {
|
||
const data = await fetchOverpricedInternet({
|
||
threshold: threshold.value, segment: segment.value, addons: addons.value, limit: 5000,
|
||
})
|
||
rows.value = data.rows || []
|
||
dataAsOf.value = data.data_as_of || null
|
||
loaded.value = true
|
||
} catch (e) {
|
||
$q.notify({ type: 'negative', message: 'Erreur de chargement : ' + e.message })
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
</script>
|