Batch 1 de l'audit d'optimisation. Les libs lourdes étaient statiquement importées donc livrées même inutilisées → passées en import dynamique / defineAsyncComponent : - MainLayout : ConversationPanel/PhoneModal/FlowEditor/NewTicket/Orchestrator/ ServiceStatus/OutboxPanel → async (chunk shell 312 KB → 56 KB, chargé sur CHAQUE page) - TaskGraphPage : TaskGantt (hy-vue-gantt) → async (2.6 MB → 20 KB ; le Gantt ne charge que dans la vue Gantt) - NetworkPage : cytoscape → import() dans loadNetworkMap (484 KB → 52 KB) - ReportAR/Revenu/Taxes : chart.js/auto → import() dans renderChart (async) (~150→~10 KB/page) Total dist inchangé (~8 M) — le code est DÉFÉRÉ en chunks à la demande, pas supprimé. Gain réel = ce que l'utilisateur télécharge pour VOIR une page. Build OK, leak-check propre, shell + route rapport vérifiés (aucune erreur d'import). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
123 lines
5.3 KiB
Vue
123 lines
5.3 KiB
Vue
<template>
|
|
<ReportScaffold
|
|
title="Âge des comptes à recevoir"
|
|
:loading="loading" :error="error" :has-run="hasRun"
|
|
:cards="cards" :columns="columns" :rows="rows" row-key="customer" searchable
|
|
:pagination="{ rowsPerPage: 50, sortBy: 'total', descending: true }"
|
|
empty="Aucun solde à recevoir à cette date."
|
|
@retry="loadReport" @export="doExport">
|
|
<template #filters>
|
|
<q-input v-model="asOfDate" type="date" label="En date du" dense outlined stack-label style="width:160px;max-width:100%" />
|
|
<q-btn color="primary" label="Générer" icon="play_arrow" @click="loadReport" :loading="loading" />
|
|
</template>
|
|
<template #chart>
|
|
<div v-if="summary" class="ops-card q-mb-md" style="height:280px;position:relative"><canvas ref="chartCanvas"></canvas></div>
|
|
</template>
|
|
<template #body-cell-total="props">
|
|
<q-td :props="props"><span class="text-weight-bold text-negative">{{ formatMoney(props.value) }}</span></q-td>
|
|
</template>
|
|
<template #body-cell-d120="props">
|
|
<q-td :props="props">
|
|
<span :class="props.value > 0 ? 'text-negative text-weight-bold' : ''">{{ props.value > 0 ? formatMoney(props.value) : '—' }}</span>
|
|
</q-td>
|
|
</template>
|
|
</ReportScaffold>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, nextTick } from 'vue'
|
|
import { fetchARReport } from 'src/api/reports'
|
|
import { formatMoney } from 'src/composables/useFormatters'
|
|
import { exportCsv } from 'src/composables/useCsvExport'
|
|
import ReportScaffold from 'src/components/shared/ReportScaffold.vue'
|
|
let Chart = null // chart.js ~90 KB → chargé à la demande au 1er rendu de graphique (hors du chunk de la page)
|
|
|
|
const asOfDate = ref(new Date().toISOString().slice(0, 10))
|
|
const loading = ref(false)
|
|
const error = ref('')
|
|
const hasRun = ref(false)
|
|
const rows = ref([])
|
|
const summary = ref(null)
|
|
const chartCanvas = ref(null)
|
|
let chartInstance = null
|
|
|
|
const cards = computed(() => {
|
|
const s = summary.value
|
|
if (!s) return []
|
|
return [
|
|
{ label: 'Courant', value: formatMoney(s.current), valueClass: 'text-positive' },
|
|
{ label: '30 jours', value: formatMoney(s.d30), valueClass: 'text-warning' },
|
|
{ label: '60 jours', value: formatMoney(s.d60), valueClass: 'text-orange' },
|
|
{ label: '90 jours', value: formatMoney(s.d90), valueClass: 'text-deep-orange' },
|
|
{ label: '120+ jours', value: formatMoney(s.d120), valueClass: 'text-negative' },
|
|
{ label: 'Total dû', value: formatMoney(s.total), valueClass: 'text-negative' },
|
|
]
|
|
})
|
|
|
|
const columns = [
|
|
{ name: 'customer', label: '#Client', field: 'customer', align: 'left', sortable: true },
|
|
{ name: 'customer_name', label: 'Nom', field: 'customer_name', align: 'left', sortable: true },
|
|
{ name: 'current', label: 'Courant', field: 'current', align: 'right', sortable: true, format: v => v > 0 ? formatMoney(v) : '—' },
|
|
{ name: 'd30', label: '30 jours', field: 'd30', align: 'right', sortable: true, format: v => v > 0 ? formatMoney(v) : '—' },
|
|
{ name: 'd60', label: '60 jours', field: 'd60', align: 'right', sortable: true, format: v => v > 0 ? formatMoney(v) : '—' },
|
|
{ name: 'd90', label: '90 jours', field: 'd90', align: 'right', sortable: true, format: v => v > 0 ? formatMoney(v) : '—' },
|
|
{ name: 'd120', label: '120+', field: 'd120', align: 'right', sortable: true },
|
|
{ name: 'total', label: 'Total', field: 'total', align: 'right', sortable: true },
|
|
]
|
|
|
|
async function loadReport () {
|
|
loading.value = true
|
|
error.value = ''
|
|
hasRun.value = true
|
|
try {
|
|
const res = await fetchARReport(asOfDate.value)
|
|
rows.value = res.rows || []
|
|
summary.value = res.summary || null
|
|
await nextTick()
|
|
renderChart()
|
|
} catch (e) {
|
|
console.error('AR report error:', e)
|
|
error.value = 'Échec du chargement du rapport — réessayez.'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function renderChart () {
|
|
if (chartInstance) chartInstance.destroy()
|
|
if (!chartCanvas.value || !summary.value) return
|
|
if (!Chart) Chart = (await import('chart.js/auto')).default
|
|
const s = summary.value
|
|
chartInstance = new Chart(chartCanvas.value, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: ['Courant', '30 jours', '60 jours', '90 jours', '120+ jours'],
|
|
datasets: [{
|
|
data: [s.current, s.d30, s.d60, s.d90, s.d120],
|
|
backgroundColor: ['#10b981', '#f59e0b', '#f97316', '#ef4444', '#991b1b'],
|
|
borderWidth: 2,
|
|
borderColor: '#fff',
|
|
}],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { position: 'right', labels: { font: { size: 12 } } },
|
|
tooltip: { callbacks: { label: ctx => ctx.label + ': ' + formatMoney(ctx.parsed) } },
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
function doExport () {
|
|
if (!rows.value.length) return
|
|
const headers = ['#Client', 'Nom', 'Courant', '30 jours', '60 jours', '90 jours', '120+ jours', 'Total']
|
|
const data = rows.value.map(r => [r.customer, r.customer_name, r.current.toFixed(2), r.d30.toFixed(2), r.d60.toFixed(2), r.d90.toFixed(2), r.d120.toFixed(2), r.total.toFixed(2)])
|
|
const totalRow = summary.value
|
|
? ['', 'TOTAL', summary.value.current.toFixed(2), summary.value.d30.toFixed(2), summary.value.d60.toFixed(2), summary.value.d90.toFixed(2), summary.value.d120.toFixed(2), summary.value.total.toFixed(2)]
|
|
: null
|
|
exportCsv(`age_comptes_${asOfDate.value}`, headers, data, { totalRow })
|
|
}
|
|
</script>
|