gigafibre-fsm/apps/ops/src/pages/ReportNegativeBillingPage.vue
louispaulb 0f65c02d83 feat(fsm): platform build — comms UI, F→ERPNext sync/billing, roster, campaigns, network, reports
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>
2026-06-15 06:12:12 -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="amber-8" /></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-red-7 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-red-7 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-red-7 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-red-7">{{ money(props.row.total) }}</q-td>
</template>
<template #body-cell-customer_total="props">
<q-td :props="props" :class="props.row.customer_total < 0 ? 'text-red-7 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="red-7" :label="`Ajout OPS (hors F) ×${props.row.manualRebates}`" />
<template v-else-if="props.row.cause === 'suspended_charge'">
<q-badge color="deep-orange-8" 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-orange-9">
{{ 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="orange-8" label="Fin de service non respectée" />
<q-badge v-else color="deep-orange-7" 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-red-7">{{ 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-orange-8"> · 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>