Compare commits
No commits in common. "main" and "v0.6.0" have entirely different histories.
38 changed files with 528 additions and 1582 deletions
|
|
@ -67,11 +67,6 @@ jobs:
|
||||||
cp src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig release-assets/ 2>/dev/null || true
|
cp src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig release-assets/ 2>/dev/null || true
|
||||||
ls -la release-assets/
|
ls -la release-assets/
|
||||||
|
|
||||||
- name: Copy changelogs to public
|
|
||||||
run: |
|
|
||||||
cp CHANGELOG.md public/CHANGELOG.md
|
|
||||||
cp CHANGELOG.fr.md public/CHANGELOG.fr.md
|
|
||||||
|
|
||||||
- name: Extract changelog
|
- name: Extract changelog
|
||||||
id: changelog
|
id: changelog
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -45,9 +45,5 @@ imports/*.csv
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Auto-generated changelogs (synced from root by vite.config.ts)
|
|
||||||
public/CHANGELOG.md
|
|
||||||
public/CHANGELOG.fr.md
|
|
||||||
|
|
||||||
# Tauri generated
|
# Tauri generated
|
||||||
src-tauri/gen/
|
src-tauri/gen/
|
||||||
|
|
|
||||||
266
CHANGELOG.fr.md
266
CHANGELOG.fr.md
|
|
@ -1,266 +0,0 @@
|
||||||
# Journal des modifications
|
|
||||||
|
|
||||||
## [Non publié]
|
|
||||||
|
|
||||||
## [0.6.6]
|
|
||||||
|
|
||||||
### Modifié
|
|
||||||
- Tableau de budget : la colonne année précédente affiche maintenant le réel (transactions) au lieu du budget planifié (#34)
|
|
||||||
- Refactorisation de `buildPrevYearTotalMap` en inline et simplification des tests (#39)
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
- Fichiers changelog synchronisés automatiquement via plugin Vite, copies obsolètes dans public/ supprimées (#37)
|
|
||||||
|
|
||||||
## [0.6.5]
|
|
||||||
|
|
||||||
### Ajouté
|
|
||||||
- Tableau de bord : menu déroulant de sélection du mois pour la section Budget vs Réel avec le dernier mois complété par défaut (#31)
|
|
||||||
|
|
||||||
### Modifié
|
|
||||||
- Rapports et tableau de bord : police réduite dans le menu déroulant de mois pour un meilleur équilibre visuel (#31)
|
|
||||||
|
|
||||||
## [0.6.4]
|
|
||||||
|
|
||||||
### Ajouté
|
|
||||||
- Tableau de budget : colonne du total de l'année précédente affichée comme première colonne de données pour servir de référence (#16)
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
- Tableau de bord : les catégories de niveau 4+ apparaissent maintenant sous leur parent au lieu du bas de la section (#23)
|
|
||||||
- Tableau de bord : la hiérarchie de catégories supporte maintenant une profondeur de niveaux arbitraire (#23)
|
|
||||||
|
|
||||||
### Modifié
|
|
||||||
- Tableau de bord : le graphique circulaire prend 1/3 de la largeur au lieu de 1/2, donnant plus d'espace au tableau budget (#23)
|
|
||||||
- Tableau de bord : les étiquettes du graphique circulaire s'affichent uniquement au survol via le tooltip (#23)
|
|
||||||
- Budget vs Réel : la colonne des catégories reste désormais fixe lors du défilement horizontal (#29)
|
|
||||||
- Budget vs Réel : titre changé pour « Budget vs Réel pour le mois de [mois] » avec un menu déroulant pour sélectionner le mois (#29)
|
|
||||||
- Budget vs Réel : le mois par défaut est maintenant le dernier mois complété au lieu du mois courant (#29)
|
|
||||||
|
|
||||||
## [0.6.3]
|
|
||||||
|
|
||||||
### Ajouté
|
|
||||||
- Tableau de bord : histogramme empilé des dépenses par catégorie et par mois (#15)
|
|
||||||
- Tableau de bord : tableau budget vs réel du mois courant avec écart en $ et % (#15)
|
|
||||||
- Tableau de budget et rapport Budget vs Réel : formatage des sous-totaux de section avec poids visuel croissant (#14)
|
|
||||||
|
|
||||||
### Modifié
|
|
||||||
- Tableau de bord : période par défaut changée de « mois » à « année à ce jour » (#15)
|
|
||||||
- Tableau de bord : section des transactions récentes supprimée (#15)
|
|
||||||
- Tous les rapports tabulaires : les lignes de grand total utilisent maintenant une police plus grande (text-sm), gras et bordure supérieure plus épaisse pour une meilleure hiérarchie visuelle (#14)
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
- Rapport catégories dans le temps : toutes les catégories sont maintenant affichées (limite passée de 8 à 50) (#13)
|
|
||||||
- Graphique par catégorie : les noms sur l'axe Y utilisent maintenant la couleur du texte principal au lieu du gris (#13)
|
|
||||||
- Graphique catégories dans le temps : le texte de la légende utilise maintenant la couleur du texte principal au lieu d'hériter la couleur de la catégorie (#13)
|
|
||||||
|
|
||||||
## [0.6.2]
|
|
||||||
|
|
||||||
### Ajouté
|
|
||||||
- Tableau de budget : sous-totaux par section (dépenses, revenus, transferts) affichés après chaque groupe (#11)
|
|
||||||
- Rapport Budget vs Réel : sous-totaux par section avec réel, prévu, écart ($) et écart (%) par type (#11)
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
- Page catégories : le panneau de détail reste maintenant visible lors du défilement d'une longue liste de catégories (#12)
|
|
||||||
|
|
||||||
## [0.6.1]
|
|
||||||
|
|
||||||
### Ajouté
|
|
||||||
- Page historique des versions : historique complet accessible depuis les Paramètres à tout moment
|
|
||||||
- Changelog bilingue : les notes de version s'affichent dans la langue choisie par l'utilisateur (FR/EN)
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
- Visibilité des labels de graphiques : les montants sur les barres empilées utilisent maintenant du texte noir avec contour blanc pour un meilleur contraste (#8)
|
|
||||||
- Tableau de budget : les cellules éditables affichent maintenant un fond au survol, un curseur pointeur et une info-bulle pour clarifier l'interaction (#9)
|
|
||||||
|
|
||||||
## [0.6.0]
|
|
||||||
|
|
||||||
### Ajouté
|
|
||||||
- Rapports : bascule entre vue tableau et graphique pour les onglets Tendances, Par catégorie et Évolution
|
|
||||||
- Rapports : option « Afficher les montants » pour afficher les valeurs directement sur les barres et courbes
|
|
||||||
- Rapports : panneau de filtres avec cases à cocher par catégorie (recherche, tout sélectionner/désélectionner) et filtre par source
|
|
||||||
- Rapports : le filtre source s'applique au niveau SQL pour des totaux filtrés précis
|
|
||||||
- Rapports : en-têtes de tableau fixes sur tous les tableaux de rapports (Rapport dynamique, Budget vs Réel)
|
|
||||||
- Rapports : survol interactif — barres non survolées estompées, info-bulle filtrée sur la catégorie survolée
|
|
||||||
- Rapports : le survol de la légende met en évidence la catégorie sur tous les mois (graphique Évolution)
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
- Tableau des transactions : l'icône de commentaire devient orange (comme l'icône de ventilation) quand une note est présente (#7)
|
|
||||||
|
|
||||||
## [0.5.0]
|
|
||||||
|
|
||||||
### Ajouté
|
|
||||||
- Gestion d'erreurs : intercepte les plantages React et affiche une page d'erreur au lieu d'un écran blanc
|
|
||||||
- Délai de démarrage (10 s) sur la connexion à la base de données — affiche une page d'erreur au lieu d'un indicateur de chargement infini
|
|
||||||
- Page d'erreur avec « Rafraîchir », « Vérifier les mises à jour » et liens de contact
|
|
||||||
- Visionneuse de journaux dans les paramètres — capture la sortie console, filtrable par niveau, copiable, persiste entre les rafraîchissements
|
|
||||||
- Licence GPL-3.0 — le projet est maintenant open source
|
|
||||||
|
|
||||||
### Modifié
|
|
||||||
- Modale détail de rapport : colonnes triables — cliquez sur les en-têtes pour trier par date, description ou montant (#1)
|
|
||||||
- Modale détail de rapport : bascule pour afficher/masquer la colonne des montants (#3)
|
|
||||||
- Tableau de budget : les en-têtes de colonnes restent fixes lors du défilement vertical (#2)
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
- Mise à jour automatique sur Linux : le champ version de `latest.json` n'a plus le préfixe `v`, téléversement au registre de paquets plus robuste
|
|
||||||
- Nouvelle tentative au démarrage : la connexion BD réessaie jusqu'à 3 fois avant d'afficher la page d'erreur (corrige l'échec au premier lancement sur Windows)
|
|
||||||
- Somme de contrôle de migration : répare automatiquement la somme de contrôle obsolète de la migration 1 au démarrage
|
|
||||||
|
|
||||||
## [0.4.4]
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
- Le binaire Linux est maintenant compatible avec glibc 2.35+ (Ubuntu 22.04 / Pop!_OS) — le CI compile dans un conteneur Ubuntu 22.04
|
|
||||||
|
|
||||||
## [0.4.3]
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
- Le point de terminaison de mise à jour automatique utilise maintenant le registre de paquets Forgejo pour une URL stable
|
|
||||||
- Les signatures Linux (.AppImage.sig) sont maintenant correctement collectées dans le CI
|
|
||||||
- Toutes les signatures de plateforme (.deb.sig, .rpm.sig) sont maintenant incluses dans les assets de la release
|
|
||||||
|
|
||||||
## [0.4.2]
|
|
||||||
|
|
||||||
### Modifié
|
|
||||||
- La mise à jour automatique pointe maintenant vers l'instance Forgejo auto-hébergée
|
|
||||||
- Les builds Windows sont maintenant compilés en croisé via cargo-xwin
|
|
||||||
|
|
||||||
## [0.4.1]
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
- Application bloquée sur un indicateur de chargement infini après mise à jour depuis v0.3.x (somme de contrôle de migration incompatible sur seed_categories.sql)
|
|
||||||
- Les erreurs de connexion BD sont maintenant journalisées dans la console au lieu d'échouer silencieusement
|
|
||||||
|
|
||||||
## [0.4.0]
|
|
||||||
|
|
||||||
### Ajouté
|
|
||||||
- Catégories : support de 3 niveaux de hiérarchie (ex : Dépenses récurrentes → Assurances → Assurance-auto)
|
|
||||||
- Rapport dynamique : nouveau champ « Catégorie (Niveau 3) »
|
|
||||||
- Budget : sous-totaux intermédiaires et indentation 3 niveaux pour les catégories imbriquées
|
|
||||||
- Catégories : gestion automatique de `is_inputable` à la création/suppression de sous-catégories
|
|
||||||
- Catégories : la validation de profondeur empêche la création d'un 4e niveau
|
|
||||||
- Données initiales : Assurances divisées en Assurance-auto, Assurance-habitation, Assurance-vie
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
- Auto-catégorisation : les mots-clés commençant/finissant par des caractères spéciaux (`[`, `]`, `(`, `)`, `-`, etc.) sont maintenant reconnus
|
|
||||||
- Auto-catégorisation : pré-compilation des regex pour de meilleures performances en lot
|
|
||||||
|
|
||||||
## [0.3.11]
|
|
||||||
|
|
||||||
### Ajouté
|
|
||||||
- Rapport dynamique : support de plusieurs dimensions en colonnes (clés composites)
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
- Rapport dynamique : n'est plus affecté par les filtres de date globaux — utilise uniquement ses propres filtres du panneau
|
|
||||||
|
|
||||||
## [0.3.10]
|
|
||||||
|
|
||||||
### Ajouté
|
|
||||||
- Rapport dynamique : les champs peuvent maintenant être utilisés dans plusieurs zones simultanément (lignes + filtres, colonnes + filtres)
|
|
||||||
- Rapport dynamique : clic-droit sur une valeur de filtre pour l'exclure (affiché barré en rouge)
|
|
||||||
- Option de période « Cette année » dans les rapports et le tableau de bord (du 1er janvier à aujourd'hui)
|
|
||||||
|
|
||||||
## [0.3.9]
|
|
||||||
|
|
||||||
### Ajouté
|
|
||||||
- Rapport dynamique (tableau croisé) : composez des rapports personnalisés en assignant des dimensions aux lignes, colonnes, filtres et mesures aux valeurs
|
|
||||||
- Suppression de mots-clés depuis la vue « Tous les mots-clés »
|
|
||||||
|
|
||||||
## [0.3.8]
|
|
||||||
|
|
||||||
### Ajouté
|
|
||||||
- Sélecteur de plage de dates personnalisée pour les rapports et le tableau de bord
|
|
||||||
- Bascule pour positionner les sous-totaux au-dessus ou en dessous des lignes de détail
|
|
||||||
- Affichage des notes de version du CHANGELOG dans les releases et le système de mise à jour
|
|
||||||
|
|
||||||
## [0.3.7]
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
- Suppression du bundle MSI pour éviter le conflit de chemin d'installation du système de mise à jour
|
|
||||||
- Changement du mode d'installation Windows à basicUi
|
|
||||||
- Amélioration de la visibilité de l'indicateur de ventilation et de la mise en page des ajustements
|
|
||||||
|
|
||||||
## 0.3.2
|
|
||||||
|
|
||||||
### Nouvelles fonctionnalités
|
|
||||||
- **Support Linux** : ajout des builds Linux (`.deb`, `.rpm`, `.AppImage`) au workflow de release
|
|
||||||
- **Ventilations sur la page Ajustements** : visualisez les ajustements de ventilation des transactions dans une section dédiée
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
- Correction de cas limites de détection automatique CSV
|
|
||||||
- Suppression de l'accent dans productName pour la compatibilité `.deb` Linux
|
|
||||||
|
|
||||||
## 0.3.1
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
- Toujours afficher le sélecteur de profil dans la barre latérale (#2)
|
|
||||||
|
|
||||||
## 0.3.0
|
|
||||||
|
|
||||||
### Nouvelles fonctionnalités
|
|
||||||
- **Profils multiples** : créez plusieurs profils avec des bases de données séparées, des noms et couleurs personnalisés
|
|
||||||
- **Protection par NIP** : protégez les profils avec un NIP numérique optionnel
|
|
||||||
- **Sélecteur de profil** : changement rapide de profil depuis la barre latérale
|
|
||||||
- **Glisser-déposer les catégories** : réordonnez les catégories ou changez le parent par glisser-déposer dans l'arborescence
|
|
||||||
- **Ventilation des transactions** : ventilez une transaction sur plusieurs catégories avec des montants ajustables
|
|
||||||
|
|
||||||
## 0.2.10
|
|
||||||
|
|
||||||
### Nouvelles fonctionnalités
|
|
||||||
- **Sélection rapide de période** : boutons de filtre rapide (Ce mois, Mois dernier, etc.) sur la page Transactions
|
|
||||||
- **Rapport Budget vs Réel** : tableau comparatif mensuel et cumulatif annuel dans les Rapports
|
|
||||||
- **Sous-totaux de catégories parentes** : la page Budget affiche les sous-totaux agrégés pour les catégories parentes
|
|
||||||
- **Guide utilisateur** : documentation complète accessible depuis les Paramètres, imprimable en PDF
|
|
||||||
|
|
||||||
### Améliorations
|
|
||||||
- Persistance de la sélection de modèle et ajout du bouton Mettre à jour le modèle
|
|
||||||
- Ne plus pré-sélectionner les fichiers déjà importés à l'entrée de la configuration source
|
|
||||||
- Rendre les imports de données de paramètres visibles dans l'historique d'import
|
|
||||||
- Remplacer les boutons de suppression par modèle par un seul bouton sur la sélection
|
|
||||||
- Remplacer l'icône de rafraîchissement par une icône de sauvegarde sur le bouton de mise à jour du modèle
|
|
||||||
- Ajout de la convention de signe à la page budget
|
|
||||||
|
|
||||||
## 0.2.9
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
- Permettre les fichiers à contenu identique avec des noms différents (#1)
|
|
||||||
|
|
||||||
## 0.2.8
|
|
||||||
|
|
||||||
### Nouvelles fonctionnalités
|
|
||||||
- **Export/import de données** : exportez et importez vos données (transactions, catégories, ou les deux) avec chiffrement AES-256-GCM optionnel (#3)
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
- Détection de doublons inter-fichiers et suivi d'import par fichier
|
|
||||||
|
|
||||||
## 0.2.5
|
|
||||||
|
|
||||||
### Nouvelles fonctionnalités
|
|
||||||
- **Modèles de configuration d'import** : sauvegardez et chargez les configurations de source d'import comme modèles réutilisables
|
|
||||||
- **Grille de budget 12 mois** : vue budgétaire annuelle complète avec cellules mensuelles et totaux annuels
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
- Corrections du budget et des catégories
|
|
||||||
- Problème de somme de contrôle de migration (schema.sql ne doit pas être modifié après la release initiale)
|
|
||||||
|
|
||||||
## 0.2.3
|
|
||||||
|
|
||||||
### Nouvelles fonctionnalités
|
|
||||||
- **Motifs de graphiques** : ajout de motifs de remplissage SVG (lignes diagonales, points, hachures, etc.) pour différencier les catégories dans les graphiques au-delà de la couleur
|
|
||||||
- **Menu contextuel des graphiques** : clic-droit sur une catégorie dans un graphique pour la masquer ou voir ses transactions dans une fenêtre de détail
|
|
||||||
- **Catégories masquées** : les catégories masquées apparaissent comme des puces au-dessus des graphiques avec un bouton « Tout afficher »
|
|
||||||
- **Modale de détail des transactions** : visualisez toutes les transactions composant le total d'une catégorie directement depuis n'importe quel graphique
|
|
||||||
- **Aperçu d'import en popup** : l'aperçu des données est maintenant une modale popup au lieu d'une étape séparée de l'assistant
|
|
||||||
- **Vérification directe des doublons** : nouveau bouton « Vérifier les doublons » sur la page de configuration d'import
|
|
||||||
|
|
||||||
### Améliorations
|
|
||||||
- Flux de l'assistant d'import simplifié : configuration source → vérification des doublons (l'aperçu est optionnel via popup)
|
|
||||||
- Le bouton retour de la vérification des doublons retourne maintenant à la configuration source
|
|
||||||
|
|
||||||
## 0.2.2
|
|
||||||
|
|
||||||
- Mise à jour de version
|
|
||||||
|
|
||||||
## 0.2.1
|
|
||||||
|
|
||||||
- Ajout de la vue « Tous les mots-clés » sur la page Catégories
|
|
||||||
- Ajout du mode sombre avec palette de gris chauds
|
|
||||||
- Correction des catégories orphelines, persistance de has_header pour les imports, ajout de la réinitialisation
|
|
||||||
- Ajout des pages Budget et Ajustements
|
|
||||||
69
CHANGELOG.md
69
CHANGELOG.md
|
|
@ -2,75 +2,6 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [0.6.6]
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Budget table: previous year column now shows actual transactions instead of planned budget (#34)
|
|
||||||
- Refactored `buildPrevYearTotalMap` inline and simplified tests (#39)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Changelog files synced automatically via Vite plugin, removed stale public/ copies (#37)
|
|
||||||
|
|
||||||
## [0.6.5]
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Dashboard: month dropdown selector for the Budget vs Actual section with last completed month as default (#31)
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Reports & Dashboard: reduced font size of month dropdown for better visual balance (#31)
|
|
||||||
|
|
||||||
## [0.6.4]
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Budget table: previous year total column displayed as first data column for baseline reference (#16)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Dashboard: level 4+ categories now appear under their parent instead of at the bottom of the section (#23)
|
|
||||||
- Dashboard: category hierarchy now supports arbitrary nesting depth (#23)
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Dashboard: pie chart takes 1/3 width instead of 1/2, giving more space to the budget table (#23)
|
|
||||||
- Dashboard: pie chart labels now shown only on hover via tooltip instead of permanent legend (#23)
|
|
||||||
- Budget vs Actual: category column now stays fixed when scrolling horizontally (#29)
|
|
||||||
- Budget vs Actual: title changed to "Budget vs Réel pour le mois de [month]" with a dropdown month selector (#29)
|
|
||||||
- Budget vs Actual: default month is now the last completed month instead of current month (#29)
|
|
||||||
|
|
||||||
## [0.6.3]
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Dashboard: expenses over time stacked bar chart by category and month (#15)
|
|
||||||
- Dashboard: budget vs actual table for current month with variance in $ and % (#15)
|
|
||||||
- Budget table and Budget vs Actual report: section subtotal formatting with increasing visual weight (#14)
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Dashboard: default period changed from "month" to "year to date" (#15)
|
|
||||||
- Dashboard: removed recent transactions section (#15)
|
|
||||||
- All report tables: grand total rows now use larger font (text-sm), bold weight, and thicker top border for better visual hierarchy (#14)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Category over time report: all categories now displayed (limit increased from 8 to 50) (#13)
|
|
||||||
- Category bar chart: Y-axis labels now use foreground color instead of muted gray (#13)
|
|
||||||
- Category over time chart: legend text now uses foreground color instead of inheriting category color (#13)
|
|
||||||
|
|
||||||
## [0.6.2]
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Budget table: section subtotals for expenses, income, and transfers displayed after each group (#11)
|
|
||||||
- Budget vs Actual report: section subtotals with actual, planned, variation ($) and variation (%) per type (#11)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Category page: detail panel now stays visible when scrolling through a long category list (#12)
|
|
||||||
|
|
||||||
## [0.6.1]
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Changelog page: full version history accessible from Settings at any time
|
|
||||||
- Bilingual changelog: release notes displayed in the user's selected language (EN/FR)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Chart label visibility: amount labels on stacked bar charts now use black text with white outline for better contrast (#8)
|
|
||||||
- Budget table: editable cells now show hover background, pointer cursor, and tooltip hint for clearer affordance (#9)
|
|
||||||
|
|
||||||
## [0.6.0]
|
## [0.6.0]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
14
CLAUDE.md
14
CLAUDE.md
|
|
@ -9,7 +9,7 @@
|
||||||
**Stockage :** SQLite local (tauri-plugin-sql)
|
**Stockage :** SQLite local (tauri-plugin-sql)
|
||||||
**Langues supportées :** Français (FR) et Anglais (EN)
|
**Langues supportées :** Français (FR) et Anglais (EN)
|
||||||
**Plateformes :** Windows, Linux
|
**Plateformes :** Windows, Linux
|
||||||
**Version actuelle :** 0.6.3
|
**Version actuelle :** 0.5.0
|
||||||
**Licence :** GPL-3.0-only
|
**Licence :** GPL-3.0-only
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -50,7 +50,7 @@ src/
|
||||||
│ └── transactions/ # Transactions
|
│ └── transactions/ # Transactions
|
||||||
├── contexts/ # ProfileContext (état global profil)
|
├── contexts/ # ProfileContext (état global profil)
|
||||||
├── hooks/ # 12 hooks custom (useReducer)
|
├── hooks/ # 12 hooks custom (useReducer)
|
||||||
├── pages/ # 11 pages
|
├── pages/ # 10 pages
|
||||||
├── services/ # 14 services métier
|
├── services/ # 14 services métier
|
||||||
├── shared/ # Types et constantes partagés
|
├── shared/ # Types et constantes partagés
|
||||||
├── utils/ # Utilitaires (parsing, CSV, charts)
|
├── utils/ # Utilitaires (parsing, CSV, charts)
|
||||||
|
|
@ -92,7 +92,6 @@ src-tauri/
|
||||||
- **Multi-profils** : bases de données séparées, protection par PIN (Argon2), switching rapide
|
- **Multi-profils** : bases de données séparées, protection par PIN (Argon2), switching rapide
|
||||||
- **Export/Import** : JSON/CSV avec chiffrement AES-256-GCM optionnel (format SREF)
|
- **Export/Import** : JSON/CSV avec chiffrement AES-256-GCM optionnel (format SREF)
|
||||||
- **Mises à jour** : auto-updater intégré (tauri-plugin-updater)
|
- **Mises à jour** : auto-updater intégré (tauri-plugin-updater)
|
||||||
- **Changelog bilingue** : page `/changelog` avec historique complet, notes de version dynamiques FR/EN depuis `CHANGELOG.md` / `CHANGELOG.fr.md` (bundlés dans `public/`)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -136,12 +135,9 @@ La documentation technique est centralisée dans `docs/` :
|
||||||
- Décision technique structurante (choix de librairie, pattern architectural, changement de stratégie) → créer un nouvel ADR dans `docs/adr/`
|
- Décision technique structurante (choix de librairie, pattern architectural, changement de stratégie) → créer un nouvel ADR dans `docs/adr/`
|
||||||
- Changement affectant l'utilisation de l'app → mettre à jour `docs/guide-utilisateur.md` et les traductions i18n correspondantes (`src/i18n/locales/fr.json`, `src/i18n/locales/en.json`, clés sous `docs.*`)
|
- Changement affectant l'utilisation de l'app → mettre à jour `docs/guide-utilisateur.md` et les traductions i18n correspondantes (`src/i18n/locales/fr.json`, `src/i18n/locales/en.json`, clés sous `docs.*`)
|
||||||
|
|
||||||
**Règle CHANGELOG :** tout changement affectant le comportement utilisateur → ajouter une entrée sous `## [Unreleased]` dans **les deux fichiers** :
|
**Règle CHANGELOG :** tout changement affectant le comportement utilisateur → ajouter une entrée sous `## [Unreleased]` dans `CHANGELOG.md`
|
||||||
- `CHANGELOG.md` (anglais) — source principale
|
- Catégories : Added, Changed, Fixed, Removed
|
||||||
- `CHANGELOG.fr.md` (français) — traduction
|
- Format [Keep a Changelog](https://keepachangelog.com/). Le contenu est extrait automatiquement par le CI pour les release notes GitHub et affiché dans l'app.
|
||||||
- Catégories : Added/Ajouté, Changed/Modifié, Fixed/Corrigé, Removed/Supprimé
|
|
||||||
- Format [Keep a Changelog](https://keepachangelog.com/). Le contenu est extrait automatiquement par le CI pour les release notes et affiché dans l'app selon la langue de l'utilisateur.
|
|
||||||
- The `public/` copies are synced automatically: Vite copies them on `dev`/`build` start via `syncChangelogs()` in `vite.config.ts`. No manual sync needed.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Architecture technique — Simpl'Résultat
|
# Architecture technique — Simpl'Résultat
|
||||||
|
|
||||||
> Document mis à jour le 2026-03-07 — Version 0.6.3
|
> Document mis à jour le 2026-03-01 — Version 0.4.7
|
||||||
|
|
||||||
## Stack technique
|
## Stack technique
|
||||||
|
|
||||||
|
|
@ -30,11 +30,11 @@ simpl-resultat/
|
||||||
│ │ ├── adjustments/ # 3 composants
|
│ │ ├── adjustments/ # 3 composants
|
||||||
│ │ ├── budget/ # 5 composants
|
│ │ ├── budget/ # 5 composants
|
||||||
│ │ ├── categories/ # 5 composants
|
│ │ ├── categories/ # 5 composants
|
||||||
│ │ ├── dashboard/ # 2 composants
|
│ │ ├── dashboard/ # 3 composants
|
||||||
│ │ ├── import/ # 13 composants (wizard d'import)
|
│ │ ├── import/ # 13 composants (wizard d'import)
|
||||||
│ │ ├── layout/ # AppShell, Sidebar
|
│ │ ├── layout/ # AppShell, Sidebar
|
||||||
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
||||||
│ │ ├── reports/ # 10 composants (graphiques + rapports tabulaires + rapport dynamique)
|
│ │ ├── reports/ # 8 composants (graphiques + rapport dynamique)
|
||||||
│ │ ├── settings/ # 3 composants (+ LogViewerCard)
|
│ │ ├── settings/ # 3 composants (+ LogViewerCard)
|
||||||
│ │ ├── shared/ # 6 composants réutilisables
|
│ │ ├── shared/ # 6 composants réutilisables
|
||||||
│ │ └── transactions/ # 5 composants
|
│ │ └── transactions/ # 5 composants
|
||||||
|
|
@ -190,7 +190,7 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
|
||||||
|
|
||||||
| Route | Page | Description |
|
| Route | Page | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `/` | `DashboardPage` | Tableau de bord (résumé, pie chart, budget vs réel, dépenses dans le temps) |
|
| `/` | `DashboardPage` | Tableau de bord avec graphiques |
|
||||||
| `/import` | `ImportPage` | Assistant d'import CSV |
|
| `/import` | `ImportPage` | Assistant d'import CSV |
|
||||||
| `/transactions` | `TransactionsPage` | Liste avec filtres |
|
| `/transactions` | `TransactionsPage` | Liste avec filtres |
|
||||||
| `/categories` | `CategoriesPage` | Gestion hiérarchique |
|
| `/categories` | `CategoriesPage` | Gestion hiérarchique |
|
||||||
|
|
@ -199,7 +199,6 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
|
||||||
| `/reports` | `ReportsPage` | Analytique et rapports |
|
| `/reports` | `ReportsPage` | Analytique et rapports |
|
||||||
| `/settings` | `SettingsPage` | Paramètres |
|
| `/settings` | `SettingsPage` | Paramètres |
|
||||||
| `/docs` | `DocsPage` | Documentation in-app |
|
| `/docs` | `DocsPage` | Documentation in-app |
|
||||||
| `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) |
|
|
||||||
|
|
||||||
Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est actif).
|
Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est actif).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,8 @@ Le tableau de bord vous donne un aperçu rapide de votre situation financière p
|
||||||
|
|
||||||
- Cartes résumées du solde, des revenus et des dépenses
|
- Cartes résumées du solde, des revenus et des dépenses
|
||||||
- Répartition des dépenses par catégorie (graphique circulaire avec motifs SVG)
|
- Répartition des dépenses par catégorie (graphique circulaire avec motifs SVG)
|
||||||
- Tableau Budget vs Réel du mois courant (écart en $ et %)
|
- Liste des transactions récentes
|
||||||
- Histogramme empilé des dépenses par catégorie et par mois
|
- Sélecteur de période ajustable
|
||||||
- Sélecteur de période ajustable (par défaut : année à ce jour)
|
|
||||||
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
|
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
|
||||||
|
|
||||||
### Comment faire
|
### Comment faire
|
||||||
|
|
@ -81,13 +80,11 @@ Le tableau de bord vous donne un aperçu rapide de votre situation financière p
|
||||||
1. Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)
|
1. Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)
|
||||||
2. Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales
|
2. Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales
|
||||||
3. Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie
|
3. Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie
|
||||||
4. Consultez le tableau Budget vs Réel pour comparer vos dépenses au budget du mois courant
|
4. Cliquez droit sur une catégorie dans le graphique pour la masquer ou voir le détail de ses transactions
|
||||||
5. Analysez l'histogramme en bas de page pour voir l'évolution de vos dépenses par catégorie dans le temps
|
5. Faites défiler vers le bas pour voir vos transactions les plus récentes
|
||||||
6. Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions
|
|
||||||
|
|
||||||
### Astuces
|
### Astuces
|
||||||
|
|
||||||
- La période par défaut est « année à ce jour » pour un aperçu annuel dès l'ouverture
|
|
||||||
- Le solde est calculé comme les revenus moins les dépenses pour la période sélectionnée
|
- Le solde est calculé comme les revenus moins les dépenses pour la période sélectionnée
|
||||||
- Les catégories masquées apparaissent sous forme de pastilles au-dessus du graphique — cliquez sur Tout afficher pour les restaurer
|
- Les catégories masquées apparaissent sous forme de pastilles au-dessus du graphique — cliquez sur Tout afficher pour les restaurer
|
||||||
- Les motifs SVG (lignes, points, hachures) aident à distinguer les catégories au-delà des couleurs
|
- Les motifs SVG (lignes, points, hachures) aident à distinguer les catégories au-delà des couleurs
|
||||||
|
|
|
||||||
344
package-lock.json
generated
344
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "simpl_result_scaffold",
|
"name": "simpl_result_scaffold",
|
||||||
"version": "0.6.5",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "simpl_result_scaffold",
|
"name": "simpl_result_scaffold",
|
||||||
"version": "0.6.5",
|
"version": "0.5.0",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|
@ -35,8 +35,7 @@
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^6.4.1",
|
"vite": "^6.4.1"
|
||||||
"vitest": "^4.0.18"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
|
|
@ -1764,17 +1763,6 @@
|
||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/chai": {
|
|
||||||
"version": "5.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
|
||||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/deep-eql": "*",
|
|
||||||
"assertion-error": "^2.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-array": {
|
"node_modules/@types/d3-array": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
|
@ -1829,13 +1817,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
|
||||||
},
|
},
|
||||||
"node_modules/@types/deep-eql": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
|
|
@ -1903,127 +1884,6 @@
|
||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/expect": {
|
|
||||||
"version": "4.0.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
|
|
||||||
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@standard-schema/spec": "^1.0.0",
|
|
||||||
"@types/chai": "^5.2.2",
|
|
||||||
"@vitest/spy": "4.0.18",
|
|
||||||
"@vitest/utils": "4.0.18",
|
|
||||||
"chai": "^6.2.1",
|
|
||||||
"tinyrainbow": "^3.0.3"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vitest/mocker": {
|
|
||||||
"version": "4.0.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
|
|
||||||
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@vitest/spy": "4.0.18",
|
|
||||||
"estree-walker": "^3.0.3",
|
|
||||||
"magic-string": "^0.30.21"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"msw": "^2.4.9",
|
|
||||||
"vite": "^6.0.0 || ^7.0.0-0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"msw": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"vite": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vitest/pretty-format": {
|
|
||||||
"version": "4.0.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
|
|
||||||
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tinyrainbow": "^3.0.3"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vitest/runner": {
|
|
||||||
"version": "4.0.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
|
|
||||||
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@vitest/utils": "4.0.18",
|
|
||||||
"pathe": "^2.0.3"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vitest/snapshot": {
|
|
||||||
"version": "4.0.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
|
|
||||||
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@vitest/pretty-format": "4.0.18",
|
|
||||||
"magic-string": "^0.30.21",
|
|
||||||
"pathe": "^2.0.3"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vitest/spy": {
|
|
||||||
"version": "4.0.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
|
|
||||||
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vitest/utils": {
|
|
||||||
"version": "4.0.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
|
|
||||||
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@vitest/pretty-format": "4.0.18",
|
|
||||||
"tinyrainbow": "^3.0.3"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/assertion-error": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.19",
|
"version": "2.9.19",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||||
|
|
@ -2086,16 +1946,6 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/chai": {
|
|
||||||
"version": "6.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
|
||||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
|
@ -2288,13 +2138,6 @@
|
||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-module-lexer": {
|
|
||||||
"version": "1.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
|
||||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/es-toolkit": {
|
"node_modules/es-toolkit": {
|
||||||
"version": "1.44.0",
|
"version": "1.44.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
||||||
|
|
@ -2350,31 +2193,11 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/estree-walker": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/estree": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eventemitter3": {
|
"node_modules/eventemitter3": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
|
||||||
},
|
},
|
||||||
"node_modules/expect-type": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
|
|
@ -2820,29 +2643,11 @@
|
||||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/obug": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
"https://github.com/sponsors/sxzz",
|
|
||||||
"https://opencollective.com/debug"
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/papaparse": {
|
"node_modules/papaparse": {
|
||||||
"version": "5.5.3",
|
"version": "5.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
|
||||||
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="
|
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="
|
||||||
},
|
},
|
||||||
"node_modules/pathe": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|
@ -3115,13 +2920,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||||
},
|
},
|
||||||
"node_modules/siginfo": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|
@ -3131,20 +2929,6 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/stackback": {
|
|
||||||
"version": "0.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
|
||||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/std-env": {
|
|
||||||
"version": "3.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
|
||||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.1.18",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||||
|
|
@ -3169,23 +2953,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
|
||||||
},
|
},
|
||||||
"node_modules/tinybench": {
|
|
||||||
"version": "2.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
|
||||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/tinyexec": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
|
|
@ -3202,16 +2969,6 @@
|
||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyrainbow": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
|
@ -3369,84 +3126,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest": {
|
|
||||||
"version": "4.0.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
|
|
||||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@vitest/expect": "4.0.18",
|
|
||||||
"@vitest/mocker": "4.0.18",
|
|
||||||
"@vitest/pretty-format": "4.0.18",
|
|
||||||
"@vitest/runner": "4.0.18",
|
|
||||||
"@vitest/snapshot": "4.0.18",
|
|
||||||
"@vitest/spy": "4.0.18",
|
|
||||||
"@vitest/utils": "4.0.18",
|
|
||||||
"es-module-lexer": "^1.7.0",
|
|
||||||
"expect-type": "^1.2.2",
|
|
||||||
"magic-string": "^0.30.21",
|
|
||||||
"obug": "^2.1.1",
|
|
||||||
"pathe": "^2.0.3",
|
|
||||||
"picomatch": "^4.0.3",
|
|
||||||
"std-env": "^3.10.0",
|
|
||||||
"tinybench": "^2.9.0",
|
|
||||||
"tinyexec": "^1.0.2",
|
|
||||||
"tinyglobby": "^0.2.15",
|
|
||||||
"tinyrainbow": "^3.0.3",
|
|
||||||
"vite": "^6.0.0 || ^7.0.0",
|
|
||||||
"why-is-node-running": "^2.3.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"vitest": "vitest.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@edge-runtime/vm": "*",
|
|
||||||
"@opentelemetry/api": "^1.9.0",
|
|
||||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
|
||||||
"@vitest/browser-playwright": "4.0.18",
|
|
||||||
"@vitest/browser-preview": "4.0.18",
|
|
||||||
"@vitest/browser-webdriverio": "4.0.18",
|
|
||||||
"@vitest/ui": "4.0.18",
|
|
||||||
"happy-dom": "*",
|
|
||||||
"jsdom": "*"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@edge-runtime/vm": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@opentelemetry/api": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/node": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@vitest/browser-playwright": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@vitest/browser-preview": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@vitest/browser-webdriverio": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@vitest/ui": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"happy-dom": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"jsdom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/void-elements": {
|
"node_modules/void-elements": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||||
|
|
@ -3455,23 +3134,6 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/why-is-node-running": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"siginfo": "^2.0.0",
|
|
||||||
"stackback": "0.0.2"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"why-is-node-running": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
{
|
{
|
||||||
"name": "simpl_result_scaffold",
|
"name": "simpl_result_scaffold",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.6",
|
"version": "0.6.0",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri"
|
||||||
"test": "vitest run",
|
|
||||||
"test:watch": "vitest"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|
@ -39,7 +37,6 @@
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^6.4.1",
|
"vite": "^6.4.1"
|
||||||
"vitest": "^4.0.18"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "simpl-result"
|
name = "simpl-result"
|
||||||
version = "0.6.6"
|
version = "0.4.4"
|
||||||
description = "Personal finance management app"
|
description = "Personal finance management app"
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Simpl Resultat",
|
"productName": "Simpl Resultat",
|
||||||
"version": "0.6.6",
|
"version": "0.6.0",
|
||||||
"identifier": "com.simpl.resultat",
|
"identifier": "com.simpl.resultat",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import BudgetPage from "./pages/BudgetPage";
|
||||||
import ReportsPage from "./pages/ReportsPage";
|
import ReportsPage from "./pages/ReportsPage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
import DocsPage from "./pages/DocsPage";
|
import DocsPage from "./pages/DocsPage";
|
||||||
import ChangelogPage from "./pages/ChangelogPage";
|
|
||||||
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
|
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
|
||||||
import ErrorPage from "./components/shared/ErrorPage";
|
import ErrorPage from "./components/shared/ErrorPage";
|
||||||
|
|
||||||
|
|
@ -103,7 +102,6 @@ export default function App() {
|
||||||
<Route path="/reports" element={<ReportsPage />} />
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/docs" element={<DocsPage />} />
|
<Route path="/docs" element={<DocsPage />} />
|
||||||
<Route path="/changelog" element={<ChangelogPage />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { useState, useRef, useEffect, Fragment } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AlertTriangle, ArrowUpDown } from "lucide-react";
|
import { AlertTriangle, ArrowUpDown } from "lucide-react";
|
||||||
import type { BudgetYearRow } from "../../shared/types";
|
import type { BudgetYearRow } from "../../shared/types";
|
||||||
import { reorderRows } from "../../utils/reorderRows";
|
|
||||||
|
|
||||||
const fmt = new Intl.NumberFormat("en-CA", {
|
const fmt = new Intl.NumberFormat("en-CA", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
|
|
@ -19,6 +18,58 @@ const MONTH_KEYS = [
|
||||||
|
|
||||||
const STORAGE_KEY = "subtotals-position";
|
const STORAGE_KEY = "subtotals-position";
|
||||||
|
|
||||||
|
function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: 0 | 1 | 2 }>(
|
||||||
|
rows: T[],
|
||||||
|
subtotalsOnTop: boolean,
|
||||||
|
): T[] {
|
||||||
|
if (subtotalsOnTop) return rows;
|
||||||
|
// Group depth-0 parents with all their descendants, then move subtotals to bottom
|
||||||
|
const groups: { parent: T | null; children: T[] }[] = [];
|
||||||
|
let current: { parent: T | null; children: T[] } | null = null;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.is_parent && (row.depth ?? 0) === 0) {
|
||||||
|
if (current) groups.push(current);
|
||||||
|
current = { parent: row, children: [] };
|
||||||
|
} else if (current) {
|
||||||
|
current.children.push(row);
|
||||||
|
} else {
|
||||||
|
if (current) groups.push(current);
|
||||||
|
current = { parent: null, children: [row] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) groups.push(current);
|
||||||
|
return groups.flatMap(({ parent, children }) => {
|
||||||
|
if (!parent) return children;
|
||||||
|
// Also move intermediate subtotals (depth-1 parents) to bottom of their sub-groups
|
||||||
|
const reorderedChildren: T[] = [];
|
||||||
|
let subParent: T | null = null;
|
||||||
|
const subChildren: T[] = [];
|
||||||
|
for (const child of children) {
|
||||||
|
if (child.is_parent && (child.depth ?? 0) === 1) {
|
||||||
|
// Flush previous sub-group
|
||||||
|
if (subParent) {
|
||||||
|
reorderedChildren.push(...subChildren, subParent);
|
||||||
|
subChildren.length = 0;
|
||||||
|
}
|
||||||
|
subParent = child;
|
||||||
|
} else if (subParent && child.parent_id === subParent.category_id) {
|
||||||
|
subChildren.push(child);
|
||||||
|
} else {
|
||||||
|
if (subParent) {
|
||||||
|
reorderedChildren.push(...subChildren, subParent);
|
||||||
|
subParent = null;
|
||||||
|
subChildren.length = 0;
|
||||||
|
}
|
||||||
|
reorderedChildren.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (subParent) {
|
||||||
|
reorderedChildren.push(...subChildren, subParent);
|
||||||
|
}
|
||||||
|
return [...reorderedChildren, parent];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface BudgetTableProps {
|
interface BudgetTableProps {
|
||||||
rows: BudgetYearRow[];
|
rows: BudgetYearRow[];
|
||||||
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
|
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
|
||||||
|
|
@ -133,16 +184,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
income: "budget.income",
|
income: "budget.income",
|
||||||
transfer: "budget.transfers",
|
transfer: "budget.transfers",
|
||||||
};
|
};
|
||||||
const typeTotalKeys: Record<string, string> = {
|
|
||||||
expense: "budget.totalExpenses",
|
|
||||||
income: "budget.totalIncome",
|
|
||||||
transfer: "budget.totalTransfers",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Column totals with sign convention (only count leaf rows to avoid double-counting parents)
|
// Column totals with sign convention (only count leaf rows to avoid double-counting parents)
|
||||||
const monthTotals: number[] = Array(12).fill(0);
|
const monthTotals: number[] = Array(12).fill(0);
|
||||||
let annualTotal = 0;
|
let annualTotal = 0;
|
||||||
let prevYearTotal = 0;
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.is_parent) continue; // skip parent subtotals to avoid double-counting
|
if (row.is_parent) continue; // skip parent subtotals to avoid double-counting
|
||||||
const sign = signFor(row.category_type);
|
const sign = signFor(row.category_type);
|
||||||
|
|
@ -150,10 +195,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
monthTotals[m] += row.months[m] * sign;
|
monthTotals[m] += row.months[m] * sign;
|
||||||
}
|
}
|
||||||
annualTotal += row.annual * sign;
|
annualTotal += row.annual * sign;
|
||||||
prevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalCols = 15; // category + prev year + annual + 12 months
|
const totalCols = 14; // category + annual + 12 months
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -179,15 +223,13 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
if (row.is_parent) {
|
if (row.is_parent) {
|
||||||
// Parent subtotal row: read-only, bold, distinct background
|
// Parent subtotal row: read-only, bold, distinct background
|
||||||
const parentDepth = row.depth ?? 0;
|
const parentDepth = row.depth ?? 0;
|
||||||
const isTopParent = parentDepth === 0;
|
const isIntermediateParent = parentDepth === 1;
|
||||||
const isIntermediateParent = parentDepth >= 1;
|
|
||||||
const parentPaddingClass = parentDepth >= 3 ? "pl-20 pr-3" : parentDepth === 2 ? "pl-14 pr-3" : parentDepth === 1 ? "pl-8 pr-3" : "px-3";
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={rowKey}
|
key={rowKey}
|
||||||
className={`border-b border-[var(--border)] ${isTopParent ? "bg-[var(--muted)]/30" : "bg-[var(--muted)]/15"}`}
|
className={`border-b border-[var(--border)] ${isIntermediateParent ? "bg-[var(--muted)]/15" : "bg-[var(--muted)]/30"}`}
|
||||||
>
|
>
|
||||||
<td className={`py-2 sticky left-0 z-10 ${isTopParent ? "px-3 bg-[var(--muted)]/30" : `${parentPaddingClass} bg-[var(--muted)]/15`}`}>
|
<td className={`py-2 sticky left-0 z-10 ${isIntermediateParent ? "pl-8 pr-3 bg-[var(--muted)]/15" : "px-3 bg-[var(--muted)]/30"}`}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
|
@ -196,9 +238,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
<span className={`truncate text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>{row.category_name}</span>
|
<span className={`truncate text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>{row.category_name}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"} text-[var(--muted-foreground)]`}>
|
|
||||||
{formatSigned(row.previousYearTotal)}
|
|
||||||
</td>
|
|
||||||
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>
|
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>
|
||||||
{formatSigned(row.annual * sign)}
|
{formatSigned(row.annual * sign)}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -218,7 +257,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors"
|
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors"
|
||||||
>
|
>
|
||||||
{/* Category name - sticky */}
|
{/* Category name - sticky */}
|
||||||
<td className={`py-2 sticky left-0 bg-[var(--card)] z-10 ${depth >= 3 ? "pl-20 pr-3" : depth === 2 ? "pl-14 pr-3" : depth === 1 ? "pl-8 pr-3" : "px-3"}`}>
|
<td className={`py-2 sticky left-0 bg-[var(--card)] z-10 ${depth === 2 ? "pl-14 pr-3" : depth === 1 ? "pl-8 pr-3" : "px-3"}`}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
|
@ -227,12 +266,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
<span className="truncate text-xs">{row.category_name}</span>
|
<span className="truncate text-xs">{row.category_name}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{/* Previous year total — read-only */}
|
|
||||||
<td className="py-2 px-2 text-right text-[var(--muted-foreground)]">
|
|
||||||
<span className="text-xs px-1 py-0.5">
|
|
||||||
{formatSigned(row.previousYearTotal)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
{/* Annual total — editable */}
|
{/* Annual total — editable */}
|
||||||
<td className="py-2 px-2 text-right">
|
<td className="py-2 px-2 text-right">
|
||||||
{editingAnnual?.categoryId === row.category_id ? (
|
{editingAnnual?.categoryId === row.category_id ? (
|
||||||
|
|
@ -250,8 +283,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleStartEditAnnual(row.category_id, row.annual)}
|
onClick={() => handleStartEditAnnual(row.category_id, row.annual)}
|
||||||
title={t("budget.clickToEdit")}
|
className="font-medium text-xs hover:text-[var(--primary)] transition-colors cursor-text"
|
||||||
className="font-medium text-xs hover:text-[var(--primary)] hover:bg-[var(--muted)]/40 transition-colors cursor-pointer rounded px-1 py-0.5"
|
|
||||||
>
|
>
|
||||||
{formatSigned(row.annual * sign)}
|
{formatSigned(row.annual * sign)}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -283,8 +315,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleStartEdit(row.category_id, mIdx, val)}
|
onClick={() => handleStartEdit(row.category_id, mIdx, val)}
|
||||||
title={t("budget.clickToEdit")}
|
className="w-full text-right hover:text-[var(--primary)] transition-colors cursor-text text-xs"
|
||||||
className="w-full text-right hover:text-[var(--primary)] hover:bg-[var(--muted)]/40 transition-colors cursor-pointer text-xs rounded px-1 py-0.5"
|
|
||||||
>
|
>
|
||||||
{formatSigned(val * sign)}
|
{formatSigned(val * sign)}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -313,9 +344,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
<th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-30 min-w-[140px]">
|
<th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-30 min-w-[140px]">
|
||||||
{t("budget.category")}
|
{t("budget.category")}
|
||||||
</th>
|
</th>
|
||||||
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
|
|
||||||
{t("budget.previousYear")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
|
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
|
||||||
{t("budget.annual")}
|
{t("budget.annual")}
|
||||||
</th>
|
</th>
|
||||||
|
|
@ -330,18 +358,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
{typeOrder.map((type) => {
|
{typeOrder.map((type) => {
|
||||||
const group = grouped[type];
|
const group = grouped[type];
|
||||||
if (!group || group.length === 0) return null;
|
if (!group || group.length === 0) return null;
|
||||||
const sign = signFor(type);
|
|
||||||
const leaves = group.filter((r) => !r.is_parent);
|
|
||||||
const sectionMonthTotals: number[] = Array(12).fill(0);
|
|
||||||
let sectionAnnualTotal = 0;
|
|
||||||
let sectionPrevYearTotal = 0;
|
|
||||||
for (const row of leaves) {
|
|
||||||
for (let m = 0; m < 12; m++) {
|
|
||||||
sectionMonthTotals[m] += row.months[m] * sign;
|
|
||||||
}
|
|
||||||
sectionAnnualTotal += row.annual * sign;
|
|
||||||
sectionPrevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={type}>
|
<Fragment key={type}>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -353,28 +369,15 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{reorderRows(group, subtotalsOnTop).map((row) => renderRow(row))}
|
{reorderRows(group, subtotalsOnTop).map((row) => renderRow(row))}
|
||||||
<tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]">
|
|
||||||
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold">
|
|
||||||
{t(typeTotalKeys[type])}
|
|
||||||
</td>
|
|
||||||
<td className="py-2.5 px-2 text-right text-sm font-semibold text-[var(--muted-foreground)]">{formatSigned(sectionPrevYearTotal)}</td>
|
|
||||||
<td className="py-2.5 px-2 text-right text-sm font-semibold">{formatSigned(sectionAnnualTotal)}</td>
|
|
||||||
{sectionMonthTotals.map((total, mIdx) => (
|
|
||||||
<td key={mIdx} className="py-2.5 px-2 text-right text-sm font-semibold">
|
|
||||||
{formatSigned(total)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{/* Totals row */}
|
{/* Totals row */}
|
||||||
<tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]">
|
<tr className="bg-[var(--muted)] font-semibold">
|
||||||
<td className="py-3 px-3 sticky left-0 bg-[var(--muted)] z-10 text-sm">{t("common.total")}</td>
|
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)] z-10 text-xs">{t("common.total")}</td>
|
||||||
<td className="py-3 px-2 text-right text-sm text-[var(--muted-foreground)]">{formatSigned(prevYearTotal)}</td>
|
<td className="py-2.5 px-2 text-right text-xs">{formatSigned(annualTotal)}</td>
|
||||||
<td className="py-3 px-2 text-right text-sm">{formatSigned(annualTotal)}</td>
|
|
||||||
{monthTotals.map((total, mIdx) => (
|
{monthTotals.map((total, mIdx) => (
|
||||||
<td key={mIdx} className="py-3 px-2 text-right text-sm">
|
<td key={mIdx} className="py-2.5 px-2 text-right text-xs">
|
||||||
{formatSigned(total)}
|
{formatSigned(total)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ export default function CategoryPieChart({
|
||||||
}: CategoryPieChartProps) {
|
}: CategoryPieChartProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
|
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
|
||||||
const [isChartHovered, setIsChartHovered] = useState(false);
|
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
|
||||||
|
|
||||||
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
|
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
|
||||||
|
|
@ -37,14 +36,17 @@ export default function CategoryPieChart({
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)]">
|
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||||
<p className="text-center text-[var(--muted-foreground)] py-6">{t("dashboard.noData")}</p>
|
<h2 className="text-lg font-semibold mb-4">{t("dashboard.expensesByCategory")}</h2>
|
||||||
|
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)]">
|
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">{t("dashboard.expensesByCategory")}</h2>
|
||||||
|
|
||||||
{hiddenCategories.size > 0 && (
|
{hiddenCategories.size > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
|
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
|
||||||
|
|
@ -67,12 +69,8 @@ export default function CategoryPieChart({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div onContextMenu={handleContextMenu}>
|
||||||
onContextMenu={handleContextMenu}
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
onMouseEnter={() => setIsChartHovered(true)}
|
|
||||||
onMouseLeave={() => setIsChartHovered(false)}
|
|
||||||
>
|
|
||||||
<ResponsiveContainer width="100%" height={180}>
|
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<ChartPatternDefs
|
<ChartPatternDefs
|
||||||
prefix="cat-pie"
|
prefix="cat-pie"
|
||||||
|
|
@ -84,8 +82,8 @@ export default function CategoryPieChart({
|
||||||
nameKey="category_name"
|
nameKey="category_name"
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
innerRadius={35}
|
innerRadius={50}
|
||||||
outerRadius={75}
|
outerRadius={100}
|
||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
>
|
>
|
||||||
{visibleData.map((item, index) => (
|
{visibleData.map((item, index) => (
|
||||||
|
|
@ -99,11 +97,9 @@ export default function CategoryPieChart({
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value) => {
|
formatter={(value) =>
|
||||||
const formatted = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value));
|
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value))
|
||||||
const pct = total > 0 ? ` (${Math.round((Number(value) / total) * 100)}%)` : "";
|
}
|
||||||
return `${formatted}${pct}`;
|
|
||||||
}}
|
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
backgroundColor: "var(--card)",
|
backgroundColor: "var(--card)",
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
|
|
@ -117,14 +113,13 @@ export default function CategoryPieChart({
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
|
||||||
{data.map((item, index) => {
|
{data.map((item, index) => {
|
||||||
const isHidden = hiddenCategories.has(item.category_name);
|
const isHidden = hiddenCategories.has(item.category_name);
|
||||||
const pct = total > 0 && !isHidden ? Math.round((item.total / total) * 100) : null;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
className={`flex items-center gap-1 text-xs ${isHidden ? "opacity-40" : ""}`}
|
className={`flex items-center gap-1.5 text-sm ${isHidden ? "opacity-40" : ""}`}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, item });
|
setContextMenu({ x: e.clientX, y: e.clientY, item });
|
||||||
|
|
@ -134,7 +129,7 @@ export default function CategoryPieChart({
|
||||||
>
|
>
|
||||||
<PatternSwatch index={index} color={item.category_color} prefix="cat-pie" />
|
<PatternSwatch index={index} color={item.category_color} prefix="cat-pie" />
|
||||||
<span className="text-[var(--muted-foreground)]">
|
<span className="text-[var(--muted-foreground)]">
|
||||||
{item.category_name}{isChartHovered && pct != null ? ` ${pct}%` : ""}
|
{item.category_name} {total > 0 && !isHidden ? `${Math.round((item.total / total) * 100)}%` : ""}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { Fragment, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowUpDown } from "lucide-react";
|
import { ArrowUpDown } from "lucide-react";
|
||||||
import type { BudgetVsActualRow } from "../../shared/types";
|
import type { BudgetVsActualRow } from "../../shared/types";
|
||||||
import { reorderRows } from "../../utils/reorderRows";
|
|
||||||
|
|
||||||
const cadFormatter = (value: number) =>
|
const cadFormatter = (value: number) =>
|
||||||
new Intl.NumberFormat("en-CA", {
|
new Intl.NumberFormat("en-CA", {
|
||||||
|
|
@ -26,6 +25,55 @@ interface BudgetVsActualTableProps {
|
||||||
|
|
||||||
const STORAGE_KEY = "subtotals-position";
|
const STORAGE_KEY = "subtotals-position";
|
||||||
|
|
||||||
|
function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: 0 | 1 | 2 }>(
|
||||||
|
rows: T[],
|
||||||
|
subtotalsOnTop: boolean,
|
||||||
|
): T[] {
|
||||||
|
if (subtotalsOnTop) return rows;
|
||||||
|
const groups: { parent: T | null; children: T[] }[] = [];
|
||||||
|
let current: { parent: T | null; children: T[] } | null = null;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.is_parent && (row.depth ?? 0) === 0) {
|
||||||
|
if (current) groups.push(current);
|
||||||
|
current = { parent: row, children: [] };
|
||||||
|
} else if (current) {
|
||||||
|
current.children.push(row);
|
||||||
|
} else {
|
||||||
|
if (current) groups.push(current);
|
||||||
|
current = { parent: null, children: [row] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) groups.push(current);
|
||||||
|
return groups.flatMap(({ parent, children }) => {
|
||||||
|
if (!parent) return children;
|
||||||
|
const reorderedChildren: T[] = [];
|
||||||
|
let subParent: T | null = null;
|
||||||
|
const subChildren: T[] = [];
|
||||||
|
for (const child of children) {
|
||||||
|
if (child.is_parent && (child.depth ?? 0) === 1) {
|
||||||
|
if (subParent) {
|
||||||
|
reorderedChildren.push(...subChildren, subParent);
|
||||||
|
subChildren.length = 0;
|
||||||
|
}
|
||||||
|
subParent = child;
|
||||||
|
} else if (subParent && child.parent_id === subParent.category_id) {
|
||||||
|
subChildren.push(child);
|
||||||
|
} else {
|
||||||
|
if (subParent) {
|
||||||
|
reorderedChildren.push(...subChildren, subParent);
|
||||||
|
subParent = null;
|
||||||
|
subChildren.length = 0;
|
||||||
|
}
|
||||||
|
reorderedChildren.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (subParent) {
|
||||||
|
reorderedChildren.push(...subChildren, subParent);
|
||||||
|
}
|
||||||
|
return [...reorderedChildren, parent];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
|
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
|
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
|
||||||
|
|
@ -57,11 +105,6 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
income: t("budget.income"),
|
income: t("budget.income"),
|
||||||
transfer: t("budget.transfers"),
|
transfer: t("budget.transfers"),
|
||||||
};
|
};
|
||||||
const typeTotalKeys: Record<SectionType, string> = {
|
|
||||||
expense: "budget.totalExpenses",
|
|
||||||
income: "budget.totalIncome",
|
|
||||||
transfer: "budget.totalTransfers",
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentType: SectionType | null = null;
|
let currentType: SectionType | null = null;
|
||||||
for (const row of data) {
|
for (const row of data) {
|
||||||
|
|
@ -103,7 +146,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 z-20">
|
<thead className="sticky top-0 z-20">
|
||||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||||
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom sticky left-0 bg-[var(--card)] z-30 min-w-[180px]">
|
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom bg-[var(--card)]">
|
||||||
{t("budget.category")}
|
{t("budget.category")}
|
||||||
</th>
|
</th>
|
||||||
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||||
|
|
@ -141,49 +184,27 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sections.map((section) => {
|
{sections.map((section) => (
|
||||||
const sectionLeaves = section.rows.filter((r) => !r.is_parent);
|
|
||||||
const sectionTotals = sectionLeaves.reduce(
|
|
||||||
(acc, r) => ({
|
|
||||||
monthActual: acc.monthActual + r.monthActual,
|
|
||||||
monthBudget: acc.monthBudget + r.monthBudget,
|
|
||||||
monthVariation: acc.monthVariation + r.monthVariation,
|
|
||||||
ytdActual: acc.ytdActual + r.ytdActual,
|
|
||||||
ytdBudget: acc.ytdBudget + r.ytdBudget,
|
|
||||||
ytdVariation: acc.ytdVariation + r.ytdVariation,
|
|
||||||
}),
|
|
||||||
{ monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 }
|
|
||||||
);
|
|
||||||
const sectionMonthPct = sectionTotals.monthBudget !== 0 ? sectionTotals.monthVariation / Math.abs(sectionTotals.monthBudget) : null;
|
|
||||||
const sectionYtdPct = sectionTotals.ytdBudget !== 0 ? sectionTotals.ytdVariation / Math.abs(sectionTotals.ytdBudget) : null;
|
|
||||||
return (
|
|
||||||
<Fragment key={section.type}>
|
<Fragment key={section.type}>
|
||||||
<tr className="bg-[var(--muted)]">
|
<tr className="bg-[var(--muted)]/50">
|
||||||
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]">
|
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider">
|
||||||
{section.label}
|
{section.label}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{reorderRows(section.rows, subtotalsOnTop).map((row) => {
|
{reorderRows(section.rows, subtotalsOnTop).map((row) => {
|
||||||
const isParent = row.is_parent;
|
const isParent = row.is_parent;
|
||||||
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
|
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
|
||||||
const isTopParent = isParent && depth === 0;
|
const isIntermediateParent = isParent && depth === 1;
|
||||||
const isIntermediateParent = isParent && depth >= 1;
|
const paddingClass = depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
||||||
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={`${row.category_id}-${row.is_parent}-${depth}`}
|
key={`${row.category_id}-${row.is_parent}-${depth}`}
|
||||||
className={`border-b border-[var(--border)]/50 ${
|
className={`border-b border-[var(--border)]/50 ${
|
||||||
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" :
|
isParent && !isIntermediateParent ? "bg-[var(--muted)]/30 font-semibold" :
|
||||||
isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
|
isIntermediateParent ? "bg-[var(--muted)]/15 font-medium" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td className={`py-1.5 sticky left-0 z-10 ${
|
<td className={`py-1.5 ${isParent && !isIntermediateParent ? "px-3" : paddingClass}`}>
|
||||||
isTopParent
|
|
||||||
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
|
|
||||||
: isIntermediateParent
|
|
||||||
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
|
|
||||||
: `${paddingClass} bg-[var(--card)]`
|
|
||||||
}`}>
|
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
|
@ -215,53 +236,29 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
|
|
||||||
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">{t(typeTotalKeys[section.type])}</td>
|
|
||||||
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
|
|
||||||
{cadFormatter(sectionTotals.monthActual)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.monthBudget)}</td>
|
|
||||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
|
|
||||||
{cadFormatter(sectionTotals.monthVariation)}
|
|
||||||
</td>
|
|
||||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
|
|
||||||
{pctFormatter(sectionMonthPct)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
|
|
||||||
{cadFormatter(sectionTotals.ytdActual)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.ytdBudget)}</td>
|
|
||||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
|
|
||||||
{cadFormatter(sectionTotals.ytdVariation)}
|
|
||||||
</td>
|
|
||||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
|
|
||||||
{pctFormatter(sectionYtdPct)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
{/* Grand totals */}
|
{/* Grand totals */}
|
||||||
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
|
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
|
||||||
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">{t("common.total")}</td>
|
<td className="px-3 py-2">{t("common.total")}</td>
|
||||||
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
<td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
|
||||||
{cadFormatter(totals.monthActual)}
|
{cadFormatter(totals.monthActual)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right px-3 py-3">{cadFormatter(totals.monthBudget)}</td>
|
<td className="text-right px-3 py-2">{cadFormatter(totals.monthBudget)}</td>
|
||||||
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
|
<td className={`text-right px-3 py-2 ${variationColor(totals.monthVariation)}`}>
|
||||||
{cadFormatter(totals.monthVariation)}
|
{cadFormatter(totals.monthVariation)}
|
||||||
</td>
|
</td>
|
||||||
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
|
<td className={`text-right px-3 py-2 ${variationColor(totals.monthVariation)}`}>
|
||||||
{pctFormatter(totalMonthPct)}
|
{pctFormatter(totalMonthPct)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
<td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
|
||||||
{cadFormatter(totals.ytdActual)}
|
{cadFormatter(totals.ytdActual)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right px-3 py-3">{cadFormatter(totals.ytdBudget)}</td>
|
<td className="text-right px-3 py-2">{cadFormatter(totals.ytdBudget)}</td>
|
||||||
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
|
<td className={`text-right px-3 py-2 ${variationColor(totals.ytdVariation)}`}>
|
||||||
{cadFormatter(totals.ytdVariation)}
|
{cadFormatter(totals.ytdVariation)}
|
||||||
</td>
|
</td>
|
||||||
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
|
<td className={`text-right px-3 py-2 ${variationColor(totals.ytdVariation)}`}>
|
||||||
{pctFormatter(totalYtdPct)}
|
{pctFormatter(totalYtdPct)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ export default function CategoryBarChart({
|
||||||
type="category"
|
type="category"
|
||||||
dataKey="category_name"
|
dataKey="category_name"
|
||||||
width={120}
|
width={120}
|
||||||
tick={{ fill: "var(--foreground)", fontSize: 12 }}
|
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||||
stroke="var(--border)"
|
stroke="var(--border)"
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
|
@ -107,9 +107,7 @@ export default function CategoryBarChart({
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
color: "var(--foreground)",
|
color: "var(--foreground)",
|
||||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
|
||||||
}}
|
}}
|
||||||
wrapperStyle={{ zIndex: 50 }}
|
|
||||||
labelStyle={{ color: "var(--foreground)" }}
|
labelStyle={{ color: "var(--foreground)" }}
|
||||||
itemStyle={{ color: "var(--foreground)" }}
|
itemStyle={{ color: "var(--foreground)" }}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -123,9 +123,7 @@ export default function CategoryOverTimeChart({
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
color: "var(--foreground)",
|
color: "var(--foreground)",
|
||||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
|
||||||
}}
|
}}
|
||||||
wrapperStyle={{ zIndex: 50 }}
|
|
||||||
labelStyle={{ color: "var(--foreground)" }}
|
labelStyle={{ color: "var(--foreground)" }}
|
||||||
itemStyle={{ color: "var(--foreground)" }}
|
itemStyle={{ color: "var(--foreground)" }}
|
||||||
filterNull
|
filterNull
|
||||||
|
|
@ -136,7 +134,6 @@ export default function CategoryOverTimeChart({
|
||||||
}}
|
}}
|
||||||
onMouseLeave={() => setHoveredCategory(null)}
|
onMouseLeave={() => setHoveredCategory(null)}
|
||||||
wrapperStyle={{ cursor: "pointer" }}
|
wrapperStyle={{ cursor: "pointer" }}
|
||||||
formatter={(value) => <span style={{ color: "var(--foreground)" }}>{value}</span>}
|
|
||||||
/>
|
/>
|
||||||
{categoryEntries.map((c) => (
|
{categoryEntries.map((c) => (
|
||||||
<Bar
|
<Bar
|
||||||
|
|
@ -155,7 +152,7 @@ export default function CategoryOverTimeChart({
|
||||||
dataKey={c.name}
|
dataKey={c.name}
|
||||||
position="center"
|
position="center"
|
||||||
formatter={(v: unknown) => Number(v) ? cadFormatter(Number(v)) : ""}
|
formatter={(v: unknown) => Number(v) ? cadFormatter(Number(v)) : ""}
|
||||||
style={{ fill: "#000", fontSize: 10, fontWeight: 600, paintOrder: "stroke", stroke: "rgba(255,255,255,0.7)", strokeWidth: 3, strokeLinejoin: "round" }}
|
style={{ fill: "#fff", fontSize: 10, fontWeight: 600, textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Bar>
|
</Bar>
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,8 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
|
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
|
||||||
<td className="px-3 py-3 sticky left-0 bg-[var(--muted)]/20 z-10">{t("common.total")}</td>
|
<td className="px-3 py-2 sticky left-0 bg-[var(--muted)]/20 z-10">{t("common.total")}</td>
|
||||||
{months.map((month) => {
|
{months.map((month) => {
|
||||||
const monthData = data.data.find((d) => d.month === month);
|
const monthData = data.data.find((d) => d.month === month);
|
||||||
const monthTotal = visibleCategories.reduce(
|
const monthTotal = visibleCategories.reduce(
|
||||||
|
|
@ -89,12 +89,12 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<td key={month} className="text-right px-3 py-3">
|
<td key={month} className="text-right px-3 py-2">
|
||||||
{cadFormatter(monthTotal)}
|
{cadFormatter(monthTotal)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
<td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
|
||||||
{cadFormatter(
|
{cadFormatter(
|
||||||
visibleCategories.reduce(
|
visibleCategories.reduce(
|
||||||
(sum, cat) => sum + data.data.reduce((s, d) => s + ((d as Record<string, unknown>)[cat] as number || 0), 0),
|
(sum, cat) => sum + data.data.reduce((s, d) => s + ((d as Record<string, unknown>)[cat] as number || 0), 0),
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,10 @@ export default function CategoryTable({ data, hiddenCategories }: CategoryTableP
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
|
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
|
||||||
<td className="px-3 py-3">{t("common.total")}</td>
|
<td className="px-3 py-2">{t("common.total")}</td>
|
||||||
<td className="text-right px-3 py-3">{cadFormatter(grandTotal)}</td>
|
<td className="text-right px-3 py-2">{cadFormatter(grandTotal)}</td>
|
||||||
<td className="text-right px-3 py-3 text-[var(--muted-foreground)]">100%</td>
|
<td className="text-right px-3 py-2 text-[var(--muted-foreground)]">100%</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
|
|
@ -192,13 +192,13 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
{/* Grand total */}
|
{/* Grand total */}
|
||||||
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
|
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
|
||||||
<td colSpan={rowDims.length || 1} className="px-3 py-3">
|
<td colSpan={rowDims.length || 1} className="px-3 py-2">
|
||||||
{t("reports.pivot.total")}
|
{t("reports.pivot.total")}
|
||||||
</td>
|
</td>
|
||||||
{colValues.map((colVal) =>
|
{colValues.map((colVal) =>
|
||||||
measures.map((m) => (
|
measures.map((m) => (
|
||||||
<td key={`total-${colVal}-${m}`} className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
<td key={`total-${colVal}-${m}`} className="text-right px-3 py-2 border-l border-[var(--border)]/50">
|
||||||
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
|
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
|
||||||
</td>
|
</td>
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,11 @@ export default function MonthlyTrendsTable({ data }: MonthlyTrendsTableProps) {
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
|
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
|
||||||
<td className="px-3 py-3">{t("common.total")}</td>
|
<td className="px-3 py-2">{t("common.total")}</td>
|
||||||
<td className="text-right px-3 py-3 text-[var(--positive)]">{cadFormatter(totals.income)}</td>
|
<td className="text-right px-3 py-2 text-[var(--positive)]">{cadFormatter(totals.income)}</td>
|
||||||
<td className="text-right px-3 py-3 text-[var(--negative)]">{cadFormatter(totals.expenses)}</td>
|
<td className="text-right px-3 py-2 text-[var(--negative)]">{cadFormatter(totals.expenses)}</td>
|
||||||
<td className={`text-right px-3 py-3 ${totals.income - totals.expenses >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`}>
|
<td className={`text-right px-3 py-2 ${totals.income - totals.expenses >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`}>
|
||||||
{cadFormatter(totals.income - totals.expenses)}
|
{cadFormatter(totals.income - totals.expenses)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import type { BudgetYearRow, BudgetTemplate } from "../shared/types";
|
||||||
import {
|
import {
|
||||||
getAllActiveCategories,
|
getAllActiveCategories,
|
||||||
getBudgetEntriesForYear,
|
getBudgetEntriesForYear,
|
||||||
getActualTotalsForYear,
|
|
||||||
upsertBudgetEntry,
|
upsertBudgetEntry,
|
||||||
upsertBudgetEntriesForYear,
|
upsertBudgetEntriesForYear,
|
||||||
getAllTemplates,
|
getAllTemplates,
|
||||||
|
|
@ -73,10 +72,9 @@ export function useBudget() {
|
||||||
dispatch({ type: "SET_ERROR", payload: null });
|
dispatch({ type: "SET_ERROR", payload: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [allCategories, entries, prevYearActuals, templates] = await Promise.all([
|
const [allCategories, entries, templates] = await Promise.all([
|
||||||
getAllActiveCategories(),
|
getAllActiveCategories(),
|
||||||
getBudgetEntriesForYear(year),
|
getBudgetEntriesForYear(year),
|
||||||
getActualTotalsForYear(year - 1),
|
|
||||||
getAllTemplates(),
|
getAllTemplates(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -89,13 +87,6 @@ export function useBudget() {
|
||||||
entryMap.get(e.category_id)!.set(e.month, e.amount);
|
entryMap.get(e.category_id)!.set(e.month, e.amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a map for previous year actuals: categoryId -> annual actual total
|
|
||||||
// Amounts are already signed (expenses negative, income positive) — stored as-is.
|
|
||||||
const prevYearTotalMap = new Map<number, number>();
|
|
||||||
for (const a of prevYearActuals) {
|
|
||||||
if (a.category_id != null) prevYearTotalMap.set(a.category_id, a.actual);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: build months array from entryMap
|
// Helper: build months array from entryMap
|
||||||
const buildMonths = (catId: number) => {
|
const buildMonths = (catId: number) => {
|
||||||
const monthMap = entryMap.get(catId);
|
const monthMap = entryMap.get(catId);
|
||||||
|
|
@ -106,8 +97,7 @@ export function useBudget() {
|
||||||
months.push(val);
|
months.push(val);
|
||||||
annual += val;
|
annual += val;
|
||||||
}
|
}
|
||||||
const previousYearTotal = prevYearTotalMap.get(catId) ?? 0;
|
return { months, annual };
|
||||||
return { months, annual, previousYearTotal };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Index categories by id and group children by parent_id
|
// Index categories by id and group children by parent_id
|
||||||
|
|
@ -127,7 +117,7 @@ export function useBudget() {
|
||||||
const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
|
const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
|
||||||
if (grandchildren.length === 0 && cat.is_inputable) {
|
if (grandchildren.length === 0 && cat.is_inputable) {
|
||||||
// Leaf at depth 2
|
// Leaf at depth 2
|
||||||
const { months, annual, previousYearTotal } = buildMonths(cat.id);
|
const { months, annual } = buildMonths(cat.id);
|
||||||
return [{
|
return [{
|
||||||
category_id: cat.id,
|
category_id: cat.id,
|
||||||
category_name: cat.name,
|
category_name: cat.name,
|
||||||
|
|
@ -138,7 +128,6 @@ export function useBudget() {
|
||||||
depth: 2,
|
depth: 2,
|
||||||
months,
|
months,
|
||||||
annual,
|
annual,
|
||||||
previousYearTotal,
|
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
if (grandchildren.length === 0 && !cat.is_inputable) {
|
if (grandchildren.length === 0 && !cat.is_inputable) {
|
||||||
|
|
@ -149,7 +138,7 @@ export function useBudget() {
|
||||||
|
|
||||||
const gcRows: BudgetYearRow[] = [];
|
const gcRows: BudgetYearRow[] = [];
|
||||||
if (cat.is_inputable) {
|
if (cat.is_inputable) {
|
||||||
const { months, annual, previousYearTotal } = buildMonths(cat.id);
|
const { months, annual } = buildMonths(cat.id);
|
||||||
gcRows.push({
|
gcRows.push({
|
||||||
category_id: cat.id,
|
category_id: cat.id,
|
||||||
category_name: `${cat.name} (direct)`,
|
category_name: `${cat.name} (direct)`,
|
||||||
|
|
@ -160,11 +149,10 @@ export function useBudget() {
|
||||||
depth: 2,
|
depth: 2,
|
||||||
months,
|
months,
|
||||||
annual,
|
annual,
|
||||||
previousYearTotal,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const gc of grandchildren) {
|
for (const gc of grandchildren) {
|
||||||
const { months, annual, previousYearTotal } = buildMonths(gc.id);
|
const { months, annual } = buildMonths(gc.id);
|
||||||
gcRows.push({
|
gcRows.push({
|
||||||
category_id: gc.id,
|
category_id: gc.id,
|
||||||
category_name: gc.name,
|
category_name: gc.name,
|
||||||
|
|
@ -175,7 +163,6 @@ export function useBudget() {
|
||||||
depth: 2,
|
depth: 2,
|
||||||
months,
|
months,
|
||||||
annual,
|
annual,
|
||||||
previousYearTotal,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (gcRows.length === 0) return [];
|
if (gcRows.length === 0) return [];
|
||||||
|
|
@ -183,11 +170,9 @@ export function useBudget() {
|
||||||
// Build intermediate subtotal
|
// Build intermediate subtotal
|
||||||
const subMonths = Array(12).fill(0) as number[];
|
const subMonths = Array(12).fill(0) as number[];
|
||||||
let subAnnual = 0;
|
let subAnnual = 0;
|
||||||
let subPrevYear = 0;
|
|
||||||
for (const cr of gcRows) {
|
for (const cr of gcRows) {
|
||||||
for (let m = 0; m < 12; m++) subMonths[m] += cr.months[m];
|
for (let m = 0; m < 12; m++) subMonths[m] += cr.months[m];
|
||||||
subAnnual += cr.annual;
|
subAnnual += cr.annual;
|
||||||
subPrevYear += cr.previousYearTotal;
|
|
||||||
}
|
}
|
||||||
const subtotal: BudgetYearRow = {
|
const subtotal: BudgetYearRow = {
|
||||||
category_id: cat.id,
|
category_id: cat.id,
|
||||||
|
|
@ -199,7 +184,6 @@ export function useBudget() {
|
||||||
depth: 1,
|
depth: 1,
|
||||||
months: subMonths,
|
months: subMonths,
|
||||||
annual: subAnnual,
|
annual: subAnnual,
|
||||||
previousYearTotal: subPrevYear,
|
|
||||||
};
|
};
|
||||||
gcRows.sort((a, b) => {
|
gcRows.sort((a, b) => {
|
||||||
if (a.category_id === cat.id) return -1;
|
if (a.category_id === cat.id) return -1;
|
||||||
|
|
@ -219,7 +203,7 @@ export function useBudget() {
|
||||||
|
|
||||||
if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) {
|
if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) {
|
||||||
// Standalone leaf (no children) — regular editable row
|
// Standalone leaf (no children) — regular editable row
|
||||||
const { months, annual, previousYearTotal } = buildMonths(cat.id);
|
const { months, annual } = buildMonths(cat.id);
|
||||||
rows.push({
|
rows.push({
|
||||||
category_id: cat.id,
|
category_id: cat.id,
|
||||||
category_name: cat.name,
|
category_name: cat.name,
|
||||||
|
|
@ -230,14 +214,13 @@ export function useBudget() {
|
||||||
depth: 0,
|
depth: 0,
|
||||||
months,
|
months,
|
||||||
annual,
|
annual,
|
||||||
previousYearTotal,
|
|
||||||
});
|
});
|
||||||
} else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
|
} else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
|
||||||
const allChildRows: BudgetYearRow[] = [];
|
const allChildRows: BudgetYearRow[] = [];
|
||||||
|
|
||||||
// If parent is also inputable, create a "(direct)" fake-child row
|
// If parent is also inputable, create a "(direct)" fake-child row
|
||||||
if (cat.is_inputable) {
|
if (cat.is_inputable) {
|
||||||
const { months, annual, previousYearTotal } = buildMonths(cat.id);
|
const { months, annual } = buildMonths(cat.id);
|
||||||
allChildRows.push({
|
allChildRows.push({
|
||||||
category_id: cat.id,
|
category_id: cat.id,
|
||||||
category_name: `${cat.name} (direct)`,
|
category_name: `${cat.name} (direct)`,
|
||||||
|
|
@ -248,7 +231,6 @@ export function useBudget() {
|
||||||
depth: 1,
|
depth: 1,
|
||||||
months,
|
months,
|
||||||
annual,
|
annual,
|
||||||
previousYearTotal,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -256,7 +238,7 @@ export function useBudget() {
|
||||||
const grandchildren = childrenByParent.get(child.id) || [];
|
const grandchildren = childrenByParent.get(child.id) || [];
|
||||||
if (grandchildren.length === 0) {
|
if (grandchildren.length === 0) {
|
||||||
// Simple leaf at depth 1
|
// Simple leaf at depth 1
|
||||||
const { months, annual, previousYearTotal } = buildMonths(child.id);
|
const { months, annual } = buildMonths(child.id);
|
||||||
allChildRows.push({
|
allChildRows.push({
|
||||||
category_id: child.id,
|
category_id: child.id,
|
||||||
category_name: child.name,
|
category_name: child.name,
|
||||||
|
|
@ -267,7 +249,6 @@ export function useBudget() {
|
||||||
depth: 1,
|
depth: 1,
|
||||||
months,
|
months,
|
||||||
annual,
|
annual,
|
||||||
previousYearTotal,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Intermediate parent at depth 1 with grandchildren
|
// Intermediate parent at depth 1 with grandchildren
|
||||||
|
|
@ -286,11 +267,9 @@ export function useBudget() {
|
||||||
const leafRows = allChildRows.filter((r) => !r.is_parent);
|
const leafRows = allChildRows.filter((r) => !r.is_parent);
|
||||||
const parentMonths = Array(12).fill(0) as number[];
|
const parentMonths = Array(12).fill(0) as number[];
|
||||||
let parentAnnual = 0;
|
let parentAnnual = 0;
|
||||||
let parentPrevYear = 0;
|
|
||||||
for (const cr of leafRows) {
|
for (const cr of leafRows) {
|
||||||
for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m];
|
for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m];
|
||||||
parentAnnual += cr.annual;
|
parentAnnual += cr.annual;
|
||||||
parentPrevYear += cr.previousYearTotal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
|
|
@ -303,7 +282,6 @@ export function useBudget() {
|
||||||
depth: 0,
|
depth: 0,
|
||||||
months: parentMonths,
|
months: parentMonths,
|
||||||
annual: parentAnnual,
|
annual: parentAnnual,
|
||||||
previousYearTotal: parentPrevYear,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort children alphabetically, but keep "(direct)" first
|
// Sort children alphabetically, but keep "(direct)" first
|
||||||
|
|
|
||||||
|
|
@ -3,25 +3,19 @@ import type {
|
||||||
DashboardPeriod,
|
DashboardPeriod,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
CategoryBreakdownItem,
|
CategoryBreakdownItem,
|
||||||
CategoryOverTimeData,
|
RecentTransaction,
|
||||||
BudgetVsActualRow,
|
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
import {
|
import {
|
||||||
getDashboardSummary,
|
getDashboardSummary,
|
||||||
getExpensesByCategory,
|
getExpensesByCategory,
|
||||||
|
getRecentTransactions,
|
||||||
} from "../services/dashboardService";
|
} from "../services/dashboardService";
|
||||||
import { getCategoryOverTime } from "../services/reportService";
|
|
||||||
import { getBudgetVsActualData } from "../services/budgetService";
|
|
||||||
import { computeDateRange } from "../utils/dateRange";
|
|
||||||
|
|
||||||
interface DashboardState {
|
interface DashboardState {
|
||||||
summary: DashboardSummary;
|
summary: DashboardSummary;
|
||||||
categoryBreakdown: CategoryBreakdownItem[];
|
categoryBreakdown: CategoryBreakdownItem[];
|
||||||
categoryOverTime: CategoryOverTimeData;
|
recentTransactions: RecentTransaction[];
|
||||||
budgetVsActual: BudgetVsActualRow[];
|
|
||||||
period: DashboardPeriod;
|
period: DashboardPeriod;
|
||||||
budgetYear: number;
|
|
||||||
budgetMonth: number;
|
|
||||||
customDateFrom: string;
|
customDateFrom: string;
|
||||||
customDateTo: string;
|
customDateTo: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|
@ -36,27 +30,22 @@ type DashboardAction =
|
||||||
payload: {
|
payload: {
|
||||||
summary: DashboardSummary;
|
summary: DashboardSummary;
|
||||||
categoryBreakdown: CategoryBreakdownItem[];
|
categoryBreakdown: CategoryBreakdownItem[];
|
||||||
categoryOverTime: CategoryOverTimeData;
|
recentTransactions: RecentTransaction[];
|
||||||
budgetVsActual: BudgetVsActualRow[];
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
| { type: "SET_PERIOD"; payload: DashboardPeriod }
|
| { type: "SET_PERIOD"; payload: DashboardPeriod }
|
||||||
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
|
|
||||||
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } };
|
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } };
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||||
const yearStartStr = `${now.getFullYear()}-01-01`;
|
const monthStartStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
|
||||||
|
|
||||||
const initialState: DashboardState = {
|
const initialState: DashboardState = {
|
||||||
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
|
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
|
||||||
categoryBreakdown: [],
|
categoryBreakdown: [],
|
||||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
recentTransactions: [],
|
||||||
budgetVsActual: [],
|
period: "month",
|
||||||
period: "year",
|
customDateFrom: monthStartStr,
|
||||||
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
|
||||||
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
|
|
||||||
customDateFrom: yearStartStr,
|
|
||||||
customDateTo: todayStr,
|
customDateTo: todayStr,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
@ -73,14 +62,11 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
|
||||||
...state,
|
...state,
|
||||||
summary: action.payload.summary,
|
summary: action.payload.summary,
|
||||||
categoryBreakdown: action.payload.categoryBreakdown,
|
categoryBreakdown: action.payload.categoryBreakdown,
|
||||||
categoryOverTime: action.payload.categoryOverTime,
|
recentTransactions: action.payload.recentTransactions,
|
||||||
budgetVsActual: action.payload.budgetVsActual,
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
};
|
};
|
||||||
case "SET_PERIOD":
|
case "SET_PERIOD":
|
||||||
return { ...state, period: action.payload };
|
return { ...state, period: action.payload };
|
||||||
case "SET_BUDGET_MONTH":
|
|
||||||
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
|
|
||||||
case "SET_CUSTOM_DATES":
|
case "SET_CUSTOM_DATES":
|
||||||
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
||||||
default:
|
default:
|
||||||
|
|
@ -88,32 +74,69 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function computeDateRange(
|
||||||
|
period: DashboardPeriod,
|
||||||
|
customDateFrom?: string,
|
||||||
|
customDateTo?: string,
|
||||||
|
): { dateFrom?: string; dateTo?: string } {
|
||||||
|
if (period === "all") return {};
|
||||||
|
if (period === "custom" && customDateFrom && customDateTo) {
|
||||||
|
return { dateFrom: customDateFrom, dateTo: customDateTo };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = now.getMonth();
|
||||||
|
const day = now.getDate();
|
||||||
|
|
||||||
|
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
let from: Date;
|
||||||
|
switch (period) {
|
||||||
|
case "month":
|
||||||
|
from = new Date(year, month, 1);
|
||||||
|
break;
|
||||||
|
case "3months":
|
||||||
|
from = new Date(year, month - 2, 1);
|
||||||
|
break;
|
||||||
|
case "6months":
|
||||||
|
from = new Date(year, month - 5, 1);
|
||||||
|
break;
|
||||||
|
case "year":
|
||||||
|
from = new Date(year, 0, 1);
|
||||||
|
break;
|
||||||
|
case "12months":
|
||||||
|
from = new Date(year, month - 11, 1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
from = new Date(year, month, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
return { dateFrom, dateTo };
|
||||||
|
}
|
||||||
|
|
||||||
export function useDashboard() {
|
export function useDashboard() {
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const fetchIdRef = useRef(0);
|
const fetchIdRef = useRef(0);
|
||||||
|
|
||||||
const fetchData = useCallback(async (
|
const fetchData = useCallback(async (period: DashboardPeriod, customFrom?: string, customTo?: string) => {
|
||||||
period: DashboardPeriod,
|
|
||||||
customFrom: string | undefined,
|
|
||||||
customTo: string | undefined,
|
|
||||||
bYear: number,
|
|
||||||
bMonth: number,
|
|
||||||
) => {
|
|
||||||
const fetchId = ++fetchIdRef.current;
|
const fetchId = ++fetchIdRef.current;
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
dispatch({ type: "SET_ERROR", payload: null });
|
dispatch({ type: "SET_ERROR", payload: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||||
const [summary, categoryBreakdown, categoryOverTime, budgetVsActual] = await Promise.all([
|
const [summary, categoryBreakdown, recentTransactions] = await Promise.all([
|
||||||
getDashboardSummary(dateFrom, dateTo),
|
getDashboardSummary(dateFrom, dateTo),
|
||||||
getExpensesByCategory(dateFrom, dateTo),
|
getExpensesByCategory(dateFrom, dateTo),
|
||||||
getCategoryOverTime(dateFrom, dateTo),
|
getRecentTransactions(10),
|
||||||
getBudgetVsActualData(bYear, bMonth),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, categoryOverTime, budgetVsActual } });
|
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, recentTransactions } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|
@ -124,8 +147,8 @@ export function useDashboard() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth);
|
fetchData(state.period, state.customDateFrom, state.customDateTo);
|
||||||
}, [state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth, fetchData]);
|
}, [state.period, state.customDateFrom, state.customDateTo, fetchData]);
|
||||||
|
|
||||||
const setPeriod = useCallback((period: DashboardPeriod) => {
|
const setPeriod = useCallback((period: DashboardPeriod) => {
|
||||||
dispatch({ type: "SET_PERIOD", payload: period });
|
dispatch({ type: "SET_PERIOD", payload: period });
|
||||||
|
|
@ -135,9 +158,5 @@ export function useDashboard() {
|
||||||
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setBudgetMonth = useCallback((year: number, month: number) => {
|
return { state, setPeriod, setCustomDates };
|
||||||
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { state, setPeriod, setCustomDates, setBudgetMonth };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import type {
|
||||||
import { getMonthlyTrends, getCategoryOverTime, getDynamicReportData } from "../services/reportService";
|
import { getMonthlyTrends, getCategoryOverTime, getDynamicReportData } from "../services/reportService";
|
||||||
import { getExpensesByCategory } from "../services/dashboardService";
|
import { getExpensesByCategory } from "../services/dashboardService";
|
||||||
import { getBudgetVsActualData } from "../services/budgetService";
|
import { getBudgetVsActualData } from "../services/budgetService";
|
||||||
import { computeDateRange } from "../utils/dateRange";
|
|
||||||
|
|
||||||
interface ReportsState {
|
interface ReportsState {
|
||||||
tab: ReportTab;
|
tab: ReportTab;
|
||||||
|
|
@ -60,8 +59,8 @@ const initialState: ReportsState = {
|
||||||
monthlyTrends: [],
|
monthlyTrends: [],
|
||||||
categorySpending: [],
|
categorySpending: [],
|
||||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||||
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
budgetYear: now.getFullYear(),
|
||||||
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
|
budgetMonth: now.getMonth() + 1,
|
||||||
budgetVsActual: [],
|
budgetVsActual: [],
|
||||||
pivotConfig: { rows: [], columns: [], filters: {}, values: [] },
|
pivotConfig: { rows: [], columns: [], filters: {}, values: [] },
|
||||||
pivotResult: { rows: [], columnValues: [], dimensionLabels: {} },
|
pivotResult: { rows: [], columnValues: [], dimensionLabels: {} },
|
||||||
|
|
@ -102,6 +101,50 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function computeDateRange(
|
||||||
|
period: DashboardPeriod,
|
||||||
|
customDateFrom?: string,
|
||||||
|
customDateTo?: string,
|
||||||
|
): { dateFrom?: string; dateTo?: string } {
|
||||||
|
if (period === "all") return {};
|
||||||
|
if (period === "custom" && customDateFrom && customDateTo) {
|
||||||
|
return { dateFrom: customDateFrom, dateTo: customDateTo };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = now.getMonth();
|
||||||
|
const day = now.getDate();
|
||||||
|
|
||||||
|
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
let from: Date;
|
||||||
|
switch (period) {
|
||||||
|
case "month":
|
||||||
|
from = new Date(year, month, 1);
|
||||||
|
break;
|
||||||
|
case "3months":
|
||||||
|
from = new Date(year, month - 2, 1);
|
||||||
|
break;
|
||||||
|
case "6months":
|
||||||
|
from = new Date(year, month - 5, 1);
|
||||||
|
break;
|
||||||
|
case "year":
|
||||||
|
from = new Date(year, 0, 1);
|
||||||
|
break;
|
||||||
|
case "12months":
|
||||||
|
from = new Date(year, month - 11, 1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
from = new Date(year, month, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
return { dateFrom, dateTo };
|
||||||
|
}
|
||||||
|
|
||||||
export function useReports() {
|
export function useReports() {
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const fetchIdRef = useRef(0);
|
const fetchIdRef = useRef(0);
|
||||||
|
|
@ -181,9 +224,18 @@ export function useReports() {
|
||||||
dispatch({ type: "SET_PERIOD", payload: period });
|
dispatch({ type: "SET_PERIOD", payload: period });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setBudgetMonth = useCallback((year: number, month: number) => {
|
const navigateBudgetMonth = useCallback((delta: -1 | 1) => {
|
||||||
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
|
let newMonth = state.budgetMonth + delta;
|
||||||
}, []);
|
let newYear = state.budgetYear;
|
||||||
|
if (newMonth < 1) {
|
||||||
|
newMonth = 12;
|
||||||
|
newYear -= 1;
|
||||||
|
} else if (newMonth > 12) {
|
||||||
|
newMonth = 1;
|
||||||
|
newYear += 1;
|
||||||
|
}
|
||||||
|
dispatch({ type: "SET_BUDGET_MONTH", payload: { year: newYear, month: newMonth } });
|
||||||
|
}, [state.budgetYear, state.budgetMonth]);
|
||||||
|
|
||||||
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
|
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
|
||||||
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
||||||
|
|
@ -197,5 +249,5 @@ export function useReports() {
|
||||||
dispatch({ type: "SET_SOURCE_ID", payload: id });
|
dispatch({ type: "SET_SOURCE_ID", payload: id });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId };
|
return { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig, setSourceId };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,6 @@
|
||||||
"app": {
|
"app": {
|
||||||
"name": "Simpl'Result"
|
"name": "Simpl'Result"
|
||||||
},
|
},
|
||||||
"changelog": {
|
|
||||||
"title": "Version History",
|
|
||||||
"description": "View what's new and fixed in each version",
|
|
||||||
"empty": "No entries available"
|
|
||||||
},
|
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
|
|
@ -26,8 +21,6 @@
|
||||||
"noData": "No data available. Start by importing your bank statements.",
|
"noData": "No data available. Start by importing your bank statements.",
|
||||||
"expensesByCategory": "Expenses by Category",
|
"expensesByCategory": "Expenses by Category",
|
||||||
"recentTransactions": "Recent Transactions",
|
"recentTransactions": "Recent Transactions",
|
||||||
"budgetVsActual": "Budget vs Actual",
|
|
||||||
"expensesOverTime": "Expenses Over Time",
|
|
||||||
"period": {
|
"period": {
|
||||||
"month": "This month",
|
"month": "This month",
|
||||||
"3months": "3 months",
|
"3months": "3 months",
|
||||||
|
|
@ -319,18 +312,13 @@
|
||||||
"actual": "Actual",
|
"actual": "Actual",
|
||||||
"difference": "Difference",
|
"difference": "Difference",
|
||||||
"annual": "Annual",
|
"annual": "Annual",
|
||||||
"previousYear": "Prev. Year",
|
|
||||||
"splitEvenly": "Split evenly across 12 months",
|
"splitEvenly": "Split evenly across 12 months",
|
||||||
"annualMismatch": "Annual total does not match the sum of monthly amounts",
|
"annualMismatch": "Annual total does not match the sum of monthly amounts",
|
||||||
"clickToEdit": "Click to edit",
|
|
||||||
"applyToMonth": "Apply to month",
|
"applyToMonth": "Apply to month",
|
||||||
"allMonths": "All 12 months",
|
"allMonths": "All 12 months",
|
||||||
"expenses": "Expenses",
|
"expenses": "Expenses",
|
||||||
"income": "Income",
|
"income": "Income",
|
||||||
"transfers": "Transfers",
|
"transfers": "Transfers",
|
||||||
"totalExpenses": "Total Expenses",
|
|
||||||
"totalIncome": "Total Income",
|
|
||||||
"totalTransfers": "Total Transfers",
|
|
||||||
"totalPlanned": "Total Planned",
|
"totalPlanned": "Total Planned",
|
||||||
"totalActual": "Total Actual",
|
"totalActual": "Total Actual",
|
||||||
"totalDifference": "Difference",
|
"totalDifference": "Difference",
|
||||||
|
|
@ -377,8 +365,7 @@
|
||||||
"ytd": "Year-to-Date",
|
"ytd": "Year-to-Date",
|
||||||
"dollarVar": "$ Var",
|
"dollarVar": "$ Var",
|
||||||
"pctVar": "% Var",
|
"pctVar": "% Var",
|
||||||
"noData": "No budget or transaction data for this period.",
|
"noData": "No budget or transaction data for this period."
|
||||||
"titlePrefix": "Budget vs Actual for"
|
|
||||||
},
|
},
|
||||||
"dynamic": "Dynamic Report",
|
"dynamic": "Dynamic Report",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
|
|
@ -435,7 +422,14 @@
|
||||||
"installing": "Installing...",
|
"installing": "Installing...",
|
||||||
"error": "Update failed",
|
"error": "Update failed",
|
||||||
"retryButton": "Retry",
|
"retryButton": "Retry",
|
||||||
"releaseNotes": "What's New"
|
"releaseNotes": "What's New",
|
||||||
|
"notes": {
|
||||||
|
"0.4.0": "### Added\n- Categories: support for 3 levels of hierarchy (e.g., Recurring Expenses → Insurance → Car Insurance)\n- Dynamic Report: new \"Category (Level 3)\" pivot field\n- Budget: intermediate subtotals and 3-level indentation for nested categories\n- Categories: automatic `is_inputable` management when creating/deleting subcategories\n- Categories: depth validation prevents creating a 4th level\n\n### Fixed\n- Auto-categorization: keywords starting/ending with special characters (`[`, `]`, `(`, `)`, `-`, etc.) now match correctly\n- Auto-categorization: pre-compile regex patterns for better batch performance",
|
||||||
|
"0.3.11": "### Added\n- Dynamic Report: support multiple column dimensions (composite column keys)\n\n### Fixed\n- Dynamic Report: no longer affected by global page date filters — uses only its own panel filters",
|
||||||
|
"0.3.10": "### Added\n- Dynamic Report: fields can now be used in multiple zones simultaneously (rows + filters, columns + filters)\n- Dynamic Report: right-click on a filter value to exclude it (shown with strikethrough in red)\n- \"This year\" period option in reports and dashboard (Jan 1 to today)",
|
||||||
|
"0.3.9": "### Added\n- Dynamic Report (pivot table): compose custom reports by assigning dimensions to rows, columns, filters and measures to values\n- Delete keywords from the \"All Keywords\" view",
|
||||||
|
"0.3.8": "### Added\n- Custom date range picker for reports and dashboard\n- Toggle to position subtotals above or below detail rows\n- Display release notes from CHANGELOG in GitHub releases and in-app updater"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dataManagement": {
|
"dataManagement": {
|
||||||
"title": "Data Management",
|
"title": "Data Management",
|
||||||
|
|
@ -590,21 +584,18 @@
|
||||||
"features": [
|
"features": [
|
||||||
"Balance, income, and expense summary cards",
|
"Balance, income, and expense summary cards",
|
||||||
"Expense breakdown by category (pie chart with SVG patterns)",
|
"Expense breakdown by category (pie chart with SVG patterns)",
|
||||||
"Budget vs Actual table for the current month (variance in $ and %)",
|
"Recent transactions list",
|
||||||
"Stacked bar chart of expenses by category and month",
|
"Adjustable time period selector",
|
||||||
"Adjustable time period selector (default: year to date)",
|
|
||||||
"Context menu (right-click) to hide a category or view its transactions"
|
"Context menu (right-click) to hide a category or view its transactions"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Use the period selector in the top-right to choose a time range (month, 3 months, year, etc.)",
|
"Use the period selector in the top-right to choose a time range (month, 3 months, year, etc.)",
|
||||||
"Review the summary cards for your balance, total income, and total expenses",
|
"Review the summary cards for your balance, total income, and total expenses",
|
||||||
"Check the pie chart to see how your spending is distributed across categories",
|
"Check the pie chart to see how your spending is distributed across categories",
|
||||||
"Review the Budget vs Actual table to compare your spending against your plan for the current month",
|
"Right-click a category in the chart to hide it or view its transaction details",
|
||||||
"Analyze the stacked bar chart at the bottom to see expense trends by category over time",
|
"Scroll down to see your most recent transactions"
|
||||||
"Right-click a category in a chart to hide it or view its transaction details"
|
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
"The default period is year to date for an annual overview when you open the app",
|
|
||||||
"The balance is calculated as income minus expenses for the selected period",
|
"The balance is calculated as income minus expenses for the selected period",
|
||||||
"Hidden categories appear as dismissible chips above the chart — click Show All to restore them",
|
"Hidden categories appear as dismissible chips above the chart — click Show All to restore them",
|
||||||
"SVG patterns (lines, dots, crosshatch) help distinguish categories beyond just color"
|
"SVG patterns (lines, dots, crosshatch) help distinguish categories beyond just color"
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,6 @@
|
||||||
"app": {
|
"app": {
|
||||||
"name": "Simpl'Résultat"
|
"name": "Simpl'Résultat"
|
||||||
},
|
},
|
||||||
"changelog": {
|
|
||||||
"title": "Historique des versions",
|
|
||||||
"description": "Consultez les nouveautés et corrections de chaque version",
|
|
||||||
"empty": "Aucune entrée disponible"
|
|
||||||
},
|
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Tableau de bord",
|
"dashboard": "Tableau de bord",
|
||||||
"import": "Importer",
|
"import": "Importer",
|
||||||
|
|
@ -26,8 +21,6 @@
|
||||||
"noData": "Aucune donnée disponible. Commencez par importer vos relevés bancaires.",
|
"noData": "Aucune donnée disponible. Commencez par importer vos relevés bancaires.",
|
||||||
"expensesByCategory": "Dépenses par catégorie",
|
"expensesByCategory": "Dépenses par catégorie",
|
||||||
"recentTransactions": "Transactions récentes",
|
"recentTransactions": "Transactions récentes",
|
||||||
"budgetVsActual": "Budget vs Réel",
|
|
||||||
"expensesOverTime": "Dépenses dans le temps",
|
|
||||||
"period": {
|
"period": {
|
||||||
"month": "Ce mois",
|
"month": "Ce mois",
|
||||||
"3months": "3 mois",
|
"3months": "3 mois",
|
||||||
|
|
@ -319,18 +312,13 @@
|
||||||
"actual": "Réel",
|
"actual": "Réel",
|
||||||
"difference": "Écart",
|
"difference": "Écart",
|
||||||
"annual": "Annuel",
|
"annual": "Annuel",
|
||||||
"previousYear": "Année préc.",
|
|
||||||
"splitEvenly": "Répartir également sur 12 mois",
|
"splitEvenly": "Répartir également sur 12 mois",
|
||||||
"annualMismatch": "Le total annuel ne correspond pas à la somme des montants mensuels",
|
"annualMismatch": "Le total annuel ne correspond pas à la somme des montants mensuels",
|
||||||
"clickToEdit": "Cliquer pour modifier",
|
|
||||||
"applyToMonth": "Appliquer au mois",
|
"applyToMonth": "Appliquer au mois",
|
||||||
"allMonths": "Les 12 mois",
|
"allMonths": "Les 12 mois",
|
||||||
"expenses": "Dépenses",
|
"expenses": "Dépenses",
|
||||||
"income": "Revenus",
|
"income": "Revenus",
|
||||||
"transfers": "Transferts",
|
"transfers": "Transferts",
|
||||||
"totalExpenses": "Total des dépenses",
|
|
||||||
"totalIncome": "Total des revenus",
|
|
||||||
"totalTransfers": "Total des transferts",
|
|
||||||
"totalPlanned": "Total prévu",
|
"totalPlanned": "Total prévu",
|
||||||
"totalActual": "Total réel",
|
"totalActual": "Total réel",
|
||||||
"totalDifference": "Écart",
|
"totalDifference": "Écart",
|
||||||
|
|
@ -377,8 +365,7 @@
|
||||||
"ytd": "Cumul annuel",
|
"ytd": "Cumul annuel",
|
||||||
"dollarVar": "$ \u00c9cart",
|
"dollarVar": "$ \u00c9cart",
|
||||||
"pctVar": "% \u00c9cart",
|
"pctVar": "% \u00c9cart",
|
||||||
"noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode.",
|
"noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode."
|
||||||
"titlePrefix": "Budget vs Réel pour le mois de"
|
|
||||||
},
|
},
|
||||||
"dynamic": "Rapport dynamique",
|
"dynamic": "Rapport dynamique",
|
||||||
"export": "Exporter",
|
"export": "Exporter",
|
||||||
|
|
@ -435,7 +422,14 @@
|
||||||
"installing": "Installation en cours...",
|
"installing": "Installation en cours...",
|
||||||
"error": "Erreur lors de la mise à jour",
|
"error": "Erreur lors de la mise à jour",
|
||||||
"retryButton": "Réessayer",
|
"retryButton": "Réessayer",
|
||||||
"releaseNotes": "Nouveautés"
|
"releaseNotes": "Nouveautés",
|
||||||
|
"notes": {
|
||||||
|
"0.4.0": "### Ajouté\n- Catégories : support de 3 niveaux de hiérarchie (ex : Dépenses récurrentes → Assurances → Assurance-auto)\n- Rapport dynamique : nouveau champ « Catégorie (Niveau 3) »\n- Budget : sous-totaux intermédiaires et indentation 3 niveaux\n- Catégories : gestion automatique de `is_inputable` à la création/suppression de sous-catégories\n- Catégories : validation de profondeur empêche la création d'un 4e niveau\n\n### Corrigé\n- Auto-catégorisation : les mots-clés commençant/finissant par des caractères spéciaux (`[`, `]`, `(`, `)`, `-`, etc.) sont maintenant reconnus\n- Auto-catégorisation : pré-compilation des regex pour de meilleures performances en lot",
|
||||||
|
"0.3.11": "### Ajouté\n- Rapport dynamique : support de plusieurs dimensions en colonnes (clés composites)\n\n### Corrigé\n- Rapport dynamique : n'est plus affecté par les filtres de date globaux — utilise uniquement ses propres filtres du panneau",
|
||||||
|
"0.3.10": "### Ajouté\n- Rapport dynamique : les champs peuvent maintenant être utilisés dans plusieurs zones simultanément (lignes + filtres, colonnes + filtres)\n- Rapport dynamique : clic-droit sur une valeur de filtre pour l'exclure (affiché barré en rouge)\n- Option de période « Cette année » dans les rapports et le tableau de bord (du 1er janvier à aujourd'hui)",
|
||||||
|
"0.3.9": "### Ajouté\n- Rapport dynamique (tableau croisé) : composez des rapports personnalisés en assignant des dimensions aux lignes, colonnes, filtres et mesures aux valeurs\n- Suppression de mots-clés depuis la vue « Tous les mots-clés »",
|
||||||
|
"0.3.8": "### Ajouté\n- Sélecteur de plage de dates personnalisée pour les rapports et le tableau de bord\n- Bascule pour positionner les sous-totaux au-dessus ou en dessous des lignes de détail\n- Affichage des notes de version du CHANGELOG dans les releases GitHub et le système de mise à jour"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dataManagement": {
|
"dataManagement": {
|
||||||
"title": "Gestion des données",
|
"title": "Gestion des données",
|
||||||
|
|
@ -590,21 +584,18 @@
|
||||||
"features": [
|
"features": [
|
||||||
"Cartes résumées du solde, des revenus et des dépenses",
|
"Cartes résumées du solde, des revenus et des dépenses",
|
||||||
"Répartition des dépenses par catégorie (graphique circulaire avec motifs SVG)",
|
"Répartition des dépenses par catégorie (graphique circulaire avec motifs SVG)",
|
||||||
"Tableau Budget vs Réel du mois courant (écart en $ et %)",
|
"Liste des transactions récentes",
|
||||||
"Histogramme empilé des dépenses par catégorie et par mois",
|
"Sélecteur de période ajustable",
|
||||||
"Sélecteur de période ajustable (par défaut : année à ce jour)",
|
|
||||||
"Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions"
|
"Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)",
|
"Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)",
|
||||||
"Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales",
|
"Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales",
|
||||||
"Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie",
|
"Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie",
|
||||||
"Consultez le tableau Budget vs Réel pour comparer vos dépenses au budget du mois courant",
|
"Cliquez droit sur une catégorie dans le graphique pour la masquer ou voir le détail de ses transactions",
|
||||||
"Analysez l'histogramme en bas de page pour voir l'évolution de vos dépenses par catégorie dans le temps",
|
"Faites défiler vers le bas pour voir vos transactions les plus récentes"
|
||||||
"Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions"
|
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
"La période par défaut est « année à ce jour » pour un aperçu annuel dès l'ouverture",
|
|
||||||
"Le solde est calculé comme les revenus moins les dépenses pour la période sélectionnée",
|
"Le solde est calculé comme les revenus moins les dépenses pour la période sélectionnée",
|
||||||
"Les catégories masquées apparaissent sous forme de pastilles au-dessus du graphique — cliquez sur Tout afficher pour les restaurer",
|
"Les catégories masquées apparaissent sous forme de pastilles au-dessus du graphique — cliquez sur Tout afficher pour les restaurer",
|
||||||
"Les motifs SVG (lignes, points, hachures) aident à distinguer les catégories au-delà des couleurs"
|
"Les motifs SVG (lignes, points, hachures) aident à distinguer les catégories au-delà des couleurs"
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ export default function CategoriesPage() {
|
||||||
onRemove={removeKeyword}
|
onRemove={removeKeyword}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-6" style={{ height: "calc(100vh - 180px)" }}>
|
<div className="flex gap-6" style={{ minHeight: "calc(100vh - 180px)" }}>
|
||||||
<div className="w-1/3 bg-[var(--card)] rounded-xl border border-[var(--border)] p-3 overflow-y-auto">
|
<div className="w-1/3 bg-[var(--card)] rounded-xl border border-[var(--border)] p-3 overflow-y-auto">
|
||||||
<CategoryTree
|
<CategoryTree
|
||||||
tree={state.tree}
|
tree={state.tree}
|
||||||
|
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
|
|
||||||
interface ChangelogEntry {
|
|
||||||
version: string;
|
|
||||||
sections: { heading: string; items: string[] }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseChangelog(markdown: string): ChangelogEntry[] {
|
|
||||||
const entries: ChangelogEntry[] = [];
|
|
||||||
let current: ChangelogEntry | null = null;
|
|
||||||
let currentSection: { heading: string; items: string[] } | null = null;
|
|
||||||
|
|
||||||
for (const line of markdown.split("\n")) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
|
|
||||||
// Version heading: ## [0.6.0] or ## 0.6.0
|
|
||||||
const versionMatch = trimmed.match(/^## \[?([^\]]+)\]?/);
|
|
||||||
if (versionMatch) {
|
|
||||||
if (currentSection && current) current.sections.push(currentSection);
|
|
||||||
if (current) entries.push(current);
|
|
||||||
current = { version: versionMatch[1], sections: [] };
|
|
||||||
currentSection = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Section heading: ### Added, ### Corrigé, etc.
|
|
||||||
const sectionMatch = trimmed.match(/^### (.+)/);
|
|
||||||
if (sectionMatch && current) {
|
|
||||||
if (currentSection) current.sections.push(currentSection);
|
|
||||||
currentSection = { heading: sectionMatch[1], items: [] };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// List item
|
|
||||||
if (trimmed.startsWith("- ") && currentSection) {
|
|
||||||
currentSection.items.push(trimmed.slice(2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentSection && current) current.sections.push(currentSection);
|
|
||||||
if (current) entries.push(current);
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChangelogPage() {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const [entries, setEntries] = useState<ChangelogEntry[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const file = i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
|
|
||||||
fetch(file)
|
|
||||||
.then((r) => r.text())
|
|
||||||
.then((text) => setEntries(parseChangelog(text)))
|
|
||||||
.catch(() => setEntries([]));
|
|
||||||
}, [i18n.language]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 max-w-2xl mx-auto space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link
|
|
||||||
to="/settings"
|
|
||||||
className="p-1.5 rounded-lg hover:bg-[var(--muted)] transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowLeft size={18} />
|
|
||||||
</Link>
|
|
||||||
<h1 className="text-2xl font-bold">{t("changelog.title")}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{entries.length === 0 ? (
|
|
||||||
<p className="text-[var(--muted-foreground)]">{t("changelog.empty")}</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{entries.map((entry) => (
|
|
||||||
<div
|
|
||||||
key={entry.version}
|
|
||||||
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-3"
|
|
||||||
>
|
|
||||||
<h2 className="text-lg font-semibold">{entry.version}</h2>
|
|
||||||
{entry.sections.map((section, si) => (
|
|
||||||
<div key={si} className="space-y-1.5">
|
|
||||||
<h3 className="text-sm font-semibold text-[var(--primary)]">
|
|
||||||
{section.heading}
|
|
||||||
</h3>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{section.items.map((item, ii) => (
|
|
||||||
<li
|
|
||||||
key={ii}
|
|
||||||
className="text-sm text-[var(--muted-foreground)] pl-3"
|
|
||||||
>
|
|
||||||
{"\u2022 "}
|
|
||||||
{item.replace(/\*\*(.+?)\*\*/g, "$1")}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +1,47 @@
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Wallet, TrendingUp, TrendingDown } from "lucide-react";
|
import { Wallet, TrendingUp, TrendingDown } from "lucide-react";
|
||||||
import { useDashboard } from "../hooks/useDashboard";
|
import { useDashboard } from "../hooks/useDashboard";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
import CategoryPieChart from "../components/dashboard/CategoryPieChart";
|
import CategoryPieChart from "../components/dashboard/CategoryPieChart";
|
||||||
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
import RecentTransactionsList from "../components/dashboard/RecentTransactionsList";
|
||||||
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
|
|
||||||
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
||||||
import type { CategoryBreakdownItem } from "../shared/types";
|
import type { CategoryBreakdownItem, DashboardPeriod } from "../shared/types";
|
||||||
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
|
|
||||||
|
|
||||||
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
|
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
|
||||||
|
|
||||||
|
function computeDateRange(
|
||||||
|
period: DashboardPeriod,
|
||||||
|
customDateFrom?: string,
|
||||||
|
customDateTo?: string,
|
||||||
|
): { dateFrom?: string; dateTo?: string } {
|
||||||
|
if (period === "all") return {};
|
||||||
|
if (period === "custom" && customDateFrom && customDateTo) {
|
||||||
|
return { dateFrom: customDateFrom, dateTo: customDateTo };
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = now.getMonth();
|
||||||
|
const day = now.getDate();
|
||||||
|
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||||
|
let from: Date;
|
||||||
|
switch (period) {
|
||||||
|
case "month": from = new Date(year, month, 1); break;
|
||||||
|
case "3months": from = new Date(year, month - 2, 1); break;
|
||||||
|
case "6months": from = new Date(year, month - 5, 1); break;
|
||||||
|
case "year": from = new Date(year, 0, 1); break;
|
||||||
|
case "12months": from = new Date(year, month - 11, 1); break;
|
||||||
|
default: from = new Date(year, month, 1); break;
|
||||||
|
}
|
||||||
|
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
||||||
|
return { dateFrom, dateTo };
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { state, setPeriod, setCustomDates, setBudgetMonth } = useDashboard();
|
const { state, setPeriod, setCustomDates } = useDashboard();
|
||||||
const { summary, categoryBreakdown, categoryOverTime, budgetVsActual, period, isLoading } = state;
|
const { summary, categoryBreakdown, recentTransactions, period, isLoading } = state;
|
||||||
|
|
||||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||||
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
||||||
|
|
@ -65,8 +90,6 @@ export default function DashboardPage() {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
|
|
||||||
|
|
||||||
const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
|
const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -85,7 +108,7 @@ export default function DashboardPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
<div
|
<div
|
||||||
key={card.labelKey}
|
key={card.labelKey}
|
||||||
|
|
@ -102,48 +125,15 @@ export default function DashboardPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
<div className="lg:col-span-1">
|
<CategoryPieChart
|
||||||
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2>
|
data={categoryBreakdown}
|
||||||
<CategoryPieChart
|
|
||||||
data={categoryBreakdown}
|
|
||||||
hiddenCategories={hiddenCategories}
|
|
||||||
onToggleHidden={toggleHidden}
|
|
||||||
onShowAll={showAll}
|
|
||||||
onViewDetails={viewDetails}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="lg:col-span-3">
|
|
||||||
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2 flex-wrap">
|
|
||||||
{t("reports.bva.titlePrefix")}
|
|
||||||
<select
|
|
||||||
value={`${state.budgetYear}-${state.budgetMonth}`}
|
|
||||||
onChange={(e) => {
|
|
||||||
const [y, m] = e.target.value.split("-").map(Number);
|
|
||||||
setBudgetMonth(y, m);
|
|
||||||
}}
|
|
||||||
className="text-base font-semibold bg-[var(--card)] border border-[var(--border)] rounded-lg px-2 py-0.5 cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
|
||||||
>
|
|
||||||
{monthOptions.map((opt) => (
|
|
||||||
<option key={opt.key} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</h2>
|
|
||||||
<BudgetVsActualTable data={budgetVsActual} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesOverTime")}</h2>
|
|
||||||
<CategoryOverTimeChart
|
|
||||||
data={categoryOverTime}
|
|
||||||
hiddenCategories={hiddenCategories}
|
hiddenCategories={hiddenCategories}
|
||||||
onToggleHidden={toggleHidden}
|
onToggleHidden={toggleHidden}
|
||||||
onShowAll={showAll}
|
onShowAll={showAll}
|
||||||
onViewDetails={viewDetails}
|
onViewDetails={viewDetails}
|
||||||
/>
|
/>
|
||||||
|
<RecentTransactionsList transactions={recentTransactions} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detailModal && (
|
{detailModal && (
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ import { useTranslation } from "react-i18next";
|
||||||
import { Hash, Table, BarChart3 } from "lucide-react";
|
import { Hash, Table, BarChart3 } from "lucide-react";
|
||||||
import { useReports } from "../hooks/useReports";
|
import { useReports } from "../hooks/useReports";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import type { ReportTab, CategoryBreakdownItem, ImportSource } from "../shared/types";
|
import type { ReportTab, CategoryBreakdownItem, DashboardPeriod, ImportSource } from "../shared/types";
|
||||||
import { getAllSources } from "../services/importSourceService";
|
import { getAllSources } from "../services/importSourceService";
|
||||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
|
import MonthNavigator from "../components/budget/MonthNavigator";
|
||||||
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
||||||
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
|
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
|
||||||
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
||||||
|
|
@ -16,13 +17,39 @@ import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
|
||||||
import DynamicReport from "../components/reports/DynamicReport";
|
import DynamicReport from "../components/reports/DynamicReport";
|
||||||
import ReportFilterPanel from "../components/reports/ReportFilterPanel";
|
import ReportFilterPanel from "../components/reports/ReportFilterPanel";
|
||||||
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
||||||
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
|
|
||||||
|
|
||||||
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual", "dynamic"];
|
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual", "dynamic"];
|
||||||
|
|
||||||
|
function computeDateRange(
|
||||||
|
period: DashboardPeriod,
|
||||||
|
customDateFrom?: string,
|
||||||
|
customDateTo?: string,
|
||||||
|
): { dateFrom?: string; dateTo?: string } {
|
||||||
|
if (period === "all") return {};
|
||||||
|
if (period === "custom" && customDateFrom && customDateTo) {
|
||||||
|
return { dateFrom: customDateFrom, dateTo: customDateTo };
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = now.getMonth();
|
||||||
|
const day = now.getDate();
|
||||||
|
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||||
|
let from: Date;
|
||||||
|
switch (period) {
|
||||||
|
case "month": from = new Date(year, month, 1); break;
|
||||||
|
case "3months": from = new Date(year, month - 2, 1); break;
|
||||||
|
case "6months": from = new Date(year, month - 5, 1); break;
|
||||||
|
case "year": from = new Date(year, 0, 1); break;
|
||||||
|
case "12months": from = new Date(year, month - 11, 1); break;
|
||||||
|
default: from = new Date(year, month, 1); break;
|
||||||
|
}
|
||||||
|
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
||||||
|
return { dateFrom, dateTo };
|
||||||
|
}
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId } = useReports();
|
const { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig, setSourceId } = useReports();
|
||||||
const [sources, setSources] = useState<ImportSource[]>([]);
|
const [sources, setSources] = useState<ImportSource[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -66,8 +93,6 @@ export default function ReportsPage() {
|
||||||
return [];
|
return [];
|
||||||
}, [state.tab, state.categorySpending, state.categoryOverTime]);
|
}, [state.tab, state.categorySpending, state.categoryOverTime]);
|
||||||
|
|
||||||
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
|
|
||||||
|
|
||||||
const hasCategories = ["byCategory", "overTime"].includes(state.tab) && filterCategories.length > 0;
|
const hasCategories = ["byCategory", "overTime"].includes(state.tab) && filterCategories.length > 0;
|
||||||
const showFilterPanel = hasCategories || (state.tab === "trends" && sources.length > 1);
|
const showFilterPanel = hasCategories || (state.tab === "trends" && sources.length > 1);
|
||||||
|
|
||||||
|
|
@ -75,30 +100,16 @@ export default function ReportsPage() {
|
||||||
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
|
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||||
<div className="relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
<div className="relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{state.tab === "budgetVsActual" ? (
|
<h1 className="text-2xl font-bold">{t("reports.title")}</h1>
|
||||||
<h1 className="text-2xl font-bold flex items-center gap-2 flex-wrap">
|
|
||||||
{t("reports.bva.titlePrefix")}
|
|
||||||
<select
|
|
||||||
value={`${state.budgetYear}-${state.budgetMonth}`}
|
|
||||||
onChange={(e) => {
|
|
||||||
const [y, m] = e.target.value.split("-").map(Number);
|
|
||||||
setBudgetMonth(y, m);
|
|
||||||
}}
|
|
||||||
className="text-lg font-bold bg-[var(--card)] border border-[var(--border)] rounded-lg px-2 py-0.5 cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
|
||||||
>
|
|
||||||
{monthOptions.map((opt) => (
|
|
||||||
<option key={opt.key} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</h1>
|
|
||||||
) : (
|
|
||||||
<h1 className="text-2xl font-bold">{t("reports.title")}</h1>
|
|
||||||
)}
|
|
||||||
<PageHelp helpKey="reports" />
|
<PageHelp helpKey="reports" />
|
||||||
</div>
|
</div>
|
||||||
{state.tab !== "budgetVsActual" && (
|
{state.tab === "budgetVsActual" ? (
|
||||||
|
<MonthNavigator
|
||||||
|
year={state.budgetYear}
|
||||||
|
month={state.budgetMonth}
|
||||||
|
onNavigate={navigateBudgetMonth}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<PeriodSelector
|
<PeriodSelector
|
||||||
value={state.period}
|
value={state.period}
|
||||||
onChange={setPeriod}
|
onChange={setPeriod}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Info,
|
Info,
|
||||||
|
|
@ -11,7 +11,6 @@ import {
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FileText,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
import { useUpdater } from "../hooks/useUpdater";
|
import { useUpdater } from "../hooks/useUpdater";
|
||||||
|
|
@ -22,44 +21,15 @@ import DataManagementCard from "../components/settings/DataManagementCard";
|
||||||
import LogViewerCard from "../components/settings/LogViewerCard";
|
import LogViewerCard from "../components/settings/LogViewerCard";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { state, checkForUpdate, downloadAndInstall, installAndRestart } =
|
const { state, checkForUpdate, downloadAndInstall, installAndRestart } =
|
||||||
useUpdater();
|
useUpdater();
|
||||||
const [version, setVersion] = useState("");
|
const [version, setVersion] = useState("");
|
||||||
const [releaseNotes, setReleaseNotes] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchReleaseNotes = useCallback(
|
|
||||||
(targetVersion: string) => {
|
|
||||||
const file =
|
|
||||||
i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
|
|
||||||
fetch(file)
|
|
||||||
.then((r) => r.text())
|
|
||||||
.then((text) => {
|
|
||||||
const escaped = targetVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
const re = new RegExp(
|
|
||||||
`^## \\[?${escaped}\\]?.*$\\n([\\s\\S]*?)(?=^## |$(?!\\n))`,
|
|
||||||
"m",
|
|
||||||
);
|
|
||||||
const match = text.match(re);
|
|
||||||
setReleaseNotes(
|
|
||||||
match ? match[1].trim() : null,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => setReleaseNotes(null));
|
|
||||||
},
|
|
||||||
[i18n.language],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getVersion().then(setVersion);
|
getVersion().then(setVersion);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (state.status === "available" && state.version) {
|
|
||||||
fetchReleaseNotes(state.version);
|
|
||||||
}
|
|
||||||
}, [state.status, state.version, fetchReleaseNotes]);
|
|
||||||
|
|
||||||
const progressPercent =
|
const progressPercent =
|
||||||
state.contentLength && state.contentLength > 0
|
state.contentLength && state.contentLength > 0
|
||||||
? Math.round((state.progress / state.contentLength) * 100)
|
? Math.round((state.progress / state.contentLength) * 100)
|
||||||
|
|
@ -108,27 +78,6 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Changelog card */}
|
|
||||||
<Link
|
|
||||||
to="/changelog"
|
|
||||||
className="block bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
|
|
||||||
<FileText size={22} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">{t("changelog.title")}</h2>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
{t("changelog.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRight size={18} className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors" />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Update card */}
|
{/* Update card */}
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
|
@ -178,7 +127,9 @@ export default function SettingsPage() {
|
||||||
{t("settings.updates.available", { version: state.version })}
|
{t("settings.updates.available", { version: state.version })}
|
||||||
</p>
|
</p>
|
||||||
{(() => {
|
{(() => {
|
||||||
const notes = releaseNotes || state.body;
|
const notesKey = `settings.updates.notes.${state.version}`;
|
||||||
|
const i18nNotes = t(notesKey, { defaultValue: "" });
|
||||||
|
const notes = i18nNotes || state.body;
|
||||||
if (!notes) return null;
|
if (!notes) return null;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -178,16 +178,6 @@ export async function deleteTemplate(templateId: number): Promise<void> {
|
||||||
await db.execute("DELETE FROM budget_templates WHERE id = $1", [templateId]);
|
await db.execute("DELETE FROM budget_templates WHERE id = $1", [templateId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Actuals helpers ---
|
|
||||||
|
|
||||||
export async function getActualTotalsForYear(
|
|
||||||
year: number
|
|
||||||
): Promise<Array<{ category_id: number | null; actual: number }>> {
|
|
||||||
const dateFrom = `${year}-01-01`;
|
|
||||||
const dateTo = `${year}-12-31`;
|
|
||||||
return getActualsByCategoryRange(dateFrom, dateTo);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Budget vs Actual ---
|
// --- Budget vs Actual ---
|
||||||
|
|
||||||
async function getActualsByCategoryRange(
|
async function getActualsByCategoryRange(
|
||||||
|
|
@ -241,6 +231,7 @@ export async function getBudgetVsActualData(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index categories
|
// Index categories
|
||||||
|
const catById = new Map(allCategories.map((c) => [c.id, c]));
|
||||||
const childrenByParent = new Map<number, Category[]>();
|
const childrenByParent = new Map<number, Category[]>();
|
||||||
for (const cat of allCategories) {
|
for (const cat of allCategories) {
|
||||||
if (cat.parent_id) {
|
if (cat.parent_id) {
|
||||||
|
|
@ -253,7 +244,7 @@ export async function getBudgetVsActualData(
|
||||||
const signFor = (type: string) => (type === "expense" ? -1 : 1);
|
const signFor = (type: string) => (type === "expense" ? -1 : 1);
|
||||||
|
|
||||||
// Compute leaf row values
|
// Compute leaf row values
|
||||||
function buildLeaf(cat: Category, parentId: number | null, depth: number): BudgetVsActualRow {
|
function buildLeaf(cat: Category, parentId: number | null, depth: 0 | 1 | 2): BudgetVsActualRow {
|
||||||
const sign = signFor(cat.type);
|
const sign = signFor(cat.type);
|
||||||
const monthMap = entryMap.get(cat.id);
|
const monthMap = entryMap.get(cat.id);
|
||||||
const rawMonthBudget = monthMap?.get(month) ?? 0;
|
const rawMonthBudget = monthMap?.get(month) ?? 0;
|
||||||
|
|
@ -290,7 +281,7 @@ export async function getBudgetVsActualData(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSubtotal(cat: Category, childRows: BudgetVsActualRow[], parentId: number | null, depth: number): BudgetVsActualRow {
|
function buildSubtotal(cat: Category, childRows: BudgetVsActualRow[], parentId: number | null, depth: 0 | 1 | 2): BudgetVsActualRow {
|
||||||
const row: BudgetVsActualRow = {
|
const row: BudgetVsActualRow = {
|
||||||
category_id: cat.id,
|
category_id: cat.id,
|
||||||
category_name: cat.name,
|
category_name: cat.name,
|
||||||
|
|
@ -332,41 +323,35 @@ export async function getBudgetVsActualData(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build rows for a sub-group (recursive, supports arbitrary depth)
|
// Build rows for a level-2 parent (intermediate parent with grandchildren)
|
||||||
function buildSubGroup(cat: Category, groupParentId: number, depth: number): BudgetVsActualRow[] {
|
function buildLevel2Group(cat: Category, grandparentId: number): BudgetVsActualRow[] {
|
||||||
const subChildren = childrenByParent.get(cat.id) || [];
|
const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
|
||||||
const hasSubChildren = subChildren.some(
|
if (grandchildren.length === 0 && cat.is_inputable) {
|
||||||
(c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0
|
// Leaf at level 2
|
||||||
);
|
const leaf = buildLeaf(cat, grandparentId, 2);
|
||||||
|
|
||||||
if (!hasSubChildren && cat.is_inputable) {
|
|
||||||
const leaf = buildLeaf(cat, groupParentId, depth);
|
|
||||||
return isRowAllZero(leaf) ? [] : [leaf];
|
return isRowAllZero(leaf) ? [] : [leaf];
|
||||||
}
|
}
|
||||||
if (!hasSubChildren) return [];
|
if (grandchildren.length === 0) return [];
|
||||||
|
|
||||||
const childRows: BudgetVsActualRow[] = [];
|
const gcRows: BudgetVsActualRow[] = [];
|
||||||
if (cat.is_inputable) {
|
if (cat.is_inputable) {
|
||||||
const direct = buildLeaf(cat, cat.id, depth + 1);
|
const direct = buildLeaf(cat, cat.id, 2);
|
||||||
direct.category_name = `${cat.name} (direct)`;
|
direct.category_name = `${cat.name} (direct)`;
|
||||||
if (!isRowAllZero(direct)) childRows.push(direct);
|
if (!isRowAllZero(direct)) gcRows.push(direct);
|
||||||
}
|
}
|
||||||
const sortedSubChildren = [...subChildren].sort((a, b) => a.name.localeCompare(b.name));
|
for (const gc of grandchildren) {
|
||||||
for (const child of sortedSubChildren) {
|
const leaf = buildLeaf(gc, cat.id, 2);
|
||||||
const grandchildren = childrenByParent.get(child.id) || [];
|
if (!isRowAllZero(leaf)) gcRows.push(leaf);
|
||||||
if (grandchildren.length > 0) {
|
|
||||||
const subRows = buildSubGroup(child, cat.id, depth + 1);
|
|
||||||
childRows.push(...subRows);
|
|
||||||
} else if (child.is_inputable) {
|
|
||||||
const leaf = buildLeaf(child, cat.id, depth + 1);
|
|
||||||
if (!isRowAllZero(leaf)) childRows.push(leaf);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (childRows.length === 0) return [];
|
if (gcRows.length === 0) return [];
|
||||||
|
|
||||||
const leafRows = childRows.filter((r) => !r.is_parent);
|
const subtotal = buildSubtotal(cat, gcRows, grandparentId, 1);
|
||||||
const subtotal = buildSubtotal(cat, leafRows, groupParentId, depth);
|
gcRows.sort((a, b) => {
|
||||||
return [subtotal, ...childRows];
|
if (a.category_id === cat.id) return -1;
|
||||||
|
if (b.category_id === cat.id) return 1;
|
||||||
|
return a.category_name.localeCompare(b.category_name);
|
||||||
|
});
|
||||||
|
return [subtotal, ...gcRows];
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows: BudgetVsActualRow[] = [];
|
const rows: BudgetVsActualRow[] = [];
|
||||||
|
|
@ -374,15 +359,15 @@ export async function getBudgetVsActualData(
|
||||||
|
|
||||||
for (const cat of topLevel) {
|
for (const cat of topLevel) {
|
||||||
const children = childrenByParent.get(cat.id) || [];
|
const children = childrenByParent.get(cat.id) || [];
|
||||||
const hasChildren = children.some(
|
const inputableChildren = children.filter((c) => c.is_inputable);
|
||||||
(c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0
|
// Also check for non-inputable intermediate parents that have their own children
|
||||||
);
|
const intermediateParents = children.filter((c) => !c.is_inputable && (childrenByParent.get(c.id) || []).length > 0);
|
||||||
|
|
||||||
if (!hasChildren && cat.is_inputable) {
|
if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) {
|
||||||
// Standalone leaf at level 0
|
// Standalone leaf at level 0
|
||||||
const leaf = buildLeaf(cat, null, 0);
|
const leaf = buildLeaf(cat, null, 0);
|
||||||
if (!isRowAllZero(leaf)) rows.push(leaf);
|
if (!isRowAllZero(leaf)) rows.push(leaf);
|
||||||
} else if (hasChildren) {
|
} else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
|
||||||
const allChildRows: BudgetVsActualRow[] = [];
|
const allChildRows: BudgetVsActualRow[] = [];
|
||||||
|
|
||||||
// Direct transactions on the parent itself
|
// Direct transactions on the parent itself
|
||||||
|
|
@ -392,19 +377,25 @@ export async function getBudgetVsActualData(
|
||||||
if (!isRowAllZero(direct)) allChildRows.push(direct);
|
if (!isRowAllZero(direct)) allChildRows.push(direct);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process children in alphabetical order
|
// Level-2 leaves (direct children that are inputable and have no children)
|
||||||
const sortedChildren = [...children].sort((a, b) => a.name.localeCompare(b.name));
|
for (const child of inputableChildren) {
|
||||||
for (const child of sortedChildren) {
|
|
||||||
const grandchildren = childrenByParent.get(child.id) || [];
|
const grandchildren = childrenByParent.get(child.id) || [];
|
||||||
if (grandchildren.length > 0) {
|
if (grandchildren.length === 0) {
|
||||||
const subRows = buildSubGroup(child, cat.id, 1);
|
|
||||||
allChildRows.push(...subRows);
|
|
||||||
} else if (child.is_inputable) {
|
|
||||||
const leaf = buildLeaf(child, cat.id, 1);
|
const leaf = buildLeaf(child, cat.id, 1);
|
||||||
if (!isRowAllZero(leaf)) allChildRows.push(leaf);
|
if (!isRowAllZero(leaf)) allChildRows.push(leaf);
|
||||||
|
} else {
|
||||||
|
// This child has its own children — it's an intermediate parent at level 1
|
||||||
|
const subRows = buildLevel2Group(child, cat.id);
|
||||||
|
allChildRows.push(...subRows);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Non-inputable intermediate parents at level 1
|
||||||
|
for (const ip of intermediateParents) {
|
||||||
|
const subRows = buildLevel2Group(ip, cat.id);
|
||||||
|
allChildRows.push(...subRows);
|
||||||
|
}
|
||||||
|
|
||||||
if (allChildRows.length === 0) continue;
|
if (allChildRows.length === 0) continue;
|
||||||
|
|
||||||
// Collect only leaf rows for parent subtotal (avoid double-counting)
|
// Collect only leaf rows for parent subtotal (avoid double-counting)
|
||||||
|
|
@ -412,19 +403,51 @@ export async function getBudgetVsActualData(
|
||||||
const parent = buildSubtotal(cat, leafRows, null, 0);
|
const parent = buildSubtotal(cat, leafRows, null, 0);
|
||||||
|
|
||||||
rows.push(parent);
|
rows.push(parent);
|
||||||
|
|
||||||
|
// Sort: "(direct)" first, then subtotals with their children, then alphabetical leaves
|
||||||
|
allChildRows.sort((a, b) => {
|
||||||
|
if (a.category_id === cat.id && !a.is_parent) return -1;
|
||||||
|
if (b.category_id === cat.id && !b.is_parent) return 1;
|
||||||
|
return a.category_name.localeCompare(b.category_name);
|
||||||
|
});
|
||||||
rows.push(...allChildRows);
|
rows.push(...allChildRows);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by type only, preserving tree order within groups (already built correctly)
|
// Sort by type, then within same type keep parent+children groups together
|
||||||
const rowOrder = new Map<BudgetVsActualRow, number>();
|
|
||||||
rows.forEach((r, i) => rowOrder.set(r, i));
|
|
||||||
|
|
||||||
rows.sort((a, b) => {
|
rows.sort((a, b) => {
|
||||||
const typeA = TYPE_ORDER[a.category_type] ?? 9;
|
const typeA = TYPE_ORDER[a.category_type] ?? 9;
|
||||||
const typeB = TYPE_ORDER[b.category_type] ?? 9;
|
const typeB = TYPE_ORDER[b.category_type] ?? 9;
|
||||||
if (typeA !== typeB) return typeA - typeB;
|
if (typeA !== typeB) return typeA - typeB;
|
||||||
return rowOrder.get(a)! - rowOrder.get(b)!;
|
// Find the top-level group id
|
||||||
|
function getGroupId(r: BudgetVsActualRow): number {
|
||||||
|
if (r.depth === 0) return r.category_id;
|
||||||
|
if (r.is_parent && r.parent_id === null) return r.category_id;
|
||||||
|
// Walk up to find the root
|
||||||
|
let pid = r.parent_id;
|
||||||
|
while (pid !== null) {
|
||||||
|
const pCat = catById.get(pid);
|
||||||
|
if (!pCat || !pCat.parent_id) return pid;
|
||||||
|
pid = pCat.parent_id;
|
||||||
|
}
|
||||||
|
return r.category_id;
|
||||||
|
}
|
||||||
|
const groupA = getGroupId(a);
|
||||||
|
const groupB = getGroupId(b);
|
||||||
|
if (groupA !== groupB) {
|
||||||
|
const catA = catById.get(groupA);
|
||||||
|
const catB = catById.get(groupB);
|
||||||
|
const orderA = catA?.sort_order ?? 999;
|
||||||
|
const orderB = catB?.sort_order ?? 999;
|
||||||
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
|
return (catA?.name ?? "").localeCompare(catB?.name ?? "");
|
||||||
|
}
|
||||||
|
// Within same group: sort by depth, then parent before children
|
||||||
|
if (a.is_parent !== b.is_parent && (a.depth ?? 0) === (b.depth ?? 0)) return a.is_parent ? -1 : 1;
|
||||||
|
if ((a.depth ?? 0) !== (b.depth ?? 0)) return (a.depth ?? 0) - (b.depth ?? 0);
|
||||||
|
if (a.parent_id && a.category_id === a.parent_id) return -1;
|
||||||
|
if (b.parent_id && b.category_id === b.parent_id) return 1;
|
||||||
|
return a.category_name.localeCompare(b.category_name);
|
||||||
});
|
});
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ export async function getMonthlyTrends(
|
||||||
export async function getCategoryOverTime(
|
export async function getCategoryOverTime(
|
||||||
dateFrom?: string,
|
dateFrom?: string,
|
||||||
dateTo?: string,
|
dateTo?: string,
|
||||||
topN: number = 50,
|
topN: number = 8,
|
||||||
sourceId?: number,
|
sourceId?: number,
|
||||||
): Promise<CategoryOverTimeData> {
|
): Promise<CategoryOverTimeData> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
|
||||||
|
|
@ -139,10 +139,9 @@ export interface BudgetYearRow {
|
||||||
category_type: "expense" | "income" | "transfer";
|
category_type: "expense" | "income" | "transfer";
|
||||||
parent_id: number | null;
|
parent_id: number | null;
|
||||||
is_parent: boolean;
|
is_parent: boolean;
|
||||||
depth?: number;
|
depth?: 0 | 1 | 2;
|
||||||
months: number[]; // index 0-11 = Jan-Dec planned amounts
|
months: number[]; // index 0-11 = Jan-Dec planned amounts
|
||||||
annual: number; // computed sum
|
annual: number; // computed sum
|
||||||
previousYearTotal: number; // actual (transactions) total from the previous year
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportConfigTemplate {
|
export interface ImportConfigTemplate {
|
||||||
|
|
@ -332,7 +331,7 @@ export interface BudgetVsActualRow {
|
||||||
category_type: "expense" | "income" | "transfer";
|
category_type: "expense" | "income" | "transfer";
|
||||||
parent_id: number | null;
|
parent_id: number | null;
|
||||||
is_parent: boolean;
|
is_parent: boolean;
|
||||||
depth?: number;
|
depth?: 0 | 1 | 2;
|
||||||
monthActual: number;
|
monthActual: number;
|
||||||
monthBudget: number;
|
monthBudget: number;
|
||||||
monthVariation: number;
|
monthVariation: number;
|
||||||
|
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { computeDateRange, buildMonthOptions } from "./dateRange";
|
|
||||||
|
|
||||||
describe("computeDateRange", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Fix "now" to 2025-07-15 for deterministic tests
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(new Date(2025, 6, 15)); // July 15, 2025
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty object for "all" period', () => {
|
|
||||||
expect(computeDateRange("all")).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns custom range for "custom" period', () => {
|
|
||||||
expect(computeDateRange("custom", "2025-01-01", "2025-06-30")).toEqual({
|
|
||||||
dateFrom: "2025-01-01",
|
|
||||||
dateTo: "2025-06-30",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to default when "custom" has missing dates', () => {
|
|
||||||
const result = computeDateRange("custom");
|
|
||||||
// Should fall through to default (same as "month")
|
|
||||||
expect(result.dateFrom).toBe("2025-07-01");
|
|
||||||
expect(result.dateTo).toBe("2025-07-15");
|
|
||||||
});
|
|
||||||
|
|
||||||
it('computes "month" period (first of current month to today)', () => {
|
|
||||||
const result = computeDateRange("month");
|
|
||||||
expect(result).toEqual({ dateFrom: "2025-07-01", dateTo: "2025-07-15" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('computes "3months" period (3 months back)', () => {
|
|
||||||
const result = computeDateRange("3months");
|
|
||||||
expect(result).toEqual({ dateFrom: "2025-05-01", dateTo: "2025-07-15" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('computes "6months" period (6 months back)', () => {
|
|
||||||
const result = computeDateRange("6months");
|
|
||||||
expect(result).toEqual({ dateFrom: "2025-02-01", dateTo: "2025-07-15" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('computes "year" period (Jan 1st of current year)', () => {
|
|
||||||
const result = computeDateRange("year");
|
|
||||||
expect(result).toEqual({ dateFrom: "2025-01-01", dateTo: "2025-07-15" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('computes "12months" period (12 months back)', () => {
|
|
||||||
const result = computeDateRange("12months");
|
|
||||||
expect(result).toEqual({ dateFrom: "2024-08-01", dateTo: "2025-07-15" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles January rollover for 3months period", () => {
|
|
||||||
vi.setSystemTime(new Date(2025, 1, 10)); // Feb 10, 2025
|
|
||||||
const result = computeDateRange("3months");
|
|
||||||
expect(result).toEqual({ dateFrom: "2024-12-01", dateTo: "2025-02-10" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles January rollover for 6months period", () => {
|
|
||||||
vi.setSystemTime(new Date(2025, 0, 20)); // Jan 20, 2025
|
|
||||||
const result = computeDateRange("6months");
|
|
||||||
expect(result).toEqual({ dateFrom: "2024-08-01", dateTo: "2025-01-20" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles January rollover for 12months period", () => {
|
|
||||||
vi.setSystemTime(new Date(2025, 0, 5)); // Jan 5, 2025
|
|
||||||
const result = computeDateRange("12months");
|
|
||||||
expect(result).toEqual({ dateFrom: "2024-02-01", dateTo: "2025-01-05" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildMonthOptions", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(new Date(2025, 6, 15)); // July 15, 2025
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 24 month options", () => {
|
|
||||||
const options = buildMonthOptions("fr");
|
|
||||||
expect(options).toHaveLength(24);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts with the current month", () => {
|
|
||||||
const options = buildMonthOptions("en");
|
|
||||||
expect(options[0].key).toBe("2025-7");
|
|
||||||
expect(options[0].value).toBe("2025-7");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ends 23 months ago", () => {
|
|
||||||
const options = buildMonthOptions("en");
|
|
||||||
expect(options[23].key).toBe("2023-8");
|
|
||||||
expect(options[23].value).toBe("2023-8");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles January rollover correctly", () => {
|
|
||||||
vi.setSystemTime(new Date(2025, 0, 15)); // Jan 15, 2025
|
|
||||||
const options = buildMonthOptions("en");
|
|
||||||
expect(options[0].key).toBe("2025-1");
|
|
||||||
expect(options[1].key).toBe("2024-12");
|
|
||||||
expect(options[12].key).toBe("2024-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("capitalizes the first letter of labels", () => {
|
|
||||||
const options = buildMonthOptions("fr");
|
|
||||||
for (const opt of options) {
|
|
||||||
expect(opt.label[0]).toBe(opt.label[0].toUpperCase());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("labels contain year information", () => {
|
|
||||||
const options = buildMonthOptions("en");
|
|
||||||
expect(options[0].label).toContain("2025");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
import type { DashboardPeriod } from "../shared/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute a date range (dateFrom / dateTo) based on the selected period.
|
|
||||||
* Shared between useDashboard, useReports, DashboardPage and ReportsPage.
|
|
||||||
*/
|
|
||||||
export function computeDateRange(
|
|
||||||
period: DashboardPeriod,
|
|
||||||
customDateFrom?: string,
|
|
||||||
customDateTo?: string,
|
|
||||||
): { dateFrom?: string; dateTo?: string } {
|
|
||||||
if (period === "all") return {};
|
|
||||||
if (period === "custom" && customDateFrom && customDateTo) {
|
|
||||||
return { dateFrom: customDateFrom, dateTo: customDateTo };
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const year = now.getFullYear();
|
|
||||||
const month = now.getMonth();
|
|
||||||
const day = now.getDate();
|
|
||||||
|
|
||||||
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
||||||
|
|
||||||
let from: Date;
|
|
||||||
switch (period) {
|
|
||||||
case "month":
|
|
||||||
from = new Date(year, month, 1);
|
|
||||||
break;
|
|
||||||
case "3months":
|
|
||||||
from = new Date(year, month - 2, 1);
|
|
||||||
break;
|
|
||||||
case "6months":
|
|
||||||
from = new Date(year, month - 5, 1);
|
|
||||||
break;
|
|
||||||
case "year":
|
|
||||||
from = new Date(year, 0, 1);
|
|
||||||
break;
|
|
||||||
case "12months":
|
|
||||||
from = new Date(year, month - 11, 1);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
from = new Date(year, month, 1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
|
||||||
|
|
||||||
return { dateFrom, dateTo };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build an array of month options for the budget month dropdown.
|
|
||||||
* Returns the last 24 months with localized labels.
|
|
||||||
*/
|
|
||||||
export function buildMonthOptions(language: string): Array<{ key: string; value: string; label: string }> {
|
|
||||||
const now = new Date();
|
|
||||||
const currentMonth = now.getMonth();
|
|
||||||
const currentYear = now.getFullYear();
|
|
||||||
return Array.from({ length: 24 }, (_, i) => {
|
|
||||||
const d = new Date(currentYear, currentMonth - i, 1);
|
|
||||||
const y = d.getFullYear();
|
|
||||||
const m = d.getMonth() + 1;
|
|
||||||
const label = new Intl.DateTimeFormat(language, { month: "long", year: "numeric" }).format(d);
|
|
||||||
return { key: `${y}-${m}`, value: `${y}-${m}`, label: label.charAt(0).toUpperCase() + label.slice(1) };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
/**
|
|
||||||
* Shared utility for reordering budget table rows.
|
|
||||||
* Recursively moves subtotal (parent) rows below their children
|
|
||||||
* at every depth level when "subtotals on bottom" is enabled.
|
|
||||||
*/
|
|
||||||
export function reorderRows<
|
|
||||||
T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: number },
|
|
||||||
>(rows: T[], subtotalsOnTop: boolean): T[] {
|
|
||||||
if (subtotalsOnTop) return rows;
|
|
||||||
|
|
||||||
function reorderGroup(groupRows: T[], parentDepth: number): T[] {
|
|
||||||
const result: T[] = [];
|
|
||||||
let currentParent: T | null = null;
|
|
||||||
let currentChildren: T[] = [];
|
|
||||||
|
|
||||||
for (const row of groupRows) {
|
|
||||||
if (row.is_parent && (row.depth ?? 0) === parentDepth) {
|
|
||||||
// Flush previous group
|
|
||||||
if (currentParent) {
|
|
||||||
result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent);
|
|
||||||
currentChildren = [];
|
|
||||||
}
|
|
||||||
currentParent = row;
|
|
||||||
} else if (currentParent) {
|
|
||||||
currentChildren.push(row);
|
|
||||||
} else {
|
|
||||||
result.push(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Flush last group
|
|
||||||
if (currentParent) {
|
|
||||||
result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return reorderGroup(rows, 0);
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +1,33 @@
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { copyFileSync } from "fs";
|
|
||||||
import { resolve } from "path";
|
|
||||||
|
|
||||||
// Sync root CHANGELOG files to public/ so the app always shows the latest version history
|
|
||||||
function syncChangelogs() {
|
|
||||||
const root = import.meta.dirname;
|
|
||||||
const files = ["CHANGELOG.md", "CHANGELOG.fr.md"];
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
copyFileSync(resolve(root, file), resolve(root, "public", file));
|
|
||||||
} catch {
|
|
||||||
// Ignore if source file doesn't exist
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-expect-error process is a nodejs global
|
// @ts-expect-error process is a nodejs global
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig(async () => {
|
export default defineConfig(async () => ({
|
||||||
// Sync changelogs before starting dev server or building
|
plugins: [react(), tailwindcss()],
|
||||||
syncChangelogs();
|
|
||||||
|
|
||||||
return {
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
plugins: [react(), tailwindcss()],
|
//
|
||||||
|
// 1. prevent Vite from obscuring rust errors
|
||||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
clearScreen: false,
|
||||||
//
|
// 2. tauri expects a fixed port, fail if that port is not available
|
||||||
// 1. prevent Vite from obscuring rust errors
|
server: {
|
||||||
clearScreen: false,
|
port: 1420,
|
||||||
// 2. tauri expects a fixed port, fail if that port is not available
|
strictPort: true,
|
||||||
server: {
|
host: host || false,
|
||||||
port: 1420,
|
hmr: host
|
||||||
strictPort: true,
|
? {
|
||||||
host: host || false,
|
protocol: "ws",
|
||||||
hmr: host
|
host,
|
||||||
? {
|
port: 1421,
|
||||||
protocol: "ws",
|
}
|
||||||
host,
|
: undefined,
|
||||||
port: 1421,
|
watch: {
|
||||||
}
|
// 3. tell Vite to ignore watching `src-tauri`
|
||||||
: undefined,
|
ignored: ["**/src-tauri/**"],
|
||||||
watch: {
|
|
||||||
// 3. tell Vite to ignore watching `src-tauri`
|
|
||||||
ignored: ["**/src-tauri/**"],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
});
|
}));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue