feat: resume l'annee civile sur l'accueil
L'accueil ne decrivait qu'un document : les quatre tuiles lisaient le dernier compte rendu importe, sans rien dire de ce que contient la base. Il resume desormais une annee civile, avec un selecteur des annees presentes. L'annee par defaut est celle du dernier compte rendu et non l'annee en cours : la fenetre etait calee sur `date.today()`, si bien que sans import depuis quelques mois l'ecran se serait vide alors que la base est pleine. Ce que les tuiles annoncent : - recettes facturees, report exclu, avec l'encaisse et le recouvrement ; - depenses, debit et credits recus ; - net reverse, rapproche des soldes annonces par les comptes rendus ; - restant du, date, car un stock ne se cumule pas d'un mois sur l'autre. Le net reverse vaut « encaisse - debit + credit », et cette egalite tombe au centime sur le solde extrait du PDF pour avril, mai et juin 2026. Elle s'ecarte de 288,52 EUR en fevrier et de 1 188,03 EUR en mars, les deux mois dont l'extraction a par ailleurs des defauts. L'ecart est donc affiche et jamais lisse : c'est le seul controle de bout en bout dont on dispose sur la qualite d'une extraction. Les regles vivent dans services/tresorerie.py, a cote de celles des revenus. Le lot compte est celui qui a ete facture dans l'annee : la table `lots` retient deux ecritures par lot (« 0001 » et « 01 »), sequelle de la normalisation des numeros, et en annoncerait 40 la ou il y en a 20. Le graphique passe du vert et rouge au bleu et ambre : sous deuteranopie, green-400 et red-400 ne se separent qu'a un delta E de 7,9, sous le seuil de 8. La paire retenue tient a 30,2. `/recent-revenus` disparait — c'etait le dernier agregat a compter les lignes de report — et `/immeubles-shortcuts` est borne a la meme annee que le reste de l'ecran, deux perimetres sur un ecran donnant deux montants sans que rien ne les distingue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,13 @@
|
||||
<div class="page">
|
||||
<div class="page-content">
|
||||
|
||||
<PeriodeBandeau
|
||||
:annee="resume.annee"
|
||||
:annees="resume.annees_disponibles"
|
||||
:perimetre="resume.perimetre"
|
||||
@change-annee="changerAnnee"
|
||||
/>
|
||||
|
||||
<!-- Zone d'upload PDF visible -->
|
||||
<div
|
||||
class="border-2 border-dashed border-gray-700 rounded-lg p-6 text-center hover:border-blue-500 transition-colors cursor-pointer bg-gray-900/50"
|
||||
@@ -24,95 +31,104 @@
|
||||
</svg>
|
||||
<p class="text-sm">
|
||||
<span v-if="!isDragging">Glisser un PDF ici ou <span class="text-blue-400">cliquer pour importer</span></span>
|
||||
<span v-else class="text-blue-400 font-medium">Deposer le fichier ici</span>
|
||||
<span v-else class="text-blue-400 font-medium">Déposer le fichier ici</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions rapides -->
|
||||
<QuickActions @import-pdf="triggerFileInput" />
|
||||
<QuickActions />
|
||||
|
||||
<!-- Resume financier -->
|
||||
<FinancialSummary :data="financialSummary" />
|
||||
<!-- Resume de l'annee -->
|
||||
<div v-if="isLoadingResume" class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<div v-for="n in 4" :key="n" class="card card-body h-28 flex items-center justify-center">
|
||||
<div class="spinner h-5 w-5"></div>
|
||||
</div>
|
||||
</div>
|
||||
<ResumeTuiles v-else :resume="resume" />
|
||||
|
||||
<AnnualChart :data="resume.par_mois" :loading="isLoadingResume" />
|
||||
|
||||
<!-- Grille principale -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
<!-- Colonne gauche: Graphique tendance + Revenus recents -->
|
||||
<div class="space-y-6">
|
||||
<MiniTrendChart :trends="monthlyTrends" :loading="isLoadingTrends" />
|
||||
<RecentRevenus :revenus="recentRevenus" :loading="isLoadingRevenus" />
|
||||
</div>
|
||||
|
||||
<!-- Colonne droite: Immeubles + Documents -->
|
||||
<div class="space-y-6">
|
||||
<ImmeubleShortcuts
|
||||
:immeubles="immeubleShortcuts"
|
||||
<DepensesParTag
|
||||
:data="depensesParTag"
|
||||
:loading="isLoadingDepenses"
|
||||
@view-all="router.push('/analytics')"
|
||||
/>
|
||||
|
||||
<!-- Les raccourcis n'ont de sens qu'a plusieurs immeubles : avec un
|
||||
seul, ils repeteraient les tuiles ci-dessus. -->
|
||||
<ImmeubleShortcuts
|
||||
v-if="immeubleShortcuts.length > 1"
|
||||
:immeubles="immeubleShortcuts"
|
||||
:loading="isLoadingImmeubles"
|
||||
@select="handleImmeubleSelect"
|
||||
@view-all="goToAnalytics"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Derniers documents -->
|
||||
<div id="recent-documents" class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Derniers documents importes</h2>
|
||||
<span class="text-xs text-gray-500">{{ documents.length }} documents</span>
|
||||
</div>
|
||||
<!-- Derniers documents -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Derniers documents importés</h2>
|
||||
<span class="text-xs text-gray-500">{{ documents.length }} affichés</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingDocs" class="p-8 text-center text-gray-500">
|
||||
<div class="spinner h-6 w-6"></div>
|
||||
</div>
|
||||
<div v-if="isLoadingDocs" class="p-8 text-center text-gray-500">
|
||||
<div class="spinner h-6 w-6"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="documents.length === 0" class="p-8 text-center text-gray-500">
|
||||
<svg class="mx-auto h-12 w-12 mb-3 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p>Aucun document importe.</p>
|
||||
<button
|
||||
@click="triggerFileInput"
|
||||
class="mt-3 text-sm text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Importer votre premier PDF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y divide-gray-800">
|
||||
<div
|
||||
v-for="doc in documents"
|
||||
:key="doc.id"
|
||||
class="px-4 py-3 hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-mono text-white">{{ doc.reference }}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-700 text-gray-400">
|
||||
{{ doc.immeuble_code }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">
|
||||
{{ formatDate(doc.date) }}
|
||||
<span v-if="doc.immeuble_adresse" class="ml-2">{{ doc.immeuble_adresse }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right ml-4">
|
||||
<span
|
||||
:class="[
|
||||
'text-sm font-medium',
|
||||
isSoldeCrediteur(doc.solde_type) ? 'text-green-400' : 'text-red-400'
|
||||
]"
|
||||
>
|
||||
{{ formatAmount(doc.solde_montant) }}
|
||||
<div v-else-if="documents.length === 0" class="p-8 text-center text-gray-500">
|
||||
<svg class="mx-auto h-12 w-12 mb-3 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p>Aucun document importé.</p>
|
||||
<button
|
||||
@click="triggerFileInput"
|
||||
class="mt-3 text-sm text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Importer votre premier PDF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y divide-gray-800">
|
||||
<div
|
||||
v-for="doc in documents"
|
||||
:key="doc.id"
|
||||
class="px-4 py-3 hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-mono text-white">{{ doc.reference }}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-700 text-gray-400">
|
||||
{{ doc.immeuble_code }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">
|
||||
{{ formatDate(doc.date) }}
|
||||
<span v-if="doc.immeuble_adresse" class="ml-2">{{ doc.immeuble_adresse }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right ml-4">
|
||||
<span
|
||||
:class="[
|
||||
'text-sm font-medium',
|
||||
isSoldeCrediteur(doc.solde_type) ? 'text-green-400' : 'text-red-400'
|
||||
]"
|
||||
>
|
||||
{{ formatMontantPrecis(doc.solde_montant) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -122,11 +138,13 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { pendingFile } from '../store'
|
||||
import { isSoldeCrediteur } from '../utils/solde'
|
||||
import { formatDate, formatMontantPrecis } from '../utils/format'
|
||||
|
||||
import QuickActions from '../components/dashboard/QuickActions.vue'
|
||||
import FinancialSummary from '../components/dashboard/FinancialSummary.vue'
|
||||
import RecentRevenus from '../components/dashboard/RecentRevenus.vue'
|
||||
import MiniTrendChart from '../components/dashboard/MiniTrendChart.vue'
|
||||
import PeriodeBandeau from '../components/dashboard/PeriodeBandeau.vue'
|
||||
import ResumeTuiles from '../components/dashboard/ResumeTuiles.vue'
|
||||
import AnnualChart from '../components/dashboard/AnnualChart.vue'
|
||||
import DepensesParTag from '../components/dashboard/DepensesParTag.vue'
|
||||
import ImmeubleShortcuts from '../components/dashboard/ImmeubleShortcuts.vue'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -135,101 +153,93 @@ const isDragging = ref(false)
|
||||
|
||||
// Loading states
|
||||
const isLoadingDocs = ref(true)
|
||||
const isLoadingRevenus = ref(true)
|
||||
const isLoadingTrends = ref(true)
|
||||
const isLoadingResume = ref(true)
|
||||
const isLoadingDepenses = ref(true)
|
||||
const isLoadingImmeubles = ref(true)
|
||||
|
||||
// Data
|
||||
const documents = ref([])
|
||||
const financialSummary = ref({
|
||||
last_document_date: null,
|
||||
last_document_reference: null,
|
||||
revenus: 0,
|
||||
impayes: 0,
|
||||
depenses: 0,
|
||||
solde: 0,
|
||||
revenus_history: [],
|
||||
impayes_history: [],
|
||||
depenses_history: [],
|
||||
solde_history: []
|
||||
})
|
||||
const recentRevenus = ref([])
|
||||
const monthlyTrends = ref([])
|
||||
const depensesParTag = ref([])
|
||||
const immeubleShortcuts = ref([])
|
||||
const resume = ref({
|
||||
annee: new Date().getFullYear(),
|
||||
annees_disponibles: [],
|
||||
perimetre: {
|
||||
nb_comptes_rendus: 0,
|
||||
premiere_date: null,
|
||||
derniere_date: null,
|
||||
nb_immeubles: 0,
|
||||
nb_lots_factures: 0
|
||||
},
|
||||
facture: 0,
|
||||
encaisse: 0,
|
||||
taux_recouvrement: 100,
|
||||
depenses_debit: 0,
|
||||
depenses_credit: 0,
|
||||
net_reverse: 0,
|
||||
soldes_annonces: 0,
|
||||
ecart_soldes: 0,
|
||||
restant_du: 0,
|
||||
restant_du_date: null,
|
||||
par_mois: []
|
||||
})
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
const [year, month, day] = dateStr.split('-')
|
||||
return `${day}/${month}/${year}`
|
||||
async function chargerJson(url) {
|
||||
try {
|
||||
const reponse = await fetch(url)
|
||||
return reponse.ok ? await reponse.json() : null
|
||||
} catch (err) {
|
||||
console.error(`Echec du chargement de ${url}:`, err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatAmount(amount) {
|
||||
if (amount == null) return '-'
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(amount)
|
||||
/**
|
||||
* Le resume est charge en premier : il fixe l'annee affichee, dont depend le
|
||||
* cadrage des depenses et des raccourcis. Les charger avant reviendrait a
|
||||
* afficher trois periodes differentes sur le meme ecran.
|
||||
*/
|
||||
async function chargerResume(annee) {
|
||||
isLoadingResume.value = true
|
||||
const url = annee
|
||||
? `/api/dashboard/resume-annuel?annee=${annee}`
|
||||
: '/api/dashboard/resume-annuel'
|
||||
const data = await chargerJson(url)
|
||||
if (data) resume.value = data
|
||||
isLoadingResume.value = false
|
||||
return resume.value.annee
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
// Charger les documents
|
||||
isLoadingDocs.value = true
|
||||
try {
|
||||
const docsRes = await fetch('/api/documents?limit=5')
|
||||
if (docsRes.ok) {
|
||||
documents.value = await docsRes.json()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load documents:', err)
|
||||
} finally {
|
||||
isLoadingDocs.value = false
|
||||
}
|
||||
async function chargerDepenses(annee) {
|
||||
isLoadingDepenses.value = true
|
||||
const data = await chargerJson(
|
||||
`/api/analytics/depenses/summary?date_debut=${annee}-01-01&date_fin=${annee}-12-31`
|
||||
)
|
||||
depensesParTag.value = data?.by_tag ?? []
|
||||
isLoadingDepenses.value = false
|
||||
}
|
||||
|
||||
// Charger le resume financier
|
||||
try {
|
||||
const summaryRes = await fetch('/api/dashboard/financial-summary')
|
||||
if (summaryRes.ok) {
|
||||
financialSummary.value = await summaryRes.json()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load financial summary:', err)
|
||||
}
|
||||
|
||||
// Charger les revenus recents
|
||||
isLoadingRevenus.value = true
|
||||
try {
|
||||
const revenusRes = await fetch('/api/dashboard/recent-revenus?limit=5')
|
||||
if (revenusRes.ok) {
|
||||
recentRevenus.value = await revenusRes.json()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load recent revenus:', err)
|
||||
} finally {
|
||||
isLoadingRevenus.value = false
|
||||
}
|
||||
|
||||
// Charger les tendances mensuelles
|
||||
isLoadingTrends.value = true
|
||||
try {
|
||||
const trendsRes = await fetch('/api/dashboard/monthly-trends?months=6')
|
||||
if (trendsRes.ok) {
|
||||
monthlyTrends.value = await trendsRes.json()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load monthly trends:', err)
|
||||
} finally {
|
||||
isLoadingTrends.value = false
|
||||
}
|
||||
|
||||
// Charger les raccourcis immeubles
|
||||
async function chargerImmeubles(annee) {
|
||||
isLoadingImmeubles.value = true
|
||||
try {
|
||||
const immeublesRes = await fetch('/api/dashboard/immeubles-shortcuts?limit=6')
|
||||
if (immeublesRes.ok) {
|
||||
immeubleShortcuts.value = await immeublesRes.json()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load immeubles shortcuts:', err)
|
||||
} finally {
|
||||
isLoadingImmeubles.value = false
|
||||
}
|
||||
const data = await chargerJson(`/api/dashboard/immeubles-shortcuts?annee=${annee}&limit=6`)
|
||||
immeubleShortcuts.value = data ?? []
|
||||
isLoadingImmeubles.value = false
|
||||
}
|
||||
|
||||
async function chargerDocuments() {
|
||||
isLoadingDocs.value = true
|
||||
const data = await chargerJson('/api/documents?limit=5')
|
||||
documents.value = data ?? []
|
||||
isLoadingDocs.value = false
|
||||
}
|
||||
|
||||
async function chargerAnnee(annee) {
|
||||
const affichee = await chargerResume(annee)
|
||||
await Promise.all([chargerDepenses(affichee), chargerImmeubles(affichee)])
|
||||
}
|
||||
|
||||
function changerAnnee(annee) {
|
||||
chargerAnnee(annee)
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
@@ -263,6 +273,7 @@ function goToAnalytics() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
chargerDocuments()
|
||||
chargerAnnee(null)
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user