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>
318 lines
10 KiB
Vue
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>
|