Compare commits

...

6 Commits

Author SHA1 Message Date
94b885aedb feat(core): add install
All checks were successful
Build and Publish Docker Image / Build App Image (push) Successful in 22s
Build and Publish Docker Image / Build Summary (push) Successful in 3s
2026-07-25 19:53:17 +02:00
14b39efe3b refactor(ui): aligne les composants de donnees sur le design system
Cartes, en-tetes, champs de filtre, tableaux, badges, paginations et
indicateurs de chargement des tableaux de bord etaient proches mais
jamais identiques (bordure presente ou absente, paddings et arrondis
variables). Ils utilisent maintenant les classes communes.

Les colonnes tronquees des tableaux sont elargies et les sept filtres
des depenses tiennent sur une ligne, la largeur retrouvee le permettant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:50:26 +02:00
02a3459291 refactor(ui): bascule le flux extraction/edition en theme sombre
Tout ce flux (visionneuse de donnees, fiches locataire et operation,
tagging, autocompletion, panneau de differences) etait en theme clair,
au milieu d'une application sombre : le contraste sautait a chaque
passage d'une page a l'edition d'un document.

Les surfaces, bordures, textes et etats colores suivent desormais la
palette commune. Les champs natifs (date, nombre, cases a cocher)
recoivent color-scheme: dark pour que le calendrier et les widgets
systeme s'affichent en sombre eux aussi.

Les fiches de metadonnees se repartissent sur 2 puis 3 colonnes selon la
largeur disponible, au lieu d'une seule colonne.

Le bouton « Annuler » du bandeau de tagging devient « Retour a
l'edition », qui decrit son action reelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:50:02 +02:00
aa365dbb63 refactor(ui): passe les pages en pleine largeur
Chaque page plafonnait sa largeur differemment (max-w-4xl pour Config,
5xl pour IA, 6xl pour l'accueil et les documents, 7xl pour les revenus
et les depenses) : sur un ecran large, les donnees se tassaient au
centre. Les pages n'ont plus de plafond, le contenu occupe l'ecran avec
une marge laterale fixe.

Pour tirer parti de la place gagnee : Config affiche ses deux sections
cote a cote, et le split apercu PDF / donnees passe de 50-50 a 40-60 en
faveur des donnees.

