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>
172 lines
9.9 KiB
Vue
172 lines
9.9 KiB
Vue
<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>
|