gigafibre-fsm/apps/ops/src/pages/ReportInternetCherPage.vue
louispaulb bde7a5ef67 feat(ops): Ville column in overpriced-internet report (sort + filter by city)
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>
2026-06-01 22:44:34 -04:00

345 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 longlet Cogeco : clique « Check Availability », colle (⌘V / Ctrl+V) puis choisis la suggestion.'
: 'Copie ladresse 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>