gigafibre-fsm/apps/ops/src/pages/ReportNegativeBillingPage.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

172 lines
9.9 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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-sm">
<q-btn flat dense round icon="arrow_back" to="/rapports" class="q-mr-sm" />
<div class="text-h6 text-weight-bold">Factures récurrentes négatives</div>
<q-space />
<q-btn outline color="primary" icon="refresh" label="Recalculer" no-caps :loading="loading" @click="load(true)" />
<q-btn flat color="grey-7" icon="download" label="CSV" no-caps class="q-ml-sm" :disable="!rows.length" @click="exportCsv" />
</div>
<q-banner dense class="bg-amber-1 text-grey-9 q-mb-md" rounded>
<template #avatar><q-icon name="info" color="warning" /></template>
Un abonnement récurrent ne peut pas être négatif. La facturation <b>F (gestionclient) est autoritaire et correcte</b> :
ces écarts sont des <b>anomalies de données côté ERPNext/OPS</b> (rabais ajoutés manuellement ou artefacts d'import) à corriger.
</q-banner>
<div v-if="loading && !rows.length" class="column items-center q-pa-xl text-grey-6">
<q-spinner size="32px" color="primary" />
<div class="q-mt-sm">Calcul en cours — scan des abonnements actifs…</div>
</div>
<div v-else-if="error" class="text-negative q-pa-md"><q-icon name="error" /> {{ error }}</div>
<template v-else>
<div class="row q-col-gutter-sm q-mb-md">
<div class="col-6 col-sm-auto">
<div style="background:var(--ops-surface);border:1px solid var(--ops-border);border-radius:10px;padding:10px 16px;min-width:140px">
<div style="font-size:12px;color:var(--ops-text-muted)">Comptes négatifs</div>
<div style="font-size:24px;font-weight:600;color:var(--ops-danger)">{{ rows.length }}</div>
</div>
</div>
<div class="col-6 col-sm-auto">
<div style="background:var(--ops-surface);border:1px solid var(--ops-border);border-radius:10px;padding:10px 16px;min-width:140px">
<div style="font-size:12px;color:var(--ops-text-muted)">Abonnements scannés</div>
<div style="font-size:24px;font-weight:600;color:var(--ops-text)">{{ scanned.toLocaleString('fr-CA') }}</div>
</div>
</div>
</div>
<div class="text-caption text-grey-7 q-mb-sm">
<b>{{ rows.length }}</b> compte{{ rows.length > 1 ? 's' : '' }} avec total récurrent négatif
<span v-if="generated"> · généré {{ fmtTime(generated) }}</span>
</div>
<q-table
:rows="rows" :columns="columns" row-key="key" flat bordered :grid="$q.screen.lt.md"
:pagination="{ rowsPerPage: 25, sortBy: 'total', descending: false }"
:rows-per-page-options="[25, 50, 100, 0]" dense>
<template #item="props">
<div class="col-12 q-pa-xs">
<q-card flat bordered class="q-pa-sm">
<div class="row items-center no-wrap q-mb-xs">
<router-link :to="'/clients/' + props.row.customer" class="text-primary text-weight-medium" style="text-decoration:none">{{ props.row.customer_name }}</router-link>
<q-space />
<q-badge :color="statusColor(props.row.account_status_label)" :label="props.row.account_status_label || ''" />
</div>
<div class="text-caption text-grey-6 q-mb-xs">{{ props.row.service_location }}</div>
<div class="row justify-between text-caption"><span class="text-grey-7">Frais</span><span class="text-weight-medium">{{ money(props.row.charges) }}</span></div>
<div class="row justify-between text-caption"><span class="text-grey-7">Rabais</span><span class="text-negative text-weight-medium">{{ money(props.row.rebate) }}</span></div>
<div class="row justify-between text-caption"><span class="text-grey-7">Total adresse</span><span class="text-negative text-weight-bold">{{ money(props.row.total) }}</span></div>
</q-card>
</div>
</template>
<template #body-cell-customer="props">
<q-td :props="props">
<router-link :to="'/clients/' + props.row.customer" class="text-primary text-weight-medium" style="text-decoration:none">
{{ props.row.customer_name }}
</router-link>
<div class="text-caption text-grey-6">{{ props.row.customer }} · {{ props.row.service_location }}</div>
</q-td>
</template>
<template #body-cell-client="props">
<q-td :props="props">
<q-badge :color="statusColor(props.row.account_status_label)" :label="props.row.account_status_label || ''">
<q-tooltip>Statut du compte GLOBAL dans F (legacy) — l'autorité. Résilié = compte terminé (rabais résiduel = ménage).</q-tooltip>
</q-badge>
</q-td>
</template>
<template #body-cell-total="props">
<q-td :props="props" class="text-weight-bold text-negative">{{ money(props.row.total) }}</q-td>
</template>
<template #body-cell-customer_total="props">
<q-td :props="props" :class="props.row.customer_total < 0 ? 'text-negative text-weight-bold' : 'text-green-7'">{{ money(props.row.customer_total) }}</q-td>
</template>
<template #body-cell-cause="props">
<q-td :props="props">
<q-badge v-if="props.row.cause === 'artifact'" color="blue-grey-5" label="Artefact d'adresse (compte OK)"><q-tooltip>Rabais et frais sur des adresses différentes — le compte n'est pas négatif.</q-tooltip></q-badge>
<q-badge v-else-if="props.row.cause === 'manual'" color="negative" :label="`Ajout OPS (hors F) ×${props.row.manualRebates}`" />
<template v-else-if="props.row.cause === 'suspended_charge'">
<q-badge color="warning" label="Charge suspendue dans ERPNext"><q-tooltip>Une charge active dans F est Suspendue/Annulée dans ERPNext → le rabais reste seul. Désync de statut.</q-tooltip></q-badge>
<div v-for="(c, i) in props.row.inactiveCharges" :key="i" class="text-caption text-warning">
{{ c.status }} : {{ c.plan }} {{ money(c.price) }}<span v-if="c.legacy"> · F #{{ c.legacy }}</span>
</div>
</template>
<q-badge v-else-if="props.row.cause === 'expired'" color="warning" label="Fin de service non respectée" />
<q-badge v-else color="warning" label="Rabais F empilés (vérifier vs F)" />
</q-td>
</template>
<template #body-cell-rebates="props">
<q-td :props="props">
<div v-for="(r, i) in props.row.rebates" :key="i" class="text-caption">
<span class="text-negative">{{ money(r.price) }}</span> · {{ r.plan || r.sku }}
<q-badge :color="r.legacy > 0 ? 'green-2' : 'red-3'" :text-color="r.legacy > 0 ? 'green-9' : 'red-9'" :label="r.source" class="q-ml-xs" />
<span v-if="r.end_date" class="text-warning"> · fin {{ r.end_date }}</span>
</div>
</q-td>
</template>
</q-table>
</template>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
import { formatMoney as money, formatDateTimeShort as fmtTime } from 'src/composables/useFormatters'
const $q = useQuasar()
const rows = ref([])
const scanned = ref(0)
const generated = ref(0)
const loading = ref(false)
const error = ref('')
let pollTimer = null
const columns = [
{ name: 'customer', label: 'Client / adresse', field: 'customer_name', align: 'left', sortable: true },
{ name: 'client', label: 'Client', field: 'customer_active', align: 'center', sortable: true },
{ name: 'charges', label: 'Frais', field: 'charges', align: 'right', sortable: true, format: v => money(v) },
{ name: 'rebate', label: 'Rabais', field: 'rebate', align: 'right', sortable: true, format: v => money(v) },
{ name: 'total', label: 'Total adresse', field: 'total', align: 'right', sortable: true },
{ name: 'customer_total', label: 'Total compte', field: 'customer_total', align: 'right', sortable: true },
{ name: 'cause', label: 'Cause probable', field: 'manualRebates', align: 'left' },
{ name: 'rebates', label: 'Détail rabais (actifs)', field: 'rebates', align: 'left' },
]
// money = formatMoney, fmtTime = formatDateTimeShort (useFormatters, consolidation)
function statusColor (label) { return label === 'Actif' ? 'green-6' : label === 'Suspendu' ? 'orange-7' : label === 'Résilié' ? 'red-5' : 'grey-5' }
async function load (refresh) {
loading.value = true; error.value = ''
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null }
try {
const r = await fetch(`${HUB_URL}/collab/negative-billing${refresh ? '?refresh=1' : ''}`)
const d = await r.json()
if (d.ready) {
rows.value = (d.rows || []).map(x => ({ ...x, key: x.customer + '|' + x.service_location }))
scanned.value = d.scanned || 0; generated.value = d.generated || 0; loading.value = false
} else if (d.error) {
error.value = d.error; loading.value = false
} else {
// calcul en arrière-plan → on re-sonde
pollTimer = setTimeout(() => load(false), 3000)
}
} catch (e) { error.value = e.message; loading.value = false }
}
function exportCsv () {
const head = ['Client', 'ID', 'Statut compte F', 'Adresse', 'Frais', 'Rabais', 'Total', 'Total compte', 'Rabais détail']
const lines = rows.value.map(r => [
r.customer_name, r.customer, r.account_status_label || (r.customer_active ? 'Actif' : 'Inactif'), r.service_location, r.charges, r.rebate, r.total, r.customer_total,
r.rebates.map(x => `${x.plan || x.sku} (${x.price})`).join(' | '),
].map(c => `"${String(c == null ? '' : c).replace(/"/g, '""')}"`).join(','))
const csv = [head.join(','), ...lines].join('\n')
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' })
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'factures-negatives.csv'; a.click()
$q.notify({ type: 'positive', message: `${rows.value.length} ligne(s) exportée(s)` })
}
onMounted(() => load(false))
</script>