Pendant le tagging, le bouton « Annuler » de l'en-tete d'edition est
masque : il faisait doublon avec celui du bandeau de tagging, qui a un
sens different (retour a l'edition, et non abandon). La sortie vers la
liste reste le fil d'Ariane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:49:43 +02:00
baeabb72d2 feat(ui): pose un design system unique pour toute l'interface
Les pages melangeaient deux themes (sombre pour les tableaux de bord,
clair pour le flux d'extraction) et repetaient des utilitaires Tailwind
divergents : trois styles de boutons, quatre variantes de cartes, des
champs de saisie a paddings differents.

Definit dans style.css une palette unique (gray-950 fond, gray-900
surfaces, gray-700 bordures, blue-600 accent) et les classes composants
associees : page, card, btn, input, table, badge, spinner, empty-state.
Le shell s'y aligne et sa navigation est generee depuis une liste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:49:32 +02:00
420a856e41 feat: normalise les numéros de lot sur 2 chiffres
Les PDF et les saisies manuelles écrivent le même lot de plusieurs façons
("6", "06", "0006"), et chaque forme créait jusqu'ici un lot distinct en
base. utils/lots.py fixe la forme canonique sur 2 chiffres et sert de point
d'entrée unique pour la normalisation.

Elle est appliquée à la source dans les parseurs (locataires texte et
tableau, codes lot des opérations), et en dernier recours dans
get_or_create_lot et save_document, pour couvrir les extractions éditées à
la main via l'API. Les numéros à plus de 2 chiffres significatifs ne sont
pas tronqués.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 11:46:33 +02:00
49 changed files with 1018 additions and 637 deletions

View File

@@ -1,4 +1,9 @@
.PHONY: dev_back dev_front dev docker docker-build
.PHONY: install dev_back dev_front dev docker docker-build
# Installe les dependances backend + frontend (a refaire par worktree)
install:
uv sync
cd frontend && npm install
# Backend avec auto-reload uvicorn
dev_back:

View File

@@ -1,68 +1,27 @@
<template>
<div class="h-screen flex flex-col bg-gray-900">
<div class="h-screen flex flex-col bg-gray-950">
<!-- Header compact -->
<header class="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
<div class="flex items-center justify-between">
<router-link to="/" class="text-lg font-semibold text-white hover:text-blue-400 transition-colors">
<header class="flex-shrink-0 bg-gray-900 border-b border-gray-700 px-6 py-2">
<div class="flex items-center justify-between gap-6">
<router-link to="/" class="text-lg font-semibold text-white hover:text-blue-400 transition-colors whitespace-nowrap">
Plesna Gerance
<span class="text-gray-400 font-normal text-sm ml-2">Extracteur de comptes rendus</span>
</router-link>
<!-- Navigation -->
<nav class="flex items-center gap-3">
<nav class="flex items-center gap-1">
<router-link
to="/"
class="px-3 py-1.5 text-sm rounded transition-colors"
:class="$route.path === '/' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'"
v-for="link in navLinks"
:key="link.to"
:to="link.to"
class="px-3 py-1.5 text-sm rounded-lg transition-colors"
:class="$route.path === link.to ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800'"
>
Accueil
</router-link>
<router-link
to="/documents"
class="px-3 py-1.5 text-sm rounded transition-colors"
:class="$route.path === '/documents' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'"
>
Documents
</router-link>
<router-link
to="/revenus"
class="px-3 py-1.5 text-sm rounded transition-colors"
:class="$route.path === '/revenus' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'"
>
Revenus
</router-link>
<router-link
to="/analytics"
class="px-3 py-1.5 text-sm rounded transition-colors"
:class="$route.path === '/analytics' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'"
>
Depenses
</router-link>
<router-link
to="/ia"
class="px-3 py-1.5 text-sm rounded transition-colors"
:class="$route.path === '/ia' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'"
>
IA
</router-link>
<router-link
to="/config"
class="px-3 py-1.5 text-sm rounded transition-colors"
:class="$route.path === '/config' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'"
>
Config
{{ link.label }}
</router-link>
<!-- Import button with file input -->
<label
class="px-3 py-1.5 text-sm rounded transition-colors cursor-pointer bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white"
:class="$route.path === '/extract' ? 'bg-blue-600 text-white' : ''"
>
<label class="btn btn-primary ml-2 cursor-pointer">
Importer un PDF
<input
ref="fileInput"
@@ -91,6 +50,15 @@ import { pendingFile } from './store'
const router = useRouter()
const fileInput = ref(null)
const navLinks = [
{ to: '/', label: 'Accueil' },
{ to: '/documents', label: 'Documents' },
{ to: '/revenus', label: 'Revenus' },
{ to: '/analytics', label: 'Depenses' },
{ to: '/ia', label: 'IA' },
{ to: '/config', label: 'Config' }
]
function onFileSelect(e) {
const file = e.target.files[0]
if (file && file.name.toLowerCase().endsWith('.pdf')) {

View File

@@ -1,6 +1,6 @@
<template>
<div class="bg-gray-50 rounded-lg p-3">
<h4 class="text-sm font-medium text-gray-700 mb-2 flex items-center gap-2">
<div class="subcard p-3">
<h4 class="text-sm font-medium text-gray-300 mb-2 flex items-center gap-2">
<!-- Icons -->
<svg v-if="icon === 'building'" class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />

View File

@@ -6,7 +6,7 @@
@update:modelValue="onUpdate"
:type="type"
:placeholder="'-'"
:displayClass="highlight ? 'text-blue-600 text-base font-medium' : 'text-gray-800 font-medium'"
:displayClass="highlight ? 'text-blue-400 text-base font-medium' : 'text-gray-200 font-medium'"
:fullWidth="false"
/>
</div>

View File

@@ -1,9 +1,9 @@
<template>
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="card">
<!-- Header -->
<button
@click="$emit('toggle')"
class="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors"
class="w-full flex items-center justify-between px-4 py-3 bg-gray-900 hover:bg-gray-800 transition-colors"
>
<div class="flex items-center gap-2">
<svg
@@ -15,15 +15,15 @@
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span class="font-semibold text-gray-800">{{ title }}</span>
<span v-if="count !== undefined" class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">
<span class="font-semibold text-white">{{ title }}</span>
<span v-if="count !== undefined" class="badge badge-info rounded-full">
{{ count }}
</span>
</div>
</button>
<!-- Content -->
<div v-show="expanded" class="p-4 bg-white">
<div v-show="expanded" class="p-4 border-t border-gray-700">
<slot />
</div>
</div>

View File

@@ -7,10 +7,10 @@
<span
v-if="!isEditing"
@click="startEditing"
class="cursor-pointer border-b border-dashed border-transparent hover:border-gray-400 hover:bg-yellow-50 px-1 py-0.5 rounded transition-colors min-w-[2rem]"
class="cursor-pointer border-b border-dashed border-transparent hover:border-gray-500 hover:bg-gray-700/60 px-1 py-0.5 rounded transition-colors min-w-[2rem]"
:class="[
displayClass,
{ 'text-gray-400 italic': isEmpty }
{ 'text-gray-500 italic': isEmpty }
]"
:title="'Cliquer pour modifier'"
>
@@ -28,7 +28,7 @@
@blur="saveAndClose"
@keydown.enter="saveAndClose"
@keydown.escape="cancelEditing"
class="px-1 py-0.5 border border-blue-400 rounded bg-blue-50 focus:outline-none focus:ring-1 focus:ring-blue-500 text-sm"
class="px-1 py-0.5 border border-blue-500 rounded bg-gray-950 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 text-sm [color-scheme:dark]"
:class="[
inputClass,
{ 'w-full': fullWidth, 'w-24': !fullWidth && (type === 'currency' || type === 'number'), 'w-32': !fullWidth && type === 'date' }

View File

@@ -4,11 +4,11 @@
<div v-if="isCollapsible" class="group">
<div
@click="toggle"
class="flex items-center gap-1 cursor-pointer hover:bg-gray-100 rounded px-1 -mx-1"
class="flex items-center gap-1 cursor-pointer hover:bg-gray-800 rounded px-1 -mx-1"
>
<!-- Expand/collapse icon -->
<svg
class="w-3 h-3 text-gray-400 transition-transform"
class="w-3 h-3 text-gray-500 transition-transform"
:class="{ 'rotate-90': isExpanded }"
fill="none"
stroke="currentColor"
@@ -18,11 +18,11 @@
</svg>
<!-- Key name -->
<span v-if="!isRoot" class="text-purple-600">{{ name }}</span>
<span v-if="!isRoot" class="text-gray-400">: </span>
<span v-if="!isRoot" class="text-purple-400">{{ name }}</span>
<span v-if="!isRoot" class="text-gray-500">: </span>
<!-- Preview when collapsed -->
<span class="text-gray-400">
<span class="text-gray-500">
{{ isArray ? '[' : '{' }}
<span v-if="!isExpanded" class="text-gray-500">
{{ itemCount }} {{ isArray ? 'elements' : 'cles' }}
@@ -32,7 +32,7 @@
</div>
<!-- Children -->
<div v-if="isExpanded" class="ml-4 border-l border-gray-200 pl-2">
<div v-if="isExpanded" class="ml-4 border-l border-gray-700 pl-2">
<JsonNode
v-for="(value, key) in data"
:key="key"
@@ -40,14 +40,14 @@
:name="String(key)"
:is-root="false"
/>
<span class="text-gray-400">{{ isArray ? ']' : '}' }}</span>
<span class="text-gray-500">{{ isArray ? ']' : '}' }}</span>
</div>
</div>
<!-- Primitive value -->
<div v-else class="flex items-start gap-1 py-0.5">
<span class="text-purple-600">{{ name }}</span>
<span class="text-gray-400">: </span>
<span class="text-purple-400">{{ name }}</span>
<span class="text-gray-500">: </span>
<span :class="valueClass">{{ formattedValue }}</span>
</div>
</div>
@@ -84,11 +84,11 @@ const itemCount = computed(() => {
})
const valueClass = computed(() => {
if (props.data === null) return 'text-gray-400 italic'
if (typeof props.data === 'string') return 'text-green-600'
if (typeof props.data === 'number') return 'text-blue-600'
if (typeof props.data === 'boolean') return 'text-orange-600'
return 'text-gray-700'
if (props.data === null) return 'text-gray-500 italic'
if (typeof props.data === 'string') return 'text-green-400'
if (typeof props.data === 'number') return 'text-blue-400'
if (typeof props.data === 'boolean') return 'text-orange-400'
return 'text-gray-300'
})
const formattedValue = computed(() => {

View File

@@ -1,27 +1,27 @@
<template>
<div class="h-full flex flex-col bg-white overflow-hidden">
<div class="h-full flex flex-col bg-gray-950 overflow-hidden">
<!-- Header -->
<div class="flex-shrink-0 flex items-center justify-between px-4 py-2 bg-gray-100 border-b border-gray-200">
<span class="text-sm font-semibold text-gray-700">Donnees extraites</span>
<div class="flex-shrink-0 flex items-center justify-between px-4 py-2 bg-gray-900 border-b border-gray-700">
<span class="card-title">Donnees extraites</span>
<div class="flex items-center gap-2">
<button
v-if="data"
@click="expandAll"
class="px-2 py-1 text-xs text-gray-600 hover:text-gray-800 hover:bg-gray-200 rounded transition-colors"
class="btn btn-sm btn-ghost"
>
Tout deplier
</button>
<button
v-if="data"
@click="collapseAll"
class="px-2 py-1 text-xs text-gray-600 hover:text-gray-800 hover:bg-gray-200 rounded transition-colors"
class="btn btn-sm btn-ghost"
>
Tout replier
</button>
<button
v-if="data"
@click="copyJson"
class="flex items-center gap-1 px-2 py-1 text-xs text-gray-600 hover:text-gray-800 hover:bg-gray-200 rounded transition-colors"
class="btn btn-sm btn-ghost"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
@@ -34,8 +34,8 @@
<!-- Content -->
<div class="flex-1 overflow-auto custom-scrollbar">
<!-- Loading state -->
<div v-if="isLoading" class="flex flex-col items-center justify-center h-full text-gray-400">
<svg class="animate-spin h-10 w-10 mb-3 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<div v-if="isLoading" class="flex flex-col items-center justify-center h-full text-gray-500">
<svg class="animate-spin h-10 w-10 mb-3 text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
@@ -43,8 +43,8 @@
</div>
<!-- Empty state -->
<div v-else-if="!data" class="flex flex-col items-center justify-center h-full text-gray-400 p-8">
<svg class="w-16 h-16 mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div v-else-if="!data" class="flex flex-col items-center justify-center h-full text-gray-500 p-8">
<svg class="w-16 h-16 mb-3 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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 class="text-sm">En attente de l'extraction...</p>
@@ -54,7 +54,7 @@
<div v-else class="p-4 space-y-3">
<!-- Source file -->
<div class="text-xs text-gray-500 mb-4">
Fichier: <span class="font-medium text-gray-700">{{ data.source_file }}</span>
Fichier: <span class="font-medium text-gray-300">{{ data.source_file }}</span>
</div>
<!-- Metadata Section -->
@@ -63,7 +63,8 @@
:expanded="expandedSections.metadata"
@toggle="toggleSection('metadata')"
>
<div class="grid grid-cols-1 gap-3">
<!-- Les fiches metadata se repartissent sur la largeur disponible -->
<div class="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-3">
<!-- Editeur -->
<DataCard title="Editeur" icon="building">
<DataRow label="Nom" :value="data.data?.metadata?.editeur?.nom" @update:value="updateMetadata('editeur.nom', $event)" />
@@ -124,7 +125,7 @@
<!-- Bouton ajouter locataire -->
<button
@click="addLocataire"
class="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 border border-dashed border-blue-300 rounded-lg transition-colors"
class="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-blue-400 hover:bg-blue-500/10 border border-dashed border-blue-500/40 rounded-lg transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
@@ -155,7 +156,7 @@
<!-- Bouton ajouter operation -->
<button
@click="addOperation"
class="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 border border-dashed border-blue-300 rounded-lg transition-colors"
class="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-blue-400 hover:bg-blue-500/10 border border-dashed border-blue-500/40 rounded-lg transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />

View File

@@ -1,13 +1,13 @@
<template>
<div ref="rootEl" class="border rounded-lg overflow-hidden transition-all duration-300" :class="changed ? 'border-amber-400 ring-2 ring-amber-400 bg-amber-50' : (isHighlighted ? 'border-blue-400 ring-2 ring-blue-400 bg-blue-50' : 'border-gray-200')">
<div ref="rootEl" class="border rounded-lg overflow-hidden transition-all duration-300" :class="changed ? 'border-amber-500 ring-2 ring-amber-500/40 bg-amber-500/5' : (isHighlighted ? 'border-blue-500 ring-2 ring-blue-500/40 bg-blue-500/5' : 'border-gray-700')">
<!-- Header -->
<div class="w-full flex items-center justify-between px-3 py-2 bg-white hover:bg-gray-50 transition-colors">
<div class="w-full flex items-center justify-between px-3 py-2 bg-gray-900 hover:bg-gray-800 transition-colors">
<button
@click="expanded = !expanded"
class="flex items-center gap-3 flex-1 text-left"
>
<svg
class="w-3 h-3 text-gray-400 transition-transform flex-shrink-0"
class="w-3 h-3 text-gray-500 transition-transform flex-shrink-0"
:class="{ 'rotate-90': expanded }"
fill="none"
stroke="currentColor"
@@ -21,10 +21,10 @@
:modelValue="locataire.lot?.numero"
@update:modelValue="updateField('lot.numero', $event)"
type="text"
displayClass="text-xs text-gray-700 font-medium"
displayClass="text-xs text-gray-300 font-medium"
@click.stop
/>
<span class="mx-1 text-gray-300">|</span>
<span class="mx-1 text-gray-600">|</span>
<EditableField
:modelValue="locataire.lot?.type"
@update:modelValue="updateField('lot.type', $event)"
@@ -37,7 +37,7 @@
:modelValue="locataire.locataire?.nom"
@update:modelValue="updateField('locataire.nom', $event)"
type="text"
displayClass="font-medium text-gray-800 text-sm"
displayClass="font-medium text-white text-sm"
placeholder="Sans nom"
@click.stop
/>
@@ -45,7 +45,7 @@
<div class="flex items-center gap-2">
<span
v-if="changed"
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 border border-amber-300"
class="badge badge-warning text-[10px] uppercase tracking-wide"
>
Modifié
</span>
@@ -53,14 +53,14 @@
<div class="text-sm font-semibold" :class="totalClass">
{{ formatCurrency(locataire.totaux?.total) }}
</div>
<div v-if="locataire.totaux?.impayes > 0" class="text-xs text-red-500">
<div v-if="locataire.totaux?.impayes > 0" class="text-xs text-red-400">
Impayes: {{ formatCurrency(locataire.totaux?.impayes) }}
</div>
</div>
<!-- Bouton supprimer -->
<button
@click.stop="$emit('remove')"
class="p-1 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
class="p-1 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
title="Supprimer ce locataire"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -71,43 +71,43 @@
</div>
<!-- Content -->
<div v-show="expanded" class="border-t border-gray-100 bg-gray-50 p-3">
<div v-show="expanded" class="border-t border-gray-700 bg-gray-900/60 p-3">
<!-- Totaux editables -->
<div class="grid grid-cols-4 gap-2 text-xs mb-3">
<div class="text-center p-2 bg-white rounded">
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
<div class="text-gray-500 mb-1">Loyers</div>
<EditableField
:modelValue="locataire.totaux?.loyers"
@update:modelValue="updateField('totaux.loyers', $event)"
type="currency"
displayClass="font-semibold text-gray-800"
displayClass="font-semibold text-gray-200"
/>
</div>
<div class="text-center p-2 bg-white rounded">
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
<div class="text-gray-500 mb-1">Taxes</div>
<EditableField
:modelValue="locataire.totaux?.taxes"
@update:modelValue="updateField('totaux.taxes', $event)"
type="currency"
displayClass="font-semibold text-gray-800"
displayClass="font-semibold text-gray-200"
/>
</div>
<div class="text-center p-2 bg-white rounded">
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
<div class="text-gray-500 mb-1">Provisions</div>
<EditableField
:modelValue="locataire.totaux?.provisions"
@update:modelValue="updateField('totaux.provisions', $event)"
type="currency"
displayClass="font-semibold text-gray-800"
displayClass="font-semibold text-gray-200"
/>
</div>
<div class="text-center p-2 bg-white rounded">
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
<div class="text-gray-500 mb-1">Regles</div>
<EditableField
:modelValue="locataire.totaux?.regles"
@update:modelValue="updateField('totaux.regles', $event)"
type="currency"
displayClass="font-semibold text-green-600"
displayClass="font-semibold text-green-400"
/>
</div>
</div>
@@ -118,7 +118,7 @@
<span class="text-xs text-gray-500 font-medium">Detail des lignes</span>
<button
@click="addLigne"
class="flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:bg-blue-50 rounded transition-colors"
class="flex items-center gap-1 px-2 py-1 text-xs text-blue-400 hover:bg-blue-500/10 rounded transition-colors"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
@@ -129,7 +129,7 @@
<div
v-for="(ligne, idx) in locataire.lignes"
:key="idx"
class="text-xs bg-white rounded p-2"
class="text-xs bg-gray-800 border border-gray-700 rounded p-2"
>
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 flex-1">
@@ -137,7 +137,7 @@
<select
:value="ligne.type"
@change="updateLigneField(idx, 'type', $event.target.value)"
class="px-1.5 py-0.5 rounded text-xs border border-gray-200 bg-gray-50 focus:outline-none focus:ring-1 focus:ring-blue-500"
class="px-1.5 py-0.5 rounded text-xs border border-gray-700 bg-gray-950 text-gray-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="loyer">loyer</option>
<option value="solde_anterieur">solde_anterieur</option>
@@ -171,7 +171,7 @@
@update:modelValue="updateLigneField(idx, 'divers.libelle', $event)"
type="text"
placeholder="libellé"
displayClass="text-xs text-gray-600 italic truncate"
displayClass="text-xs text-gray-400 italic truncate"
/>
</div>
@@ -182,20 +182,20 @@
:modelValue="ligne.divers?.montant"
@update:modelValue="updateLigneField(idx, 'divers.montant', $event)"
type="currency"
displayClass="font-medium text-gray-800"
displayClass="font-medium text-gray-200"
/>
<EditableField
v-else
:modelValue="ligne.total || ligne.loyers"
@update:modelValue="updateLigneField(idx, 'total', $event)"
type="currency"
displayClass="font-medium text-gray-800"
displayClass="font-medium text-gray-200"
/>
<!-- Bouton supprimer ligne -->
<button
@click="removeLigne(idx)"
class="p-1 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
class="p-1 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
title="Supprimer cette ligne"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -211,7 +211,7 @@
<div v-else class="text-center py-2">
<button
@click="addLigne"
class="flex items-center gap-1 px-3 py-1.5 text-xs text-blue-600 hover:bg-blue-50 rounded transition-colors mx-auto"
class="flex items-center gap-1 px-3 py-1.5 text-xs text-blue-400 hover:bg-blue-500/10 rounded transition-colors mx-auto"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
@@ -267,9 +267,9 @@ watch(() => props.highlighted, (val) => {
const totalClass = computed(() => {
const total = props.locataire.totaux?.total || 0
if (total > 0) return 'text-green-600'
if (total < 0) return 'text-red-600'
return 'text-gray-600'
if (total > 0) return 'text-green-400'
if (total < 0) return 'text-red-400'
return 'text-gray-400'
})
function formatCurrency(value) {

View File

@@ -1,13 +1,13 @@
<template>
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="card">
<!-- Header -->
<div class="w-full flex items-center justify-between px-3 py-2 bg-white hover:bg-gray-50 transition-colors">
<div class="w-full flex items-center justify-between px-3 py-2 bg-gray-900 hover:bg-gray-800 transition-colors">
<button
@click="expanded = !expanded"
class="flex items-center gap-2 flex-1 text-left"
>
<svg
class="w-3 h-3 text-gray-400 transition-transform flex-shrink-0"
class="w-3 h-3 text-gray-500 transition-transform flex-shrink-0"
:class="{ 'rotate-90': expanded }"
fill="none"
stroke="currentColor"
@@ -15,25 +15,25 @@
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span class="font-medium text-gray-800 text-sm">{{ formatCategorie(categorie) }}</span>
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
<span class="font-medium text-white text-sm">{{ formatCategorie(categorie) }}</span>
<span class="badge badge-neutral rounded-full">
{{ operations.length }} operation{{ operations.length > 1 ? 's' : '' }}
</span>
</button>
<div class="flex items-center gap-2">
<span
v-if="changedIndices.length"
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 border border-amber-300"
class="badge badge-warning text-[10px] uppercase tracking-wide"
>
{{ changedIndices.length }} modifiée{{ changedIndices.length > 1 ? 's' : '' }}
</span>
<div class="text-sm font-semibold text-gray-700">
<div class="text-sm font-semibold text-gray-200">
{{ formatCurrency(totalDebit) }}
</div>
<!-- Bouton ajouter operation -->
<button
@click.stop="addOperation"
class="p-1 text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded transition-colors"
class="p-1 text-gray-500 hover:text-blue-400 hover:bg-blue-500/10 rounded transition-colors"
title="Ajouter une operation"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -44,20 +44,20 @@
</div>
<!-- Content -->
<div v-show="expanded" class="border-t border-gray-100 bg-gray-50">
<div class="divide-y divide-gray-100">
<div v-show="expanded" class="border-t border-gray-700 bg-gray-900/60">
<div class="divide-y divide-gray-800">
<div
v-for="(op, idx) in operations"
:key="idx"
:ref="el => { if (el) opRefs[idx] = el }"
class="px-3 py-2 bg-white text-xs transition-all duration-300"
:class="changedIndices.includes(idx) ? 'ring-2 ring-amber-400 bg-amber-50' : (highlightedIdx === idx ? 'ring-2 ring-blue-400 bg-blue-50' : '')"
class="px-3 py-2 text-xs transition-all duration-300"
:class="changedIndices.includes(idx) ? 'ring-2 ring-amber-500/50 bg-amber-500/5' : (highlightedIdx === idx ? 'ring-2 ring-blue-500/50 bg-blue-500/5' : '')"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0 space-y-1">
<!-- Sous-categorie -->
<div class="flex items-center gap-2">
<span class="text-gray-400 text-xs w-20">Sous-cat:</span>
<span class="text-gray-500 text-xs w-20">Sous-cat:</span>
<EditableField
:modelValue="op.sous_categorie"
@update:modelValue="updateOperationField(idx, 'sous_categorie', $event)"
@@ -69,19 +69,19 @@
<!-- Fournisseur -->
<div class="flex items-center gap-2">
<span class="text-gray-400 text-xs w-20">Fournisseur:</span>
<span class="text-gray-500 text-xs w-20">Fournisseur:</span>
<EditableField
:modelValue="op.fournisseur"
@update:modelValue="updateOperationField(idx, 'fournisseur', $event)"
type="text"
placeholder="-"
displayClass="font-medium text-gray-800"
displayClass="font-medium text-gray-200"
/>
</div>
<!-- Description -->
<div class="flex items-center gap-2">
<span class="text-gray-400 text-xs w-20">Description:</span>
<span class="text-gray-500 text-xs w-20">Description:</span>
<EditableField
:modelValue="op.description"
@update:modelValue="updateOperationField(idx, 'description', $event)"
@@ -94,20 +94,20 @@
<!-- Lot -->
<div class="flex items-center gap-2">
<span class="text-gray-400 text-xs w-20">Lot:</span>
<span class="text-gray-500 text-xs w-20">Lot:</span>
<EditableField
:modelValue="op.lot_numero"
@update:modelValue="updateOperationField(idx, 'lot_numero', $event)"
type="text"
placeholder="N°"
displayClass="text-blue-500 font-medium text-xs"
displayClass="text-blue-400 font-medium text-xs"
/>
<EditableField
:modelValue="op.lot_concerne"
@update:modelValue="updateOperationField(idx, 'lot_concerne', $event)"
type="text"
placeholder="Concerne"
displayClass="text-blue-500 text-xs"
displayClass="text-blue-400 text-xs"
/>
</div>
</div>
@@ -116,48 +116,48 @@
<!-- Montants -->
<div class="text-right space-y-0.5">
<div class="flex items-center justify-end gap-1">
<span class="text-gray-400 text-xs">Debit:</span>
<span class="text-gray-500 text-xs">Debit:</span>
<EditableField
:modelValue="op.montants?.debit"
@update:modelValue="updateOperationField(idx, 'montants.debit', $event)"
type="currency"
displayClass="font-semibold text-gray-800"
displayClass="font-semibold text-gray-200"
/>
</div>
<div class="flex items-center justify-end gap-1">
<span class="text-gray-400 text-xs">Credit:</span>
<span class="text-gray-500 text-xs">Credit:</span>
<EditableField
:modelValue="op.montants?.credit"
@update:modelValue="updateOperationField(idx, 'montants.credit', $event)"
type="currency"
displayClass="text-green-600"
displayClass="text-green-400"
/>
</div>
<div class="flex items-center justify-end gap-1">
<span class="text-gray-400 text-xs">TVA:</span>
<span class="text-gray-500 text-xs">TVA:</span>
<EditableField
:modelValue="op.montants?.tva"
@update:modelValue="updateOperationField(idx, 'montants.tva', $event)"
type="currency"
displayClass="text-gray-400"
displayClass="text-gray-500"
/>
</div>
<div class="flex items-center justify-end gap-1">
<span class="text-gray-400 text-xs">Locatif:</span>
<span class="text-gray-500 text-xs">Locatif:</span>
<EditableField
:modelValue="op.montants?.locatif"
@update:modelValue="updateOperationField(idx, 'montants.locatif', $event)"
type="currency"
displayClass="text-green-600 text-xs"
displayClass="text-green-400 text-xs"
/>
</div>
<div class="flex items-center justify-end gap-1">
<span class="text-gray-400 text-xs">Deductible:</span>
<span class="text-gray-500 text-xs">Deductible:</span>
<EditableField
:modelValue="op.montants?.deductible"
@update:modelValue="updateOperationField(idx, 'montants.deductible', $event)"
type="currency"
displayClass="text-orange-600 text-xs"
displayClass="text-orange-400 text-xs"
/>
</div>
</div>
@@ -165,7 +165,7 @@
<!-- Bouton supprimer -->
<button
@click="removeOperation(idx)"
class="p-1 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
class="p-1 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
title="Supprimer cette operation"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -178,11 +178,11 @@
</div>
<!-- Totaux de la categorie -->
<div class="px-3 py-2 bg-gray-100 border-t border-gray-200 text-xs">
<div class="px-3 py-2 bg-gray-800 border-t border-gray-700 text-xs">
<div class="flex justify-between items-center">
<span class="font-medium text-gray-600">Total {{ formatCategorie(categorie) }}</span>
<span class="font-medium text-gray-400">Total {{ formatCategorie(categorie) }}</span>
<div class="text-right">
<span class="font-semibold text-gray-800">{{ formatCurrency(totalDebit) }}</span>
<span class="font-semibold text-white">{{ formatCurrency(totalDebit) }}</span>
<span v-if="totalTva > 0" class="text-gray-500 ml-2">(TVA: {{ formatCurrency(totalTva) }})</span>
</div>
</div>

View File

@@ -1,14 +1,14 @@
<template>
<div class="flex flex-col h-full bg-gray-800 overflow-hidden relative">
<div class="flex flex-col h-full bg-gray-950 overflow-hidden relative">
<!-- Header -->
<div class="flex-shrink-0 flex items-center px-3 py-2 bg-gray-900 text-white">
<div class="flex-shrink-0 flex items-center px-3 py-2 bg-gray-900 border-b border-gray-700 text-white">
<span class="text-sm font-medium truncate">{{ fileName }}</span>
</div>
<!-- États -->
<div
v-if="loading"
class="flex-1 flex items-center justify-center text-gray-400 text-sm"
class="flex-1 flex items-center justify-center text-gray-500 text-sm"
>
Chargement du PDF
</div>
@@ -24,12 +24,12 @@
<div
v-else-if="hasSource"
ref="container"
class="flex-1 overflow-auto p-2 bg-gray-800"
class="flex-1 overflow-auto custom-scrollbar p-2 bg-gray-950"
/>
<!-- Empty state -->
<div
v-else
class="flex-1 flex items-center justify-center text-gray-400 text-sm"
class="flex-1 flex items-center justify-center text-gray-500 text-sm"
>
Aucun PDF sélectionné
</div>

View File

@@ -2,9 +2,9 @@
<div
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors"
:class="{
'border-blue-500 bg-blue-50': isDragging,
'border-gray-300 hover:border-gray-400': !isDragging && !file,
'border-green-500 bg-green-50': file && !isLoading
'border-blue-500 bg-blue-500/10': isDragging,
'border-gray-700 hover:border-gray-600': !isDragging && !file,
'border-green-500 bg-green-500/10': file && !isLoading
}"
@dragenter.prevent="onDragEnter"
@dragleave.prevent="onDragLeave"
@@ -13,11 +13,11 @@
>
<!-- Loading state -->
<div v-if="isLoading" class="flex flex-col items-center gap-4">
<svg class="animate-spin h-12 w-12 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<svg class="animate-spin h-12 w-12 text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-gray-600">Extraction en cours...</p>
<p class="text-gray-400">Extraction en cours...</p>
</div>
<!-- File selected state -->
@@ -25,18 +25,18 @@
<svg class="h-12 w-12 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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 class="text-gray-700 font-medium">{{ file.name }}</p>
<p class="text-gray-200 font-medium">{{ file.name }}</p>
<p class="text-sm text-gray-500">{{ formatFileSize(file.size) }}</p>
<div class="flex gap-3">
<button
@click="extractPdf"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
class="btn btn-primary"
>
Extraire les donnees
</button>
<button
@click="clearFile"
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
class="btn btn-secondary"
>
Annuler
</button>
@@ -45,12 +45,12 @@
<!-- Default upload state -->
<div v-else class="flex flex-col items-center gap-4">
<svg class="h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-12 w-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<div>
<p class="text-gray-600 mb-1">Glissez un PDF ici ou</p>
<label class="cursor-pointer text-blue-600 hover:text-blue-700 font-medium">
<p class="text-gray-400 mb-1">Glissez un PDF ici ou</p>
<label class="cursor-pointer text-blue-400 hover:text-blue-300 font-medium">
cliquez pour selectionner
<input
type="file"
@@ -60,12 +60,12 @@
/>
</label>
</div>
<p class="text-sm text-gray-400">Fichiers PDF uniquement</p>
<p class="text-sm text-gray-500">Fichiers PDF uniquement</p>
</div>
<!-- Error message -->
<div v-if="error" class="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p class="text-red-600 text-sm">{{ error }}</p>
<div v-if="error" class="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p class="text-red-400 text-sm">{{ error }}</p>
</div>
</div>
</template>

View File

@@ -1,18 +1,18 @@
<template>
<div class="flex-shrink-0 border-b border-amber-300 bg-amber-50">
<div class="flex-shrink-0 border-b border-amber-500/40 bg-amber-500/10">
<!-- En-tête -->
<div class="flex items-center justify-between px-4 py-2">
<div class="flex items-center gap-2 text-sm">
<svg class="w-4 h-4 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span v-if="diff.summary.total === 0" class="font-medium text-amber-800">
<span v-if="diff.summary.total === 0" class="font-medium text-amber-300">
Nouvelle extraction aucune différence avec la version précédente
</span>
<span v-else class="font-medium text-amber-800">
<span v-else class="font-medium text-amber-300">
Nouvelle extraction {{ diff.summary.total }} différence{{ diff.summary.total > 1 ? 's' : '' }}
<span class="text-amber-600 font-normal">
<span class="text-amber-400/80 font-normal">
({{ diff.summary.metadata }} métadonnée{{ diff.summary.metadata > 1 ? 's' : '' }},
{{ diff.summary.locataires }} locataire{{ diff.summary.locataires > 1 ? 's' : '' }},
{{ diff.summary.operations }} opération{{ diff.summary.operations > 1 ? 's' : '' }})
@@ -22,7 +22,7 @@
<div class="flex items-center gap-2">
<button
@click="$emit('revert')"
class="px-2 py-1 text-xs text-amber-700 hover:text-amber-900 hover:bg-amber-100 rounded transition-colors"
class="px-2 py-1 text-xs text-amber-300 hover:text-amber-100 hover:bg-amber-500/20 rounded transition-colors"
title="Restaurer les données d'avant la re-extraction"
>
Revenir à la version précédente
@@ -30,13 +30,13 @@
<button
v-if="diff.summary.total > 0"
@click="open = !open"
class="px-2 py-1 text-xs text-amber-700 hover:text-amber-900 hover:bg-amber-100 rounded transition-colors"
class="px-2 py-1 text-xs text-amber-300 hover:text-amber-100 hover:bg-amber-500/20 rounded transition-colors"
>
{{ open ? 'Masquer le détail' : 'Voir le détail' }}
</button>
<button
@click="$emit('close')"
class="p-1 text-amber-500 hover:text-amber-700 hover:bg-amber-100 rounded transition-colors"
class="p-1 text-amber-400 hover:text-amber-200 hover:bg-amber-500/20 rounded transition-colors"
title="Fermer"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -50,33 +50,33 @@
<div v-if="open && diff.summary.total > 0" class="max-h-56 overflow-auto px-4 pb-3 space-y-3 text-xs">
<!-- Métadonnées -->
<div v-if="diff.metadata.length">
<div class="font-semibold text-amber-800 mb-1">Métadonnées</div>
<div class="font-semibold text-amber-300 mb-1">Métadonnées</div>
<div class="space-y-0.5">
<div v-for="(c, i) in diff.metadata" :key="'m' + i" class="flex items-baseline gap-2">
<span class="text-gray-500 w-40 flex-shrink-0">{{ c.label }}</span>
<span class="text-red-600 line-through">{{ c.before }}</span>
<span class="text-gray-400"></span>
<span class="text-green-700 font-medium">{{ c.after }}</span>
<span class="text-red-400 line-through">{{ c.before }}</span>
<span class="text-gray-500"></span>
<span class="text-green-400 font-medium">{{ c.after }}</span>
</div>
</div>
</div>
<!-- Locataires -->
<div v-if="diff.locataires.length">
<div class="font-semibold text-amber-800 mb-1">Locataires</div>
<div class="font-semibold text-amber-300 mb-1">Locataires</div>
<div class="space-y-1.5">
<div v-for="(loc, i) in diff.locataires" :key="'l' + i" class="bg-white/60 rounded p-1.5">
<div v-for="(loc, i) in diff.locataires" :key="'l' + i" class="bg-gray-900/70 border border-gray-700 rounded p-1.5">
<div class="flex items-center gap-2 mb-0.5">
<span :class="kindClass(loc.kind)" class="text-[10px] uppercase font-semibold px-1 rounded">
{{ kindLabel(loc.kind) }}
</span>
<span class="font-medium text-gray-700">{{ loc.title }}</span>
<span class="font-medium text-gray-200">{{ loc.title }}</span>
</div>
<div v-for="(f, j) in loc.fields" :key="j" class="flex items-baseline gap-2 pl-2">
<span class="text-gray-500 w-24 flex-shrink-0">{{ f.label }}</span>
<span class="text-red-600 line-through break-all">{{ f.before }}</span>
<span class="text-gray-400"></span>
<span class="text-green-700 font-medium break-all">{{ f.after }}</span>
<span class="text-red-400 line-through break-all">{{ f.before }}</span>
<span class="text-gray-500"></span>
<span class="text-green-400 font-medium break-all">{{ f.after }}</span>
</div>
</div>
</div>
@@ -84,9 +84,9 @@
<!-- Opérations -->
<div v-if="diff.operations.length">
<div class="font-semibold text-amber-800 mb-1">Opérations</div>
<div class="font-semibold text-amber-300 mb-1">Opérations</div>
<div class="space-y-1.5">
<div v-for="(op, i) in diff.operations" :key="'o' + i" class="bg-white/60 rounded p-1.5">
<div v-for="(op, i) in diff.operations" :key="'o' + i" class="bg-gray-900/70 border border-gray-700 rounded p-1.5">
<div class="flex items-center gap-2 mb-0.5">
<span :class="kindClass(op.kind)" class="text-[10px] uppercase font-semibold px-1 rounded">
{{ kindLabel(op.kind) }}
@@ -94,8 +94,8 @@
<span v-if="op.index >= 0" class="text-gray-500">opération #{{ op.index + 1 }}</span>
</div>
<div class="pl-2">
<div class="text-red-600 line-through break-all">{{ op.before }}</div>
<div class="text-green-700 font-medium break-all">{{ op.after }}</div>
<div class="text-red-400 line-through break-all">{{ op.before }}</div>
<div class="text-green-400 font-medium break-all">{{ op.after }}</div>
</div>
</div>
</div>
@@ -125,8 +125,8 @@ function kindLabel(kind) {
}
function kindClass(kind) {
if (kind === 'added') return 'bg-green-100 text-green-700'
if (kind === 'removed') return 'bg-red-100 text-red-700'
return 'bg-amber-100 text-amber-700'
if (kind === 'added') return 'bg-green-500/20 text-green-300'
if (kind === 'removed') return 'bg-red-500/20 text-red-300'
return 'bg-amber-500/20 text-amber-300'
}
</script>

View File

@@ -14,7 +14,7 @@
@keydown.escape="closeDropdown"
@keydown.tab="closeDropdown"
:placeholder="placeholder"
class="w-full px-3 py-2 pr-8 border rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
class="w-full px-3 py-2 pr-8 border rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
:class="inputClasses"
/>
@@ -22,7 +22,7 @@
<button
v-if="modelValue"
@mousedown.prevent="clearSelection"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 rounded"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-500 hover:text-white rounded"
type="button"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -34,7 +34,7 @@
<button
v-else
@mousedown.prevent="toggleDropdown"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 rounded"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-500 hover:text-white rounded"
type="button"
>
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': isOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -46,7 +46,7 @@
<!-- Dropdown -->
<div
v-show="isOpen && filteredOptions.length > 0"
class="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto"
class="absolute z-50 w-full mt-1 bg-gray-900 border border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto custom-scrollbar"
>
<div
v-for="(option, index) in filteredOptions"
@@ -55,8 +55,8 @@
@mouseenter="highlightedIndex = index"
class="px-3 py-2 cursor-pointer text-sm transition-colors"
:class="{
'bg-blue-50 text-blue-700': index === highlightedIndex,
'hover:bg-gray-50': index !== highlightedIndex
'bg-blue-500/20 text-blue-300': index === highlightedIndex,
'text-gray-300 hover:bg-gray-800': index !== highlightedIndex
}"
>
<span v-html="highlightMatch(option.nom)"></span>
@@ -66,7 +66,7 @@
<!-- No results -->
<div
v-show="isOpen && searchQuery && filteredOptions.length === 0"
class="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg p-3 text-sm text-gray-500"
class="absolute z-50 w-full mt-1 bg-gray-900 border border-gray-700 rounded-lg shadow-lg p-3 text-sm text-gray-500"
>
Aucun tag trouvé pour "{{ searchQuery }}"
</div>
@@ -112,12 +112,12 @@ const isUserInput = ref(true)
// Compute input classes based on state
const inputClasses = computed(() => {
if (props.modelValue && props.hasPrediction) {
return 'bg-green-50 border-green-300'
return 'bg-gray-950 border-green-500/60'
}
if (!props.modelValue) {
return 'bg-yellow-50 border-yellow-300'
return 'bg-gray-950 border-amber-500/60'
}
return 'border-gray-300'
return 'bg-gray-950 border-gray-700'
})
// Get selected option name for display
@@ -249,6 +249,6 @@ function highlightMatch(text) {
const query = searchQuery.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(`(${query})`, 'gi')
return text.replace(regex, '<mark class="bg-yellow-200 rounded px-0.5">$1</mark>')
return text.replace(regex, '<mark class="bg-amber-500/30 text-amber-200 rounded px-0.5">$1</mark>')
}
</script>

View File

@@ -1,20 +1,20 @@
<template>
<div class="flex flex-col h-full bg-gray-50">
<div class="flex flex-col h-full bg-gray-950">
<!-- Header -->
<div class="flex-shrink-0 p-4 bg-white border-b border-gray-200">
<div class="flex-shrink-0 p-4 bg-gray-900 border-b border-gray-700">
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold text-gray-900">Tagging des dépenses</h2>
<span v-if="isDuplicate" class="px-2 py-1 text-xs font-medium bg-orange-100 text-orange-800 rounded">
<h2 class="page-title">Tagging des dépenses</h2>
<span v-if="isDuplicate" class="badge badge-warning">
Écrasement
</span>
</div>
<p class="text-sm text-gray-600 mt-1">
<p class="page-subtitle">
{{ taggedCount }} / {{ depenses.length }} dépenses taggées
<span v-if="predictions.length > 0" class="text-green-600 ml-2">
<span v-if="predictions.length > 0" class="text-green-400 ml-2">
({{ predictedCount }} pré-remplies automatiquement)
</span>
</p>
<p v-if="isDuplicate" class="text-xs text-orange-600 mt-1">
<p v-if="isDuplicate" class="text-xs text-amber-400 mt-1">
Les données existantes seront remplacées lors de la validation
</p>
</div>
@@ -22,8 +22,8 @@
<!-- Loading state -->
<div v-if="isLoadingPredictions" class="flex-1 flex items-center justify-center">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p class="text-gray-600">Analyse en cours...</p>
<div class="spinner h-12 w-12 mx-auto mb-4"></div>
<p class="text-gray-400">Analyse en cours...</p>
</div>
</div>
@@ -32,15 +32,15 @@
<div
v-for="(depense, index) in depenses"
:key="index"
class="bg-white rounded-lg shadow-sm border border-gray-200 p-4"
class="card card-body"
>
<!-- Expense info -->
<div class="flex items-start justify-between gap-4 mb-3">
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900 truncate">
<div class="font-medium text-white truncate">
{{ depense.fournisseur || 'Fournisseur inconnu' }}
</div>
<div class="text-sm text-gray-600 truncate">
<div class="text-sm text-gray-400 truncate">
{{ depense.sous_categorie || depense.description || 'Aucune description' }}
</div>
<div class="text-sm text-gray-500 mt-1">
@@ -49,7 +49,7 @@
</div>
<!-- Tag selector -->
<div class="flex-shrink-0 w-56">
<div class="flex-shrink-0 w-64">
<TagAutocomplete
v-model="selectedTags[index]"
:options="availableTags"
@@ -64,9 +64,9 @@
<!-- Prediction info -->
<div
v-if="predictions[index] && predictions[index].tag_id"
class="flex items-center gap-2 text-xs text-gray-600 bg-blue-50 border border-blue-200 rounded px-3 py-2"
class="flex items-center gap-2 text-xs text-gray-300 bg-blue-500/10 border border-blue-500/30 rounded px-3 py-2"
>
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>
@@ -77,7 +77,7 @@
<!-- No prediction warning -->
<div
v-else-if="predictions[index] && !predictions[index].tag_id && !selectedTags[index]"
class="flex items-center gap-2 text-xs text-gray-600 bg-gray-50 border border-gray-200 rounded px-3 py-2"
class="flex items-center gap-2 text-xs text-gray-400 bg-gray-800 border border-gray-700 rounded px-3 py-2"
>
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
@@ -88,23 +88,23 @@
</div>
<!-- Action buttons -->
<div class="flex-shrink-0 p-4 bg-white border-t border-gray-200">
<div class="flex-shrink-0 p-4 bg-gray-900 border-t border-gray-700">
<div class="flex items-center justify-between gap-4">
<button
@click="$emit('cancel')"
class="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
class="btn btn-secondary"
>
Annuler
Retour à l'édition
</button>
<div class="flex items-center gap-3">
<span v-if="taggedCount < depenses.length" class="text-sm text-yellow-600">
<span v-if="taggedCount < depenses.length" class="text-sm text-amber-400">
⚠️ {{ depenses.length - taggedCount }} dépense(s) sans tag
</span>
<button
@click="validateAndSave"
:disabled="taggedCount === 0"
class="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
class="btn btn-primary px-6"
>
Valider et enregistrer
</button>

View File

@@ -1,6 +1,6 @@
<template>
<div class="bg-gray-900 rounded-lg p-4">
<h3 class="text-sm font-medium text-gray-300 mb-4">Repartition par categorie</h3>
<div class="card card-body">
<h3 class="card-title block mb-4">Repartition par categorie</h3>
<div class="h-64">
<Doughnut
v-if="chartData.labels.length > 0"

View File

@@ -1,7 +1,7 @@
<template>
<div class="bg-gray-900 rounded-lg overflow-hidden">
<div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-300">
<div class="card">
<div class="card-header">
<h3 class="card-title">
Detail des depenses
<span class="text-gray-500 font-normal ml-2">({{ depenses.length }} resultats)</span>
</h3>
@@ -25,65 +25,64 @@
Aucune depense trouvee avec les filtres actuels.
</div>
<div v-else class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-800/50">
<div v-else class="table-wrap">
<table class="table">
<thead>
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Date</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Immeuble</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Lot</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Categorie</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Fournisseur</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Description</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Tag</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-400 uppercase">Debit</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-400 uppercase">Credit</th>
<th class="px-3 py-2 w-10"></th>
<th>Date</th>
<th>Immeuble</th>
<th>Lot</th>
<th>Categorie</th>
<th>Fournisseur</th>
<th>Description</th>
<th>Tag</th>
<th class="text-right">Debit</th>
<th class="text-right">Credit</th>
<th class="w-10"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800">
<tbody>
<tr
v-for="dep in paginatedDepenses"
:key="dep.id"
class="hover:bg-gray-800/50"
>
<td class="px-3 py-2 text-gray-300 whitespace-nowrap">
<td class="text-gray-300 whitespace-nowrap">
{{ formatDate(dep.document_date) }}
</td>
<td class="px-3 py-2 text-gray-400 whitespace-nowrap">
<td class="text-gray-400 whitespace-nowrap">
{{ dep.immeuble_code }}
</td>
<td class="px-3 py-2 text-gray-400">
<td class="text-gray-400">
{{ dep.lot_numero || '-' }}
</td>
<td class="px-3 py-2 text-gray-300">
<td class="text-gray-300">
<div class="text-xs text-gray-500">{{ formatCategorie(dep.categorie) }}</div>
<div v-if="dep.sous_categorie" class="text-xs text-gray-400 truncate max-w-[150px]">
<div v-if="dep.sous_categorie" class="text-xs text-gray-400 truncate max-w-[220px]">
{{ dep.sous_categorie }}
</div>
</td>
<td class="px-3 py-2 text-white truncate max-w-[150px]">
<td class="text-white truncate max-w-[240px]">
{{ dep.fournisseur || '-' }}
</td>
<td class="px-3 py-2 text-gray-400 truncate max-w-[200px]">
<td class="text-gray-400 truncate max-w-[360px]">
{{ dep.description || '-' }}
</td>
<td class="px-3 py-2">
<td>
<span
v-if="dep.tag_nom"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-500/20 text-blue-400"
class="badge badge-info"
>
{{ dep.tag_nom }}
</span>
<span v-else class="text-gray-600">-</span>
</td>
<td class="px-3 py-2 text-right text-red-400 font-mono whitespace-nowrap">
<td class="text-right text-red-400 font-mono whitespace-nowrap">
{{ dep.debit > 0 ? formatCurrency(dep.debit) : '-' }}
</td>
<td class="px-3 py-2 text-right text-green-400 font-mono whitespace-nowrap">
<td class="text-right text-green-400 font-mono whitespace-nowrap">
{{ dep.credit > 0 ? formatCurrency(dep.credit) : '-' }}
</td>
<td class="px-3 py-2 text-center">
<td class="text-center">
<router-link
:to="{ path: '/documents/' + dep.document_id + '/edit', query: { highlight: 'operation', fournisseur: dep.fournisseur, description: dep.description } }"
class="text-gray-500 hover:text-blue-400 transition-colors"
@@ -108,14 +107,14 @@
<button
@click="currentPage = 1"
:disabled="currentPage === 1"
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
class="btn btn-sm btn-secondary"
>
&lt;&lt;
</button>
<button
@click="currentPage--"
:disabled="currentPage === 1"
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
class="btn btn-sm btn-secondary"
>
&lt;
</button>
@@ -125,14 +124,14 @@
<button
@click="currentPage++"
:disabled="currentPage === totalPages"
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
class="btn btn-sm btn-secondary"
>
&gt;
</button>
<button
@click="currentPage = totalPages"
:disabled="currentPage === totalPages"
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
class="btn btn-sm btn-secondary"
>
&gt;&gt;
</button>

View File

@@ -1,7 +1,7 @@
<template>
<div class="bg-gray-900 rounded-lg p-4 space-y-4">
<div class="card card-body space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-300">Filtres</h3>
<h3 class="card-title">Filtres</h3>
<button
v-if="hasActiveFilters"
@click="resetFilters"
@@ -11,14 +11,14 @@
</button>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 2xl:grid-cols-7 gap-3">
<!-- Immeuble -->
<div>
<label class="block text-xs text-gray-400 mb-1">Immeuble</label>
<select
v-model="filters.immeuble_id"
@change="onImmeubleChange"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
class="input"
>
<option :value="null">Tous</option>
<option v-for="imm in immeubles" :key="imm.id" :value="imm.id">
@@ -33,7 +33,7 @@
<select
v-model="filters.lot_id"
@change="emitFilters"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
class="input"
:disabled="!filters.immeuble_id"
>
<option :value="null">Tous</option>
@@ -49,7 +49,7 @@
<select
v-model="filters.categorie"
@change="emitFilters"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
class="input"
>
<option :value="null">Toutes</option>
<option v-for="cat in categories" :key="cat" :value="cat">
@@ -64,7 +64,7 @@
<select
v-model="filters.tag_id"
@change="emitFilters"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
class="input"
>
<option :value="null">Tous</option>
<option v-for="tag in tags" :key="tag.id" :value="tag.id">
@@ -80,7 +80,7 @@
type="date"
v-model="filters.date_debut"
@change="emitFilters"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
class="input"
/>
</div>
@@ -91,21 +91,21 @@
type="date"
v-model="filters.date_fin"
@change="emitFilters"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
class="input"
/>
</div>
</div>
<!-- Fournisseur (recherche) -->
<div class="max-w-xs">
<label class="block text-xs text-gray-400 mb-1">Fournisseur</label>
<input
type="text"
v-model="filters.fournisseur"
@input="emitFilters"
placeholder="Rechercher..."
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
/>
<!-- Fournisseur (recherche) -->
<div>
<label class="block text-xs text-gray-400 mb-1">Fournisseur</label>
<input
type="text"
v-model="filters.fournisseur"
@input="emitFilters"
placeholder="Rechercher..."
class="input"
/>
</div>
</div>
</div>
</template>

View File

@@ -3,7 +3,7 @@
<div
v-for="kpi in kpis"
:key="kpi.key"
class="bg-gray-900 rounded-lg p-4"
class="card card-body"
>
<div class="text-xs text-gray-400 mb-1">{{ kpi.label }}</div>
<div class="text-xl font-bold" :class="kpi.colorClass || 'text-white'">

View File

@@ -1,6 +1,6 @@
<template>
<div class="bg-gray-900 rounded-lg p-4">
<h3 class="text-sm font-medium text-gray-300 mb-4">Evolution mensuelle</h3>
<div class="card card-body">
<h3 class="card-title block mb-4">Evolution mensuelle</h3>
<div class="h-64">
<Bar
v-if="chartData.labels.length > 0"

View File

@@ -1,6 +1,6 @@
<template>
<div class="bg-gray-900 rounded-lg p-4">
<h3 class="text-sm font-medium text-gray-300 mb-4">Top fournisseurs</h3>
<div class="card card-body">
<h3 class="card-title block mb-4">Top fournisseurs</h3>
<div class="h-64">
<Bar
v-if="chartData.labels.length > 0"

View File

@@ -8,7 +8,7 @@
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<!-- Revenus -->
<div class="relative bg-gray-900 rounded-lg p-4 border border-gray-700 overflow-hidden">
<div class="card card-body relative">
<!-- Sparkline background -->
<svg
v-if="data.revenus_history?.length"
@@ -35,7 +35,7 @@
</div>
<!-- Depenses -->
<div class="relative bg-gray-900 rounded-lg p-4 border border-gray-700 overflow-hidden">
<div class="card card-body relative">
<!-- Sparkline background -->
<svg
v-if="data.depenses_history?.length"
@@ -62,7 +62,7 @@
</div>
<!-- Impayes -->
<div class="relative bg-gray-900 rounded-lg p-4 border border-gray-700 overflow-hidden">
<div class="card card-body relative">
<!-- Sparkline background -->
<svg
v-if="data.impayes_history?.length"
@@ -98,7 +98,7 @@
</div>
<!-- Solde -->
<div class="relative bg-gray-900 rounded-lg p-4 border border-gray-700 overflow-hidden">
<div class="card card-body relative">
<!-- Sparkline background -->
<svg
v-if="data.solde_history?.length"

View File

@@ -1,7 +1,7 @@
<template>
<div class="bg-gray-900 rounded-lg overflow-hidden border border-gray-700">
<div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
<h2 class="text-sm font-medium text-gray-300">Vos immeubles</h2>
<div class="card">
<div class="card-header">
<h2 class="card-title">Vos immeubles</h2>
<button
@click="$emit('view-all')"
class="text-xs text-blue-400 hover:text-blue-300 transition-colors"
@@ -11,7 +11,7 @@
</div>
<div v-if="loading" class="p-8 text-center text-gray-500">
<div class="inline-block animate-spin rounded-full h-6 w-6 border-2 border-gray-600 border-t-blue-400"></div>
<div class="spinner h-6 w-6"></div>
</div>
<div v-else-if="immeubles.length === 0" class="p-8 text-center text-gray-500">

View File

@@ -1,7 +1,7 @@
<template>
<div class="bg-gray-900 rounded-lg overflow-hidden border border-gray-700">
<div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
<h2 class="text-sm font-medium text-gray-300">Evolution mensuelle</h2>
<div class="card">
<div class="card-header">
<h2 class="card-title">Evolution mensuelle</h2>
<div class="flex items-center gap-3 text-xs">
<span class="flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-green-400"></span>
@@ -15,7 +15,7 @@
</div>
<div v-if="loading" class="p-8 text-center text-gray-500">
<div class="inline-block animate-spin rounded-full h-6 w-6 border-2 border-gray-600 border-t-blue-400"></div>
<div class="spinner h-6 w-6"></div>
</div>
<div v-else-if="trends.length === 0" class="p-8 text-center text-gray-500">

View File

@@ -1,12 +1,12 @@
<template>
<div class="bg-gray-900 rounded-lg overflow-hidden border border-gray-700">
<div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
<h2 class="text-sm font-medium text-gray-300">Derniers loyers</h2>
<div class="card">
<div class="card-header">
<h2 class="card-title">Derniers loyers</h2>
<span class="text-xs text-gray-500">{{ revenus.length }} entrees</span>
</div>
<div v-if="loading" class="p-8 text-center text-gray-500">
<div class="inline-block animate-spin rounded-full h-6 w-6 border-2 border-gray-600 border-t-blue-400"></div>
<div class="spinner h-6 w-6"></div>
</div>
<div v-else-if="revenus.length === 0" class="p-8 text-center text-gray-500">

View File

@@ -1,5 +1,5 @@
<template>
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
<div class="card card-body">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-white">Filtres</h3>
<button
@@ -17,7 +17,7 @@
<select
v-model="filters.immeuble_id"
@change="emitFilters"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500"
class="input"
>
<option :value="null">Tous</option>
<option
@@ -36,7 +36,7 @@
<select
v-model="filters.type_ligne"
@change="emitFilters"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500"
class="input"
>
<option :value="null">Tous</option>
<option value="loyer">Loyer</option>
@@ -53,7 +53,7 @@
type="date"
v-model="filters.date_debut"
@change="emitFilters"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500"
class="input"
/>
</div>
@@ -64,7 +64,7 @@
type="date"
v-model="filters.date_fin"
@change="emitFilters"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500"
class="input"
/>
</div>
@@ -75,7 +75,7 @@
type="checkbox"
v-model="filters.impayes_only"
@change="emitFilters"
class="w-4 h-4 rounded bg-gray-800 border-gray-700 text-amber-500 focus:ring-amber-500 focus:ring-offset-gray-900"
class="w-4 h-4 rounded border-gray-700 accent-blue-500 [color-scheme:dark]"
/>
<span class="text-sm text-gray-300">Impayes uniquement</span>
</label>

View File

@@ -1,5 +1,5 @@
<template>
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
<div class="card card-body">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-white">Revenus par immeuble</h3>
<span class="text-xs text-gray-500">{{ data.length }} immeubles</span>

View File

@@ -1,7 +1,7 @@
<template>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<!-- Total Revenus -->
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
<div class="card card-body">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-400 uppercase tracking-wide">Total Revenus</span>
<svg class="w-4 h-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -17,7 +17,7 @@
</div>
<!-- Regles -->
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
<div class="card card-body">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-400 uppercase tracking-wide">Regles</span>
<svg class="w-4 h-4 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -33,7 +33,7 @@
</div>
<!-- Impayes -->
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
<div class="card card-body">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-400 uppercase tracking-wide">Impayes</span>
<div
@@ -55,7 +55,7 @@
</div>
<!-- Taux Recouvrement -->
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
<div class="card card-body">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-400 uppercase tracking-wide">Recouvrement</span>
<svg class="w-4 h-4 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -74,7 +74,7 @@
</div>
<!-- Locataires Actifs -->
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
<div class="card card-body">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-400 uppercase tracking-wide">Locataires</span>
<svg class="w-4 h-4 text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -90,7 +90,7 @@
</div>
<!-- Lots Occupes -->
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
<div class="card card-body">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-400 uppercase tracking-wide">Lots</span>
<svg class="w-4 h-4 text-pink-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">

View File

@@ -1,5 +1,5 @@
<template>
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
<div class="card card-body">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-white">Evolution mensuelle des revenus</h3>
<div class="flex items-center gap-4 text-xs">

View File

@@ -1,23 +1,21 @@
<template>
<div class="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
<div class="p-4 border-b border-gray-700">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h3 class="text-sm font-medium text-white">Detail des revenus</h3>
<span class="text-xs text-gray-500">({{ revenus.length }} lignes)</span>
</div>
<button
@click="exportCsv"
class="text-xs text-blue-400 hover:text-blue-300 transition-colors"
>
Exporter CSV
</button>
<div class="card">
<div class="card-header">
<div class="flex items-center gap-2">
<h3 class="card-title">Detail des revenus</h3>
<span class="text-xs text-gray-500">({{ revenus.length }} lignes)</span>
</div>
<button
@click="exportCsv"
class="text-xs text-blue-400 hover:text-blue-300 transition-colors"
>
Exporter CSV
</button>
</div>
<!-- Loading -->
<div v-if="isLoading" class="p-8 text-center text-gray-500">
<svg class="animate-spin h-6 w-6 mx-auto mb-2" fill="none" viewBox="0 0 24 24">
<div v-if="isLoading" class="empty-state">
<svg class="animate-spin h-6 w-6 mx-auto mb-2 text-blue-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
@@ -25,60 +23,59 @@
</div>
<!-- Table -->
<div v-else-if="revenus.length > 0" class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-800/50 text-gray-400 text-xs uppercase">
<div v-else-if="revenus.length > 0" class="table-wrap">
<table class="table">
<thead>
<tr>
<th class="px-4 py-3 text-left">Date</th>
<th class="px-4 py-3 text-left">Locataire</th>
<th class="px-4 py-3 text-left">Lot</th>
<th class="px-4 py-3 text-left">Type</th>
<th class="px-4 py-3 text-right">Loyers</th>
<th class="px-4 py-3 text-right">Taxes</th>
<th class="px-4 py-3 text-right">Total</th>
<th class="px-4 py-3 text-right">Regle</th>
<th class="px-4 py-3 text-right">Impaye</th>
<th class="px-4 py-3 w-10"></th>
<th>Date</th>
<th>Locataire</th>
<th>Lot</th>
<th>Type</th>
<th class="text-right">Loyers</th>
<th class="text-right">Taxes</th>
<th class="text-right">Total</th>
<th class="text-right">Regle</th>
<th class="text-right">Impaye</th>
<th class="w-10"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800">
<tbody>
<tr
v-for="revenu in paginatedRevenus"
:key="revenu.id"
class="hover:bg-gray-800/30 transition-colors"
>
<td class="px-4 py-3 text-gray-300 whitespace-nowrap">
<td class="text-gray-300 whitespace-nowrap">
{{ formatDate(revenu.document_date) }}
</td>
<td class="px-4 py-3 text-white font-medium max-w-[200px] truncate" :title="revenu.locataire_nom">
<td class="text-white font-medium max-w-[280px] truncate" :title="revenu.locataire_nom">
{{ revenu.locataire_nom }}
</td>
<td class="px-4 py-3 text-gray-400 whitespace-nowrap" :title="getImmeubleName(revenu)">
<td class="text-gray-400 whitespace-nowrap" :title="getImmeubleName(revenu)">
{{ getImmeubleName(revenu) }} / {{ revenu.lot_numero }}
</td>
<td class="px-4 py-3">
<td>
<span :class="getTypeBadgeClass(revenu.type_ligne)">
{{ revenu.type_ligne }}
</span>
</td>
<td class="px-4 py-3 text-right text-gray-300 whitespace-nowrap">
<td class="text-right text-gray-300 whitespace-nowrap">
{{ formatAmount(revenu.loyers) }}
</td>
<td class="px-4 py-3 text-right text-gray-400 whitespace-nowrap">
<td class="text-right text-gray-400 whitespace-nowrap">
{{ formatAmount(revenu.taxes) }}
</td>
<td class="px-4 py-3 text-right text-green-400 font-medium whitespace-nowrap">
<td class="text-right text-green-400 font-medium whitespace-nowrap">
{{ formatAmount(revenu.total) }}
</td>
<td class="px-4 py-3 text-right text-blue-400 whitespace-nowrap">
<td class="text-right text-blue-400 whitespace-nowrap">
{{ formatAmount(revenu.regles) }}
</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<td class="text-right whitespace-nowrap">
<span :class="revenu.impayes > 0 ? 'text-amber-400 font-medium' : 'text-gray-500'">
{{ formatAmount(revenu.impayes) }}
</span>
</td>
<td class="px-4 py-3 text-center">
<td class="text-center">
<router-link
:to="{ path: '/documents/' + revenu.document_id + '/edit', query: { highlight: 'locataire', lot_numero: revenu.lot_numero, locataire_nom: revenu.locataire_nom } }"
class="text-gray-500 hover:text-blue-400 transition-colors"
@@ -95,12 +92,12 @@
</div>
<!-- Empty state -->
<div v-else class="p-8 text-center text-gray-500">
<div v-else class="empty-state">
Aucun revenu trouve
</div>
<!-- Pagination -->
<div v-if="revenus.length > pageSize" class="p-4 border-t border-gray-700 flex items-center justify-between">
<div v-if="revenus.length > pageSize" class="card-header border-b-0 border-t border-gray-700">
<div class="text-xs text-gray-500">
Affichage {{ startIndex + 1 }} - {{ endIndex }} sur {{ revenus.length }}
</div>
@@ -108,14 +105,14 @@
<button
@click="currentPage = 1"
:disabled="currentPage === 1"
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
class="btn btn-sm btn-secondary"
>
&laquo;
</button>
<button
@click="currentPage--"
:disabled="currentPage === 1"
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
class="btn btn-sm btn-secondary"
>
&lsaquo;
</button>
@@ -125,14 +122,14 @@
<button
@click="currentPage++"
:disabled="currentPage === totalPages"
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
class="btn btn-sm btn-secondary"
>
&rsaquo;
</button>
<button
@click="currentPage = totalPages"
:disabled="currentPage === totalPages"
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
class="btn btn-sm btn-secondary"
>
&raquo;
</button>

View File

@@ -1,5 +1,5 @@
<template>
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
<div class="card card-body">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<h3 class="text-sm font-medium text-white">Locataires avec impayes</h3>

View File

@@ -1,12 +1,12 @@
<template>
<div class="flex-1 overflow-auto p-6 bg-gray-800">
<div class="max-w-7xl mx-auto space-y-6">
<div class="page">
<div class="page-content">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="page-header">
<div>
<h1 class="text-xl font-semibold text-white">Analyse des depenses</h1>
<p class="text-sm text-gray-400 mt-1">
<h1 class="page-title">Analyse des depenses</h1>
<p class="page-subtitle">
Visualisez et filtrez les depenses de vos immeubles
</p>
</div>

View File

@@ -1,114 +1,128 @@
<template>
<div class="flex-1 overflow-y-auto p-6">
<div class="max-w-4xl mx-auto space-y-6">
<div class="page">
<div class="page-content">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-white">Configuration</h1>
<p class="text-gray-400 text-sm mt-1">Paramètres LLM et gestion des tags</p>
<div class="page-header">
<div>
<h1 class="page-title">Configuration</h1>
<p class="page-subtitle">Paramètres LLM et gestion des tags</p>
</div>
</div>
<!-- Section LLM -->
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">Configuration LLM (Ollama)</h2>
<!-- Les deux sections cote a cote des que la largeur le permet -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 items-start">
<div class="space-y-4">
<div v-for="field in settingsFields" :key="field.key" class="flex items-end gap-3">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<label class="text-sm font-medium text-gray-300">{{ field.label }}</label>
<span
class="text-xs px-1.5 py-0.5 rounded"
:class="sourceBadgeClass(settingsSources[field.key])"
>{{ settingsSources[field.key] || 'default' }}</span>
<!-- Section LLM -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Configuration LLM (Ollama)</h2>
</div>
<div class="card-body space-y-4">
<div v-for="field in settingsFields" :key="field.key" class="flex items-end gap-3">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<label class="form-label mb-0">{{ field.label }}</label>
<span class="badge" :class="sourceBadgeClass(settingsSources[field.key])">
{{ settingsSources[field.key] || 'default' }}
</span>
</div>
<input
v-model="settingsForm[field.key]"
:type="field.type || 'text'"
:placeholder="field.placeholder"
class="input"
/>
</div>
<input
v-model="settingsForm[field.key]"
:type="field.type || 'text'"
:placeholder="field.placeholder"
class="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 transition-colors"
/>
<button
@click="resetSetting(field.key)"
class="btn btn-secondary"
title="Reset au défaut"
>Reset</button>
</div>
<div class="flex items-center gap-3 pt-2">
<button
@click="saveSettings"
:disabled="savingSettings"
class="btn btn-primary"
>{{ savingSettings ? 'Enregistrement...' : 'Enregistrer' }}</button>
<span v-if="settingsMessage" class="text-sm" :class="settingsMessageError ? 'text-red-400' : 'text-green-400'">{{ settingsMessage }}</span>
</div>
<button
@click="resetSetting(field.key)"
class="px-3 py-2 text-sm text-gray-400 border border-gray-600 rounded-lg hover:text-white hover:border-gray-500 transition-colors"
title="Reset au défaut"
>Reset</button>
</div>
</div>
<div class="mt-4 flex items-center gap-3">
<button
@click="saveSettings"
:disabled="savingSettings"
class="px-5 py-2 bg-blue-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 hover:bg-blue-700"
>{{ savingSettings ? 'Enregistrement...' : 'Enregistrer' }}</button>
<span v-if="settingsMessage" class="text-sm" :class="settingsMessageError ? 'text-red-400' : 'text-green-400'">{{ settingsMessage }}</span>
<!-- Section Tags -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Gestion des tags</h2>
<span class="text-xs text-gray-500">{{ tags.length }} tags</span>
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th class="w-16">ID</th>
<th>Nom</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="tag in tags" :key="tag.id">
<td class="text-gray-500">{{ tag.id }}</td>
<td>
<input
v-if="editingTagId === tag.id"
v-model="editingTagNom"
class="input py-1"
@keydown.enter="confirmRenameTag(tag.id)"
@keydown.escape="cancelEditTag"
ref="editInput"
/>
<span v-else class="text-white">{{ tag.nom }}</span>
</td>
<td class="text-right whitespace-nowrap">
<template v-if="editingTagId === tag.id">
<button
@click="confirmRenameTag(tag.id)"
class="text-green-400 hover:text-green-300 text-sm mr-2"
>Valider</button>
<button
@click="cancelEditTag"
class="text-gray-400 hover:text-white text-sm"
>Annuler</button>
</template>
<button
v-else
@click="startEditTag(tag)"
class="text-blue-400 hover:text-blue-300 text-sm"
>Renommer</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Ajout d'un nouveau tag -->
<div class="card-body border-t border-gray-700">
<div class="flex gap-3">
<input
v-model="newTagNom"
type="text"
placeholder="Nouveau tag..."
class="input"
@keydown.enter="createTag"
/>
<button
@click="createTag"
:disabled="!newTagNom.trim()"
class="btn btn-primary whitespace-nowrap"
>Ajouter</button>
</div>
<p v-if="tagMessage" class="mt-2 text-sm" :class="tagMessageError ? 'text-red-400' : 'text-green-400'">{{ tagMessage }}</p>
</div>
</div>
</div>
<!-- Section Tags -->
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">Gestion des tags</h2>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-700">
<th class="text-left py-2 text-gray-400 font-medium">ID</th>
<th class="text-left py-2 text-gray-400 font-medium">Nom</th>
<th class="text-right py-2 text-gray-400 font-medium">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="tag in tags" :key="tag.id" class="border-b border-gray-700/50">
<td class="py-2 text-gray-500">{{ tag.id }}</td>
<td class="py-2">
<input
v-if="editingTagId === tag.id"
v-model="editingTagNom"
class="bg-gray-900 border border-blue-500 rounded px-2 py-1 text-white text-sm focus:outline-none"
@keydown.enter="confirmRenameTag(tag.id)"
@keydown.escape="cancelEditTag"
ref="editInput"
/>
<span v-else class="text-white">{{ tag.nom }}</span>
</td>
<td class="py-2 text-right">
<template v-if="editingTagId === tag.id">
<button
@click="confirmRenameTag(tag.id)"
class="text-green-400 hover:text-green-300 text-sm mr-2"
>Valider</button>
<button
@click="cancelEditTag"
class="text-gray-400 hover:text-gray-300 text-sm"
>Annuler</button>
</template>
<button
v-else
@click="startEditTag(tag)"
class="text-blue-400 hover:text-blue-300 text-sm"
>Renommer</button>
</td>
</tr>
</tbody>
</table>
<!-- Ajout d'un nouveau tag -->
<div class="mt-4 flex gap-3">
<input
v-model="newTagNom"
type="text"
placeholder="Nouveau tag..."
class="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 transition-colors text-sm"
@keydown.enter="createTag"
/>
<button
@click="createTag"
:disabled="!newTagNom.trim()"
class="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 hover:bg-green-700"
>Ajouter</button>
</div>
<p v-if="tagMessage" class="mt-2 text-sm" :class="tagMessageError ? 'text-red-400' : 'text-green-400'">{{ tagMessage }}</p>
</div>
</div>
</div>
@@ -187,9 +201,9 @@ async function resetSetting(key) {
}
function sourceBadgeClass(source) {
if (source === 'database') return 'bg-blue-600/30 text-blue-400'
if (source === 'env') return 'bg-yellow-600/30 text-yellow-400'
return 'bg-gray-600/30 text-gray-400'
if (source === 'database') return 'badge-info'
if (source === 'env') return 'badge-warning'
return 'badge-neutral'
}
// ============================================================

View File

@@ -1,19 +1,19 @@
<template>
<div class="flex-1 overflow-auto p-6 bg-gray-800">
<div class="max-w-6xl mx-auto space-y-6">
<div class="page">
<div class="page-content">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="page-header">
<div>
<h1 class="text-xl font-semibold text-white">Documents importes</h1>
<p class="text-sm text-gray-400 mt-1">
<h1 class="page-title">Documents importes</h1>
<p class="page-subtitle">
{{ documents.length }} document{{ documents.length > 1 ? 's' : '' }} en base
</p>
</div>
<!-- Import button -->
<label class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors cursor-pointer flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<label class="btn btn-primary cursor-pointer">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Importer un PDF
@@ -27,13 +27,13 @@
</div>
<!-- Loading state -->
<div v-if="isLoading" class="bg-gray-900 rounded-lg p-12 text-center">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-2 border-gray-600 border-t-blue-400"></div>
<div v-if="isLoading" class="card empty-state">
<div class="spinner h-8 w-8"></div>
<p class="text-gray-400 mt-4">Chargement des documents...</p>
</div>
<!-- Empty state -->
<div v-else-if="documents.length === 0" class="bg-gray-900 rounded-lg p-12 text-center">
<div v-else-if="documents.length === 0" class="card empty-state">
<svg class="mx-auto h-16 w-16 text-gray-600 mb-4" 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>
@@ -42,26 +42,26 @@
</div>
<!-- Documents table -->
<div v-else class="bg-gray-900 rounded-lg overflow-hidden border border-gray-700">
<table class="w-full">
<thead class="bg-gray-800 border-b border-gray-700">
<div v-else class="card">
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Document</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Immeuble</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Date</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider">Solde</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">Fichiers</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider">Actions</th>
<th>Document</th>
<th>Immeuble</th>
<th>Date</th>
<th class="text-right">Solde</th>
<th class="text-center">Fichiers</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800">
<tbody>
<tr
v-for="doc in documents"
:key="doc.id"
class="hover:bg-gray-800/50 transition-colors"
>
<!-- Document info -->
<td class="px-4 py-4">
<td>
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 24 24">
@@ -77,19 +77,19 @@
</td>
<!-- Immeuble -->
<td class="px-4 py-4">
<td>
<div class="text-sm text-white">{{ doc.immeuble_adresse || '-' }}</div>
<div class="text-xs text-gray-500 font-mono">{{ doc.immeuble_code }}</div>
</td>
<!-- Date -->
<td class="px-4 py-4">
<td>
<div class="text-sm text-white">{{ formatDate(doc.date) }}</div>
<div class="text-xs text-gray-500">Importe le {{ formatDateTime(doc.created_at) }}</div>
</td>
<!-- Solde -->
<td class="px-4 py-4 text-right">
<td class="text-right">
<span
:class="[
'text-sm font-medium',
@@ -102,7 +102,7 @@
</td>
<!-- Files status -->
<td class="px-4 py-4">
<td>
<div class="flex items-center justify-center gap-2">
<!-- PDF download -->
<a
@@ -143,12 +143,12 @@
</td>
<!-- Actions -->
<td class="px-4 py-4 text-right">
<td class="text-right">
<div class="flex items-center justify-end gap-2">
<!-- Edit & reimport button -->
<button
@click="router.push(`/documents/${doc.id}/edit`)"
class="px-3 py-1.5 text-xs rounded bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white transition-colors flex items-center gap-1"
class="btn btn-sm btn-secondary"
title="Editer et reimporter"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -161,6 +161,7 @@
</tr>
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -1,27 +1,29 @@
<template>
<div class="flex-1 flex flex-col overflow-hidden">
<div class="page-fixed">
<!-- Header avec breadcrumb et actions -->
<div class="flex-shrink-0 px-6 py-4 bg-gray-800 border-b border-gray-700">
<div class="flex items-center justify-between">
<div class="flex-shrink-0 px-6 py-4 bg-gray-900 border-b border-gray-700">
<div class="page-header">
<div>
<div class="flex items-center gap-2 text-sm text-gray-400 mb-1">
<router-link to="/documents" class="hover:text-white transition-colors">Documents</router-link>
<span></span>
<span class="text-white">{{ documentData?.reference || 'Chargement...' }}</span>
</div>
<h1 class="text-xl font-semibold text-white">
<h1 class="page-title">
Édition du document {{ documentData?.reference }}
</h1>
<p v-if="documentData" class="text-sm text-gray-400 mt-1">
<p v-if="documentData" class="page-subtitle">
{{ documentData.immeuble?.adresse || '-' }} - {{ formatDate(documentData.date) }}
</p>
</div>
<div class="flex items-center gap-3">
<!-- Masque pendant le tagging : celui du bandeau de tagging fait foi. -->
<button
v-if="!showTagging"
@click="cancel"
:disabled="isSaving"
class="px-4 py-2 text-sm text-gray-400 hover:text-white transition-colors disabled:opacity-50"
class="btn btn-ghost"
>
Annuler
</button>
@@ -30,7 +32,7 @@
@click="reExtract"
:disabled="isSaving || isReExtracting || !documentData?.has_pdf"
:title="documentData?.has_pdf ? 'Relancer l\'extraction depuis le PDF stocké' : 'PDF non disponible pour ce document'"
class="px-4 py-2 text-sm bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors disabled:opacity-50 flex items-center gap-2"
class="btn btn-warning"
>
<svg
class="w-4 h-4"
@@ -46,7 +48,7 @@
v-if="!showTagging && extractedData"
@click="goToTagging"
:disabled="isSaving"
class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2"
class="btn btn-primary"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
@@ -58,9 +60,9 @@
</div>
<!-- Loading state -->
<div v-if="isLoading" class="flex-1 flex items-center justify-center bg-gray-800">
<div v-if="isLoading" class="flex-1 flex items-center justify-center bg-gray-950">
<div class="text-center">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-4 border-gray-600 border-t-blue-400 mb-4"></div>
<div class="spinner h-12 w-12 mb-4"></div>
<p class="text-gray-400">Chargement du document...</p>
</div>
</div>
@@ -68,9 +70,9 @@
<!-- Corps : Split view PDF + Données -->
<div v-else class="flex-1 flex overflow-hidden">
<!-- Gauche : PDF Preview -->
<div class="w-1/2 border-r border-gray-700 flex flex-col">
<div class="flex-shrink-0 px-4 py-2 bg-gray-700/50 border-b border-gray-700">
<span class="text-sm text-gray-300">Aperçu PDF</span>
<div class="w-2/5 border-r border-gray-700 flex flex-col">
<div class="flex-shrink-0 px-4 py-2 bg-gray-900 border-b border-gray-700">
<span class="card-title">Aperçu PDF</span>
</div>
<div class="flex-1 overflow-hidden">
<PdfPreview
@@ -79,7 +81,7 @@
:file-name="documentData?.source_file || 'document.pdf'"
class="h-full"
/>
<div v-else class="h-full flex items-center justify-center bg-gray-800 text-gray-500">
<div v-else class="h-full flex items-center justify-center bg-gray-950 text-gray-500">
<div class="text-center">
<svg class="mx-auto h-16 w-16 text-gray-600 mb-4" 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" />
@@ -92,9 +94,9 @@
</div>
<!-- Droite : Édition ou Tagging -->
<div class="w-1/2 flex flex-col bg-gray-50">
<div class="w-3/5 flex flex-col bg-gray-950">
<!-- Bandeau erreur de re-extraction -->
<div v-if="reExtractError" class="flex-shrink-0 px-4 py-2 bg-red-500/10 border-b border-red-500/30 text-sm text-red-600">
<div v-if="reExtractError" class="flex-shrink-0 px-4 py-2 bg-red-500/10 border-b border-red-500/30 text-sm text-red-400">
{{ reExtractError }}
</div>

View File

@@ -1,12 +1,12 @@
<template>
<div class="flex-1 flex overflow-hidden">
<!-- Left panel: Upload or PDF Preview -->
<div class="w-1/2 flex flex-col border-r border-gray-700">
<div class="w-2/5 flex flex-col border-r border-gray-700">
<!-- Upload zone when no file -->
<div v-if="!pdfFile" class="flex-1 flex items-center justify-center p-8 bg-gray-800">
<div v-if="!pdfFile" class="flex-1 flex items-center justify-center p-8 bg-gray-950">
<div class="max-w-md w-full">
<div
class="border-2 border-dashed border-gray-600 rounded-lg p-8 text-center hover:border-blue-500 transition-colors cursor-pointer bg-gray-900/50"
class="border-2 border-dashed border-gray-700 rounded-lg p-8 text-center hover:border-blue-500 transition-colors cursor-pointer bg-gray-900/50"
@click="triggerFileInput"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@@ -39,22 +39,22 @@
</div>
<!-- Right panel: JSON Viewer or Tagging Step -->
<div class="w-1/2 flex flex-col bg-gray-50">
<div class="w-3/5 flex flex-col bg-gray-950">
<!-- Extract button bar -->
<div v-if="pdfFile && !extractedData && !isExtracting && !showTagging" class="flex-shrink-0 p-4 bg-white border-b border-gray-200">
<div v-if="pdfFile && !extractedData && !isExtracting && !showTagging" class="flex-shrink-0 p-4 bg-gray-900 border-b border-gray-700">
<button
@click="extractCurrentFile"
class="w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
class="btn btn-primary w-full py-2.5"
>
Extraire les donnees du PDF
</button>
</div>
<!-- Continue to tagging button (when data is extracted) -->
<div v-if="extractedData && !isExtracting && !showTagging" class="flex-shrink-0 p-4 bg-white border-b border-gray-200">
<div v-if="extractedData && !isExtracting && !showTagging" class="flex-shrink-0 p-4 bg-gray-900 border-b border-gray-700">
<!-- Duplicate warning -->
<div v-if="saveMessage && saveMessage.type === 'duplicate'" class="mb-3">
<div class="px-4 py-3 rounded-lg text-sm bg-yellow-100 text-yellow-800 border border-yellow-200">
<div class="px-4 py-3 rounded-lg text-sm bg-amber-500/10 text-amber-400 border border-amber-500/30">
<div class="font-medium flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
@@ -67,7 +67,7 @@
<button
@click="goToTagging"
class="w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
class="btn btn-primary w-full py-2.5"
>
Continuer vers le tagging
</button>
@@ -84,13 +84,13 @@
/>
<!-- Save feedback message -->
<div v-if="saveMessage && showTagging" class="flex-shrink-0 p-4 bg-white border-t border-gray-200">
<div v-if="saveMessage && showTagging" class="flex-shrink-0 p-4 bg-gray-900 border-t border-gray-700">
<div
:class="[
'px-4 py-3 rounded-lg text-sm',
saveMessage.type === 'success' ? 'bg-green-100 text-green-800 border border-green-200' : '',
saveMessage.type === 'duplicate' ? 'bg-yellow-100 text-yellow-800 border border-yellow-200' : '',
saveMessage.type === 'error' ? 'bg-red-100 text-red-800 border border-red-200' : ''
'px-4 py-3 rounded-lg text-sm border',
saveMessage.type === 'success' ? 'bg-green-500/10 text-green-400 border-green-500/30' : '',
saveMessage.type === 'duplicate' ? 'bg-amber-500/10 text-amber-400 border-amber-500/30' : '',
saveMessage.type === 'error' ? 'bg-red-500/10 text-red-400 border-red-500/30' : ''
]"
>
<div class="font-medium">{{ saveMessage.title }}</div>
@@ -99,7 +99,7 @@
</div>
<!-- Placeholder when no file -->
<div v-if="!pdfFile && !showTagging" class="flex-1 flex items-center justify-center text-gray-400">
<div v-if="!pdfFile && !showTagging" class="flex-1 flex items-center justify-center text-gray-500">
<p>Selectionnez un PDF pour commencer</p>
</div>

View File

@@ -1,10 +1,10 @@
<template>
<div class="flex-1 overflow-auto p-6 bg-gray-800">
<div class="max-w-6xl mx-auto space-y-6">
<div class="page">
<div class="page-content">
<!-- Zone d'upload PDF visible -->
<div
class="border-2 border-dashed border-gray-600 rounded-lg p-6 text-center hover:border-blue-500 transition-colors cursor-pointer bg-gray-900/50"
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"
@click="triggerFileInput"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@@ -54,14 +54,14 @@
/>
<!-- Derniers documents -->
<div id="recent-documents" class="bg-gray-900 rounded-lg overflow-hidden border border-gray-700">
<div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
<h2 class="text-sm font-medium text-gray-300">Derniers documents importes</h2>
<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>
<div v-if="isLoadingDocs" class="p-8 text-center text-gray-500">
<div class="inline-block animate-spin rounded-full h-6 w-6 border-2 border-gray-600 border-t-blue-400"></div>
<div class="spinner h-6 w-6"></div>
</div>
<div v-else-if="documents.length === 0" class="p-8 text-center text-gray-500">

View File

@@ -1,11 +1,11 @@
<template>
<div class="flex-1 overflow-y-auto p-6">
<div class="max-w-5xl mx-auto space-y-6">
<div class="page">
<div class="page-content">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="page-header">
<div>
<h1 class="text-2xl font-bold text-white">Assistant IA</h1>
<p class="text-gray-400 text-sm mt-1">Posez des questions sur vos données en langage naturel</p>
<h1 class="page-title">Assistant IA</h1>
<p class="page-subtitle">Posez des questions sur vos données en langage naturel</p>
</div>
<div class="flex items-center gap-2">
<span
@@ -19,19 +19,19 @@
</div>
<!-- Formulaire -->
<div class="bg-gray-800 rounded-lg border border-gray-700 p-4">
<div class="card card-body">
<div class="flex gap-3">
<input
v-model="question"
type="text"
placeholder="Posez votre question..."
class="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 transition-colors"
class="input"
@keydown.enter="askQuestion"
:disabled="loading"
/>
<select
v-model="chartType"
class="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-blue-500 transition-colors"
class="input w-auto"
:disabled="loading"
>
<option value="auto">Auto</option>
@@ -43,7 +43,7 @@
<button
@click="askQuestion"
:disabled="!question.trim() || loading"
class="px-5 py-2.5 bg-blue-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed hover:bg-blue-700"
class="btn btn-primary whitespace-nowrap"
>
{{ loading ? 'Analyse...' : 'Demander' }}
</button>
@@ -51,14 +51,14 @@
</div>
<!-- Exemples de questions -->
<div v-if="!result && !loading && !error" class="bg-gray-800 rounded-lg border border-gray-700 p-4">
<div v-if="!result && !loading && !error" class="card card-body">
<p class="text-gray-400 text-sm mb-3">Exemples de questions :</p>
<div class="flex flex-wrap gap-2">
<button
v-for="example in examples"
:key="example"
@click="question = example; askQuestion()"
class="px-3 py-1.5 text-sm bg-gray-900 border border-gray-700 rounded-lg text-gray-300 hover:border-blue-500 hover:text-white transition-colors"
class="btn btn-secondary"
>
{{ example }}
</button>
@@ -66,8 +66,8 @@
</div>
<!-- Spinner -->
<div v-if="loading" class="bg-gray-800 rounded-lg border border-gray-700 p-8 text-center">
<div class="inline-block w-8 h-8 border-2 border-gray-600 border-t-blue-500 rounded-full animate-spin mb-3"></div>
<div v-if="loading" class="card empty-state">
<div class="spinner w-8 h-8 mb-3"></div>
<p class="text-gray-400">{{ loadingMessage }}</p>
</div>
@@ -77,23 +77,33 @@
</div>
<!-- Résultat -->
<div v-if="result" class="space-y-4">
<div v-if="result" class="space-y-6">
<!-- Réponse texte -->
<div class="bg-gray-800 rounded-lg border border-gray-700 p-4">
<h2 class="text-sm font-medium text-gray-400 mb-2">Réponse</h2>
<p class="text-white whitespace-pre-wrap">{{ result.answer }}</p>
<div class="card">
<div class="card-header">
<h2 class="card-title">Réponse</h2>
</div>
<div class="card-body">
<p class="text-white whitespace-pre-wrap">{{ result.answer }}</p>
</div>
</div>
<!-- Requête SQL -->
<div v-if="result.sql_query" class="bg-gray-800 rounded-lg border border-gray-700 p-4">
<h2 class="text-sm font-medium text-gray-400 mb-2">Requête SQL exécutée</h2>
<pre class="text-xs text-gray-300 font-mono bg-gray-900 rounded p-3 overflow-x-auto">{{ result.sql_query }}</pre>
<div v-if="result.sql_query" class="card">
<div class="card-header">
<h2 class="card-title">Requête SQL exécutée</h2>
</div>
<div class="card-body">
<pre class="text-xs text-gray-300 font-mono bg-gray-950 border border-gray-700 rounded p-3 overflow-x-auto">{{ result.sql_query }}</pre>
</div>
</div>
<!-- Graphique -->
<div v-if="result.chart_config && chartComponent" class="bg-gray-800 rounded-lg border border-gray-700 p-4">
<h2 class="text-sm font-medium text-gray-400 mb-3">Graphique</h2>
<div class="bg-gray-900 rounded-lg p-4">
<div v-if="result.chart_config && chartComponent" class="card">
<div class="card-header">
<h2 class="card-title">Graphique</h2>
</div>
<div class="card-body">
<component
:is="chartComponent"
:data="result.chart_config.data"
@@ -103,34 +113,22 @@
</div>
<!-- Tableau de données -->
<div v-if="result.data && result.data.columns && result.data.rows && result.data.rows.length > 0" class="bg-gray-800 rounded-lg border border-gray-700 p-4">
<h2 class="text-sm font-medium text-gray-400 mb-3">
Données ({{ result.data.row_count }} ligne{{ result.data.row_count > 1 ? 's' : '' }})
</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<div v-if="result.data && result.data.columns && result.data.rows && result.data.rows.length > 0" class="card">
<div class="card-header">
<h2 class="card-title">
Données ({{ result.data.row_count }} ligne{{ result.data.row_count > 1 ? 's' : '' }})
</h2>
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr class="border-b border-gray-700">
<th
v-for="col in result.data.columns"
:key="col"
class="text-left px-3 py-2 text-gray-400 font-medium"
>
{{ col }}
</th>
<tr>
<th v-for="col in result.data.columns" :key="col">{{ col }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, idx) in result.data.rows"
:key="idx"
class="border-b border-gray-700/50 hover:bg-gray-700/30"
>
<td
v-for="(cell, cidx) in row"
:key="cidx"
class="px-3 py-2 text-gray-300"
>
<tr v-for="(row, idx) in result.data.rows" :key="idx">
<td v-for="(cell, cidx) in row" :key="cidx">
{{ formatCell(cell) }}
</td>
</tr>

View File

@@ -1,12 +1,12 @@
<template>
<div class="flex-1 overflow-auto p-6 bg-gray-800">
<div class="max-w-7xl mx-auto space-y-6">
<div class="page">
<div class="page-content">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="page-header">
<div>
<h1 class="text-xl font-semibold text-white">Dashboard Revenus Locatifs</h1>
<p class="text-sm text-gray-400 mt-1">
<h1 class="page-title">Dashboard Revenus Locatifs</h1>
<p class="page-subtitle">
Suivi et analyse des loyers, reglements et impayes
</p>
</div>
@@ -17,7 +17,7 @@
<button
@click="loadData"
:disabled="isLoading"
class="px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors disabled:opacity-50"
class="btn btn-primary"
>
<span v-if="isLoading">Chargement...</span>
<span v-else>Actualiser</span>

View File

@@ -2,22 +2,204 @@
@tailwind components;
@tailwind utilities;
/* Custom scrollbar for JSON viewer */
/*
* Design system : une seule palette sombre pour toute l'application.
*
* gray-950 fond de page gray-900 surfaces (cartes, header)
* gray-800 sous-surfaces gray-700 bordures
* white / gray-400 / gray-500 textes principal / secondaire / tertiaire
* blue-600 accent
*/
@layer components {
/* --- Structure de page ------------------------------------------------ */
/* Conteneur racine d'une page : pleine largeur, marge laterale fixe. */
.page {
@apply flex-1 overflow-auto bg-gray-950 px-6 py-6;
}
/* Page sans defilement propre (split view type edition). */
.page-fixed {
@apply flex-1 flex flex-col overflow-hidden bg-gray-950;
}
.page-content {
@apply space-y-6;
}
.page-header {
@apply flex items-start justify-between gap-4;
}
.page-title {
@apply text-xl font-semibold text-white;
}
.page-subtitle {
@apply text-sm text-gray-400 mt-1;
}
/* --- Cartes ----------------------------------------------------------- */
.card {
@apply bg-gray-900 border border-gray-700 rounded-lg overflow-hidden;
}
.card-header {
@apply px-4 py-3 border-b border-gray-700 flex items-center justify-between gap-3;
}
.card-title {
@apply text-sm font-medium text-gray-300;
}
.card-body {
@apply p-4;
}
/* Bloc secondaire a l'interieur d'une carte. */
.subcard {
@apply bg-gray-800 border border-gray-700 rounded-lg;
}
/* --- Boutons ---------------------------------------------------------- */
.btn {
@apply inline-flex items-center justify-center gap-2 px-3 py-1.5 text-sm rounded-lg
transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply bg-blue-600 text-white hover:bg-blue-700;
}
.btn-secondary {
@apply bg-gray-800 text-gray-300 border border-gray-700 hover:bg-gray-700 hover:text-white;
}
.btn-ghost {
@apply text-gray-400 hover:text-white hover:bg-gray-800;
}
.btn-warning {
@apply bg-amber-600 text-white hover:bg-amber-700;
}
.btn-danger {
@apply bg-red-600 text-white hover:bg-red-700;
}
/* Variante compacte pour les actions dans les listes et tableaux. */
.btn-sm {
@apply px-2 py-1 text-xs gap-1.5;
}
/* --- Formulaires ------------------------------------------------------ */
.form-label {
@apply block text-sm font-medium text-gray-300 mb-1;
}
/* [color-scheme:dark] : les widgets natifs (calendrier, spinners) suivent le theme. */
.input {
@apply w-full bg-gray-950 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white
placeholder-gray-500 focus:outline-none focus:border-blue-500 transition-colors
[color-scheme:dark];
}
select.input {
@apply cursor-pointer;
}
.form-hint {
@apply text-xs text-gray-500 mt-1;
}
/* --- Tableaux --------------------------------------------------------- */
/* Enveloppe a poser autour d'un <table> pour le defilement horizontal. */
.table-wrap {
@apply overflow-x-auto;
}
.table {
@apply w-full text-sm;
}
.table thead {
@apply bg-gray-800 border-b border-gray-700;
}
.table th {
@apply px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider whitespace-nowrap;
}
.table tbody {
@apply divide-y divide-gray-800;
}
.table td {
@apply px-4 py-3 text-gray-300;
}
.table tbody tr {
@apply hover:bg-gray-800/50 transition-colors;
}
/* --- Badges ----------------------------------------------------------- */
.badge {
@apply inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium;
}
.badge-neutral {
@apply bg-gray-700 text-gray-300;
}
.badge-info {
@apply bg-blue-500/15 text-blue-400;
}
.badge-success {
@apply bg-green-500/15 text-green-400;
}
.badge-warning {
@apply bg-amber-500/15 text-amber-400;
}
.badge-danger {
@apply bg-red-500/15 text-red-400;
}
/* --- Etats ------------------------------------------------------------ */
.empty-state {
@apply p-12 text-center text-gray-500;
}
.spinner {
@apply inline-block animate-spin rounded-full border-2 border-gray-700 border-t-blue-400;
}
}
/* Barres de defilement accordees au theme sombre. */
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #f1f5f9;
background: #111827; /* gray-900 */
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #cbd5e1;
background: #374151; /* gray-700 */
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
background: #4b5563; /* gray-600 */
}

View File

@@ -8,6 +8,11 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from ..utils.amounts import parse_amount
from ..utils.lots import (
LOT_NUMERO_INCONNU,
normalize_extraction_lots,
normalize_lot_numero,
)
from . import storage
from .models import Depense, Document, Immeuble, Locataire, Lot, Revenu, Tag
@@ -55,7 +60,12 @@ class DatabaseService:
def get_or_create_lot(
self, immeuble_id: int, numero: str, lot_type: str = None
) -> Lot:
"""Get existing lot or create new one."""
"""Get existing lot or create new one.
Le numero est normalise sur 2 chiffres pour qu'un meme lot saisi "6",
"06" ou "0006" ne soit pas duplique.
"""
numero = normalize_lot_numero(numero) or LOT_NUMERO_INCONNU
stmt = select(Lot).where(Lot.immeuble_id == immeuble_id, Lot.numero == numero)
lot = self.session.execute(stmt).scalar_one_or_none()
@@ -134,6 +144,9 @@ class DatabaseService:
Raises:
DuplicateDocumentError: If document already exists and overwrite=False
"""
# Uniformiser les numéros de lot avant toute persistance (JSON + tables)
normalize_extraction_lots(data)
metadata = data.get("metadata", {})
doc_info = metadata.get("document", {})
immeuble_info = metadata.get("immeuble", {})
@@ -247,7 +260,7 @@ class DatabaseService:
# Get or create lot
lot = self.get_or_create_lot(
immeuble_id=immeuble.id,
numero=lot_info.get("numero", "0000"),
numero=lot_info.get("numero") or LOT_NUMERO_INCONNU,
lot_type=lot_info.get("type"),
)

View File

@@ -4,6 +4,7 @@ import re
from ..utils.amounts import extract_amounts_from_line
from ..utils.dates import parse_french_date
from ..utils.lots import normalize_lot_numero
def _preprocess_locataires_text(text: str) -> str:
@@ -84,7 +85,7 @@ def extract_situation_locataires(text: str) -> list[dict]:
Returns:
Liste des situations par lot, chacune contenant:
- lot: numéro et type
- lot: numéro (2 chiffres) et type
- locataire: nom
- lignes: détail des loyers, charges, etc.
- totaux: sommes par catégorie
@@ -112,14 +113,14 @@ def extract_situation_locataires(text: str) -> list[dict]:
# Nouveau lot (peut avoir les données de loyer sur la même ligne)
lot_match = re.match(
r"Lot\s+(\d{4})\s+(Loc\.\s*Commercial|Appartement\s+T\d|Studio|Garage|Cave|Parking)",
r"Lot\s+(\d{1,4})\s+(Loc\.\s*Commercial|Appartement\s+T\d|Studio|Garage|Cave|Parking)",
line_stripped,
)
if lot_match:
if current_lot:
situations.append(current_lot)
lot_num = lot_match.group(1)
lot_num = normalize_lot_numero(lot_match.group(1))
lot_type = lot_match.group(2)
current_lot = {

View File

@@ -26,12 +26,13 @@ import pdfplumber
from ..utils.amounts import extract_amounts_from_line
from ..utils.dates import parse_french_date
from ..utils.lots import normalize_lot_numero
# Tolérance verticale (points PDF) pour regrouper les mots d'une même ligne visuelle.
_Y_TOL = 3.0
_LOT_RE = re.compile(
r"^Lot\s+(\d{4})\s+"
r"^Lot\s+(\d{1,4})\s+"
r"(Loc\.\s*Commercial|Appartement\s+T\d|Studio|Garage|Cave|Parking)"
)
_PERIODE_RE = re.compile(r"Du\s+\d{2}\.\d{2}\.\d{2}\s+Au\s+(\d{2}\.\d{2}\.\d{2})")
@@ -149,7 +150,7 @@ def _rows_from_page(page) -> list[dict]:
def _new_lot(numero: str, lot_type: str) -> dict:
return {
"lot": {"numero": numero, "type": lot_type},
"lot": {"numero": normalize_lot_numero(numero), "type": lot_type},
"locataire": {"nom": ""},
"lignes": [],
"totaux": {

View File

@@ -3,6 +3,7 @@
import re
from ..utils.amounts import parse_amount
from ..utils.lots import normalize_lot_numero
def _extract_lot_code_from_description(description: str) -> str | None:
@@ -10,18 +11,18 @@ def _extract_lot_code_from_description(description: str) -> str | None:
Les codes lots suivent le format: {Lettre}{Numéro} où:
- La lettre identifie l'immeuble (M=Marietton, S=Servient, B=Bloch, etc.)
- Le numéro correspond au lot (ex: 06 -> lot 0006)
- Le numéro correspond au lot (ex: 06 -> lot 06)
Exemples:
- "M06 - Commande moteur pompe" -> "0006"
- "S05 - Mise en service" -> "0005"
- "B01 - Plaques" -> "0001"
- "M06 - Commande moteur pompe" -> "06"
- "S05 - Mise en service" -> "05"
- "B01 - Plaques" -> "01"
Args:
description: Description de l'opération
Returns:
Code lot au format 4 chiffres (ex: "0006") ou None si non trouvé
Code lot au format 2 chiffres (ex: "06") ou None si non trouvé
"""
if not description:
return None
@@ -31,9 +32,7 @@ def _extract_lot_code_from_description(description: str) -> str | None:
# code) et "S01 SOLDE ..." (code lot non suivi d'un tiret).
match = re.search(r"\b[A-Z]\s*(\d{1,2})(?=[\s-]|$)", description)
if match:
lot_num = match.group(1)
# Formater sur 4 chiffres (ex: "6" -> "0006", "12" -> "0012")
return lot_num.zfill(4)
return normalize_lot_numero(match.group(1))
return None

View File

@@ -132,7 +132,7 @@ Tables et colonnes :
3. **lots** — Lots (appartements, locaux commerciaux) au sein d'un immeuble
- id (INTEGER, PK)
- immeuble_id (INTEGER, FK → immeubles.id)
- numero (VARCHAR(10)) — numéro du lot dans l'immeuble
- numero (VARCHAR(10)) — numéro du lot dans l'immeuble, sur 2 chiffres ("01", "17")
- type (VARCHAR(50)) — type de lot ("Appartement", "Loc. Commercial", etc.)
- created_at (DATETIME)
Contrainte : (immeuble_id, numero) est unique

View File

@@ -0,0 +1,79 @@
"""Utilitaires pour la normalisation des numéros de lot."""
import re
from typing import Any
#: Largeur canonique d'un numéro de lot (ex: "06", "17").
LOT_NUMERO_WIDTH = 2
#: Numéro utilisé quand aucun lot n'est identifiable.
LOT_NUMERO_INCONNU = "00"
def normalize_lot_numero(value: str | int | None) -> str | None:
"""Normalise un numéro de lot sur exactement 2 chiffres.
Les PDF et les saisies manuelles utilisent des formats variés ("6", "06",
"0006"). Tous doivent aboutir au même numéro canonique, sinon un même lot
est dupliqué en base.
Gère:
- "0006" -> "06"
- "6" -> "06"
- "06" -> "06"
- "0020" -> "20"
- "0000" -> "00"
Les numéros comportant plus de 2 chiffres significatifs (ex: "0123") sont
conservés sans leurs zéros de tête ("123") : les tronquer perdrait de
l'information.
Args:
value: Numéro de lot dans un format quelconque
Returns:
Numéro sur 2 chiffres, ou None si aucun chiffre n'est présent
"""
if value is None:
return None
digits = re.sub(r"\D", "", str(value))
if not digits:
return None
significant = digits.lstrip("0")
if not significant:
return LOT_NUMERO_INCONNU
return significant.zfill(LOT_NUMERO_WIDTH)
def normalize_extraction_lots(data: dict[str, Any]) -> dict[str, Any]:
"""Normalise, sur place, tous les numéros de lot d'une extraction.
Couvre les deux emplacements où un numéro de lot apparaît:
- ``situation_locataires[].lot.numero``
- ``recapitulatif_operations[].lot_numero``
Utile pour les extractions éditées à la main via l'API, où le numéro saisi
peut arriver sous n'importe quelle forme.
Args:
data: Extraction (metadata, situation_locataires, recapitulatif_operations)
Returns:
Le même dict, numéros de lot normalisés
"""
if not isinstance(data, dict):
return data
for situation in data.get("situation_locataires") or []:
lot = situation.get("lot")
if isinstance(lot, dict) and lot.get("numero") is not None:
lot["numero"] = normalize_lot_numero(lot["numero"])
for operation in data.get("recapitulatif_operations") or []:
if isinstance(operation, dict) and operation.get("lot_numero") is not None:
operation["lot_numero"] = normalize_lot_numero(operation["lot_numero"])
return data

View File

@@ -53,7 +53,7 @@ def sample_data():
},
"situation_locataires": [
{
"lot": {"numero": "001", "type": "Appartement"},
"lot": {"numero": "01", "type": "Appartement"},
"locataire": {"nom": "DUPONT"},
"lignes": [
{

View File

@@ -27,7 +27,7 @@ def test_save_document_creates_full_graph(db_session, sample_data):
# Immeuble / lot / locataire créés
assert db_session.query(Immeuble).filter_by(code="IMM1").count() == 1
assert db_session.query(Lot).filter_by(numero="001").count() == 1
assert db_session.query(Lot).filter_by(numero="01").count() == 1
assert db_session.query(Locataire).filter_by(nom="DUPONT").count() == 1
# Revenu et dépense rattachés

121
tests/test_lots.py Normal file
View File

@@ -0,0 +1,121 @@
"""Tests de la normalisation des numéros de lot."""
import pytest
from plesna_gerance.parsers.operations import _extract_lot_code_from_description
from plesna_gerance.utils.lots import normalize_extraction_lots, normalize_lot_numero
@pytest.mark.parametrize(
"raw,expected",
[
("0006", "06"),
("06", "06"),
("6", "06"),
(6, "06"),
("0020", "20"),
("20", "20"),
("Lot 6", "06"),
(" 7 ", "07"),
("0000", "00"),
("0", "00"),
# Plus de 2 chiffres significatifs : on ne tronque pas
("0123", "123"),
# Rien d'exploitable
(None, None),
("", None),
("abc", None),
],
)
def test_normalize_lot_numero(raw, expected):
assert normalize_lot_numero(raw) == expected
@pytest.mark.parametrize(
"description,expected",
[
("M06 - Commande moteur pompe", "06"),
("S05 - Mise en service", "05"),
("B01 - Plaques", "01"),
("S10 - Reparation", "10"),
("S 17 - Espace dans le code", "17"),
("S01 SOLDE LOCATAIRE", "01"),
("Nettoyage immeuble", None),
("", None),
],
)
def test_extract_lot_code_from_description(description, expected):
assert _extract_lot_code_from_description(description) == expected
def test_normalize_extraction_lots():
data = {
"situation_locataires": [
{"lot": {"numero": "0006", "type": "Appartement T3"}},
{"lot": {"numero": "7", "type": "Studio"}},
{"lot": {"numero": None, "type": "Garage"}},
],
"recapitulatif_operations": [
{"lot_numero": "0013"},
{"lot_numero": "1"},
{"lot_numero": None},
],
}
normalize_extraction_lots(data)
assert [s["lot"]["numero"] for s in data["situation_locataires"]] == [
"06",
"07",
None,
]
assert [o["lot_numero"] for o in data["recapitulatif_operations"]] == [
"13",
"01",
None,
]
def test_normalize_extraction_lots_tolerates_missing_sections():
# Une extraction partielle ne doit pas lever
assert normalize_extraction_lots({}) == {}
assert normalize_extraction_lots({"situation_locataires": None}) == {
"situation_locataires": None
}
def test_get_or_create_lot_deduplicates_formats(db_session):
""""0006", "06" et "6" désignent le même lot : un seul enregistrement."""
from plesna_gerance.database.models import Immeuble, Lot
from plesna_gerance.database.service import DatabaseService
service = DatabaseService(db_session)
immeuble = service.get_or_create_immeuble(code="IMM1")
lots = [
service.get_or_create_lot(immeuble_id=immeuble.id, numero=numero)
for numero in ("0006", "06", "6")
]
assert {lot.id for lot in lots} == {lots[0].id}
assert lots[0].numero == "06"
assert db_session.query(Lot).filter_by(immeuble_id=immeuble.id).count() == 1
assert db_session.query(Immeuble).count() == 1
def test_save_document_normalizes_lot_numero(db_session, sample_data):
from plesna_gerance.database.models import Lot
from plesna_gerance.database.service import DatabaseService
sample_data["situation_locataires"][0]["lot"]["numero"] = "0001"
sample_data["recapitulatif_operations"][0]["lot_numero"] = "1"
service = DatabaseService(db_session)
doc = service.save_document(data=sample_data)
# Le lot du locataire et celui de l'opération sont le même
assert db_session.query(Lot).count() == 1
assert db_session.query(Lot).first().numero == "01"
# Le JSON persisté est normalisé lui aussi
assert '"numero": "01"' in doc.json_data
assert '"lot_numero": "01"' in doc.json_data