Compare commits
75 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99fdf4f9ea | |||
|
|
003f456203 | ||
| 61a5b2e40d | |||
| c7caab0ef4 | |||
| 03c5f2538f | |||
| 5493e0c4e2 | |||
| ecbcf44f86 | |||
| 9afe23180f | |||
| 21bf1173ea | |||
| a764ae0d38 | |||
| fd88ba41ba | |||
| 4b6b4d96ef | |||
| 501051f9ed | |||
| 2a18d9be2d | |||
| 4e70eee0a8 | |||
| 52faa017f3 | |||
| f8b44ebb6e | |||
| 65815ef2e0 | |||
| 7d770f8b66 | |||
| 376ca4b477 | |||
| 7458e087e1 | |||
| 64b7d8d11b | |||
| 8971e443d8 | |||
| 0d324b89c4 | |||
| c5a3e0f696 | |||
| 9551399f5f | |||
| 18c7ef3ee9 | |||
| 66d0cd85ff | |||
| 4a5b5fb5fe | |||
| dbe249783e | |||
| 8742c25945 | |||
| e32a14557f | |||
| a6a46dd7b6 | |||
| 4923880a6e | |||
| d5790a08e9 | |||
| 097c16dc14 | |||
|
|
c8b92517e8 | ||
|
|
32bcd27a5a | ||
|
|
0bbbcc541b | ||
|
|
861d78eca2 | ||
|
|
e1192beca3 | ||
|
|
420506b074 | ||
|
|
6ca62db4a9 | ||
|
|
ec38cd5669 | ||
|
|
d23fcd6bdb | ||
|
|
820360df5b | ||
|
|
0a5b7bce10 | ||
|
|
6cb9c75a55 | ||
|
|
be662ee52e | ||
|
|
079ddfb0e7 | ||
|
|
fc906d6d55 | ||
|
|
55fbb1ae92 | ||
|
|
08c54b1f75 | ||
|
|
efb922eb0e | ||
|
|
4030cc90b2 | ||
|
|
c777dbb7b8 | ||
|
|
e3992298f0 | ||
|
|
457dbce6c2 | ||
|
|
1b49871ea0 | ||
|
|
f5bf4e720b | ||
|
|
2a61ffcdb4 | ||
|
|
3e0f826256 | ||
|
|
15d626cbbb | ||
|
|
4328c2f929 | ||
|
|
fb92cfc12c | ||
|
|
6f84964689 | ||
|
|
d604d5ae63 | ||
|
|
7d7be4f591 | ||
|
|
3896a1ac1a | ||
|
|
849945f339 | ||
|
|
f126d08da3 | ||
|
|
b34527730d | ||
|
|
90f095a8cf | ||
|
|
9ab8d3d7df | ||
|
|
3302d79c38 |
52 changed files with 3913 additions and 780 deletions
|
|
@ -8,31 +8,34 @@ on:
|
|||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu
|
||||
container: ubuntu:22.04
|
||||
env:
|
||||
PATH: /root/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Setup environment
|
||||
- name: Install base tools and runtimes
|
||||
run: |
|
||||
# Ensure Rust is available
|
||||
if ! command -v cargo &>/dev/null; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
fi
|
||||
source "$HOME/.cargo/env"
|
||||
apt-get update
|
||||
apt-get install -y curl wget git sudo ca-certificates gnupg
|
||||
# Install Node.js 20
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y nodejs
|
||||
# Install Rust
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
rustc --version
|
||||
cargo --version
|
||||
node --version
|
||||
npm --version
|
||||
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Install Linux dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf jq libssl-dev xdg-utils
|
||||
apt-get install -y build-essential libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf jq libssl-dev xdg-utils
|
||||
|
||||
- name: Install Windows cross-compile dependencies
|
||||
run: |
|
||||
sudo apt-get install -y lld llvm clang nsis
|
||||
source "$HOME/.cargo/env"
|
||||
apt-get install -y lld llvm clang nsis
|
||||
rustup target add x86_64-pc-windows-msvc
|
||||
cargo install --locked cargo-xwin
|
||||
|
||||
|
|
@ -44,29 +47,31 @@ jobs:
|
|||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
source "$HOME/.cargo/env"
|
||||
npx tauri build
|
||||
npx tauri build --bundles deb,rpm
|
||||
|
||||
- name: Build Tauri Windows
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
source "$HOME/.cargo/env"
|
||||
npx tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc
|
||||
|
||||
- name: Collect release files
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
cp src-tauri/target/release/bundle/deb/*.deb release-assets/ 2>/dev/null || true
|
||||
cp src-tauri/target/release/bundle/appimage/*.AppImage release-assets/ 2>/dev/null || true
|
||||
cp src-tauri/target/release/bundle/appimage/*.AppImage.tar.gz release-assets/ 2>/dev/null || true
|
||||
cp src-tauri/target/release/bundle/appimage/*.AppImage.tar.gz.sig release-assets/ 2>/dev/null || true
|
||||
cp src-tauri/target/release/bundle/deb/*.deb.sig release-assets/ 2>/dev/null || true
|
||||
cp src-tauri/target/release/bundle/rpm/*.rpm release-assets/ 2>/dev/null || true
|
||||
cp src-tauri/target/release/bundle/rpm/*.rpm.sig release-assets/ 2>/dev/null || true
|
||||
cp src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe 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/
|
||||
|
||||
- name: Copy changelogs to public
|
||||
run: |
|
||||
cp CHANGELOG.md public/CHANGELOG.md
|
||||
cp CHANGELOG.fr.md public/CHANGELOG.fr.md
|
||||
|
||||
- name: Extract changelog
|
||||
id: changelog
|
||||
run: |
|
||||
|
|
@ -83,21 +88,24 @@ jobs:
|
|||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate latest.json
|
||||
env:
|
||||
CHANGELOG_NOTES: ${{ steps.changelog.outputs.notes }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
BASE_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${TAG}"
|
||||
|
||||
LINUX_SIG=""
|
||||
APPIMAGE_TAR=""
|
||||
LINUX_DEB=""
|
||||
WINDOWS_SIG=""
|
||||
WINDOWS_EXE=""
|
||||
|
||||
for f in release-assets/*.AppImage.tar.gz.sig; do
|
||||
for f in release-assets/*.deb.sig; do
|
||||
[ -f "$f" ] && LINUX_SIG=$(cat "$f")
|
||||
done
|
||||
for f in release-assets/*.AppImage.tar.gz; do
|
||||
[ -f "$f" ] && APPIMAGE_TAR=$(basename "$f")
|
||||
for f in release-assets/*.deb; do
|
||||
[ -f "$f" ] && LINUX_DEB=$(basename "$f")
|
||||
done
|
||||
for f in release-assets/*-setup.exe.sig; do
|
||||
[ -f "$f" ] && WINDOWS_SIG=$(cat "$f")
|
||||
|
|
@ -107,10 +115,10 @@ jobs:
|
|||
done
|
||||
|
||||
PLATFORMS="{}"
|
||||
if [ -n "$LINUX_SIG" ] && [ -n "$APPIMAGE_TAR" ]; then
|
||||
if [ -n "$LINUX_SIG" ] && [ -n "$LINUX_DEB" ]; then
|
||||
PLATFORMS=$(echo "$PLATFORMS" | jq \
|
||||
--arg sig "$LINUX_SIG" \
|
||||
--arg url "${BASE_URL}/${APPIMAGE_TAR}" \
|
||||
--arg url "${BASE_URL}/${LINUX_DEB}" \
|
||||
'. + {"linux-x86_64": {"signature": $sig, "url": $url}}')
|
||||
fi
|
||||
if [ -n "$WINDOWS_SIG" ] && [ -n "$WINDOWS_EXE" ]; then
|
||||
|
|
@ -121,8 +129,8 @@ jobs:
|
|||
fi
|
||||
|
||||
jq -n \
|
||||
--arg version "$TAG" \
|
||||
--arg notes "${{ steps.changelog.outputs.notes }}" \
|
||||
--arg version "$VERSION" \
|
||||
--arg notes "$CHANGELOG_NOTES" \
|
||||
--arg pub_date "$PUB_DATE" \
|
||||
--argjson platforms "$PLATFORMS" \
|
||||
'{version: $version, notes: $notes, pub_date: $pub_date, platforms: $platforms}' \
|
||||
|
|
@ -134,22 +142,31 @@ jobs:
|
|||
- name: Create release and upload assets
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CHANGELOG_NOTES: ${{ steps.changelog.outputs.notes }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
API_URL="${GITHUB_SERVER_URL}/api/v1"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
|
||||
BODY=$(cat <<'BODY_EOF'
|
||||
${{ steps.changelog.outputs.notes }}
|
||||
BODY="${CHANGELOG_NOTES}
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
**Windows** : Téléchargez le fichier `.exe` ci-dessous.
|
||||
**Linux** : Téléchargez le fichier `.deb` ou `.AppImage` ci-dessous.
|
||||
BODY_EOF
|
||||
)
|
||||
**Windows** : Téléchargez le fichier \`.exe\` ci-dessous.
|
||||
**Linux** : Téléchargez le fichier \`.deb\` ou \`.AppImage\` ci-dessous."
|
||||
|
||||
# Delete existing release for this tag (if any) to allow re-creation
|
||||
EXISTING=$(curl -s "${API_URL}/repos/${REPO}/releases/tags/${TAG}" \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}")
|
||||
EXISTING_ID=$(echo "$EXISTING" | jq -r '.id // empty')
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
echo "Deleting existing release ID: $EXISTING_ID"
|
||||
curl -s -X DELETE \
|
||||
"${API_URL}/repos/${REPO}/releases/${EXISTING_ID}" \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}"
|
||||
fi
|
||||
|
||||
# Create release
|
||||
RELEASE_RESPONSE=$(curl -s -X POST \
|
||||
|
|
@ -192,3 +209,38 @@ jobs:
|
|||
done
|
||||
|
||||
echo "Release created: ${GITHUB_SERVER_URL}/${REPO}/releases/tag/${TAG}"
|
||||
|
||||
- name: Publish latest.json to package registry
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
run: |
|
||||
# DELETE uses API v1, PUT uses the package upload API
|
||||
DELETE_URL="${GITHUB_SERVER_URL}/api/v1/packages/${GITHUB_REPOSITORY_OWNER}/generic/simpl-resultat/latest"
|
||||
UPLOAD_URL="${GITHUB_SERVER_URL}/api/packages/${GITHUB_REPOSITORY_OWNER}/generic/simpl-resultat/latest"
|
||||
|
||||
# Delete the old package version to avoid 409 conflicts
|
||||
echo "Deleting old package version (if any)..."
|
||||
DEL_CODE=$(curl -s -w "%{http_code}" -X DELETE \
|
||||
"${DELETE_URL}" \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-o /tmp/del_response.json)
|
||||
echo "Delete HTTP $DEL_CODE"
|
||||
# 204 = deleted, 404 = didn't exist (both OK)
|
||||
if [ "$DEL_CODE" != "204" ] && [ "$DEL_CODE" != "404" ]; then
|
||||
echo "WARNING: Unexpected delete response:"
|
||||
cat /tmp/del_response.json
|
||||
fi
|
||||
|
||||
echo "Uploading latest.json to package registry..."
|
||||
HTTP_CODE=$(curl -w "%{http_code}" -X PUT \
|
||||
"${UPLOAD_URL}/latest.json" \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary "@release-assets/latest.json" \
|
||||
-o /tmp/pkg_response.json)
|
||||
echo "Upload HTTP $HTTP_CODE"
|
||||
if [ "$HTTP_CODE" != "201" ]; then
|
||||
echo "ERROR: Failed to publish latest.json:"
|
||||
cat /tmp/pkg_response.json
|
||||
exit 1
|
||||
fi
|
||||
|
|
|
|||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -23,8 +23,13 @@ imports/*.csv
|
|||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
*.local
|
||||
|
||||
# Secrets
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# IDE
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
|
@ -40,5 +45,9 @@ imports/*.csv
|
|||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Auto-generated changelogs (synced from root by vite.config.ts)
|
||||
public/CHANGELOG.md
|
||||
public/CHANGELOG.fr.md
|
||||
|
||||
# Tauri generated
|
||||
src-tauri/gen/
|
||||
|
|
|
|||
266
CHANGELOG.fr.md
Normal file
266
CHANGELOG.fr.md
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
# 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
|
||||
114
CHANGELOG.md
114
CHANGELOG.md
|
|
@ -2,6 +2,120 @@
|
|||
|
||||
## [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]
|
||||
|
||||
### Added
|
||||
- Reports: toggle between table and chart view for Trends, By Category, and Over Time tabs
|
||||
- Reports: "Show amounts" toggle displays values directly on chart bars and area curves
|
||||
- Reports: filter panel with category checkboxes (search, select all/none) and source dropdown
|
||||
- Reports: source filter applies at SQL level for accurate filtered totals
|
||||
- Reports: sticky table headers on all report tables (Dynamic Report, Budget vs Actual)
|
||||
- Reports: interactive hover — dimmed non-hovered bars, tooltip filtered to hovered category
|
||||
- Reports: legend hover highlights category across all months (Over Time chart)
|
||||
|
||||
### Fixed
|
||||
- Transaction table: comment icon now turns orange (like split icon) when a note is present (#7)
|
||||
|
||||
## [0.5.0]
|
||||
|
||||
### Added
|
||||
- Error boundary catches React crashes and displays an error page instead of a white screen
|
||||
- Startup timeout (10s) on database connection — shows error page instead of infinite spinner
|
||||
- Error page with "Refresh", "Check for updates", and contact/issue links
|
||||
- Log viewer in settings page — captures console output, filterable by level, copyable, persists across refresh
|
||||
- GPL-3.0 license — project is now open source
|
||||
|
||||
### Changed
|
||||
- Report detail modal: sortable columns — click headers to sort by date, description, or amount (#1)
|
||||
- Report detail modal: toggle to show/hide amounts column (#3)
|
||||
- Budget table: column headers stay fixed when scrolling vertically (#2)
|
||||
|
||||
### Fixed
|
||||
- Auto-updater on Linux: `latest.json` version field no longer has `v` prefix, package registry upload is more robust
|
||||
- Startup retry: DB connection retries up to 3 times before showing error page (fixes first-launch failure on Windows)
|
||||
- Migration checksum mismatch: automatically repairs stale migration 1 checksum on startup
|
||||
|
||||
## [0.4.4]
|
||||
|
||||
### Fixed
|
||||
- Linux binary now compatible with glibc 2.35+ (Ubuntu 22.04 / Pop!_OS) — CI builds in Ubuntu 22.04 container
|
||||
|
||||
## [0.4.3]
|
||||
|
||||
### Fixed
|
||||
- Auto-updater endpoint now uses Forgejo package registry for stable URL
|
||||
- Linux updater signatures (.AppImage.sig) now correctly collected in CI
|
||||
- All platform signatures (.deb.sig, .rpm.sig) now included in release assets
|
||||
|
||||
## [0.4.2]
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
25
CLAUDE.md
25
CLAUDE.md
|
|
@ -9,7 +9,8 @@
|
|||
**Stockage :** SQLite local (tauri-plugin-sql)
|
||||
**Langues supportées :** Français (FR) et Anglais (EN)
|
||||
**Plateformes :** Windows, Linux
|
||||
**Version actuelle :** 0.3.11
|
||||
**Version actuelle :** 0.6.3
|
||||
**Licence :** GPL-3.0-only
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -35,7 +36,7 @@
|
|||
|
||||
```
|
||||
src/
|
||||
├── components/ # 49 composants React organisés par domaine
|
||||
├── components/ # 53 composants React organisés par domaine
|
||||
│ ├── adjustments/ # Ajustements
|
||||
│ ├── budget/ # Budget
|
||||
│ ├── categories/ # Catégories hiérarchiques
|
||||
|
|
@ -49,7 +50,7 @@ src/
|
|||
│ └── transactions/ # Transactions
|
||||
├── contexts/ # ProfileContext (état global profil)
|
||||
├── hooks/ # 12 hooks custom (useReducer)
|
||||
├── pages/ # 10 pages
|
||||
├── pages/ # 11 pages
|
||||
├── services/ # 14 services métier
|
||||
├── shared/ # Types et constantes partagés
|
||||
├── utils/ # Utilitaires (parsing, CSV, charts)
|
||||
|
|
@ -67,7 +68,7 @@ src-tauri/
|
|||
│ │ ├── schema.sql # Schéma initial (v1)
|
||||
│ │ ├── seed_categories.sql # Seed catégories (v2)
|
||||
│ │ └── consolidated_schema.sql # Schéma complet (nouveaux profils)
|
||||
│ ├── lib.rs # Point d'entrée, 6 migrations inline, plugins
|
||||
│ ├── lib.rs # Point d'entrée, 7 migrations inline, plugins
|
||||
│ └── main.rs
|
||||
└── Cargo.toml
|
||||
```
|
||||
|
|
@ -91,6 +92,7 @@ src-tauri/
|
|||
- **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)
|
||||
- **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/`)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -114,8 +116,8 @@ src-tauri/
|
|||
|
||||
## Base de données
|
||||
|
||||
- **13 tables** SQLite, **9 index** (voir `docs/architecture.md` pour le détail)
|
||||
- **6 migrations inline** dans `lib.rs` (via `tauri_plugin_sql::Migration`)
|
||||
- **13 tables** SQLite, **15 index** (voir `docs/architecture.md` pour le détail)
|
||||
- **7 migrations inline** dans `lib.rs` (via `tauri_plugin_sql::Migration`)
|
||||
- **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils
|
||||
- Les migrations appliquées sont protégées par checksum — ne jamais modifier une migration existante, toujours en créer une nouvelle
|
||||
|
||||
|
|
@ -134,9 +136,12 @@ 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/`
|
||||
- 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 `CHANGELOG.md`
|
||||
- Catégories : Added, Changed, Fixed, Removed
|
||||
- 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.
|
||||
**Règle CHANGELOG :** tout changement affectant le comportement utilisateur → ajouter une entrée sous `## [Unreleased]` dans **les deux fichiers** :
|
||||
- `CHANGELOG.md` (anglais) — source principale
|
||||
- `CHANGELOG.fr.md` (français) — traduction
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -153,7 +158,7 @@ Pour maintenir l'éligibilité aux crédits d'impôt R&D (RS&DE fédéral + CRIC
|
|||
## CI/CD
|
||||
|
||||
- GitHub Actions (`release.yml`) déclenché par tags `v*`
|
||||
- Build Windows (NSIS `.exe`) + Linux (`.deb`, `.AppImage`)
|
||||
- Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`)
|
||||
- Signature des binaires + JSON d'updater pour mises à jour automatiques
|
||||
|
||||
---
|
||||
|
|
|
|||
674
LICENSE
Normal file
674
LICENSE
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Architecture technique — Simpl'Résultat
|
||||
|
||||
> Document généré le 2026-02-19 — Version 0.3.7
|
||||
> Document mis à jour le 2026-03-07 — Version 0.6.3
|
||||
|
||||
## Stack technique
|
||||
|
||||
|
|
@ -26,17 +26,17 @@
|
|||
```
|
||||
simpl-resultat/
|
||||
├── src/ # Frontend React/TypeScript
|
||||
│ ├── components/ # 49 composants organisés par domaine
|
||||
│ ├── components/ # 55 composants organisés par domaine
|
||||
│ │ ├── adjustments/ # 3 composants
|
||||
│ │ ├── budget/ # 5 composants
|
||||
│ │ ├── categories/ # 5 composants
|
||||
│ │ ├── dashboard/ # 3 composants
|
||||
│ │ ├── dashboard/ # 2 composants
|
||||
│ │ ├── import/ # 13 composants (wizard d'import)
|
||||
│ │ ├── layout/ # AppShell, Sidebar
|
||||
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
||||
│ │ ├── reports/ # 8 composants (graphiques + rapport dynamique)
|
||||
│ │ ├── settings/ # 2 composants
|
||||
│ │ ├── shared/ # 4 composants réutilisables
|
||||
│ │ ├── reports/ # 10 composants (graphiques + rapports tabulaires + rapport dynamique)
|
||||
│ │ ├── settings/ # 3 composants (+ LogViewerCard)
|
||||
│ │ ├── shared/ # 6 composants réutilisables
|
||||
│ │ └── transactions/ # 5 composants
|
||||
│ ├── contexts/ # ProfileContext (état global profil)
|
||||
│ ├── hooks/ # 12 hooks custom (useReducer)
|
||||
|
|
@ -103,10 +103,11 @@ Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plug
|
|||
| 4 | v4 | Ajout `is_inputable` sur `categories` |
|
||||
| 5 | v5 | Création de `import_config_templates` |
|
||||
| 6 | v6 | Changement contrainte unique `imported_files` (hash → filename) |
|
||||
| 7 | v7 | Ajout sous-catégories d'assurance (niveau 3) |
|
||||
|
||||
Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations).
|
||||
|
||||
## Services TypeScript (14)
|
||||
## Services TypeScript (15)
|
||||
|
||||
| Service | Responsabilité |
|
||||
|---------|---------------|
|
||||
|
|
@ -124,6 +125,7 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
|
|||
| `reportService.ts` | Génération de rapports et analytique |
|
||||
| `dataExportService.ts` | Export de données (chiffré) |
|
||||
| `userPreferenceService.ts` | Stockage préférences utilisateur |
|
||||
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
|
||||
|
||||
## Hooks (12)
|
||||
|
||||
|
|
@ -144,7 +146,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
|||
| `useTheme` | Thème clair/sombre |
|
||||
| `useUpdater` | Mise à jour de l'application |
|
||||
|
||||
## Commandes Tauri (17)
|
||||
## Commandes Tauri (18)
|
||||
|
||||
### `fs_commands.rs` — Système de fichiers (6)
|
||||
|
||||
|
|
@ -163,7 +165,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
|||
- `read_import_file` — Lecture fichier chiffré
|
||||
- `is_file_encrypted` — Vérification magic SREF
|
||||
|
||||
### `profile_commands.rs` — Gestion des profils (6)
|
||||
### `profile_commands.rs` — Gestion des profils (7)
|
||||
|
||||
- `load_profiles` — Chargement depuis `profiles.json`
|
||||
- `save_profiles` — Sauvegarde de la configuration
|
||||
|
|
@ -171,14 +173,24 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
|||
- `get_new_profile_init_sql` — Récupération du schéma consolidé
|
||||
- `hash_pin` — Hachage Argon2 du PIN
|
||||
- `verify_pin` — Vérification du PIN
|
||||
- `repair_migrations` — Réparation des checksums de migration (rusqlite)
|
||||
|
||||
## Pages et routing
|
||||
|
||||
Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `AppShell` (sidebar + layout). L'accès est contrôlé par `ProfileContext` (gate).
|
||||
|
||||
### Gestion d'erreurs
|
||||
|
||||
- **`ErrorBoundary`** (class component) : wrape `<App />` dans `main.tsx`, attrape les crashs React et affiche `ErrorPage` en fallback
|
||||
- **`ErrorPage`** : page d'erreur réutilisable avec détails techniques (collapsible), bouton "Actualiser", vérification de mises à jour, et liens de contact/issues
|
||||
- **Timeout au démarrage** : `App.tsx` applique un timeout de 10 secondes sur `connectActiveProfile()` — affiche `ErrorPage` au lieu d'un spinner infini si la connexion DB échoue
|
||||
- **Retry au démarrage** : `connectActiveProfile()` réessaie jusqu'à 3 fois avec 1s de délai avant d'afficher l'erreur
|
||||
- **Réparation de migrations** : `repair_migrations` (Rust/rusqlite) supprime les checksums invalides de `_sqlx_migrations` avant le chargement de la DB
|
||||
- **Log viewer** : `logService.ts` capture les `console.log/warn/error` dans un buffer circulaire (500 entrées, persisté en `sessionStorage`), affiché dans la page Paramètres via `LogViewerCard`
|
||||
|
||||
| Route | Page | Description |
|
||||
|-------|------|-------------|
|
||||
| `/` | `DashboardPage` | Tableau de bord avec graphiques |
|
||||
| `/` | `DashboardPage` | Tableau de bord (résumé, pie chart, budget vs réel, dépenses dans le temps) |
|
||||
| `/import` | `ImportPage` | Assistant d'import CSV |
|
||||
| `/transactions` | `TransactionsPage` | Liste avec filtres |
|
||||
| `/categories` | `CategoriesPage` | Gestion hiérarchique |
|
||||
|
|
@ -187,6 +199,7 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
|
|||
| `/reports` | `ReportsPage` | Analytique et rapports |
|
||||
| `/settings` | `SettingsPage` | Paramètres |
|
||||
| `/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).
|
||||
|
||||
|
|
|
|||
|
|
@ -71,8 +71,9 @@ 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
|
||||
- Répartition des dépenses par catégorie (graphique circulaire avec motifs SVG)
|
||||
- Liste des transactions récentes
|
||||
- Sélecteur de période ajustable
|
||||
- Tableau Budget vs Réel du mois courant (écart en $ et %)
|
||||
- Histogramme empilé des dépenses par catégorie et par mois
|
||||
- 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
|
||||
|
||||
### Comment faire
|
||||
|
|
@ -80,11 +81,13 @@ 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.)
|
||||
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
|
||||
4. Cliquez droit sur une catégorie dans le graphique pour la masquer ou voir le détail de ses transactions
|
||||
5. Faites défiler vers le bas pour voir vos transactions les plus récentes
|
||||
4. Consultez le tableau Budget vs Réel pour comparer vos dépenses au budget du mois courant
|
||||
5. Analysez l'histogramme en bas de page pour voir l'évolution de vos dépenses par catégorie dans le temps
|
||||
6. Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions
|
||||
|
||||
### 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
|
||||
- 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
|
||||
|
|
@ -223,6 +226,7 @@ Planifiez votre budget mensuel pour chaque catégorie et suivez le prévu par ra
|
|||
- Répartition égale du montant annuel sur 12 mois
|
||||
- Modèles de budget pour sauvegarder et appliquer des configurations
|
||||
- Sous-totaux par catégorie parente
|
||||
- En-têtes de colonnes fixes au défilement vertical
|
||||
|
||||
### Comment faire
|
||||
|
||||
|
|
@ -253,6 +257,8 @@ Visualisez vos données financières avec des graphiques interactifs et comparez
|
|||
- Rapport dynamique : tableau croisé dynamique (pivot table) personnalisable
|
||||
- Motifs SVG (lignes, points, hachures) pour distinguer les catégories
|
||||
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
|
||||
- Détail des transactions par catégorie avec tri par colonne (date, description, montant)
|
||||
- Toggle pour afficher ou masquer les montants dans le détail des transactions
|
||||
|
||||
### Comment faire
|
||||
|
||||
|
|
@ -261,6 +267,8 @@ Visualisez vos données financières avec des graphiques interactifs et comparez
|
|||
3. Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions
|
||||
4. Les catégories masquées apparaissent comme pastilles au-dessus du graphique — cliquez dessus pour les réafficher
|
||||
5. Dans Budget vs Réel, basculez entre les vues Mensuel et Cumul annuel
|
||||
6. Dans le détail d'une catégorie, cliquez sur un en-tête de colonne pour trier les transactions
|
||||
7. Utilisez l'icône œil dans le détail pour masquer ou afficher la colonne des montants
|
||||
|
||||
### Astuces
|
||||
|
||||
|
|
@ -295,6 +303,7 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
|
|||
- Affichage de la version de l'application
|
||||
- Guide d'utilisation complet accessible directement depuis les paramètres
|
||||
- Vérification automatique des mises à jour avec installation en un clic
|
||||
- Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement
|
||||
- Export des données (transactions, catégories, ou les deux) en format JSON ou CSV
|
||||
- Import des données depuis un fichier exporté précédemment
|
||||
- Chiffrement AES-256-GCM optionnel pour les fichiers exportés
|
||||
|
|
@ -303,9 +312,10 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
|
|||
|
||||
1. Cliquez sur Guide d'utilisation pour accéder à la documentation complète
|
||||
2. Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible
|
||||
3. Utilisez la section Gestion des données pour exporter ou importer vos données
|
||||
4. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement
|
||||
5. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe
|
||||
3. Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez
|
||||
4. Utilisez la section Gestion des données pour exporter ou importer vos données
|
||||
5. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement
|
||||
6. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe
|
||||
|
||||
### Astuces
|
||||
|
||||
|
|
@ -313,3 +323,5 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
|
|||
- Changez la langue de l'application via le sélecteur de langue dans la barre latérale
|
||||
- Exportez régulièrement pour garder une sauvegarde de vos données
|
||||
- Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer
|
||||
- Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page
|
||||
- En cas de problème, copiez les journaux et joignez-les à votre signalement
|
||||
|
|
|
|||
577
package-lock.json
generated
577
package-lock.json
generated
|
|
@ -1,12 +1,13 @@
|
|||
{
|
||||
"name": "simpl_result_scaffold",
|
||||
"version": "0.3.7",
|
||||
"version": "0.6.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "simpl_result_scaffold",
|
||||
"version": "0.3.7",
|
||||
"version": "0.6.5",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
|
@ -34,7 +35,8 @@
|
|||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^6.4.1"
|
||||
"vite": "^6.4.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
|
|
@ -859,325 +861,350 @@
|
|||
"dev": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
|
|
@ -1737,6 +1764,17 @@
|
|||
"@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": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
|
|
@ -1791,6 +1829,13 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
|
|
@ -1858,6 +1903,127 @@
|
|||
"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": {
|
||||
"version": "2.9.19",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||
|
|
@ -1920,6 +2086,16 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
|
|
@ -2112,6 +2288,13 @@
|
|||
"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": {
|
||||
"version": "1.44.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
||||
|
|
@ -2167,11 +2350,31 @@
|
|||
"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": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"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": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
|
|
@ -2617,11 +2820,29 @@
|
|||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||
"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": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
|
@ -2831,10 +3052,11 @@
|
|||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
|
|
@ -2846,31 +3068,31 @@
|
|||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
||||
"@rollup/rollup-android-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||
"@rollup/rollup-android-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
|
|
@ -2893,6 +3115,13 @@
|
|||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
|
@ -2902,6 +3131,20 @@
|
|||
"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": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
|
|
@ -2926,6 +3169,23 @@
|
|||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"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": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
|
|
@ -2942,6 +3202,16 @@
|
|||
"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": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
|
|
@ -3099,6 +3369,84 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
|
|
@ -3107,6 +3455,23 @@
|
|||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -1,13 +1,16 @@
|
|||
{
|
||||
"name": "simpl_result_scaffold",
|
||||
"private": true,
|
||||
"version": "0.4.2",
|
||||
"version": "0.6.6",
|
||||
"license": "GPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -36,6 +39,7 @@
|
|||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^6.4.1"
|
||||
"vite": "^6.4.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
[package]
|
||||
name = "simpl-result"
|
||||
version = "0.4.2"
|
||||
version = "0.6.6"
|
||||
description = "Personal finance management app"
|
||||
license = "GPL-3.0-only"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ tauri-plugin-dialog = "2"
|
|||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
libsqlite3-sys = { version = "0.30", features = ["bundled"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sha2::{Digest, Sha256, Sha384};
|
||||
use std::fs;
|
||||
use tauri::Manager;
|
||||
|
||||
|
|
@ -155,3 +155,65 @@ pub fn verify_pin(pin: String, stored_hash: String) -> Result<bool, String> {
|
|||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
|
||||
/// Repair migration checksums for a profile database.
|
||||
/// Updates stored checksums to match current migration SQL, avoiding re-application
|
||||
/// of destructive migrations (e.g., migration 2 which DELETEs categories/keywords).
|
||||
#[tauri::command]
|
||||
pub fn repair_migrations(app: tauri::AppHandle, db_filename: String) -> Result<bool, String> {
|
||||
let app_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("Cannot get app data dir: {}", e))?;
|
||||
let db_path = app_dir.join(&db_filename);
|
||||
|
||||
if !db_path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let conn = rusqlite::Connection::open(&db_path)
|
||||
.map_err(|e| format!("Cannot open database: {}", e))?;
|
||||
|
||||
let table_exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='_sqlx_migrations'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !table_exists {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Current migration SQL — must match the vec in lib.rs
|
||||
let migrations: &[(i64, &str)] = &[
|
||||
(1, database::SCHEMA),
|
||||
(2, database::SEED_CATEGORIES),
|
||||
];
|
||||
|
||||
let mut repaired = false;
|
||||
for (version, sql) in migrations {
|
||||
let expected_checksum = Sha384::digest(sql.as_bytes()).to_vec();
|
||||
|
||||
// Check if this migration exists with a different checksum
|
||||
let needs_repair: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) > 0 FROM _sqlx_migrations WHERE version = ?1 AND checksum != ?2",
|
||||
rusqlite::params![version, expected_checksum],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if needs_repair {
|
||||
conn.execute(
|
||||
"UPDATE _sqlx_migrations SET checksum = ?1 WHERE version = ?2",
|
||||
rusqlite::params![expected_checksum, version],
|
||||
)
|
||||
.map_err(|e| format!("Cannot repair migration {}: {}", version, e))?;
|
||||
repaired = true;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(repaired)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ pub fn run() {
|
|||
commands::get_new_profile_init_sql,
|
||||
commands::hash_pin,
|
||||
commands::verify_pin,
|
||||
commands::repair_migrations,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Simpl Resultat",
|
||||
"version": "0.4.2",
|
||||
"version": "0.6.6",
|
||||
"identifier": "com.simpl.resultat",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgyRDc4MDEyQjQ0MzAxRTMKUldUakFVTzBFb0RYZ3NRNmFxMHdnTzBMZzFacTlCbTdtMEU3Ym5pZWNSN3FRZk43R3lZSUM2OHQK",
|
||||
"endpoints": [
|
||||
"https://git.lacompagniemaximus.com/maximus/simpl-resultat/releases/latest/download/latest.json"
|
||||
"https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "basicUi"
|
||||
|
|
|
|||
55
src/App.tsx
55
src/App.tsx
|
|
@ -1,5 +1,6 @@
|
|||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useProfile } from "./contexts/ProfileContext";
|
||||
import AppShell from "./components/layout/AppShell";
|
||||
import DashboardPage from "./pages/DashboardPage";
|
||||
|
|
@ -11,20 +12,59 @@ import BudgetPage from "./pages/BudgetPage";
|
|||
import ReportsPage from "./pages/ReportsPage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import DocsPage from "./pages/DocsPage";
|
||||
import ChangelogPage from "./pages/ChangelogPage";
|
||||
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
|
||||
import ErrorPage from "./components/shared/ErrorPage";
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 10_000;
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY_MS = 1_000;
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile();
|
||||
const [dbReady, setDbReady] = useState(false);
|
||||
const [startupError, setStartupError] = useState<string | null>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProfile && !isLoading) {
|
||||
setDbReady(false);
|
||||
connectActiveProfile()
|
||||
.then(() => setDbReady(true))
|
||||
.catch((err) => console.error("Failed to connect profile:", err));
|
||||
setStartupError(null);
|
||||
cancelledRef.current = false;
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setStartupError(t("error.startupTimeout"));
|
||||
}, STARTUP_TIMEOUT_MS);
|
||||
|
||||
const attemptConnect = async (attempt: number): Promise<void> => {
|
||||
try {
|
||||
await connectActiveProfile();
|
||||
if (cancelledRef.current) return;
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setDbReady(true);
|
||||
} catch (err) {
|
||||
if (cancelledRef.current) return;
|
||||
console.error(`Failed to connect profile (attempt ${attempt}/${MAX_RETRIES}):`, err);
|
||||
if (attempt < MAX_RETRIES) {
|
||||
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
||||
if (!cancelledRef.current) return attemptConnect(attempt + 1);
|
||||
} else {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setStartupError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
attemptConnect(1);
|
||||
}
|
||||
}, [activeProfile, isLoading, connectActiveProfile]);
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, [activeProfile, isLoading, connectActiveProfile, t]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -34,6 +74,10 @@ export default function App() {
|
|||
);
|
||||
}
|
||||
|
||||
if (startupError) {
|
||||
return <ErrorPage error={startupError} />;
|
||||
}
|
||||
|
||||
if (!activeProfile) {
|
||||
return <ProfileSelectionPage />;
|
||||
}
|
||||
|
|
@ -59,6 +103,7 @@ export default function App() {
|
|||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/docs" element={<DocsPage />} />
|
||||
<Route path="/changelog" element={<ChangelogPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState, useRef, useEffect, Fragment } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, ArrowUpDown } from "lucide-react";
|
||||
import type { BudgetYearRow } from "../../shared/types";
|
||||
import { reorderRows } from "../../utils/reorderRows";
|
||||
|
||||
const fmt = new Intl.NumberFormat("en-CA", {
|
||||
style: "currency",
|
||||
|
|
@ -18,58 +19,6 @@ const MONTH_KEYS = [
|
|||
|
||||
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 {
|
||||
rows: BudgetYearRow[];
|
||||
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
|
||||
|
|
@ -184,10 +133,16 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
income: "budget.income",
|
||||
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)
|
||||
const monthTotals: number[] = Array(12).fill(0);
|
||||
let annualTotal = 0;
|
||||
let prevYearTotal = 0;
|
||||
for (const row of rows) {
|
||||
if (row.is_parent) continue; // skip parent subtotals to avoid double-counting
|
||||
const sign = signFor(row.category_type);
|
||||
|
|
@ -195,9 +150,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
monthTotals[m] += row.months[m] * sign;
|
||||
}
|
||||
annualTotal += row.annual * sign;
|
||||
prevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
|
||||
}
|
||||
|
||||
const totalCols = 14; // category + annual + 12 months
|
||||
const totalCols = 15; // category + prev year + annual + 12 months
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
|
|
@ -223,13 +179,15 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
if (row.is_parent) {
|
||||
// Parent subtotal row: read-only, bold, distinct background
|
||||
const parentDepth = row.depth ?? 0;
|
||||
const isIntermediateParent = parentDepth === 1;
|
||||
const isTopParent = parentDepth === 0;
|
||||
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 (
|
||||
<tr
|
||||
key={rowKey}
|
||||
className={`border-b border-[var(--border)] ${isIntermediateParent ? "bg-[var(--muted)]/15" : "bg-[var(--muted)]/30"}`}
|
||||
className={`border-b border-[var(--border)] ${isTopParent ? "bg-[var(--muted)]/30" : "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"}`}>
|
||||
<td className={`py-2 sticky left-0 z-10 ${isTopParent ? "px-3 bg-[var(--muted)]/30" : `${parentPaddingClass} bg-[var(--muted)]/15`}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
|
|
@ -238,6 +196,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
<span className={`truncate text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>{row.category_name}</span>
|
||||
</div>
|
||||
</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"}`}>
|
||||
{formatSigned(row.annual * sign)}
|
||||
</td>
|
||||
|
|
@ -257,7 +218,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"
|
||||
>
|
||||
{/* Category name - sticky */}
|
||||
<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"}`}>
|
||||
<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"}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
|
|
@ -266,6 +227,12 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
<span className="truncate text-xs">{row.category_name}</span>
|
||||
</div>
|
||||
</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 */}
|
||||
<td className="py-2 px-2 text-right">
|
||||
{editingAnnual?.categoryId === row.category_id ? (
|
||||
|
|
@ -283,7 +250,8 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => handleStartEditAnnual(row.category_id, row.annual)}
|
||||
className="font-medium text-xs hover:text-[var(--primary)] transition-colors cursor-text"
|
||||
title={t("budget.clickToEdit")}
|
||||
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)}
|
||||
</button>
|
||||
|
|
@ -315,7 +283,8 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
) : (
|
||||
<button
|
||||
onClick={() => handleStartEdit(row.category_id, mIdx, val)}
|
||||
className="w-full text-right hover:text-[var(--primary)] transition-colors cursor-text text-xs"
|
||||
title={t("budget.clickToEdit")}
|
||||
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)}
|
||||
</button>
|
||||
|
|
@ -337,13 +306,16 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||
<table className="w-full text-sm whitespace-nowrap">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border)]">
|
||||
<th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-10 min-w-[140px]">
|
||||
<thead className="sticky top-0 z-20">
|
||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||
<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")}
|
||||
</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]">
|
||||
{t("budget.annual")}
|
||||
</th>
|
||||
|
|
@ -358,6 +330,18 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
{typeOrder.map((type) => {
|
||||
const group = grouped[type];
|
||||
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 (
|
||||
<Fragment key={type}>
|
||||
<tr>
|
||||
|
|
@ -369,15 +353,28 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
</td>
|
||||
</tr>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
{/* Totals row */}
|
||||
<tr className="bg-[var(--muted)] font-semibold">
|
||||
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)] z-10 text-xs">{t("common.total")}</td>
|
||||
<td className="py-2.5 px-2 text-right text-xs">{formatSigned(annualTotal)}</td>
|
||||
<tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]">
|
||||
<td className="py-3 px-3 sticky left-0 bg-[var(--muted)] z-10 text-sm">{t("common.total")}</td>
|
||||
<td className="py-3 px-2 text-right text-sm text-[var(--muted-foreground)]">{formatSigned(prevYearTotal)}</td>
|
||||
<td className="py-3 px-2 text-right text-sm">{formatSigned(annualTotal)}</td>
|
||||
{monthTotals.map((total, mIdx) => (
|
||||
<td key={mIdx} className="py-2.5 px-2 text-right text-xs">
|
||||
<td key={mIdx} className="py-3 px-2 text-right text-sm">
|
||||
{formatSigned(total)}
|
||||
</td>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export default function CategoryPieChart({
|
|||
}: CategoryPieChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
|
||||
const [isChartHovered, setIsChartHovered] = useState(false);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
|
||||
|
||||
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
|
||||
|
|
@ -36,17 +37,14 @@ export default function CategoryPieChart({
|
|||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<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>
|
||||
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
|
||||
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)]">
|
||||
<p className="text-center text-[var(--muted-foreground)] py-6">{t("dashboard.noData")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)]">
|
||||
{hiddenCategories.size > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
|
||||
|
|
@ -69,8 +67,12 @@ export default function CategoryPieChart({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div onContextMenu={handleContextMenu}>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<div
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={() => setIsChartHovered(true)}
|
||||
onMouseLeave={() => setIsChartHovered(false)}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<PieChart>
|
||||
<ChartPatternDefs
|
||||
prefix="cat-pie"
|
||||
|
|
@ -82,8 +84,8 @@ export default function CategoryPieChart({
|
|||
nameKey="category_name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={100}
|
||||
innerRadius={35}
|
||||
outerRadius={75}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{visibleData.map((item, index) => (
|
||||
|
|
@ -97,9 +99,11 @@ export default function CategoryPieChart({
|
|||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) =>
|
||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value))
|
||||
}
|
||||
formatter={(value) => {
|
||||
const formatted = 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={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
|
|
@ -113,13 +117,14 @@ export default function CategoryPieChart({
|
|||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||
{data.map((item, index) => {
|
||||
const isHidden = hiddenCategories.has(item.category_name);
|
||||
const pct = total > 0 && !isHidden ? Math.round((item.total / total) * 100) : null;
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={`flex items-center gap-1.5 text-sm ${isHidden ? "opacity-40" : ""}`}
|
||||
className={`flex items-center gap-1 text-xs ${isHidden ? "opacity-40" : ""}`}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, item });
|
||||
|
|
@ -129,7 +134,7 @@ export default function CategoryPieChart({
|
|||
>
|
||||
<PatternSwatch index={index} color={item.category_color} prefix="cat-pie" />
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{item.category_name} {total > 0 && !isHidden ? `${Math.round((item.total / total) * 100)}%` : ""}
|
||||
{item.category_name}{isChartHovered && pct != null ? ` ${pct}%` : ""}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Fragment, useState } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUpDown } from "lucide-react";
|
||||
import type { BudgetVsActualRow } from "../../shared/types";
|
||||
import { reorderRows } from "../../utils/reorderRows";
|
||||
|
||||
const cadFormatter = (value: number) =>
|
||||
new Intl.NumberFormat("en-CA", {
|
||||
|
|
@ -25,55 +26,6 @@ interface BudgetVsActualTableProps {
|
|||
|
||||
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) {
|
||||
const { t } = useTranslation();
|
||||
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
|
||||
|
|
@ -105,6 +57,11 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
|||
income: t("budget.income"),
|
||||
transfer: t("budget.transfers"),
|
||||
};
|
||||
const typeTotalKeys: Record<SectionType, string> = {
|
||||
expense: "budget.totalExpenses",
|
||||
income: "budget.totalIncome",
|
||||
transfer: "budget.totalTransfers",
|
||||
};
|
||||
|
||||
let currentType: SectionType | null = null;
|
||||
for (const row of data) {
|
||||
|
|
@ -142,69 +99,91 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
|||
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border)]">
|
||||
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom">
|
||||
<thead className="sticky top-0 z-20">
|
||||
<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]">
|
||||
{t("budget.category")}
|
||||
</th>
|
||||
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
|
||||
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||
{t("reports.bva.monthly")}
|
||||
</th>
|
||||
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
|
||||
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||
{t("reports.bva.ytd")}
|
||||
</th>
|
||||
</tr>
|
||||
<tr className="border-b border-[var(--border)]">
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
|
||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||
{t("budget.actual")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("budget.planned")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("reports.bva.dollarVar")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("reports.bva.pctVar")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||
{t("budget.actual")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("budget.planned")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("reports.bva.dollarVar")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("reports.bva.pctVar")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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}>
|
||||
<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">
|
||||
<tr className="bg-[var(--muted)]">
|
||||
<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)]">
|
||||
{section.label}
|
||||
</td>
|
||||
</tr>
|
||||
{reorderRows(section.rows, subtotalsOnTop).map((row) => {
|
||||
const isParent = row.is_parent;
|
||||
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
|
||||
const isIntermediateParent = isParent && depth === 1;
|
||||
const paddingClass = depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
||||
const isTopParent = isParent && depth === 0;
|
||||
const isIntermediateParent = isParent && depth >= 1;
|
||||
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
||||
return (
|
||||
<tr
|
||||
key={`${row.category_id}-${row.is_parent}-${depth}`}
|
||||
className={`border-b border-[var(--border)]/50 ${
|
||||
isParent && !isIntermediateParent ? "bg-[var(--muted)]/30 font-semibold" :
|
||||
isIntermediateParent ? "bg-[var(--muted)]/15 font-medium" : ""
|
||||
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" :
|
||||
isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
|
||||
}`}
|
||||
>
|
||||
<td className={`py-1.5 ${isParent && !isIntermediateParent ? "px-3" : paddingClass}`}>
|
||||
<td className={`py-1.5 sticky left-0 z-10 ${
|
||||
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="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
|
|
@ -236,29 +215,53 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
|||
</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>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{/* Grand totals */}
|
||||
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
|
||||
<td className="px-3 py-2">{t("common.total")}</td>
|
||||
<td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
|
||||
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
|
||||
<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="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(totals.monthActual)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-2">{cadFormatter(totals.monthBudget)}</td>
|
||||
<td className={`text-right px-3 py-2 ${variationColor(totals.monthVariation)}`}>
|
||||
<td className="text-right px-3 py-3">{cadFormatter(totals.monthBudget)}</td>
|
||||
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
|
||||
{cadFormatter(totals.monthVariation)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-2 ${variationColor(totals.monthVariation)}`}>
|
||||
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
|
||||
{pctFormatter(totalMonthPct)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
|
||||
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(totals.ytdActual)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-2">{cadFormatter(totals.ytdBudget)}</td>
|
||||
<td className={`text-right px-3 py-2 ${variationColor(totals.ytdVariation)}`}>
|
||||
<td className="text-right px-3 py-3">{cadFormatter(totals.ytdBudget)}</td>
|
||||
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
|
||||
{cadFormatter(totals.ytdVariation)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-2 ${variationColor(totals.ytdVariation)}`}>
|
||||
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
|
||||
{pctFormatter(totalYtdPct)}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
LabelList,
|
||||
} from "recharts";
|
||||
import { Eye } from "lucide-react";
|
||||
import type { CategoryBreakdownItem } from "../../shared/types";
|
||||
|
|
@ -23,6 +24,7 @@ interface CategoryBarChartProps {
|
|||
onToggleHidden: (categoryName: string) => void;
|
||||
onShowAll: () => void;
|
||||
onViewDetails: (item: CategoryBreakdownItem) => void;
|
||||
showAmounts?: boolean;
|
||||
}
|
||||
|
||||
export default function CategoryBarChart({
|
||||
|
|
@ -31,9 +33,11 @@ export default function CategoryBarChart({
|
|||
onToggleHidden,
|
||||
onShowAll,
|
||||
onViewDetails,
|
||||
showAmounts,
|
||||
}: CategoryBarChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
|
||||
|
||||
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
|
||||
|
|
@ -93,7 +97,7 @@ export default function CategoryBarChart({
|
|||
type="category"
|
||||
dataKey="category_name"
|
||||
width={120}
|
||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||
tick={{ fill: "var(--foreground)", fontSize: 12 }}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
<Tooltip
|
||||
|
|
@ -103,7 +107,9 @@ export default function CategoryBarChart({
|
|||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
color: "var(--foreground)",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
}}
|
||||
wrapperStyle={{ zIndex: 50 }}
|
||||
labelStyle={{ color: "var(--foreground)" }}
|
||||
itemStyle={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
|
|
@ -112,11 +118,21 @@ export default function CategoryBarChart({
|
|||
<Cell
|
||||
key={index}
|
||||
fill={getPatternFill("cat-bar", index, item.category_color)}
|
||||
onMouseEnter={() => { hoveredRef.current = item; }}
|
||||
onMouseLeave={() => { hoveredRef.current = null; }}
|
||||
fillOpacity={hoveredIndex === null || hoveredIndex === index ? 1 : 0.3}
|
||||
onMouseEnter={() => { hoveredRef.current = item; setHoveredIndex(index); }}
|
||||
onMouseLeave={() => { hoveredRef.current = null; setHoveredIndex(null); }}
|
||||
cursor="context-menu"
|
||||
style={{ transition: "fill-opacity 150ms" }}
|
||||
/>
|
||||
))}
|
||||
{showAmounts && (
|
||||
<LabelList
|
||||
dataKey="total"
|
||||
position="right"
|
||||
formatter={(v: unknown) => cadFormatter(Number(v))}
|
||||
style={{ fill: "var(--foreground)", fontSize: 11 }}
|
||||
/>
|
||||
)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
ResponsiveContainer,
|
||||
Legend,
|
||||
CartesianGrid,
|
||||
LabelList,
|
||||
} from "recharts";
|
||||
import { Eye } from "lucide-react";
|
||||
import type { CategoryOverTimeData, CategoryBreakdownItem } from "../../shared/types";
|
||||
|
|
@ -30,6 +31,7 @@ interface CategoryOverTimeChartProps {
|
|||
onToggleHidden: (categoryName: string) => void;
|
||||
onShowAll: () => void;
|
||||
onViewDetails: (item: CategoryBreakdownItem) => void;
|
||||
showAmounts?: boolean;
|
||||
}
|
||||
|
||||
export default function CategoryOverTimeChart({
|
||||
|
|
@ -38,9 +40,11 @@ export default function CategoryOverTimeChart({
|
|||
onToggleHidden,
|
||||
onShowAll,
|
||||
onViewDetails,
|
||||
showAmounts,
|
||||
}: CategoryOverTimeChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const hoveredRef = useRef<string | null>(null);
|
||||
const [hoveredCategory, setHoveredCategory] = useState<string | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
|
||||
|
||||
const visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name));
|
||||
|
|
@ -109,28 +113,52 @@ export default function CategoryOverTimeChart({
|
|||
width={80}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
|
||||
formatter={(value: unknown, name: unknown) => {
|
||||
if (hoveredCategory && name !== hoveredCategory) return [null, null];
|
||||
return [cadFormatter(Number(value) || 0), String(name)];
|
||||
}}
|
||||
labelFormatter={(label) => formatMonth(String(label))}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
color: "var(--foreground)",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
}}
|
||||
wrapperStyle={{ zIndex: 50 }}
|
||||
labelStyle={{ color: "var(--foreground)" }}
|
||||
itemStyle={{ color: "var(--foreground)" }}
|
||||
filterNull
|
||||
/>
|
||||
<Legend
|
||||
onMouseEnter={(e) => {
|
||||
if (e && e.dataKey) setHoveredCategory(String(e.dataKey));
|
||||
}}
|
||||
onMouseLeave={() => setHoveredCategory(null)}
|
||||
wrapperStyle={{ cursor: "pointer" }}
|
||||
formatter={(value) => <span style={{ color: "var(--foreground)" }}>{value}</span>}
|
||||
/>
|
||||
<Legend />
|
||||
{categoryEntries.map((c) => (
|
||||
<Bar
|
||||
key={c.name}
|
||||
dataKey={c.name}
|
||||
stackId="stack"
|
||||
fill={getPatternFill("cat-time", c.index, c.color)}
|
||||
onMouseEnter={() => { hoveredRef.current = c.name; }}
|
||||
onMouseLeave={() => { hoveredRef.current = null; }}
|
||||
fillOpacity={hoveredCategory === null || hoveredCategory === c.name ? 1 : 0.2}
|
||||
onMouseEnter={() => { hoveredRef.current = c.name; setHoveredCategory(c.name); }}
|
||||
onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }}
|
||||
cursor="context-menu"
|
||||
/>
|
||||
style={{ transition: "fill-opacity 150ms" }}
|
||||
>
|
||||
{showAmounts && (
|
||||
<LabelList
|
||||
dataKey={c.name}
|
||||
position="center"
|
||||
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" }}
|
||||
/>
|
||||
)}
|
||||
</Bar>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
|
|
|||
111
src/components/reports/CategoryOverTimeTable.tsx
Normal file
111
src/components/reports/CategoryOverTimeTable.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import type { CategoryOverTimeData } from "../../shared/types";
|
||||
|
||||
const cadFormatter = (value: number) =>
|
||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
function formatMonth(month: string): string {
|
||||
const [year, m] = month.split("-");
|
||||
const date = new Date(Number(year), Number(m) - 1);
|
||||
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
|
||||
}
|
||||
|
||||
interface CategoryOverTimeTableProps {
|
||||
data: CategoryOverTimeData;
|
||||
hiddenCategories?: Set<string>;
|
||||
}
|
||||
|
||||
export default function CategoryOverTimeTable({ data, hiddenCategories }: CategoryOverTimeTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (data.data.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
||||
{t("dashboard.noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleCategories = hiddenCategories?.size
|
||||
? data.categories.filter((name) => !hiddenCategories.has(name))
|
||||
: data.categories;
|
||||
|
||||
const months = data.data.map((d) => d.month);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||
<table className="w-full text-sm whitespace-nowrap">
|
||||
<thead className="sticky top-0 z-20">
|
||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] sticky left-0 z-30 min-w-[140px]">
|
||||
{t("budget.category")}
|
||||
</th>
|
||||
{months.map((month) => (
|
||||
<th key={month} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] min-w-[90px]">
|
||||
{formatMonth(month)}
|
||||
</th>
|
||||
))}
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] border-l border-[var(--border)] min-w-[90px]">
|
||||
{t("common.total")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleCategories.map((category) => {
|
||||
const rowTotal = data.data.reduce((sum, d) => sum + ((d as Record<string, unknown>)[category] as number || 0), 0);
|
||||
return (
|
||||
<tr key={category} className="border-b border-[var(--border)]/50">
|
||||
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: data.colors[category] }}
|
||||
/>
|
||||
{category}
|
||||
</span>
|
||||
</td>
|
||||
{months.map((month) => {
|
||||
const monthData = data.data.find((d) => d.month === month);
|
||||
const value = (monthData as Record<string, unknown>)?.[category] as number || 0;
|
||||
return (
|
||||
<td key={month} className="text-right px-3 py-1.5">
|
||||
{value ? cadFormatter(value) : "—"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50">
|
||||
{cadFormatter(rowTotal)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
|
||||
<td className="px-3 py-3 sticky left-0 bg-[var(--muted)]/20 z-10">{t("common.total")}</td>
|
||||
{months.map((month) => {
|
||||
const monthData = data.data.find((d) => d.month === month);
|
||||
const monthTotal = visibleCategories.reduce(
|
||||
(sum, cat) => sum + ((monthData as Record<string, unknown>)?.[cat] as number || 0),
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<td key={month} className="text-right px-3 py-3">
|
||||
{cadFormatter(monthTotal)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(
|
||||
visibleCategories.reduce(
|
||||
(sum, cat) => sum + data.data.reduce((s, d) => s + ((d as Record<string, unknown>)[cat] as number || 0), 0),
|
||||
0,
|
||||
),
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/components/reports/CategoryTable.tsx
Normal file
74
src/components/reports/CategoryTable.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import type { CategoryBreakdownItem } from "../../shared/types";
|
||||
|
||||
const cadFormatter = (value: number) =>
|
||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
interface CategoryTableProps {
|
||||
data: CategoryBreakdownItem[];
|
||||
hiddenCategories?: Set<string>;
|
||||
}
|
||||
|
||||
export default function CategoryTable({ data, hiddenCategories }: CategoryTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const visibleData = hiddenCategories?.size
|
||||
? data.filter((d) => !hiddenCategories.has(d.category_name))
|
||||
: data;
|
||||
|
||||
if (visibleData.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
||||
{t("dashboard.noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const grandTotal = visibleData.reduce((sum, row) => sum + row.total, 0);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 z-20">
|
||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("budget.category")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("common.total")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
%
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleData.map((row) => (
|
||||
<tr key={row.category_id ?? "uncategorized"} className="border-b border-[var(--border)]/50">
|
||||
<td className="px-3 py-1.5">
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: row.category_color }}
|
||||
/>
|
||||
{row.category_name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-right px-3 py-1.5">{cadFormatter(row.total)}</td>
|
||||
<td className="text-right px-3 py-1.5 text-[var(--muted-foreground)]">
|
||||
{grandTotal !== 0 ? `${((row.total / grandTotal) * 100).toFixed(1)}%` : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
|
||||
<td className="px-3 py-3">{t("common.total")}</td>
|
||||
<td className="text-right px-3 py-3">{cadFormatter(grandTotal)}</td>
|
||||
<td className="text-right px-3 py-3 text-[var(--muted-foreground)]">100%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -149,18 +149,18 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border)]">
|
||||
<thead className="sticky top-0 z-20">
|
||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||
{rowDims.map((dim) => (
|
||||
<th key={dim} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)]">
|
||||
<th key={dim} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{fieldLabel(dim)}
|
||||
</th>
|
||||
))}
|
||||
{colValues.map((colVal) =>
|
||||
measures.map((m) => (
|
||||
<th key={`${colVal}-${m}`} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
|
||||
<th key={`${colVal}-${m}`} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||
{colDims.length > 0 ? (measures.length > 1 ? `${colLabel(colVal)} — ${measureLabel(m)}` : colLabel(colVal)) : measureLabel(m)}
|
||||
</th>
|
||||
))
|
||||
|
|
@ -192,13 +192,13 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
|
|||
))
|
||||
)}
|
||||
{/* Grand total */}
|
||||
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
|
||||
<td colSpan={rowDims.length || 1} className="px-3 py-2">
|
||||
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
|
||||
<td colSpan={rowDims.length || 1} className="px-3 py-3">
|
||||
{t("reports.pivot.total")}
|
||||
</td>
|
||||
{colValues.map((colVal) =>
|
||||
measures.map((m) => (
|
||||
<td key={`total-${colVal}-${m}`} className="text-right px-3 py-2 border-l border-[var(--border)]/50">
|
||||
<td key={`total-${colVal}-${m}`} className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
|
||||
</td>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
LabelList,
|
||||
} from "recharts";
|
||||
import type { MonthlyTrendItem } from "../../shared/types";
|
||||
|
||||
|
|
@ -21,9 +22,10 @@ function formatMonth(month: string): string {
|
|||
|
||||
interface MonthlyTrendsChartProps {
|
||||
data: MonthlyTrendItem[];
|
||||
showAmounts?: boolean;
|
||||
}
|
||||
|
||||
export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) {
|
||||
export default function MonthlyTrendsChart({ data, showAmounts }: MonthlyTrendsChartProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (data.length === 0) {
|
||||
|
|
@ -80,7 +82,16 @@ export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) {
|
|||
stroke="var(--positive)"
|
||||
fill="url(#gradientIncome)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
>
|
||||
{showAmounts && (
|
||||
<LabelList
|
||||
dataKey="income"
|
||||
position="top"
|
||||
formatter={(v: unknown) => cadFormatter(Number(v))}
|
||||
style={{ fill: "var(--positive)", fontSize: 10, fontWeight: 600 }}
|
||||
/>
|
||||
)}
|
||||
</Area>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="expenses"
|
||||
|
|
@ -88,7 +99,16 @@ export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) {
|
|||
stroke="var(--negative)"
|
||||
fill="url(#gradientExpenses)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
>
|
||||
{showAmounts && (
|
||||
<LabelList
|
||||
dataKey="expenses"
|
||||
position="bottom"
|
||||
formatter={(v: unknown) => cadFormatter(Number(v))}
|
||||
style={{ fill: "var(--negative)", fontSize: 10, fontWeight: 600 }}
|
||||
/>
|
||||
)}
|
||||
</Area>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
|
|
|||
77
src/components/reports/MonthlyTrendsTable.tsx
Normal file
77
src/components/reports/MonthlyTrendsTable.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import type { MonthlyTrendItem } from "../../shared/types";
|
||||
|
||||
const cadFormatter = (value: number) =>
|
||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
function formatMonth(month: string): string {
|
||||
const [year, m] = month.split("-");
|
||||
const date = new Date(Number(year), Number(m) - 1);
|
||||
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
|
||||
}
|
||||
|
||||
interface MonthlyTrendsTableProps {
|
||||
data: MonthlyTrendItem[];
|
||||
}
|
||||
|
||||
export default function MonthlyTrendsTable({ data }: MonthlyTrendsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
||||
{t("dashboard.noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totals = data.reduce(
|
||||
(acc, row) => ({ income: acc.income + row.income, expenses: acc.expenses + row.expenses }),
|
||||
{ income: 0, expenses: 0 },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 z-20">
|
||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("reports.pivot.month")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("dashboard.income")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("dashboard.expenses")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("dashboard.net")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row) => (
|
||||
<tr key={row.month} className="border-b border-[var(--border)]/50">
|
||||
<td className="px-3 py-1.5">{formatMonth(row.month)}</td>
|
||||
<td className="text-right px-3 py-1.5 text-[var(--positive)]">{cadFormatter(row.income)}</td>
|
||||
<td className="text-right px-3 py-1.5 text-[var(--negative)]">{cadFormatter(row.expenses)}</td>
|
||||
<td className={`text-right px-3 py-1.5 ${row.income - row.expenses >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`}>
|
||||
{cadFormatter(row.income - row.expenses)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
|
||||
<td className="px-3 py-3">{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-3 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)]"}`}>
|
||||
{cadFormatter(totals.income - totals.expenses)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
src/components/reports/ReportFilterPanel.tsx
Normal file
135
src/components/reports/ReportFilterPanel.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Filter, Search } from "lucide-react";
|
||||
import type { ImportSource } from "../../shared/types";
|
||||
|
||||
interface ReportFilterPanelProps {
|
||||
categories: { name: string; color: string }[];
|
||||
hiddenCategories: Set<string>;
|
||||
onToggleHidden: (name: string) => void;
|
||||
onShowAll: () => void;
|
||||
sources: ImportSource[];
|
||||
selectedSourceId: number | null;
|
||||
onSourceChange: (id: number | null) => void;
|
||||
}
|
||||
|
||||
export default function ReportFilterPanel({
|
||||
categories,
|
||||
hiddenCategories,
|
||||
onToggleHidden,
|
||||
onShowAll,
|
||||
sources,
|
||||
selectedSourceId,
|
||||
onSourceChange,
|
||||
}: ReportFilterPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const filtered = search
|
||||
? categories.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: categories;
|
||||
|
||||
const allVisible = hiddenCategories.size === 0;
|
||||
const allHidden = hiddenCategories.size === categories.length;
|
||||
|
||||
return (
|
||||
<div className="w-56 shrink-0 sticky top-4 self-start space-y-3">
|
||||
{/* Source filter */}
|
||||
{sources.length > 1 && (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||
<div className="px-3 py-2.5 text-sm font-medium text-[var(--foreground)] flex items-center gap-2">
|
||||
<Filter size={14} className="text-[var(--muted-foreground)]" />
|
||||
{t("transactions.table.source")}
|
||||
</div>
|
||||
<div className="border-t border-[var(--border)] px-2 py-2">
|
||||
<select
|
||||
value={selectedSourceId ?? ""}
|
||||
onChange={(e) => onSourceChange(e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full px-2 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||
>
|
||||
<option value="">{t("transactions.filters.allSources")}</option>
|
||||
{sources.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category filter */}
|
||||
{categories.length > 0 && <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm font-medium text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<Filter size={14} className="text-[var(--muted-foreground)]" />
|
||||
{t("reports.filters.title")}
|
||||
<span className="ml-auto text-xs text-[var(--muted-foreground)]">
|
||||
{categories.length - hiddenCategories.size}/{categories.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="border-t border-[var(--border)]">
|
||||
<div className="px-2 py-2">
|
||||
<div className="relative">
|
||||
<Search size={13} className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)]" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("reports.filters.search")}
|
||||
className="w-full pl-7 pr-2 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-2 pb-1 flex gap-1">
|
||||
<button
|
||||
onClick={onShowAll}
|
||||
disabled={allVisible}
|
||||
className="text-xs px-2 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
{t("reports.filters.all")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => categories.forEach((c) => { if (!hiddenCategories.has(c.name)) onToggleHidden(c.name); })}
|
||||
disabled={allHidden}
|
||||
className="text-xs px-2 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
{t("reports.filters.none")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-64 overflow-y-auto px-2 pb-2 space-y-0.5">
|
||||
{filtered.map((cat) => {
|
||||
const visible = !hiddenCategories.has(cat.name);
|
||||
return (
|
||||
<label
|
||||
key={cat.name}
|
||||
className="flex items-center gap-2 px-1.5 py-1 rounded hover:bg-[var(--muted)] cursor-pointer transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visible}
|
||||
onChange={() => onToggleHidden(cat.name)}
|
||||
className="rounded border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)] h-3.5 w-3.5"
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: cat.color }}
|
||||
/>
|
||||
<span className={`text-xs truncate ${visible ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)] line-through"}`}>
|
||||
{cat.name}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
src/components/settings/LogViewerCard.tsx
Normal file
116
src/components/settings/LogViewerCard.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { useState, useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollText, Trash2, Copy, Check } from "lucide-react";
|
||||
import { getLogs, clearLogs, subscribe, type LogLevel } from "../../services/logService";
|
||||
|
||||
type Filter = "all" | LogLevel;
|
||||
|
||||
export default function LogViewerCard() {
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const logs = useSyncExternalStore(subscribe, getLogs, getLogs);
|
||||
|
||||
const filtered = filter === "all" ? logs : logs.filter((l) => l.level === filter);
|
||||
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [filtered.length]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = filtered
|
||||
.map((l) => {
|
||||
const time = new Date(l.timestamp).toLocaleTimeString();
|
||||
return `[${time}] [${l.level.toUpperCase()}] ${l.message}`;
|
||||
})
|
||||
.join("\n");
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const levelColor: Record<LogLevel, string> = {
|
||||
info: "text-[var(--muted-foreground)]",
|
||||
warn: "text-amber-500",
|
||||
error: "text-[var(--negative)]",
|
||||
};
|
||||
|
||||
const filters: { value: Filter; label: string }[] = [
|
||||
{ value: "all", label: t("settings.logs.filterAll") },
|
||||
{ value: "error", label: "Error" },
|
||||
{ value: "warn", label: "Warn" },
|
||||
{ value: "info", label: "Info" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<ScrollText size={18} />
|
||||
{t("settings.logs.title")}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={filtered.length === 0}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
{copied ? t("settings.logs.copied") : t("settings.logs.copy")}
|
||||
</button>
|
||||
<button
|
||||
onClick={clearLogs}
|
||||
disabled={logs.length === 0}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("settings.logs.clear")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => setFilter(f.value)}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||
filter === f.value
|
||||
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
|
||||
: "bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
className="h-64 overflow-y-auto rounded-lg bg-[var(--background)] border border-[var(--border)] p-3 font-mono text-xs space-y-0.5"
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-[var(--muted-foreground)] text-center py-8">
|
||||
{t("settings.logs.empty")}
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((entry, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<span className="text-[var(--muted-foreground)] shrink-0">
|
||||
{new Date(entry.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className={`shrink-0 uppercase font-semibold w-12 ${levelColor[entry.level]}`}>
|
||||
{entry.level}
|
||||
</span>
|
||||
<span className="text-[var(--foreground)] break-all">{entry.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/shared/ErrorBoundary.tsx
Normal file
34
src/components/shared/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { Component, type ReactNode } from "react";
|
||||
import ErrorPage from "./ErrorPage";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error: error.message };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return <ErrorPage error={this.state.error ?? undefined} />;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
123
src/components/shared/ErrorPage.tsx
Normal file
123
src/components/shared/ErrorPage.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, ChevronDown, ChevronUp, RefreshCw, Download, Mail, Bug } from "lucide-react";
|
||||
import { check } from "@tauri-apps/plugin-updater";
|
||||
|
||||
interface ErrorPageProps {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function ErrorPage({ error }: ErrorPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [updateStatus, setUpdateStatus] = useState<"idle" | "checking" | "available" | "upToDate" | "error">("idle");
|
||||
const [updateVersion, setUpdateVersion] = useState<string | null>(null);
|
||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
setUpdateStatus("checking");
|
||||
setUpdateError(null);
|
||||
try {
|
||||
const update = await check();
|
||||
if (update) {
|
||||
setUpdateStatus("available");
|
||||
setUpdateVersion(update.version);
|
||||
} else {
|
||||
setUpdateStatus("upToDate");
|
||||
}
|
||||
} catch (e) {
|
||||
setUpdateStatus("error");
|
||||
setUpdateError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-[var(--background)] p-4">
|
||||
<div className="max-w-md w-full space-y-6 text-center">
|
||||
<AlertTriangle className="mx-auto h-16 w-16 text-[var(--destructive)]" />
|
||||
|
||||
<h1 className="text-2xl font-bold text-[var(--foreground)]">
|
||||
{t("error.title")}
|
||||
</h1>
|
||||
|
||||
{error && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="inline-flex items-center gap-1 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
{showDetails ? t("error.hideDetails") : t("error.showDetails")}
|
||||
{showDetails ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
{showDetails && (
|
||||
<pre className="mt-2 p-3 bg-[var(--muted)] rounded-md text-xs text-left text-[var(--muted-foreground)] overflow-auto max-h-40">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t("error.refresh")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCheckUpdate}
|
||||
disabled={updateStatus === "checking"}
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{updateStatus === "checking" ? t("common.loading") : t("error.checkUpdate")}
|
||||
</button>
|
||||
|
||||
{updateStatus === "available" && updateVersion && (
|
||||
<p className="text-sm text-[var(--primary)]">
|
||||
{t("error.updateAvailable", { version: updateVersion })}
|
||||
</p>
|
||||
)}
|
||||
{updateStatus === "upToDate" && (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{t("error.upToDate")}
|
||||
</p>
|
||||
)}
|
||||
{updateStatus === "error" && updateError && (
|
||||
<p className="text-sm text-[var(--destructive)]">{updateError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-[var(--border)]">
|
||||
<p className="text-sm font-medium text-[var(--foreground)] mb-3">
|
||||
{t("error.contactUs")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<a
|
||||
href="mailto:lacompagniemaximus@protonmail.com"
|
||||
className="inline-flex items-center justify-center gap-2 text-[var(--primary)] hover:underline"
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
{t("error.contactEmail")} lacompagniemaximus@protonmail.com
|
||||
</a>
|
||||
<a
|
||||
href="https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center gap-2 text-[var(--primary)] hover:underline"
|
||||
>
|
||||
<Bug className="h-4 w-4" />
|
||||
{t("error.reportIssue")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Loader2 } from "lucide-react";
|
||||
import { X, Loader2, ArrowUp, ArrowDown, Eye, EyeOff } from "lucide-react";
|
||||
import { getTransactionsByCategory } from "../../services/dashboardService";
|
||||
import type { TransactionRow } from "../../shared/types";
|
||||
|
||||
|
|
@ -10,6 +10,9 @@ const cadFormatter = new Intl.NumberFormat("en-CA", {
|
|||
currency: "CAD",
|
||||
});
|
||||
|
||||
type SortColumn = "date" | "description" | "amount";
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
interface TransactionDetailModalProps {
|
||||
categoryId: number | null;
|
||||
categoryName: string;
|
||||
|
|
@ -31,6 +34,9 @@ export default function TransactionDetailModal({
|
|||
const [rows, setRows] = useState<TransactionRow[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>("date");
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
|
||||
const [showAmounts, setShowAmounts] = useState(true);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
|
@ -57,8 +63,42 @@ export default function TransactionDetailModal({
|
|||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
|
||||
const handleSort = (column: SortColumn) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection(column === "description" ? "asc" : "desc");
|
||||
}
|
||||
};
|
||||
|
||||
const sortedRows = useMemo(() => {
|
||||
const sorted = [...rows];
|
||||
const dir = sortDirection === "asc" ? 1 : -1;
|
||||
sorted.sort((a, b) => {
|
||||
switch (sortColumn) {
|
||||
case "date":
|
||||
return (a.date < b.date ? -1 : a.date > b.date ? 1 : 0) * dir;
|
||||
case "description":
|
||||
return a.description.localeCompare(b.description) * dir;
|
||||
case "amount":
|
||||
return (a.amount - b.amount) * dir;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
return sorted;
|
||||
}, [rows, sortColumn, sortDirection]);
|
||||
|
||||
const total = rows.reduce((sum, r) => sum + r.amount, 0);
|
||||
|
||||
const SortIcon = ({ column }: { column: SortColumn }) => {
|
||||
if (sortColumn !== column) return null;
|
||||
return sortDirection === "asc" ? <ArrowUp size={14} /> : <ArrowDown size={14} />;
|
||||
};
|
||||
|
||||
const thClass = "px-6 py-2 font-medium cursor-pointer select-none hover:text-[var(--foreground)] transition-colors";
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50"
|
||||
|
|
@ -77,12 +117,21 @@ export default function TransactionDetailModal({
|
|||
({rows.length} {t("charts.transactions")})
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-lg hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setShowAmounts((v) => !v)}
|
||||
className="p-1 rounded-lg hover:bg-[var(--muted)] transition-colors text-[var(--muted-foreground)]"
|
||||
title={showAmounts ? t("reports.detail.hideAmounts") : t("reports.detail.showAmounts")}
|
||||
>
|
||||
{showAmounts ? <Eye size={18} /> : <EyeOff size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-lg hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
|
|
@ -107,34 +156,64 @@ export default function TransactionDetailModal({
|
|||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border)] text-[var(--muted-foreground)]">
|
||||
<th className="text-left px-6 py-2 font-medium">{t("transactions.date")}</th>
|
||||
<th className="text-left px-6 py-2 font-medium">{t("transactions.description")}</th>
|
||||
<th className="text-right px-6 py-2 font-medium">{t("transactions.amount")}</th>
|
||||
<th
|
||||
className={`text-left ${thClass}`}
|
||||
onClick={() => handleSort("date")}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("transactions.date")}
|
||||
<SortIcon column="date" />
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
className={`text-left ${thClass}`}
|
||||
onClick={() => handleSort("description")}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("transactions.description")}
|
||||
<SortIcon column="description" />
|
||||
</span>
|
||||
</th>
|
||||
{showAmounts && (
|
||||
<th
|
||||
className={`text-right ${thClass}`}
|
||||
onClick={() => handleSort("amount")}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 justify-end">
|
||||
{t("transactions.amount")}
|
||||
<SortIcon column="amount" />
|
||||
</span>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
{sortedRows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-[var(--border)] hover:bg-[var(--muted)]">
|
||||
<td className="px-6 py-2 whitespace-nowrap">{row.date}</td>
|
||||
<td className="px-6 py-2 truncate max-w-[300px]">{row.description}</td>
|
||||
<td className={`px-6 py-2 text-right whitespace-nowrap font-medium ${
|
||||
row.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
||||
}`}>
|
||||
{cadFormatter.format(row.amount)}
|
||||
</td>
|
||||
{showAmounts && (
|
||||
<td className={`px-6 py-2 text-right whitespace-nowrap font-medium ${
|
||||
row.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
||||
}`}>
|
||||
{cadFormatter.format(row.amount)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="font-semibold">
|
||||
<td className="px-6 py-3" colSpan={2}>{t("charts.total")}</td>
|
||||
<td className={`px-6 py-3 text-right ${
|
||||
total >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
||||
}`}>
|
||||
{cadFormatter.format(total)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
{showAmounts && (
|
||||
<tfoot>
|
||||
<tr className="font-semibold">
|
||||
<td className="px-6 py-3" colSpan={2}>{t("charts.total")}</td>
|
||||
<td className={`px-6 py-3 text-right ${
|
||||
total >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
||||
}`}>
|
||||
{cadFormatter.format(total)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ export default function TransactionTable({
|
|||
onClick={() => toggleNotes(row)}
|
||||
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors ${
|
||||
row.notes
|
||||
? "text-[var(--primary)]"
|
||||
? "text-orange-500"
|
||||
: "text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
title={t("transactions.notes.placeholder")}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { BudgetYearRow, BudgetTemplate } from "../shared/types";
|
|||
import {
|
||||
getAllActiveCategories,
|
||||
getBudgetEntriesForYear,
|
||||
getActualTotalsForYear,
|
||||
upsertBudgetEntry,
|
||||
upsertBudgetEntriesForYear,
|
||||
getAllTemplates,
|
||||
|
|
@ -72,9 +73,10 @@ export function useBudget() {
|
|||
dispatch({ type: "SET_ERROR", payload: null });
|
||||
|
||||
try {
|
||||
const [allCategories, entries, templates] = await Promise.all([
|
||||
const [allCategories, entries, prevYearActuals, templates] = await Promise.all([
|
||||
getAllActiveCategories(),
|
||||
getBudgetEntriesForYear(year),
|
||||
getActualTotalsForYear(year - 1),
|
||||
getAllTemplates(),
|
||||
]);
|
||||
|
||||
|
|
@ -87,6 +89,13 @@ export function useBudget() {
|
|||
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
|
||||
const buildMonths = (catId: number) => {
|
||||
const monthMap = entryMap.get(catId);
|
||||
|
|
@ -97,7 +106,8 @@ export function useBudget() {
|
|||
months.push(val);
|
||||
annual += val;
|
||||
}
|
||||
return { months, annual };
|
||||
const previousYearTotal = prevYearTotalMap.get(catId) ?? 0;
|
||||
return { months, annual, previousYearTotal };
|
||||
};
|
||||
|
||||
// Index categories by id and group children by parent_id
|
||||
|
|
@ -117,7 +127,7 @@ export function useBudget() {
|
|||
const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
|
||||
if (grandchildren.length === 0 && cat.is_inputable) {
|
||||
// Leaf at depth 2
|
||||
const { months, annual } = buildMonths(cat.id);
|
||||
const { months, annual, previousYearTotal } = buildMonths(cat.id);
|
||||
return [{
|
||||
category_id: cat.id,
|
||||
category_name: cat.name,
|
||||
|
|
@ -128,6 +138,7 @@ export function useBudget() {
|
|||
depth: 2,
|
||||
months,
|
||||
annual,
|
||||
previousYearTotal,
|
||||
}];
|
||||
}
|
||||
if (grandchildren.length === 0 && !cat.is_inputable) {
|
||||
|
|
@ -138,7 +149,7 @@ export function useBudget() {
|
|||
|
||||
const gcRows: BudgetYearRow[] = [];
|
||||
if (cat.is_inputable) {
|
||||
const { months, annual } = buildMonths(cat.id);
|
||||
const { months, annual, previousYearTotal } = buildMonths(cat.id);
|
||||
gcRows.push({
|
||||
category_id: cat.id,
|
||||
category_name: `${cat.name} (direct)`,
|
||||
|
|
@ -149,10 +160,11 @@ export function useBudget() {
|
|||
depth: 2,
|
||||
months,
|
||||
annual,
|
||||
previousYearTotal,
|
||||
});
|
||||
}
|
||||
for (const gc of grandchildren) {
|
||||
const { months, annual } = buildMonths(gc.id);
|
||||
const { months, annual, previousYearTotal } = buildMonths(gc.id);
|
||||
gcRows.push({
|
||||
category_id: gc.id,
|
||||
category_name: gc.name,
|
||||
|
|
@ -163,6 +175,7 @@ export function useBudget() {
|
|||
depth: 2,
|
||||
months,
|
||||
annual,
|
||||
previousYearTotal,
|
||||
});
|
||||
}
|
||||
if (gcRows.length === 0) return [];
|
||||
|
|
@ -170,9 +183,11 @@ export function useBudget() {
|
|||
// Build intermediate subtotal
|
||||
const subMonths = Array(12).fill(0) as number[];
|
||||
let subAnnual = 0;
|
||||
let subPrevYear = 0;
|
||||
for (const cr of gcRows) {
|
||||
for (let m = 0; m < 12; m++) subMonths[m] += cr.months[m];
|
||||
subAnnual += cr.annual;
|
||||
subPrevYear += cr.previousYearTotal;
|
||||
}
|
||||
const subtotal: BudgetYearRow = {
|
||||
category_id: cat.id,
|
||||
|
|
@ -184,6 +199,7 @@ export function useBudget() {
|
|||
depth: 1,
|
||||
months: subMonths,
|
||||
annual: subAnnual,
|
||||
previousYearTotal: subPrevYear,
|
||||
};
|
||||
gcRows.sort((a, b) => {
|
||||
if (a.category_id === cat.id) return -1;
|
||||
|
|
@ -203,7 +219,7 @@ export function useBudget() {
|
|||
|
||||
if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) {
|
||||
// Standalone leaf (no children) — regular editable row
|
||||
const { months, annual } = buildMonths(cat.id);
|
||||
const { months, annual, previousYearTotal } = buildMonths(cat.id);
|
||||
rows.push({
|
||||
category_id: cat.id,
|
||||
category_name: cat.name,
|
||||
|
|
@ -214,13 +230,14 @@ export function useBudget() {
|
|||
depth: 0,
|
||||
months,
|
||||
annual,
|
||||
previousYearTotal,
|
||||
});
|
||||
} else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
|
||||
const allChildRows: BudgetYearRow[] = [];
|
||||
|
||||
// If parent is also inputable, create a "(direct)" fake-child row
|
||||
if (cat.is_inputable) {
|
||||
const { months, annual } = buildMonths(cat.id);
|
||||
const { months, annual, previousYearTotal } = buildMonths(cat.id);
|
||||
allChildRows.push({
|
||||
category_id: cat.id,
|
||||
category_name: `${cat.name} (direct)`,
|
||||
|
|
@ -231,6 +248,7 @@ export function useBudget() {
|
|||
depth: 1,
|
||||
months,
|
||||
annual,
|
||||
previousYearTotal,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -238,7 +256,7 @@ export function useBudget() {
|
|||
const grandchildren = childrenByParent.get(child.id) || [];
|
||||
if (grandchildren.length === 0) {
|
||||
// Simple leaf at depth 1
|
||||
const { months, annual } = buildMonths(child.id);
|
||||
const { months, annual, previousYearTotal } = buildMonths(child.id);
|
||||
allChildRows.push({
|
||||
category_id: child.id,
|
||||
category_name: child.name,
|
||||
|
|
@ -249,6 +267,7 @@ export function useBudget() {
|
|||
depth: 1,
|
||||
months,
|
||||
annual,
|
||||
previousYearTotal,
|
||||
});
|
||||
} else {
|
||||
// Intermediate parent at depth 1 with grandchildren
|
||||
|
|
@ -267,9 +286,11 @@ export function useBudget() {
|
|||
const leafRows = allChildRows.filter((r) => !r.is_parent);
|
||||
const parentMonths = Array(12).fill(0) as number[];
|
||||
let parentAnnual = 0;
|
||||
let parentPrevYear = 0;
|
||||
for (const cr of leafRows) {
|
||||
for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m];
|
||||
parentAnnual += cr.annual;
|
||||
parentPrevYear += cr.previousYearTotal;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
|
|
@ -282,6 +303,7 @@ export function useBudget() {
|
|||
depth: 0,
|
||||
months: parentMonths,
|
||||
annual: parentAnnual,
|
||||
previousYearTotal: parentPrevYear,
|
||||
});
|
||||
|
||||
// Sort children alphabetically, but keep "(direct)" first
|
||||
|
|
|
|||
|
|
@ -3,19 +3,25 @@ import type {
|
|||
DashboardPeriod,
|
||||
DashboardSummary,
|
||||
CategoryBreakdownItem,
|
||||
RecentTransaction,
|
||||
CategoryOverTimeData,
|
||||
BudgetVsActualRow,
|
||||
} from "../shared/types";
|
||||
import {
|
||||
getDashboardSummary,
|
||||
getExpensesByCategory,
|
||||
getRecentTransactions,
|
||||
} from "../services/dashboardService";
|
||||
import { getCategoryOverTime } from "../services/reportService";
|
||||
import { getBudgetVsActualData } from "../services/budgetService";
|
||||
import { computeDateRange } from "../utils/dateRange";
|
||||
|
||||
interface DashboardState {
|
||||
summary: DashboardSummary;
|
||||
categoryBreakdown: CategoryBreakdownItem[];
|
||||
recentTransactions: RecentTransaction[];
|
||||
categoryOverTime: CategoryOverTimeData;
|
||||
budgetVsActual: BudgetVsActualRow[];
|
||||
period: DashboardPeriod;
|
||||
budgetYear: number;
|
||||
budgetMonth: number;
|
||||
customDateFrom: string;
|
||||
customDateTo: string;
|
||||
isLoading: boolean;
|
||||
|
|
@ -30,22 +36,27 @@ type DashboardAction =
|
|||
payload: {
|
||||
summary: DashboardSummary;
|
||||
categoryBreakdown: CategoryBreakdownItem[];
|
||||
recentTransactions: RecentTransaction[];
|
||||
categoryOverTime: CategoryOverTimeData;
|
||||
budgetVsActual: BudgetVsActualRow[];
|
||||
};
|
||||
}
|
||||
| { type: "SET_PERIOD"; payload: DashboardPeriod }
|
||||
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
|
||||
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } };
|
||||
|
||||
const now = new Date();
|
||||
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
const monthStartStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
|
||||
const yearStartStr = `${now.getFullYear()}-01-01`;
|
||||
|
||||
const initialState: DashboardState = {
|
||||
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
|
||||
categoryBreakdown: [],
|
||||
recentTransactions: [],
|
||||
period: "month",
|
||||
customDateFrom: monthStartStr,
|
||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||
budgetVsActual: [],
|
||||
period: "year",
|
||||
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
||||
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
|
||||
customDateFrom: yearStartStr,
|
||||
customDateTo: todayStr,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
|
@ -62,11 +73,14 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
|
|||
...state,
|
||||
summary: action.payload.summary,
|
||||
categoryBreakdown: action.payload.categoryBreakdown,
|
||||
recentTransactions: action.payload.recentTransactions,
|
||||
categoryOverTime: action.payload.categoryOverTime,
|
||||
budgetVsActual: action.payload.budgetVsActual,
|
||||
isLoading: false,
|
||||
};
|
||||
case "SET_PERIOD":
|
||||
return { ...state, period: action.payload };
|
||||
case "SET_BUDGET_MONTH":
|
||||
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
|
||||
case "SET_CUSTOM_DATES":
|
||||
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
||||
default:
|
||||
|
|
@ -74,69 +88,32 @@ 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() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
const fetchData = useCallback(async (period: DashboardPeriod, customFrom?: string, customTo?: string) => {
|
||||
const fetchData = useCallback(async (
|
||||
period: DashboardPeriod,
|
||||
customFrom: string | undefined,
|
||||
customTo: string | undefined,
|
||||
bYear: number,
|
||||
bMonth: number,
|
||||
) => {
|
||||
const fetchId = ++fetchIdRef.current;
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
dispatch({ type: "SET_ERROR", payload: null });
|
||||
|
||||
try {
|
||||
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||
const [summary, categoryBreakdown, recentTransactions] = await Promise.all([
|
||||
const [summary, categoryBreakdown, categoryOverTime, budgetVsActual] = await Promise.all([
|
||||
getDashboardSummary(dateFrom, dateTo),
|
||||
getExpensesByCategory(dateFrom, dateTo),
|
||||
getRecentTransactions(10),
|
||||
getCategoryOverTime(dateFrom, dateTo),
|
||||
getBudgetVsActualData(bYear, bMonth),
|
||||
]);
|
||||
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, recentTransactions } });
|
||||
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, categoryOverTime, budgetVsActual } });
|
||||
} catch (e) {
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({
|
||||
|
|
@ -147,8 +124,8 @@ export function useDashboard() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(state.period, state.customDateFrom, state.customDateTo);
|
||||
}, [state.period, state.customDateFrom, state.customDateTo, fetchData]);
|
||||
fetchData(state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth);
|
||||
}, [state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth, fetchData]);
|
||||
|
||||
const setPeriod = useCallback((period: DashboardPeriod) => {
|
||||
dispatch({ type: "SET_PERIOD", payload: period });
|
||||
|
|
@ -158,5 +135,9 @@ export function useDashboard() {
|
|||
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
||||
}, []);
|
||||
|
||||
return { state, setPeriod, setCustomDates };
|
||||
const setBudgetMonth = useCallback((year: number, month: number) => {
|
||||
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
|
||||
}, []);
|
||||
|
||||
return { state, setPeriod, setCustomDates, setBudgetMonth };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@ import type {
|
|||
import { getMonthlyTrends, getCategoryOverTime, getDynamicReportData } from "../services/reportService";
|
||||
import { getExpensesByCategory } from "../services/dashboardService";
|
||||
import { getBudgetVsActualData } from "../services/budgetService";
|
||||
import { computeDateRange } from "../utils/dateRange";
|
||||
|
||||
interface ReportsState {
|
||||
tab: ReportTab;
|
||||
period: DashboardPeriod;
|
||||
customDateFrom: string;
|
||||
customDateTo: string;
|
||||
sourceId: number | null;
|
||||
monthlyTrends: MonthlyTrendItem[];
|
||||
categorySpending: CategoryBreakdownItem[];
|
||||
categoryOverTime: CategoryOverTimeData;
|
||||
|
|
@ -42,7 +44,8 @@ type ReportsAction =
|
|||
| { type: "SET_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] }
|
||||
| { type: "SET_PIVOT_CONFIG"; payload: PivotConfig }
|
||||
| { type: "SET_PIVOT_RESULT"; payload: PivotResult }
|
||||
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } };
|
||||
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }
|
||||
| { type: "SET_SOURCE_ID"; payload: number | null };
|
||||
|
||||
const now = new Date();
|
||||
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
|
|
@ -53,11 +56,12 @@ const initialState: ReportsState = {
|
|||
period: "6months",
|
||||
customDateFrom: monthStartStr,
|
||||
customDateTo: todayStr,
|
||||
sourceId: null,
|
||||
monthlyTrends: [],
|
||||
categorySpending: [],
|
||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||
budgetYear: now.getFullYear(),
|
||||
budgetMonth: now.getMonth() + 1,
|
||||
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
||||
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
|
||||
budgetVsActual: [],
|
||||
pivotConfig: { rows: [], columns: [], filters: {}, values: [] },
|
||||
pivotResult: { rows: [], columnValues: [], dimensionLabels: {} },
|
||||
|
|
@ -91,55 +95,13 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState {
|
|||
return { ...state, pivotResult: action.payload, isLoading: false };
|
||||
case "SET_CUSTOM_DATES":
|
||||
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
||||
case "SET_SOURCE_ID":
|
||||
return { ...state, sourceId: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
|
@ -152,6 +114,7 @@ export function useReports() {
|
|||
customFrom?: string,
|
||||
customTo?: string,
|
||||
pivotCfg?: PivotConfig,
|
||||
srcId?: number | null,
|
||||
) => {
|
||||
const fetchId = ++fetchIdRef.current;
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
|
|
@ -161,21 +124,21 @@ export function useReports() {
|
|||
switch (tab) {
|
||||
case "trends": {
|
||||
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||
const data = await getMonthlyTrends(dateFrom, dateTo);
|
||||
const data = await getMonthlyTrends(dateFrom, dateTo, srcId ?? undefined);
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
|
||||
break;
|
||||
}
|
||||
case "byCategory": {
|
||||
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||
const data = await getExpensesByCategory(dateFrom, dateTo);
|
||||
const data = await getExpensesByCategory(dateFrom, dateTo, srcId ?? undefined);
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
|
||||
break;
|
||||
}
|
||||
case "overTime": {
|
||||
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||
const data = await getCategoryOverTime(dateFrom, dateTo);
|
||||
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, srcId ?? undefined);
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
||||
break;
|
||||
|
|
@ -207,8 +170,8 @@ export function useReports() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig);
|
||||
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, fetchData]);
|
||||
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId);
|
||||
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, fetchData]);
|
||||
|
||||
const setTab = useCallback((tab: ReportTab) => {
|
||||
dispatch({ type: "SET_TAB", payload: tab });
|
||||
|
|
@ -218,18 +181,9 @@ export function useReports() {
|
|||
dispatch({ type: "SET_PERIOD", payload: period });
|
||||
}, []);
|
||||
|
||||
const navigateBudgetMonth = useCallback((delta: -1 | 1) => {
|
||||
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 setBudgetMonth = useCallback((year: number, month: number) => {
|
||||
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
|
||||
}, []);
|
||||
|
||||
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
|
||||
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
||||
|
|
@ -239,5 +193,9 @@ export function useReports() {
|
|||
dispatch({ type: "SET_PIVOT_CONFIG", payload: config });
|
||||
}, []);
|
||||
|
||||
return { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig };
|
||||
const setSourceId = useCallback((id: number | null) => {
|
||||
dispatch({ type: "SET_SOURCE_ID", payload: id });
|
||||
}, []);
|
||||
|
||||
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
"app": {
|
||||
"name": "Simpl'Result"
|
||||
},
|
||||
"changelog": {
|
||||
"title": "Version History",
|
||||
"description": "View what's new and fixed in each version",
|
||||
"empty": "No entries available"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"import": "Import",
|
||||
|
|
@ -17,9 +22,12 @@
|
|||
"balance": "Balance",
|
||||
"income": "Income",
|
||||
"expenses": "Expenses",
|
||||
"net": "Net",
|
||||
"noData": "No data available. Start by importing your bank statements.",
|
||||
"expensesByCategory": "Expenses by Category",
|
||||
"recentTransactions": "Recent Transactions",
|
||||
"budgetVsActual": "Budget vs Actual",
|
||||
"expensesOverTime": "Expenses Over Time",
|
||||
"period": {
|
||||
"month": "This month",
|
||||
"3months": "3 months",
|
||||
|
|
@ -311,13 +319,18 @@
|
|||
"actual": "Actual",
|
||||
"difference": "Difference",
|
||||
"annual": "Annual",
|
||||
"previousYear": "Prev. Year",
|
||||
"splitEvenly": "Split evenly across 12 months",
|
||||
"annualMismatch": "Annual total does not match the sum of monthly amounts",
|
||||
"clickToEdit": "Click to edit",
|
||||
"applyToMonth": "Apply to month",
|
||||
"allMonths": "All 12 months",
|
||||
"expenses": "Expenses",
|
||||
"income": "Income",
|
||||
"transfers": "Transfers",
|
||||
"totalExpenses": "Total Expenses",
|
||||
"totalIncome": "Total Income",
|
||||
"totalTransfers": "Total Transfers",
|
||||
"totalPlanned": "Total Planned",
|
||||
"totalActual": "Total Actual",
|
||||
"totalDifference": "Difference",
|
||||
|
|
@ -349,12 +362,23 @@
|
|||
"budgetVsActual": "Budget vs Actual",
|
||||
"subtotalsOnTop": "Subtotals on top",
|
||||
"subtotalsOnBottom": "Subtotals on bottom",
|
||||
"detail": {
|
||||
"showAmounts": "Show amounts",
|
||||
"hideAmounts": "Hide amounts"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Categories",
|
||||
"search": "Search...",
|
||||
"all": "All",
|
||||
"none": "None"
|
||||
},
|
||||
"bva": {
|
||||
"monthly": "Monthly",
|
||||
"ytd": "Year-to-Date",
|
||||
"dollarVar": "$ 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",
|
||||
"export": "Export",
|
||||
|
|
@ -411,14 +435,7 @@
|
|||
"installing": "Installing...",
|
||||
"error": "Update failed",
|
||||
"retryButton": "Retry",
|
||||
"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"
|
||||
}
|
||||
"releaseNotes": "What's New"
|
||||
},
|
||||
"dataManagement": {
|
||||
"title": "Data Management",
|
||||
|
|
@ -467,6 +484,14 @@
|
|||
"title": "User Guide",
|
||||
"description": "Learn how to use all features of the app"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logs",
|
||||
"clear": "Clear",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
"empty": "No logs",
|
||||
"filterAll": "All"
|
||||
},
|
||||
"dataSafeNotice": "Your data is safe — only the app binary is replaced, your database is not modified.",
|
||||
"help": {
|
||||
"title": "About Settings",
|
||||
|
|
@ -565,18 +590,21 @@
|
|||
"features": [
|
||||
"Balance, income, and expense summary cards",
|
||||
"Expense breakdown by category (pie chart with SVG patterns)",
|
||||
"Recent transactions list",
|
||||
"Adjustable time period selector",
|
||||
"Budget vs Actual table for the current month (variance in $ and %)",
|
||||
"Stacked bar chart of expenses by category and month",
|
||||
"Adjustable time period selector (default: year to date)",
|
||||
"Context menu (right-click) to hide a category or view its transactions"
|
||||
],
|
||||
"steps": [
|
||||
"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",
|
||||
"Check the pie chart to see how your spending is distributed across categories",
|
||||
"Right-click a category in the chart to hide it or view its transaction details",
|
||||
"Scroll down to see your most recent transactions"
|
||||
"Review the Budget vs Actual table to compare your spending against your plan for the current month",
|
||||
"Analyze the stacked bar chart at the bottom to see expense trends by category over time",
|
||||
"Right-click a category in a chart to hide it or view its transaction details"
|
||||
],
|
||||
"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",
|
||||
"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"
|
||||
|
|
@ -691,7 +719,8 @@
|
|||
"Annual column with automatic totals",
|
||||
"Split annual amount evenly across 12 months",
|
||||
"Budget templates to save and apply configurations",
|
||||
"Parent category subtotals"
|
||||
"Parent category subtotals",
|
||||
"Column headers stay fixed when scrolling vertically"
|
||||
],
|
||||
"steps": [
|
||||
"Use the year navigator to select the budget year",
|
||||
|
|
@ -714,15 +743,20 @@
|
|||
"Expenses by Category: spending breakdown (pie chart)",
|
||||
"Category Over Time: track how each category evolves (line chart)",
|
||||
"Budget vs Actual: monthly and year-to-date comparison table",
|
||||
"Dynamic Report: customizable pivot table",
|
||||
"SVG patterns (lines, dots, crosshatch) to distinguish categories",
|
||||
"Context menu (right-click) to hide a category or view its transactions"
|
||||
"Context menu (right-click) to hide a category or view its transactions",
|
||||
"Transaction detail by category with sortable columns (date, description, amount)",
|
||||
"Toggle to show or hide amounts in transaction detail"
|
||||
],
|
||||
"steps": [
|
||||
"Use the tabs to switch between Trends, By Category, Over Time, and Budget vs Actual views",
|
||||
"Adjust the time period using the period selector",
|
||||
"Right-click a category in any chart to hide it or view its transaction details",
|
||||
"Hidden categories appear as dismissible chips above the chart — click them to show again",
|
||||
"In Budget vs Actual, toggle between Monthly and Year-to-Date views"
|
||||
"In Budget vs Actual, toggle between Monthly and Year-to-Date views",
|
||||
"In the category detail, click a column header to sort transactions",
|
||||
"Use the eye icon in the detail view to show or hide the amounts column"
|
||||
],
|
||||
"tips": [
|
||||
"Hidden categories are remembered while you stay on the page — click Show All to reset",
|
||||
|
|
@ -738,6 +772,7 @@
|
|||
"App version display",
|
||||
"Complete user guide accessible directly from settings",
|
||||
"Automatic update checker with one-click install",
|
||||
"Application logs viewable with level filters, copy, and clear",
|
||||
"Data export (transactions, categories, or both) in JSON or CSV format",
|
||||
"Data import from a previously exported file",
|
||||
"Optional AES-256-GCM encryption for exported files"
|
||||
|
|
@ -745,6 +780,7 @@
|
|||
"steps": [
|
||||
"Click User Guide to access the full documentation",
|
||||
"Click Check for Updates to see if a new version is available",
|
||||
"View the Logs section to see application logs — filter by level (All, Error, Warn, Info), copy or clear",
|
||||
"Use the Data Management section to export or import your data",
|
||||
"When exporting, choose what to include and optionally set a password for encryption",
|
||||
"When importing, select a previously exported file — encrypted files will prompt for the password"
|
||||
|
|
@ -753,7 +789,9 @@
|
|||
"Updates only replace the app binary — your database is never modified",
|
||||
"Change the app language using the language selector in the sidebar",
|
||||
"Export regularly to keep a backup of your data",
|
||||
"The user guide can be printed or exported to PDF via the Print button"
|
||||
"The user guide can be printed or exported to PDF via the Print button",
|
||||
"Logs persist for the session — they survive a page refresh",
|
||||
"If you encounter an issue, copy the logs and attach them to your report"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -778,6 +816,20 @@
|
|||
"manageProfiles": "Manage Profiles",
|
||||
"default": "Default"
|
||||
},
|
||||
"error": {
|
||||
"title": "An error occurred",
|
||||
"startupTimeout": "Database connection timed out",
|
||||
"unexpectedError": "An unexpected error occurred",
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details",
|
||||
"refresh": "Refresh",
|
||||
"checkUpdate": "Check for updates",
|
||||
"updateAvailable": "Update available: v{{version}}",
|
||||
"upToDate": "The application is up to date",
|
||||
"contactUs": "Contact us",
|
||||
"contactEmail": "Send an email to",
|
||||
"reportIssue": "Report an issue"
|
||||
},
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
"app": {
|
||||
"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": {
|
||||
"dashboard": "Tableau de bord",
|
||||
"import": "Importer",
|
||||
|
|
@ -17,9 +22,12 @@
|
|||
"balance": "Solde",
|
||||
"income": "Revenus",
|
||||
"expenses": "Dépenses",
|
||||
"net": "Net",
|
||||
"noData": "Aucune donnée disponible. Commencez par importer vos relevés bancaires.",
|
||||
"expensesByCategory": "Dépenses par catégorie",
|
||||
"recentTransactions": "Transactions récentes",
|
||||
"budgetVsActual": "Budget vs Réel",
|
||||
"expensesOverTime": "Dépenses dans le temps",
|
||||
"period": {
|
||||
"month": "Ce mois",
|
||||
"3months": "3 mois",
|
||||
|
|
@ -311,13 +319,18 @@
|
|||
"actual": "Réel",
|
||||
"difference": "Écart",
|
||||
"annual": "Annuel",
|
||||
"previousYear": "Année préc.",
|
||||
"splitEvenly": "Répartir également sur 12 mois",
|
||||
"annualMismatch": "Le total annuel ne correspond pas à la somme des montants mensuels",
|
||||
"clickToEdit": "Cliquer pour modifier",
|
||||
"applyToMonth": "Appliquer au mois",
|
||||
"allMonths": "Les 12 mois",
|
||||
"expenses": "Dépenses",
|
||||
"income": "Revenus",
|
||||
"transfers": "Transferts",
|
||||
"totalExpenses": "Total des dépenses",
|
||||
"totalIncome": "Total des revenus",
|
||||
"totalTransfers": "Total des transferts",
|
||||
"totalPlanned": "Total prévu",
|
||||
"totalActual": "Total réel",
|
||||
"totalDifference": "Écart",
|
||||
|
|
@ -349,12 +362,23 @@
|
|||
"budgetVsActual": "Budget vs R\u00e9el",
|
||||
"subtotalsOnTop": "Sous-totaux en haut",
|
||||
"subtotalsOnBottom": "Sous-totaux en bas",
|
||||
"detail": {
|
||||
"showAmounts": "Afficher les montants",
|
||||
"hideAmounts": "Masquer les montants"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Catégories",
|
||||
"search": "Rechercher...",
|
||||
"all": "Toutes",
|
||||
"none": "Aucune"
|
||||
},
|
||||
"bva": {
|
||||
"monthly": "Mensuel",
|
||||
"ytd": "Cumul annuel",
|
||||
"dollarVar": "$ \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",
|
||||
"export": "Exporter",
|
||||
|
|
@ -411,14 +435,7 @@
|
|||
"installing": "Installation en cours...",
|
||||
"error": "Erreur lors de la mise à jour",
|
||||
"retryButton": "Réessayer",
|
||||
"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"
|
||||
}
|
||||
"releaseNotes": "Nouveautés"
|
||||
},
|
||||
"dataManagement": {
|
||||
"title": "Gestion des données",
|
||||
|
|
@ -467,6 +484,14 @@
|
|||
"title": "Guide d'utilisation",
|
||||
"description": "Apprenez à utiliser toutes les fonctionnalités de l'application"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Journaux",
|
||||
"clear": "Effacer",
|
||||
"copy": "Copier",
|
||||
"copied": "Copié !",
|
||||
"empty": "Aucun journal",
|
||||
"filterAll": "Tout"
|
||||
},
|
||||
"dataSafeNotice": "Vos données sont en sécurité — seul le programme est remplacé, votre base de données n'est pas modifiée.",
|
||||
"help": {
|
||||
"title": "À propos des Paramètres",
|
||||
|
|
@ -565,18 +590,21 @@
|
|||
"features": [
|
||||
"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)",
|
||||
"Liste des transactions récentes",
|
||||
"Sélecteur de période ajustable",
|
||||
"Tableau Budget vs Réel du mois courant (écart en $ et %)",
|
||||
"Histogramme empilé des dépenses par catégorie et par mois",
|
||||
"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"
|
||||
],
|
||||
"steps": [
|
||||
"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",
|
||||
"Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie",
|
||||
"Cliquez droit sur une catégorie dans le graphique pour la masquer ou voir le détail de ses transactions",
|
||||
"Faites défiler vers le bas pour voir vos transactions les plus récentes"
|
||||
"Consultez le tableau Budget vs Réel pour comparer vos dépenses au budget du mois courant",
|
||||
"Analysez l'histogramme en bas de page pour voir l'évolution de vos dépenses par catégorie dans le temps",
|
||||
"Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions"
|
||||
],
|
||||
"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",
|
||||
"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"
|
||||
|
|
@ -691,7 +719,8 @@
|
|||
"Colonne annuelle avec totaux automatiques",
|
||||
"Répartition égale du montant annuel sur 12 mois",
|
||||
"Modèles de budget pour sauvegarder et appliquer des configurations",
|
||||
"Sous-totaux par catégorie parente"
|
||||
"Sous-totaux par catégorie parente",
|
||||
"En-têtes de colonnes fixes au défilement vertical"
|
||||
],
|
||||
"steps": [
|
||||
"Utilisez le navigateur d'année pour sélectionner l'année du budget",
|
||||
|
|
@ -714,15 +743,20 @@
|
|||
"Dépenses par catégorie : répartition des dépenses (graphique circulaire)",
|
||||
"Catégories dans le temps : suivez l'évolution de chaque catégorie (graphique en ligne)",
|
||||
"Budget vs Réel : tableau comparatif mensuel et cumul annuel",
|
||||
"Rapport dynamique : tableau croisé dynamique (pivot table) personnalisable",
|
||||
"Motifs SVG (lignes, points, hachures) pour distinguer les catégories",
|
||||
"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",
|
||||
"Détail des transactions par catégorie avec tri par colonne (date, description, montant)",
|
||||
"Toggle pour afficher ou masquer les montants dans le détail des transactions"
|
||||
],
|
||||
"steps": [
|
||||
"Utilisez les onglets pour basculer entre Tendances, Par catégorie, Dans le temps et Budget vs Réel",
|
||||
"Ajustez la période avec le sélecteur de période",
|
||||
"Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions",
|
||||
"Les catégories masquées apparaissent comme pastilles au-dessus du graphique — cliquez dessus pour les réafficher",
|
||||
"Dans Budget vs Réel, basculez entre les vues Mensuel et Cumul annuel"
|
||||
"Dans Budget vs Réel, basculez entre les vues Mensuel et Cumul annuel",
|
||||
"Dans le détail d'une catégorie, cliquez sur un en-tête de colonne pour trier les transactions",
|
||||
"Utilisez l'icône œil dans le détail pour masquer ou afficher la colonne des montants"
|
||||
],
|
||||
"tips": [
|
||||
"Les catégories masquées sont mémorisées tant que vous restez sur la page — cliquez sur Tout afficher pour réinitialiser",
|
||||
|
|
@ -738,6 +772,7 @@
|
|||
"Affichage de la version de l'application",
|
||||
"Guide d'utilisation complet accessible directement depuis les paramètres",
|
||||
"Vérification automatique des mises à jour avec installation en un clic",
|
||||
"Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement",
|
||||
"Export des données (transactions, catégories, ou les deux) en format JSON ou CSV",
|
||||
"Import des données depuis un fichier exporté précédemment",
|
||||
"Chiffrement AES-256-GCM optionnel pour les fichiers exportés"
|
||||
|
|
@ -745,6 +780,7 @@
|
|||
"steps": [
|
||||
"Cliquez sur Guide d'utilisation pour accéder à la documentation complète",
|
||||
"Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible",
|
||||
"Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez",
|
||||
"Utilisez la section Gestion des données pour exporter ou importer vos données",
|
||||
"Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement",
|
||||
"Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe"
|
||||
|
|
@ -753,7 +789,9 @@
|
|||
"Les mises à jour ne remplacent que le programme — votre base de données n'est jamais modifiée",
|
||||
"Changez la langue de l'application via le sélecteur de langue dans la barre latérale",
|
||||
"Exportez régulièrement pour garder une sauvegarde de vos données",
|
||||
"Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer"
|
||||
"Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer",
|
||||
"Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page",
|
||||
"En cas de problème, copiez les journaux et joignez-les à votre signalement"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -778,6 +816,20 @@
|
|||
"manageProfiles": "Gérer les profils",
|
||||
"default": "Par défaut"
|
||||
},
|
||||
"error": {
|
||||
"title": "Une erreur est survenue",
|
||||
"startupTimeout": "La connexion à la base de données a expiré",
|
||||
"unexpectedError": "Une erreur inattendue s'est produite",
|
||||
"showDetails": "Afficher les détails",
|
||||
"hideDetails": "Masquer les détails",
|
||||
"refresh": "Actualiser",
|
||||
"checkUpdate": "Vérifier les mises à jour",
|
||||
"updateAvailable": "Mise à jour disponible : v{{version}}",
|
||||
"upToDate": "L'application est à jour",
|
||||
"contactUs": "Nous contacter",
|
||||
"contactEmail": "Envoyez un email à",
|
||||
"reportIssue": "Signaler un problème"
|
||||
},
|
||||
"common": {
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
|
|
|
|||
|
|
@ -2,13 +2,19 @@ import React from "react";
|
|||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { ProfileProvider } from "./contexts/ProfileContext";
|
||||
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||
import { initLogCapture } from "./services/logService";
|
||||
import "./i18n/config";
|
||||
import "./styles.css";
|
||||
|
||||
initLogCapture();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<ProfileProvider>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</ProfileProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export default function CategoriesPage() {
|
|||
onRemove={removeKeyword}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex gap-6" style={{ minHeight: "calc(100vh - 180px)" }}>
|
||||
<div className="flex gap-6" style={{ height: "calc(100vh - 180px)" }}>
|
||||
<div className="w-1/3 bg-[var(--card)] rounded-xl border border-[var(--border)] p-3 overflow-y-auto">
|
||||
<CategoryTree
|
||||
tree={state.tree}
|
||||
|
|
|
|||
107
src/pages/ChangelogPage.tsx
Normal file
107
src/pages/ChangelogPage.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
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,47 +1,22 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Wallet, TrendingUp, TrendingDown } from "lucide-react";
|
||||
import { useDashboard } from "../hooks/useDashboard";
|
||||
import { PageHelp } from "../components/shared/PageHelp";
|
||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||
import CategoryPieChart from "../components/dashboard/CategoryPieChart";
|
||||
import RecentTransactionsList from "../components/dashboard/RecentTransactionsList";
|
||||
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
||||
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
|
||||
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
||||
import type { CategoryBreakdownItem, DashboardPeriod } from "../shared/types";
|
||||
import type { CategoryBreakdownItem } from "../shared/types";
|
||||
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
|
||||
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const { state, setPeriod, setCustomDates } = useDashboard();
|
||||
const { summary, categoryBreakdown, recentTransactions, period, isLoading } = state;
|
||||
const { t, i18n } = useTranslation();
|
||||
const { state, setPeriod, setCustomDates, setBudgetMonth } = useDashboard();
|
||||
const { summary, categoryBreakdown, categoryOverTime, budgetVsActual, period, isLoading } = state;
|
||||
|
||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
||||
|
|
@ -90,6 +65,8 @@ export default function DashboardPage() {
|
|||
},
|
||||
];
|
||||
|
||||
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
|
||||
|
||||
const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
|
||||
|
||||
return (
|
||||
|
|
@ -108,7 +85,7 @@ export default function DashboardPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.labelKey}
|
||||
|
|
@ -125,15 +102,48 @@ export default function DashboardPage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<CategoryPieChart
|
||||
data={categoryBreakdown}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="lg:col-span-1">
|
||||
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2>
|
||||
<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}
|
||||
onToggleHidden={toggleHidden}
|
||||
onShowAll={showAll}
|
||||
onViewDetails={viewDetails}
|
||||
/>
|
||||
<RecentTransactionsList transactions={recentTransactions} />
|
||||
</div>
|
||||
|
||||
{detailModal && (
|
||||
|
|
|
|||
|
|
@ -1,52 +1,40 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Hash, Table, BarChart3 } from "lucide-react";
|
||||
import { useReports } from "../hooks/useReports";
|
||||
import { PageHelp } from "../components/shared/PageHelp";
|
||||
import type { ReportTab, CategoryBreakdownItem, DashboardPeriod } from "../shared/types";
|
||||
import type { ReportTab, CategoryBreakdownItem, ImportSource } from "../shared/types";
|
||||
import { getAllSources } from "../services/importSourceService";
|
||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||
import MonthNavigator from "../components/budget/MonthNavigator";
|
||||
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
||||
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
|
||||
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
||||
import CategoryTable from "../components/reports/CategoryTable";
|
||||
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
||||
import CategoryOverTimeTable from "../components/reports/CategoryOverTimeTable";
|
||||
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
|
||||
import DynamicReport from "../components/reports/DynamicReport";
|
||||
import ReportFilterPanel from "../components/reports/ReportFilterPanel";
|
||||
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
||||
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
|
||||
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig } = useReports();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId } = useReports();
|
||||
const [sources, setSources] = useState<ImportSource[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getAllSources().then(setSources);
|
||||
}, []);
|
||||
|
||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
||||
const [showAmounts, setShowAmounts] = useState(() => localStorage.getItem("reports-show-amounts") === "true");
|
||||
const [viewMode, setViewMode] = useState<"chart" | "table">(() =>
|
||||
(localStorage.getItem("reports-view-mode") as "chart" | "table") || "chart"
|
||||
);
|
||||
|
||||
const toggleHidden = useCallback((name: string) => {
|
||||
setHiddenCategories((prev) => {
|
||||
|
|
@ -65,20 +53,52 @@ export default function ReportsPage() {
|
|||
|
||||
const { dateFrom, dateTo } = computeDateRange(state.period, state.customDateFrom, state.customDateTo);
|
||||
|
||||
const filterCategories = useMemo(() => {
|
||||
if (state.tab === "byCategory") {
|
||||
return state.categorySpending.map((c) => ({ name: c.category_name, color: c.category_color }));
|
||||
}
|
||||
if (state.tab === "overTime") {
|
||||
return state.categoryOverTime.categories.map((name) => ({
|
||||
name,
|
||||
color: state.categoryOverTime.colors[name] || "#9ca3af",
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}, [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 showFilterPanel = hasCategories || (state.tab === "trends" && sources.length > 1);
|
||||
|
||||
return (
|
||||
<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="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">{t("reports.title")}</h1>
|
||||
{state.tab === "budgetVsActual" ? (
|
||||
<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" />
|
||||
</div>
|
||||
{state.tab === "budgetVsActual" ? (
|
||||
<MonthNavigator
|
||||
year={state.budgetYear}
|
||||
month={state.budgetMonth}
|
||||
onNavigate={navigateBudgetMonth}
|
||||
/>
|
||||
) : (
|
||||
{state.tab !== "budgetVsActual" && (
|
||||
<PeriodSelector
|
||||
value={state.period}
|
||||
onChange={setPeriod}
|
||||
|
|
@ -89,7 +109,7 @@ export default function ReportsPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
<div className="flex gap-2 mb-6 flex-wrap items-center">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
|
|
@ -103,6 +123,54 @@ export default function ReportsPage() {
|
|||
{t(`reports.${tab}`)}
|
||||
</button>
|
||||
))}
|
||||
{["trends", "byCategory", "overTime"].includes(state.tab) && (
|
||||
<>
|
||||
<div className="mx-1 h-6 w-px bg-[var(--border)]" />
|
||||
{([
|
||||
{ mode: "chart" as const, icon: <BarChart3 size={14} />, label: t("reports.pivot.viewChart") },
|
||||
{ mode: "table" as const, icon: <Table size={14} />, label: t("reports.pivot.viewTable") },
|
||||
]).map(({ mode, icon, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => {
|
||||
setViewMode(mode);
|
||||
localStorage.setItem("reports-view-mode", mode);
|
||||
}}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
mode === viewMode
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{viewMode === "chart" && (
|
||||
<>
|
||||
<div className="mx-1 h-6 w-px bg-[var(--border)]" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAmounts((prev) => {
|
||||
const next = !prev;
|
||||
localStorage.setItem("reports-show-amounts", String(next));
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
showAmounts
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||
}`}
|
||||
title={showAmounts ? t("reports.detail.hideAmounts") : t("reports.detail.showAmounts")}
|
||||
>
|
||||
<Hash size={14} />
|
||||
{showAmounts ? t("reports.detail.hideAmounts") : t("reports.detail.showAmounts")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.error && (
|
||||
|
|
@ -111,35 +179,66 @@ export default function ReportsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{state.tab === "trends" && <MonthlyTrendsChart data={state.monthlyTrends} />}
|
||||
{state.tab === "byCategory" && (
|
||||
<CategoryBarChart
|
||||
data={state.categorySpending}
|
||||
hiddenCategories={hiddenCategories}
|
||||
onToggleHidden={toggleHidden}
|
||||
onShowAll={showAll}
|
||||
onViewDetails={viewDetails}
|
||||
/>
|
||||
)}
|
||||
{state.tab === "overTime" && (
|
||||
<CategoryOverTimeChart
|
||||
data={state.categoryOverTime}
|
||||
hiddenCategories={hiddenCategories}
|
||||
onToggleHidden={toggleHidden}
|
||||
onShowAll={showAll}
|
||||
onViewDetails={viewDetails}
|
||||
/>
|
||||
)}
|
||||
{state.tab === "budgetVsActual" && (
|
||||
<BudgetVsActualTable data={state.budgetVsActual} />
|
||||
)}
|
||||
{state.tab === "dynamic" && (
|
||||
<DynamicReport
|
||||
config={state.pivotConfig}
|
||||
result={state.pivotResult}
|
||||
onConfigChange={setPivotConfig}
|
||||
/>
|
||||
)}
|
||||
<div className={showFilterPanel ? "flex gap-4 items-start" : ""}>
|
||||
<div className={showFilterPanel ? "flex-1 min-w-0" : ""}>
|
||||
{state.tab === "trends" && (
|
||||
viewMode === "chart" ? (
|
||||
<MonthlyTrendsChart data={state.monthlyTrends} showAmounts={showAmounts} />
|
||||
) : (
|
||||
<MonthlyTrendsTable data={state.monthlyTrends} />
|
||||
)
|
||||
)}
|
||||
{state.tab === "byCategory" && (
|
||||
viewMode === "chart" ? (
|
||||
<CategoryBarChart
|
||||
data={state.categorySpending}
|
||||
hiddenCategories={hiddenCategories}
|
||||
onToggleHidden={toggleHidden}
|
||||
onShowAll={showAll}
|
||||
onViewDetails={viewDetails}
|
||||
showAmounts={showAmounts}
|
||||
/>
|
||||
) : (
|
||||
<CategoryTable data={state.categorySpending} hiddenCategories={hiddenCategories} />
|
||||
)
|
||||
)}
|
||||
{state.tab === "overTime" && (
|
||||
viewMode === "chart" ? (
|
||||
<CategoryOverTimeChart
|
||||
data={state.categoryOverTime}
|
||||
hiddenCategories={hiddenCategories}
|
||||
onToggleHidden={toggleHidden}
|
||||
onShowAll={showAll}
|
||||
onViewDetails={viewDetails}
|
||||
showAmounts={showAmounts}
|
||||
/>
|
||||
) : (
|
||||
<CategoryOverTimeTable data={state.categoryOverTime} hiddenCategories={hiddenCategories} />
|
||||
)
|
||||
)}
|
||||
{state.tab === "budgetVsActual" && (
|
||||
<BudgetVsActualTable data={state.budgetVsActual} />
|
||||
)}
|
||||
{state.tab === "dynamic" && (
|
||||
<DynamicReport
|
||||
config={state.pivotConfig}
|
||||
result={state.pivotResult}
|
||||
onConfigChange={setPivotConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showFilterPanel && (
|
||||
<ReportFilterPanel
|
||||
categories={filterCategories}
|
||||
hiddenCategories={hiddenCategories}
|
||||
onToggleHidden={toggleHidden}
|
||||
onShowAll={showAll}
|
||||
sources={sources}
|
||||
selectedSourceId={state.sourceId}
|
||||
onSourceChange={setSourceId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detailModal && (
|
||||
<TransactionDetailModal
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Info,
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
ShieldCheck,
|
||||
BookOpen,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { useUpdater } from "../hooks/useUpdater";
|
||||
|
|
@ -18,17 +19,47 @@ import { Link } from "react-router-dom";
|
|||
import { APP_NAME } from "../shared/constants";
|
||||
import { PageHelp } from "../components/shared/PageHelp";
|
||||
import DataManagementCard from "../components/settings/DataManagementCard";
|
||||
import LogViewerCard from "../components/settings/LogViewerCard";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { state, checkForUpdate, downloadAndInstall, installAndRestart } =
|
||||
useUpdater();
|
||||
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(() => {
|
||||
getVersion().then(setVersion);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === "available" && state.version) {
|
||||
fetchReleaseNotes(state.version);
|
||||
}
|
||||
}, [state.status, state.version, fetchReleaseNotes]);
|
||||
|
||||
const progressPercent =
|
||||
state.contentLength && state.contentLength > 0
|
||||
? Math.round((state.progress / state.contentLength) * 100)
|
||||
|
|
@ -77,6 +108,27 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
</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 */}
|
||||
<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">
|
||||
|
|
@ -126,9 +178,7 @@ export default function SettingsPage() {
|
|||
{t("settings.updates.available", { version: state.version })}
|
||||
</p>
|
||||
{(() => {
|
||||
const notesKey = `settings.updates.notes.${state.version}`;
|
||||
const i18nNotes = t(notesKey, { defaultValue: "" });
|
||||
const notes = i18nNotes || state.body;
|
||||
const notes = releaseNotes || state.body;
|
||||
if (!notes) return null;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -221,6 +271,9 @@ export default function SettingsPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Logs */}
|
||||
<LogViewerCard />
|
||||
|
||||
{/* Data management */}
|
||||
<DataManagementCard />
|
||||
|
||||
|
|
|
|||
|
|
@ -178,6 +178,16 @@ export async function deleteTemplate(templateId: number): Promise<void> {
|
|||
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 ---
|
||||
|
||||
async function getActualsByCategoryRange(
|
||||
|
|
@ -231,7 +241,6 @@ export async function getBudgetVsActualData(
|
|||
}
|
||||
|
||||
// Index categories
|
||||
const catById = new Map(allCategories.map((c) => [c.id, c]));
|
||||
const childrenByParent = new Map<number, Category[]>();
|
||||
for (const cat of allCategories) {
|
||||
if (cat.parent_id) {
|
||||
|
|
@ -244,7 +253,7 @@ export async function getBudgetVsActualData(
|
|||
const signFor = (type: string) => (type === "expense" ? -1 : 1);
|
||||
|
||||
// Compute leaf row values
|
||||
function buildLeaf(cat: Category, parentId: number | null, depth: 0 | 1 | 2): BudgetVsActualRow {
|
||||
function buildLeaf(cat: Category, parentId: number | null, depth: number): BudgetVsActualRow {
|
||||
const sign = signFor(cat.type);
|
||||
const monthMap = entryMap.get(cat.id);
|
||||
const rawMonthBudget = monthMap?.get(month) ?? 0;
|
||||
|
|
@ -281,7 +290,7 @@ export async function getBudgetVsActualData(
|
|||
};
|
||||
}
|
||||
|
||||
function buildSubtotal(cat: Category, childRows: BudgetVsActualRow[], parentId: number | null, depth: 0 | 1 | 2): BudgetVsActualRow {
|
||||
function buildSubtotal(cat: Category, childRows: BudgetVsActualRow[], parentId: number | null, depth: number): BudgetVsActualRow {
|
||||
const row: BudgetVsActualRow = {
|
||||
category_id: cat.id,
|
||||
category_name: cat.name,
|
||||
|
|
@ -323,35 +332,41 @@ export async function getBudgetVsActualData(
|
|||
);
|
||||
}
|
||||
|
||||
// Build rows for a level-2 parent (intermediate parent with grandchildren)
|
||||
function buildLevel2Group(cat: Category, grandparentId: number): BudgetVsActualRow[] {
|
||||
const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
|
||||
if (grandchildren.length === 0 && cat.is_inputable) {
|
||||
// Leaf at level 2
|
||||
const leaf = buildLeaf(cat, grandparentId, 2);
|
||||
// Build rows for a sub-group (recursive, supports arbitrary depth)
|
||||
function buildSubGroup(cat: Category, groupParentId: number, depth: number): BudgetVsActualRow[] {
|
||||
const subChildren = childrenByParent.get(cat.id) || [];
|
||||
const hasSubChildren = subChildren.some(
|
||||
(c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0
|
||||
);
|
||||
|
||||
if (!hasSubChildren && cat.is_inputable) {
|
||||
const leaf = buildLeaf(cat, groupParentId, depth);
|
||||
return isRowAllZero(leaf) ? [] : [leaf];
|
||||
}
|
||||
if (grandchildren.length === 0) return [];
|
||||
if (!hasSubChildren) return [];
|
||||
|
||||
const gcRows: BudgetVsActualRow[] = [];
|
||||
const childRows: BudgetVsActualRow[] = [];
|
||||
if (cat.is_inputable) {
|
||||
const direct = buildLeaf(cat, cat.id, 2);
|
||||
const direct = buildLeaf(cat, cat.id, depth + 1);
|
||||
direct.category_name = `${cat.name} (direct)`;
|
||||
if (!isRowAllZero(direct)) gcRows.push(direct);
|
||||
if (!isRowAllZero(direct)) childRows.push(direct);
|
||||
}
|
||||
for (const gc of grandchildren) {
|
||||
const leaf = buildLeaf(gc, cat.id, 2);
|
||||
if (!isRowAllZero(leaf)) gcRows.push(leaf);
|
||||
const sortedSubChildren = [...subChildren].sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const child of sortedSubChildren) {
|
||||
const grandchildren = childrenByParent.get(child.id) || [];
|
||||
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 (gcRows.length === 0) return [];
|
||||
if (childRows.length === 0) return [];
|
||||
|
||||
const subtotal = buildSubtotal(cat, gcRows, grandparentId, 1);
|
||||
gcRows.sort((a, b) => {
|
||||
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 leafRows = childRows.filter((r) => !r.is_parent);
|
||||
const subtotal = buildSubtotal(cat, leafRows, groupParentId, depth);
|
||||
return [subtotal, ...childRows];
|
||||
}
|
||||
|
||||
const rows: BudgetVsActualRow[] = [];
|
||||
|
|
@ -359,15 +374,15 @@ export async function getBudgetVsActualData(
|
|||
|
||||
for (const cat of topLevel) {
|
||||
const children = childrenByParent.get(cat.id) || [];
|
||||
const inputableChildren = children.filter((c) => c.is_inputable);
|
||||
// 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);
|
||||
const hasChildren = children.some(
|
||||
(c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0
|
||||
);
|
||||
|
||||
if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) {
|
||||
if (!hasChildren && cat.is_inputable) {
|
||||
// Standalone leaf at level 0
|
||||
const leaf = buildLeaf(cat, null, 0);
|
||||
if (!isRowAllZero(leaf)) rows.push(leaf);
|
||||
} else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
|
||||
} else if (hasChildren) {
|
||||
const allChildRows: BudgetVsActualRow[] = [];
|
||||
|
||||
// Direct transactions on the parent itself
|
||||
|
|
@ -377,25 +392,19 @@ export async function getBudgetVsActualData(
|
|||
if (!isRowAllZero(direct)) allChildRows.push(direct);
|
||||
}
|
||||
|
||||
// Level-2 leaves (direct children that are inputable and have no children)
|
||||
for (const child of inputableChildren) {
|
||||
// Process children in alphabetical order
|
||||
const sortedChildren = [...children].sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const child of sortedChildren) {
|
||||
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);
|
||||
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;
|
||||
|
||||
// Collect only leaf rows for parent subtotal (avoid double-counting)
|
||||
|
|
@ -403,51 +412,19 @@ export async function getBudgetVsActualData(
|
|||
const parent = buildSubtotal(cat, leafRows, null, 0);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by type, then within same type keep parent+children groups together
|
||||
// Sort by type only, preserving tree order within groups (already built correctly)
|
||||
const rowOrder = new Map<BudgetVsActualRow, number>();
|
||||
rows.forEach((r, i) => rowOrder.set(r, i));
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const typeA = TYPE_ORDER[a.category_type] ?? 9;
|
||||
const typeB = TYPE_ORDER[b.category_type] ?? 9;
|
||||
if (typeA !== typeB) return typeA - typeB;
|
||||
// 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 rowOrder.get(a)! - rowOrder.get(b)!;
|
||||
});
|
||||
|
||||
return rows;
|
||||
|
|
|
|||
|
|
@ -53,7 +53,8 @@ export async function getDashboardSummary(
|
|||
|
||||
export async function getExpensesByCategory(
|
||||
dateFrom?: string,
|
||||
dateTo?: string
|
||||
dateTo?: string,
|
||||
sourceId?: number,
|
||||
): Promise<CategoryBreakdownItem[]> {
|
||||
const db = await getDb();
|
||||
|
||||
|
|
@ -71,6 +72,11 @@ export async function getExpensesByCategory(
|
|||
params.push(dateTo);
|
||||
paramIndex++;
|
||||
}
|
||||
if (sourceId != null) {
|
||||
whereClauses.push(`t.source_id = $${paramIndex}`);
|
||||
params.push(sourceId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Database from "@tauri-apps/plugin-sql";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
let dbInstance: Database | null = null;
|
||||
|
||||
|
|
@ -14,6 +15,15 @@ export async function connectToProfile(dbFilename: string): Promise<void> {
|
|||
await dbInstance.close();
|
||||
dbInstance = null;
|
||||
}
|
||||
// Repair migration checksums before loading (fixes "migration was modified" error)
|
||||
try {
|
||||
const repaired = await invoke<boolean>("repair_migrations", { dbFilename });
|
||||
if (repaired) {
|
||||
console.warn("Migration checksums repaired for", dbFilename);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Migration repair failed:", e);
|
||||
}
|
||||
dbInstance = await Database.load(`sqlite:${dbFilename}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
98
src/services/logService.ts
Normal file
98
src/services/logService.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
export type LogLevel = "info" | "warn" | "error";
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: number;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
}
|
||||
|
||||
type LogListener = () => void;
|
||||
|
||||
const MAX_ENTRIES = 500;
|
||||
const STORAGE_KEY = "simpl-resultat-logs";
|
||||
const logs: LogEntry[] = [];
|
||||
const listeners = new Set<LogListener>();
|
||||
|
||||
let initialized = false;
|
||||
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed: LogEntry[] = JSON.parse(stored);
|
||||
logs.push(...parsed);
|
||||
}
|
||||
} catch {
|
||||
// ignore corrupted storage
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage() {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(logs));
|
||||
} catch {
|
||||
// ignore quota errors
|
||||
}
|
||||
}
|
||||
|
||||
function addEntry(level: LogLevel, args: unknown[]) {
|
||||
const message = args
|
||||
.map((a) => {
|
||||
if (typeof a === "string") return a;
|
||||
try {
|
||||
return JSON.stringify(a);
|
||||
} catch {
|
||||
return String(a);
|
||||
}
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
logs.push({ timestamp: Date.now(), level, message });
|
||||
if (logs.length > MAX_ENTRIES) {
|
||||
logs.splice(0, logs.length - MAX_ENTRIES);
|
||||
}
|
||||
|
||||
saveToStorage();
|
||||
listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
export function initLogCapture() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
loadFromStorage();
|
||||
|
||||
const origLog = console.log.bind(console);
|
||||
const origWarn = console.warn.bind(console);
|
||||
const origError = console.error.bind(console);
|
||||
|
||||
console.log = (...args: unknown[]) => {
|
||||
addEntry("info", args);
|
||||
origLog(...args);
|
||||
};
|
||||
|
||||
console.warn = (...args: unknown[]) => {
|
||||
addEntry("warn", args);
|
||||
origWarn(...args);
|
||||
};
|
||||
|
||||
console.error = (...args: unknown[]) => {
|
||||
addEntry("error", args);
|
||||
origError(...args);
|
||||
};
|
||||
}
|
||||
|
||||
export function getLogs(): readonly LogEntry[] {
|
||||
return logs;
|
||||
}
|
||||
|
||||
export function clearLogs() {
|
||||
logs.length = 0;
|
||||
saveToStorage();
|
||||
listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
export function subscribe(listener: LogListener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
|
@ -12,7 +12,8 @@ import type {
|
|||
|
||||
export async function getMonthlyTrends(
|
||||
dateFrom?: string,
|
||||
dateTo?: string
|
||||
dateTo?: string,
|
||||
sourceId?: number,
|
||||
): Promise<MonthlyTrendItem[]> {
|
||||
const db = await getDb();
|
||||
|
||||
|
|
@ -30,6 +31,11 @@ export async function getMonthlyTrends(
|
|||
params.push(dateTo);
|
||||
paramIndex++;
|
||||
}
|
||||
if (sourceId != null) {
|
||||
whereClauses.push(`source_id = $${paramIndex}`);
|
||||
params.push(sourceId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereSQL =
|
||||
whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
||||
|
|
@ -50,7 +56,8 @@ export async function getMonthlyTrends(
|
|||
export async function getCategoryOverTime(
|
||||
dateFrom?: string,
|
||||
dateTo?: string,
|
||||
topN: number = 8
|
||||
topN: number = 50,
|
||||
sourceId?: number,
|
||||
): Promise<CategoryOverTimeData> {
|
||||
const db = await getDb();
|
||||
|
||||
|
|
@ -68,6 +75,11 @@ export async function getCategoryOverTime(
|
|||
params.push(dateTo);
|
||||
paramIndex++;
|
||||
}
|
||||
if (sourceId != null) {
|
||||
whereClauses.push(`t.source_id = $${paramIndex}`);
|
||||
params.push(sourceId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -139,9 +139,10 @@ export interface BudgetYearRow {
|
|||
category_type: "expense" | "income" | "transfer";
|
||||
parent_id: number | null;
|
||||
is_parent: boolean;
|
||||
depth?: 0 | 1 | 2;
|
||||
depth?: number;
|
||||
months: number[]; // index 0-11 = Jan-Dec planned amounts
|
||||
annual: number; // computed sum
|
||||
previousYearTotal: number; // actual (transactions) total from the previous year
|
||||
}
|
||||
|
||||
export interface ImportConfigTemplate {
|
||||
|
|
@ -331,7 +332,7 @@ export interface BudgetVsActualRow {
|
|||
category_type: "expense" | "income" | "transfer";
|
||||
parent_id: number | null;
|
||||
is_parent: boolean;
|
||||
depth?: 0 | 1 | 2;
|
||||
depth?: number;
|
||||
monthActual: number;
|
||||
monthBudget: number;
|
||||
monthVariation: number;
|
||||
|
|
|
|||
123
src/utils/dateRange.test.ts
Normal file
123
src/utils/dateRange.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
66
src/utils/dateRange.ts
Normal file
66
src/utils/dateRange.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
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) };
|
||||
});
|
||||
}
|
||||
38
src/utils/reorderRows.ts
Normal file
38
src/utils/reorderRows.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* 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,33 +1,53 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
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
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react(), tailwindcss()],
|
||||
export default defineConfig(async () => {
|
||||
// Sync changelogs before starting dev server or building
|
||||
syncChangelogs();
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue