gigafibre-fsm/apps/ops/src/pages/ReportRevenuPage.vue
louispaulb 607ea54b5c refactor: reduce token count, DRY code, consolidate docs
Backend services:
- targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons
  lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas,
  extract dispatch scoring weights, trim section dividers across 9 files
- modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(),
  consolidate DM query factory, fix duplicate username fill bug, trim headers
  (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%)

Frontend:
- useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into
  6 focused helpers (processOnlineStatus, processWanIPs, processRadios,
  processMeshNodes, processClients, checkRadioIssues)
- EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments

Documentation (17 → 13 files, -1,400 lines):
- New consolidated README.md (architecture, services, dependencies, auth)
- Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md
- Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md
- Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md
- Update ROADMAP.md with current phase status
- Delete CONTEXT.md (absorbed into README)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 08:39:58 -04:00

318 lines
10 KiB
Vue

<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">Revenus par compte</div>
<q-space />
<q-btn flat dense icon="download" label="CSV" @click="downloadCSV" :disable="!data.accounts?.length" />
</div>
<!-- Filters -->
<div class="row q-col-gutter-sm q-mb-md items-end">
<div class="col-auto">
<q-input v-model="startDate" type="date" label="Début" dense outlined style="width:160px" />
</div>
<div class="col-auto">
<q-input v-model="endDate" type="date" label="Fin" dense outlined style="width:160px" />
</div>
<div class="col-auto">
<q-btn-toggle
v-model="mode"
dense no-caps unelevated
toggle-color="primary"
:options="[{ label: 'Comptes GL', value: 'gl' }, { label: 'Groupes articles', value: 'items' }]"
/>
</div>
<div class="col-auto">
<q-btn color="primary" label="Générer" icon="play_arrow" @click="loadReport" :loading="loading" />
</div>
</div>
<!-- Account selector -->
<div v-if="allAccounts.length" class="q-mb-md">
<q-select
v-model="selectedAccounts"
:options="accountOptions"
label="Comptes à afficher (vide = tous)"
multiple
use-chips
dense outlined
emit-value map-options
option-value="value"
option-label="label"
clearable
style="max-width:800px"
>
<template #option="{ opt, selected, toggleOption }">
<q-item clickable @click="toggleOption(opt)" :active="selected" dense>
<q-item-section side>
<q-checkbox :model-value="selected" @update:model-value="toggleOption(opt)" dense />
</q-item-section>
<q-item-section>
<q-item-label>{{ opt.label }}</q-item-label>
</q-item-section>
</q-item>
</template>
<template #after>
<q-btn flat dense icon="refresh" @click="loadReport" :loading="loading" />
</template>
</q-select>
</div>
<!-- Chart -->
<div v-if="chartAccounts.length" class="ops-card q-mb-md" style="height:400px;position:relative">
<canvas ref="chartCanvas"></canvas>
</div>
<!-- Summary cards -->
<div v-if="data.accounts?.length" 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">Revenu total</div>
<div class="text-h6 text-weight-bold text-positive">{{ formatMoney(grandTotal) }}</div>
</div>
</div>
<div class="col-auto">
<div class="ops-card text-center" style="min-width:140px">
<div class="text-caption text-grey-6">Comptes actifs</div>
<div class="text-h6 text-weight-bold">{{ data.accounts.length }}</div>
</div>
</div>
<div class="col-auto">
<div class="ops-card text-center" style="min-width:140px">
<div class="text-caption text-grey-6">Mois</div>
<div class="text-h6 text-weight-bold">{{ data.months?.length || 0 }}</div>
</div>
</div>
<div class="col-auto">
<div class="ops-card text-center" style="min-width:140px">
<div class="text-caption text-grey-6">Mode</div>
<div class="text-subtitle2 text-weight-bold">{{ data.mode === 'items' ? 'Articles' : 'GL' }}</div>
</div>
</div>
</div>
<!-- Data table -->
<q-table
v-if="data.accounts?.length"
:rows="tableRows"
:columns="tableColumns"
row-key="name"
flat bordered
class="ops-table"
:pagination="{ rowsPerPage: 50 }"
dense
>
<template #body-cell-total="props">
<q-td :props="props">
<span class="text-weight-bold">{{ formatMoney(props.value) }}</span>
</q-td>
</template>
</q-table>
<!-- Empty state -->
<div v-if="!loading && !data.accounts?.length" class="text-center text-grey-5 q-pa-xl">
<q-icon name="trending_up" size="64px" class="q-mb-md" />
<div>Sélectionnez une période et cliquez <b>Générer</b></div>
</div>
</q-page>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import { fetchRevenueReport } from 'src/api/reports'
import { formatMoney } from 'src/composables/useFormatters'
import Chart from 'chart.js/auto'
const now = new Date()
const startDate = ref(new Date(now.getFullYear(), 0, 1).toISOString().slice(0, 10))
const endDate = ref(now.toISOString().slice(0, 10))
const mode = ref('gl')
const loading = ref(false)
const data = ref({})
const chartCanvas = ref(null)
let chartInstance = null
const selectedAccounts = ref([])
const allAccounts = ref([])
const accountOptions = computed(() =>
allAccounts.value.map(a => ({
label: a.number ? `${a.number}${a.label}` : a.label,
value: a.name,
}))
)
const FR_SHORT = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun', 'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc']
const grandTotal = computed(() => {
if (!data.value.accounts) return 0
return data.value.accounts.reduce((s, a) => s + a.total, 0)
})
// Accounts shown in chart — selected or all if none selected
const chartAccounts = computed(() => {
if (!data.value.accounts?.length) return []
if (!selectedAccounts.value?.length) return data.value.accounts
return data.value.accounts.filter(a =>
selectedAccounts.value.includes(a.name)
)
})
const tableColumns = computed(() => {
const cols = [
{ name: 'number', label: '#', field: 'number', align: 'left', sortable: true, style: 'width:60px' },
{ name: 'label', label: 'Compte', field: 'label', align: 'left', sortable: true },
]
for (const m of (data.value.months || [])) {
const [y, mo] = m.split('-')
cols.push({
name: m,
label: FR_SHORT[parseInt(mo) - 1] + ' ' + y,
field: m,
align: 'right',
sortable: true,
format: v => v ? formatMoney(v) : '—',
})
}
cols.push({ name: 'total', label: 'Total', field: 'total', align: 'right', sortable: true })
return cols
})
const tableRows = computed(() => {
if (!data.value.accounts) return []
return data.value.accounts.map(a => {
const row = { name: a.name, number: a.number, label: a.label, total: a.total }
for (let i = 0; i < (data.value.months || []).length; i++) {
row[data.value.months[i]] = a.monthly[i] || 0
}
return row
})
})
async function loadReport () {
loading.value = true
try {
const filter = selectedAccounts.value?.length ? selectedAccounts.value.join(',') : ''
data.value = await fetchRevenueReport(startDate.value, endDate.value, { mode: mode.value, filter })
// Populate account selector from API response
if (data.value.all_accounts?.length) {
allAccounts.value = data.value.all_accounts
}
await nextTick()
renderChart()
} catch (e) {
console.error('Revenue report error:', e)
} finally {
loading.value = false
}
}
const CHART_COLORS = [
'#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#06b6d4', '#f97316', '#ec4899', '#14b8a6', '#84cc16',
'#a855f7', '#3b82f6', '#d946ef', '#0ea5e9', '#f43f5e',
]
function renderChart () {
if (chartInstance) chartInstance.destroy()
if (!chartCanvas.value || !chartAccounts.value.length) return
const months = data.value.months || []
const labels = months.map(m => {
const [y, mo] = m.split('-')
return FR_SHORT[parseInt(mo) - 1] + ' ' + y.slice(2)
})
// Group small accounts into "Autres" if more than 8 shown
let accts = [...chartAccounts.value]
let datasets
if (accts.length > 8) {
const top = accts.slice(0, 7)
const others = accts.slice(7)
const othersMonthly = months.map((_, mi) =>
others.reduce((s, a) => s + (a.monthly[mi] || 0), 0)
)
datasets = [
...top.map((a, i) => makeDataset(a, i)),
{
label: 'Autres',
data: othersMonthly,
backgroundColor: 'rgba(156,163,175,0.4)',
borderColor: '#9ca3af',
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 0,
pointHitRadius: 10,
},
]
} else {
datasets = accts.map((a, i) => makeDataset(a, i))
}
chartInstance = new Chart(chartCanvas.value, {
type: 'line',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 11 } } },
tooltip: {
callbacks: {
label: ctx => ctx.dataset.label + ': ' + formatMoney(ctx.parsed.y),
footer: items => 'Total: ' + formatMoney(items.reduce((s, i) => s + i.parsed.y, 0)),
},
},
},
scales: {
x: { grid: { display: false } },
y: {
stacked: true,
ticks: { callback: v => (v / 1000).toFixed(0) + 'k$' },
grid: { color: 'rgba(0,0,0,0.06)' },
},
},
},
})
}
function makeDataset (account, index) {
const color = CHART_COLORS[index % CHART_COLORS.length]
return {
label: account.number ? `${account.number} ${account.label}` : account.label,
data: account.monthly,
backgroundColor: color + '55',
borderColor: color,
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 0,
pointHitRadius: 10,
}
}
function downloadCSV () {
if (!data.value.accounts?.length) return
const months = data.value.months || []
const header = ['#Compte', 'Nom', ...months, 'Total']
const rows = data.value.accounts.map(a =>
[a.number, a.label, ...a.monthly.map(v => v.toFixed(2)), a.total.toFixed(2)]
)
const totals = months.map((_, i) =>
data.value.accounts.reduce((s, a) => s + (a.monthly[i] || 0), 0).toFixed(2)
)
rows.push(['', 'TOTAL', ...totals, grandTotal.value.toFixed(2)])
const csv = [header, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `revenus_${startDate.value}_${endDate.value}.csv`
a.click()
}
</script>