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>
179 lines
6.8 KiB
Vue
179 lines
6.8 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">Rapport de ventes</div>
|
|
<q-space />
|
|
<q-btn flat dense icon="download" label="CSV" @click="downloadCSV" :disable="!rows.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 color="primary" label="Générer" icon="play_arrow" @click="loadReport" :loading="loading" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Summary cards -->
|
|
<div v-if="summary" class="row q-col-gutter-sm q-mb-md">
|
|
<div class="col-auto">
|
|
<div class="ops-card text-center" style="min-width:130px">
|
|
<div class="text-caption text-grey-6">Factures</div>
|
|
<div class="text-h6 text-weight-bold">{{ summary.count }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<div class="ops-card text-center" style="min-width:130px">
|
|
<div class="text-caption text-grey-6">Sous-total</div>
|
|
<div class="text-h6 text-weight-bold">{{ formatMoney(summary.subtotal) }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<div class="ops-card text-center" style="min-width:110px">
|
|
<div class="text-caption text-grey-6">TPS</div>
|
|
<div class="text-h6 text-weight-bold">{{ formatMoney(summary.tps) }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<div class="ops-card text-center" style="min-width:110px">
|
|
<div class="text-caption text-grey-6">TVQ</div>
|
|
<div class="text-h6 text-weight-bold">{{ formatMoney(summary.tvq) }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<div class="ops-card text-center" style="min-width:130px">
|
|
<div class="text-caption text-grey-6">Total</div>
|
|
<div class="text-h6 text-weight-bold text-positive">{{ formatMoney(summary.total) }}</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="summary.returns" class="col-auto">
|
|
<div class="ops-card text-center" style="min-width:130px">
|
|
<div class="text-caption text-grey-6">Notes de crédit</div>
|
|
<div class="text-h6 text-weight-bold text-negative">{{ summary.returns }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Data table -->
|
|
<q-table
|
|
v-if="rows.length"
|
|
:rows="rows"
|
|
:columns="columns"
|
|
row-key="name"
|
|
flat bordered
|
|
class="ops-table"
|
|
:pagination="{ rowsPerPage: 50 }"
|
|
dense
|
|
:filter="search"
|
|
>
|
|
<template #top-right>
|
|
<q-input v-model="search" dense outlined placeholder="Filtrer..." clearable style="width:200px">
|
|
<template #prepend><q-icon name="search" /></template>
|
|
</q-input>
|
|
</template>
|
|
|
|
<template #body-cell-name="props">
|
|
<q-td :props="props">
|
|
<a :href="erpLink('Sales Invoice', props.value)" target="_blank" class="text-primary">{{ props.value }}</a>
|
|
</q-td>
|
|
</template>
|
|
|
|
<template #body-cell-total="props">
|
|
<q-td :props="props">
|
|
<span :class="props.row.is_return ? 'text-negative' : 'text-weight-bold'">
|
|
{{ formatMoney(props.value) }}
|
|
</span>
|
|
</q-td>
|
|
</template>
|
|
|
|
<template #body-cell-status="props">
|
|
<q-td :props="props">
|
|
<q-badge :color="statusColor(props.value)" :label="props.value" />
|
|
</q-td>
|
|
</template>
|
|
</q-table>
|
|
|
|
<!-- Empty state -->
|
|
<div v-if="!loading && !rows.length" class="text-center text-grey-5 q-pa-xl">
|
|
<q-icon name="receipt_long" 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 } from 'vue'
|
|
import { fetchSalesReport } from 'src/api/reports'
|
|
import { formatMoney, formatDateShort, erpLink } from 'src/composables/useFormatters'
|
|
|
|
const now = new Date()
|
|
const startDate = ref(new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10))
|
|
const endDate = ref(now.toISOString().slice(0, 10))
|
|
const loading = ref(false)
|
|
const rows = ref([])
|
|
const summary = ref(null)
|
|
const search = ref('')
|
|
|
|
const columns = [
|
|
{ name: 'name', label: '#Facture', field: 'name', align: 'left', sortable: true },
|
|
{ name: 'date', label: 'Date', field: 'date', align: 'left', sortable: true, format: v => formatDateShort(v) },
|
|
{ name: 'customer_name', label: 'Client', field: 'customer_name', align: 'left', sortable: true },
|
|
{ name: 'subtotal', label: 'Sous-total', field: 'subtotal', align: 'right', sortable: true, format: v => formatMoney(v) },
|
|
{ name: 'tps', label: 'TPS', field: 'tps', align: 'right', sortable: true, format: v => formatMoney(v) },
|
|
{ name: 'tvq', label: 'TVQ', field: 'tvq', align: 'right', sortable: true, format: v => formatMoney(v) },
|
|
{ name: 'total', label: 'Total', field: 'total', align: 'right', sortable: true },
|
|
{ name: 'outstanding', label: 'Impayé', field: 'outstanding', align: 'right', sortable: true, format: v => v > 0 ? formatMoney(v) : '—' },
|
|
{ name: 'status', label: 'Statut', field: 'status', align: 'center', sortable: true },
|
|
]
|
|
|
|
function statusColor (s) {
|
|
if (s === 'Paid') return 'positive'
|
|
if (s === 'Overdue') return 'negative'
|
|
if (s === 'Return') return 'purple'
|
|
if (s === 'Cancelled') return 'grey'
|
|
return 'warning'
|
|
}
|
|
|
|
async function loadReport () {
|
|
loading.value = true
|
|
try {
|
|
const res = await fetchSalesReport(startDate.value, endDate.value)
|
|
rows.value = res.rows || []
|
|
summary.value = res.summary || null
|
|
} catch (e) {
|
|
console.error('Sales report error:', e)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function downloadCSV () {
|
|
if (!rows.value.length) return
|
|
const header = ['#Facture', 'Date', '#Client', 'Client', 'SousTotal', 'TPS', 'TVQ', 'Total', 'Impayé', 'Statut']
|
|
const csvRows = rows.value.map(r => [
|
|
r.name, r.date, r.customer, `"${r.customer_name}"`,
|
|
r.subtotal.toFixed(2), r.tps.toFixed(2), r.tvq.toFixed(2),
|
|
r.total.toFixed(2), r.outstanding.toFixed(2), r.status,
|
|
])
|
|
// Summary row
|
|
if (summary.value) {
|
|
csvRows.push(['', '', '', 'TOTAL',
|
|
summary.value.subtotal.toFixed(2), summary.value.tps.toFixed(2),
|
|
summary.value.tvq.toFixed(2), summary.value.total.toFixed(2), '', '',
|
|
])
|
|
}
|
|
const csv = [header, ...csvRows].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 = `ventes_${startDate.value}_${endDate.value}.csv`
|
|
a.click()
|
|
}
|
|
</script>
|