Compare commits
No commits in common. "main" and "issue-51-compte-maximus-oauth" have entirely different histories.
main
...
issue-51-c
102 changed files with 2083 additions and 8533 deletions
|
|
@ -25,8 +25,7 @@ jobs:
|
|||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl wget git ca-certificates build-essential pkg-config \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev \
|
||||
libdbus-1-dev
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev
|
||||
# Node.js is required by actions/checkout and actions/cache (they
|
||||
# are JavaScript actions and need `node` in the container PATH).
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
|
|
@ -64,16 +63,6 @@ jobs:
|
|||
- name: cargo test
|
||||
run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets
|
||||
|
||||
# Informational audit of transitive dependencies. Failure does not
|
||||
# block the CI (advisories can appear on unrelated crates and stall
|
||||
# unrelated work); surface them in the job log so we see them on
|
||||
# every PR run and can react in a follow-up.
|
||||
- name: cargo audit
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cargo install --locked cargo-audit || true
|
||||
cargo audit --file src-tauri/Cargo.lock || true
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu
|
||||
container: ubuntu:22.04
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
|
||||
- name: Install Linux dependencies
|
||||
run: |
|
||||
apt-get install -y build-essential libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf jq libssl-dev xdg-utils libdbus-1-dev
|
||||
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: |
|
||||
|
|
|
|||
|
|
@ -2,66 +2,6 @@
|
|||
|
||||
## [Non publié]
|
||||
|
||||
## [0.8.2] - 2026-04-17
|
||||
|
||||
### Ajouté
|
||||
- **Widget Feedback Hub** (Paramètres → Journaux) : un bouton *Envoyer un feedback* dans la carte Journaux ouvre un dialogue pour soumettre suggestions, commentaires ou rapports de bogue vers le Feedback Hub central. Un dialogue de consentement (affiché une seule fois) explique que l'envoi atteint `feedback.lacompagniemaximus.com` — une exception explicite au fonctionnement 100 % local de l'app. Trois cases à cocher opt-in (toutes décochées par défaut) : inclure le contexte de navigation (page, thème, écran, version, OS), inclure les derniers logs d'erreur, m'identifier avec mon compte Maximus. L'envoi passe par une commande Rust côté backend, donc rien ne quitte la machine tant que l'utilisateur n'a pas cliqué *Envoyer* (#67)
|
||||
- **Rapport Cartes** (`/reports/cartes`) : nouveau sous-rapport de type tableau de bord dans le hub Rapports. Combine quatre cartes KPI (Revenus, Dépenses, Solde net, Taux d'épargne) affichant les deltas MoM et YoY simultanément avec une sparkline 13 mois dont le mois de référence est mis en évidence, un graphique overlay revenus vs dépenses sur 12 mois (barres + ligne de solde net), le top 5 des catégories en hausse et en baisse par rapport au mois précédent, une carte d'adhérence au budget (N/M dans la cible plus les 3 pires dépassements avec barres de progression) et une carte de saisonnalité qui compare le mois de référence à la moyenne du même mois sur les deux années précédentes. Toutes les données proviennent d'un seul appel `getCartesSnapshot()` qui exécute ses requêtes en parallèle (#97)
|
||||
|
||||
### Modifié
|
||||
- **Rapport Comparables** (`/reports/compare`) : passage de trois onglets (MoM / YoY / Budget) à deux modes (Réel vs réel / Réel vs budget). La vue « Réel vs réel » affiche désormais un sélecteur de mois de référence en en-tête (défaut : mois précédent), un sous-toggle MoM ↔ YoY, et un graphique en barres groupées côte-à-côte (deux barres par catégorie : période de référence vs période comparée). Le `PeriodSelector` d'URL reste synchronisé avec le sélecteur de mois (#96)
|
||||
|
||||
## [0.8.0] - 2026-04-14
|
||||
|
||||
### Ajouté
|
||||
- **Hub des rapports** : `/reports` devient un hub affichant un panneau de faits saillants (solde mois courant + cumul annuel avec sparklines, top mouvements vs mois précédent, plus grosses transactions récentes) et quatre cartes de navigation vers des sous-rapports dédiés (#69–#76)
|
||||
- **Rapport Faits saillants** (`/reports/highlights`) : tuiles de solde avec sparklines 12 mois, tableau triable des top mouvements, graphique en barres divergentes, liste des grosses transactions avec fenêtre 30/60/90 jours (#71)
|
||||
- **Rapport Tendances** (`/reports/trends`) : bascule interne entre flux global (revenus vs dépenses) et évolution par catégorie, toggle graphique/tableau sur les deux (#72)
|
||||
- **Rapport Comparables** (`/reports/compare`) : barre d'onglets pour Mois vs Mois précédent, Année vs Année précédente, et Réel vs Budget ; graphique en barres divergentes centré sur zéro pour les deux premiers modes (#73)
|
||||
- **Zoom catégorie** (`/reports/category`) : analyse ciblée avec donut chart de la répartition par sous-catégorie, graphique d'évolution mensuelle en aires, et tableau filtrable des transactions (#74)
|
||||
- **Édition contextuelle des mots-clés** : clic droit sur n'importe quelle ligne de transaction pour ajouter sa description comme mot-clé de catégorisation ; un dialog de prévisualisation montre toutes les transactions qui seraient recatégorisées (limitées à 50, avec checkbox explicite pour les suivantes) avant validation. Disponible sur le zoom catégorie, la liste des faits saillants, et la page Transactions principale (#74, #75)
|
||||
- **Période bookmarkable** : la période des rapports vit maintenant dans l'URL (`?from=YYYY-MM-DD&to=YYYY-MM-DD`), vous pouvez copier, coller et partager le lien en conservant l'état (#70)
|
||||
- **Préférence chart/table** mémorisée dans `localStorage` par section de rapport
|
||||
|
||||
### Modifié
|
||||
- Le hook monolithique `useReports` a été splitté en hooks par domaine (`useReportsPeriod`, `useHighlights`, `useTrends`, `useCompare`, `useCategoryZoom`) pour que chaque sous-rapport ne possède que l'état qu'il utilise (#70)
|
||||
- Le menu contextuel (clic droit) des rapports est désormais un composant générique `ContextMenu` réutilisé par le menu des graphiques existant et le nouveau dialog d'ajout de mot-clé (#69)
|
||||
|
||||
### Supprimé
|
||||
- Le tableau croisé dynamique a été retiré. Plus de 90 % de son usage réel consistait à zoomer sur une catégorie, ce que le nouveau rapport Zoom catégorie traite mieux. L'historique git préserve l'ancienne implémentation si jamais elle doit revenir (#69)
|
||||
|
||||
### Sécurité
|
||||
- Le nouveau `AddKeywordDialog` impose une longueur de 2 à 64 caractères sur les mots-clés utilisateurs pour empêcher les attaques ReDoS sur de grands ensembles de transactions (CWE-1333), utilise des requêtes `LIKE` paramétrées pour la prévisualisation (CWE-89), encapsule l'INSERT + les UPDATE par transaction dans une transaction SQL BEGIN/COMMIT/ROLLBACK explicite (CWE-662), affiche toutes les descriptions non-sûres via rendu React enfants (CWE-79), et ne recatégorise que les lignes explicitement cochées par l'utilisateur — jamais rétroactivement. Le remplacement d'un mot-clé existant sur une autre catégorie nécessite une confirmation explicite (#74)
|
||||
- `getCategoryZoom` parcourt l'arbre des catégories via une CTE récursive **bornée** (`WHERE depth < 5`), protégeant contre les cycles `parent_id` malformés (CWE-835) (#74)
|
||||
|
||||
## [0.7.4] - 2026-04-14
|
||||
|
||||
### Modifié
|
||||
- Les tokens OAuth sont maintenant stockés dans le trousseau du système d'exploitation (Credential Manager sous Windows, Secret Service sous Linux) au lieu d'un fichier JSON en clair. Les utilisateurs existants sont migrés de façon transparente au prochain rafraîchissement de session ; l'ancien fichier est écrasé avec des zéros puis supprimé. Une bannière « tokens en stockage local » apparaît dans les Paramètres si le trousseau est indisponible (#66, #78, #79, #81)
|
||||
- Le cache d'informations de compte est désormais signé par HMAC avec une clé stockée dans le trousseau : modifier manuellement le champ `subscription_status` dans `account.json` ne permet plus de contourner le gating Premium (#80)
|
||||
- Hachage du PIN migré de SHA-256 vers Argon2id pour résistance au brute-force (CWE-916). Les PINs SHA-256 existants sont vérifiés de façon transparente et rehachés au prochain déverrouillage réussi ; les nouveaux PINs utilisent Argon2id (#54)
|
||||
|
||||
### Sécurité
|
||||
- Correction de CWE-312 (stockage en clair des tokens OAuth), CWE-345 (absence de vérification d'intégrité du cache d'abonnement) et CWE-916 (hachage faible du PIN). Les anciens fichiers `tokens.json` et les caches `account.json` non signés sont rejetés par le chemin de gating jusqu'à ce que le prochain rafraîchissement rétablisse un anchor de confiance dans le trousseau (#66, #54)
|
||||
|
||||
## [0.7.3] - 2026-04-13
|
||||
|
||||
### Corrigé
|
||||
- Connexion Compte Maximus : le callback deep-link utilise maintenant l'API canonique Tauri v2 `on_open_url`, donc le code d'autorisation parvient bien à l'app en cours d'exécution au lieu de laisser l'interface bloquée en « chargement » (#51, #65)
|
||||
- Les callbacks OAuth contenant un paramètre `error` remontent maintenant l'erreur à l'interface au lieu d'être ignorés silencieusement (#51)
|
||||
|
||||
## [0.7.2] - 2026-04-13
|
||||
|
||||
### Modifié
|
||||
- Les mises à jour automatiques sont temporairement ouvertes à l'édition Gratuite en attendant que le serveur de licences (issue #49) soit en ligne. Le gating sera restauré une fois l'activation payante fonctionnelle de bout en bout (#48)
|
||||
|
||||
## [0.7.1] - 2026-04-13
|
||||
|
||||
### Corrigé
|
||||
- Connexion Compte Maximus : le callback OAuth2 revient maintenant correctement dans l'instance en cours au lieu de lancer une deuxième instance et de laisser l'app d'origine bloquée en « chargement » (#51, #65)
|
||||
|
||||
## [0.7.0] - 2026-04-11
|
||||
|
||||
### Ajouté
|
||||
- CI : nouveau workflow `check.yml` qui exécute `cargo check`/`cargo test` et le build frontend sur chaque push de branche et PR, détectant les erreurs avant le merge plutôt qu'au moment de la release (#60)
|
||||
- Carte de licence dans les Paramètres : affiche l'édition actuelle (Gratuite/Base/Premium), accepte une clé de licence et redirige vers la page d'achat (#47)
|
||||
|
|
|
|||
60
CHANGELOG.md
60
CHANGELOG.md
|
|
@ -2,66 +2,6 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.2] - 2026-04-17
|
||||
|
||||
### Added
|
||||
- **Feedback Hub widget** (Settings → Logs): a *Send feedback* button in the Logs card opens a dialog to submit suggestions, comments, or bug reports to the central Feedback Hub. A one-time consent prompt explains that submission reaches `feedback.lacompagniemaximus.com` — an explicit exception to the app's 100% local operation. Three opt-in checkboxes (all unchecked by default): include navigation context (page, theme, viewport, app version, OS), include recent error logs, identify with your Maximus account. Routed through a Rust-side command so nothing is sent unless you press *Send* (#67)
|
||||
- **Cartes report** (`/reports/cartes`): new dashboard-style sub-report in the Reports hub. Combines four KPI cards (income, expenses, net balance, savings rate) showing MoM and YoY deltas simultaneously with a 13-month sparkline highlighting the reference month, a 12-month income vs. expenses overlay chart (bars + net balance line), top 5 category increases and top 5 decreases vs. the previous month, a budget-adherence card (N/M on-target plus the three worst overruns with progress bars), and a seasonality card that compares the reference month against the same calendar month from the two previous years. All data comes from a single `getCartesSnapshot()` service call that runs its queries concurrently (#97)
|
||||
|
||||
### Changed
|
||||
- **Compare report** (`/reports/compare`): reduced from three tabs (MoM / YoY / Budget) to two modes (Actual vs. actual / Actual vs. budget). The actual-vs-actual view now has an explicit reference-month dropdown in the header (defaults to the previous month), a MoM ↔ YoY sub-toggle, and a grouped side-by-side bar chart (two bars per category: reference period vs. comparison period). The URL `PeriodSelector` stays in sync with the reference month picker (#96)
|
||||
|
||||
## [0.8.0] - 2026-04-14
|
||||
|
||||
### Added
|
||||
- **Reports hub**: `/reports` is now a hub surfacing a live highlights panel (current month + YTD net balance with sparklines, top movers vs. last month, top recent transactions) and four navigation cards to dedicated sub-reports (#69–#76)
|
||||
- **Highlights report** (`/reports/highlights`): balance tiles with 12-month sparklines, sortable top movers table, diverging bar chart, recent transactions list with 30/60/90 day window toggle (#71)
|
||||
- **Trends report** (`/reports/trends`): internal sub-view toggle between global flow (income vs. expenses) and by-category evolution, chart/table toggle on both (#72)
|
||||
- **Compare report** (`/reports/compare`): tab bar for Month vs. Previous Month, Year vs. Previous Year, and Actual vs. Budget; diverging bar chart centered on zero for the first two modes (#73)
|
||||
- **Category zoom** (`/reports/category`): single-category drill-down with donut chart of subcategory breakdown, monthly evolution area chart, and filterable transactions table (#74)
|
||||
- **Contextual keyword editing**: right-click any transaction row to add its description as a categorization keyword; a preview dialog shows every transaction that would be recategorized (capped at 50, with an opt-in checkbox for N+) before you confirm. Available on the category zoom, the highlights list, and the main transactions page (#74, #75)
|
||||
- **Bookmarkable period**: the reports period now lives in the URL (`?from=YYYY-MM-DD&to=YYYY-MM-DD`), so you can copy, paste, and share the link and keep the same state (#70)
|
||||
- **View mode preference** (chart vs. table) is now persisted in `localStorage` per report section
|
||||
|
||||
### Changed
|
||||
- The legacy monolithic `useReports` hook has been split into per-domain hooks (`useReportsPeriod`, `useHighlights`, `useTrends`, `useCompare`, `useCategoryZoom`) so every sub-report owns only the state it needs (#70)
|
||||
- Context menu on reports (right-click) is now a generic `ContextMenu` shell reused by the existing chart menu and the new keyword dialog (#69)
|
||||
|
||||
### Removed
|
||||
- The dynamic pivot table report was removed. Over 90% of its real usage was zooming into a single category, which is better served by the new Category Zoom report. Git history preserves the old implementation if it ever needs to come back (#69)
|
||||
|
||||
### Security
|
||||
- New `AddKeywordDialog` enforces a 2–64 character length bound on user keywords to prevent ReDoS on large transaction sets (CWE-1333), uses parameterized `LIKE` queries for the preview (CWE-89), wraps its INSERT + per-transaction UPDATEs in an explicit BEGIN/COMMIT/ROLLBACK transaction (CWE-662), renders all untrusted descriptions as React children (CWE-79), and recategorizes only the rows the user explicitly checked — never retroactively. Keyword reassignment across categories requires an explicit confirmation step (#74)
|
||||
- `getCategoryZoom` walks the category tree through a **bounded** recursive CTE (`WHERE depth < 5`), protecting against malformed `parent_id` cycles (CWE-835) (#74)
|
||||
|
||||
## [0.7.4] - 2026-04-14
|
||||
|
||||
### Changed
|
||||
- OAuth tokens are now stored in the OS keychain (Credential Manager on Windows, Secret Service on Linux) instead of a plaintext JSON file. Existing users are migrated transparently on the next sign-in refresh; the old file is zeroed and removed. A "tokens stored in plaintext fallback" banner appears in Settings if the keychain is unavailable (#66, #78, #79, #81)
|
||||
- Cached account info is now HMAC-signed with a keychain-stored key: writing `subscription_status` to `account.json` manually can no longer bypass the Premium gate (#80)
|
||||
- PIN hashing migrated from SHA-256 to Argon2id for brute-force resistance (CWE-916). Existing SHA-256 PINs are verified transparently and rehashed on next successful unlock; new PINs use Argon2id (#54)
|
||||
|
||||
### Security
|
||||
- Closed CWE-312 (cleartext storage of OAuth tokens), CWE-345 (missing integrity check on the subscription cache), and CWE-916 (weak PIN hashing). Legacy `tokens.json` and legacy unsigned `account.json` caches are rejected by the gating path until the next token refresh re-establishes a keychain-anchored trust (#66, #54)
|
||||
|
||||
## [0.7.3] - 2026-04-13
|
||||
|
||||
### Fixed
|
||||
- Maximus Account sign-in: the deep-link callback now uses the canonical Tauri v2 `on_open_url` API, so the auth code is properly received by the running app instead of leaving the UI stuck in "loading" (#51, #65)
|
||||
- OAuth callbacks containing an `error` parameter now surface the error to the UI instead of being silently ignored (#51)
|
||||
|
||||
## [0.7.2] - 2026-04-13
|
||||
|
||||
### Changed
|
||||
- Auto-updates are temporarily open to the Free edition until the license server (issue #49) is live. Gating will be restored once paid activation works end-to-end (#48)
|
||||
|
||||
## [0.7.1] - 2026-04-13
|
||||
|
||||
### Fixed
|
||||
- Maximus Account sign-in: the OAuth2 callback now correctly returns to the running app instead of launching a second instance and leaving the original one stuck in "loading" (#51, #65)
|
||||
|
||||
## [0.7.0] - 2026-04-11
|
||||
|
||||
### Added
|
||||
- CI: new `check.yml` workflow runs `cargo check`/`cargo test` and the frontend build on every branch push and PR, catching errors before merge instead of waiting for the release tag (#60)
|
||||
- License card in Settings page: shows the current edition (Free/Base/Premium), accepts a license key, and links to the purchase page (#47)
|
||||
|
|
|
|||
|
|
@ -1,127 +0,0 @@
|
|||
# ADR-0006 : Stockage des tokens OAuth dans le trousseau OS
|
||||
|
||||
- **Date** : 2026-04-14
|
||||
- **Statut** : Accepté
|
||||
|
||||
## Contexte
|
||||
|
||||
Depuis la v0.7.0, Simpl'Résultat utilise OAuth2 Authorization Code + PKCE pour authentifier les utilisateurs auprès de Logto (Compte Maximus). Les tokens résultants (`access_token`, `refresh_token`, `id_token`) étaient persistés dans `<app_data>/auth/tokens.json`, protégés uniquement par les permissions fichier (`0600` sous Unix, aucune ACL sous Windows).
|
||||
|
||||
Le refresh token donne une session longue durée. Le laisser en clair expose l'utilisateur à plusieurs classes d'attaques :
|
||||
|
||||
- Malware local tournant sous le même UID (lecture du home directory).
|
||||
- Backups automatiques (home sync, backup tools) qui copient le fichier sans distinction.
|
||||
- Shell non-root obtenu par l'attaquant.
|
||||
- Sous Windows, absence de protection ACL rendait le fichier lisible par n'importe quel process utilisateur.
|
||||
|
||||
De plus, avant cette ADR, `account.json` était également en clair et son champ `subscription_status` servait au gating licence (Premium). Écrire manuellement `{"subscription_status": "active"}` dans ce fichier contournait le paywall.
|
||||
|
||||
## Options considérées
|
||||
|
||||
### Option 1 — Trousseau OS via `keyring` crate (RETENUE)
|
||||
|
||||
Librairie Rust `keyring` (v3.6) qui expose une API unifiée au-dessus des trousseaux natifs :
|
||||
- **Windows** : Credential Manager (Win32 API, toujours présent)
|
||||
- **Linux** : Secret Service via D-Bus (gnome-keyring, kwallet, keepassxc)
|
||||
- **macOS** : Keychain Services (hors cible actuelle)
|
||||
|
||||
**Avantages** :
|
||||
- Le système d'exploitation gère la clé maître (session utilisateur).
|
||||
- Pas de mot de passe supplémentaire à demander à l'utilisateur.
|
||||
- Support multi-plateforme natif.
|
||||
|
||||
**Inconvénients** :
|
||||
- Sur Linux, requiert un service Secret Service actif (D-Bus + keyring daemon). Sur une session headless ou un CI sans D-Bus, il faut un fallback.
|
||||
- Dépendance de build supplémentaire (`libdbus-1-dev`).
|
||||
|
||||
### Option 2 — `tauri-plugin-stronghold`
|
||||
|
||||
Chiffrement au repos avec une master password déverrouillée au démarrage.
|
||||
|
||||
**Rejeté** parce que :
|
||||
- Casse l'UX de connexion silencieuse (refresh automatique au démarrage).
|
||||
- Ajoute une saisie de passphrase à chaque lancement.
|
||||
- Surface de UX plus large qu'un simple fallback keychain.
|
||||
|
||||
### Option 3 — Chiffrement AES-256-GCM custom avec clé dérivée du PIN
|
||||
|
||||
**Rejeté** parce que :
|
||||
- Seulement applicable aux profils protégés par PIN (minorité).
|
||||
- Les tokens OAuth doivent être lus sans interaction (refresh silencieux), donc aucune clé à demander.
|
||||
- Simplement déplace le problème de la clé maître.
|
||||
|
||||
## Décision
|
||||
|
||||
**Trousseau OS via `keyring` crate, avec fallback fichier supervisé.**
|
||||
|
||||
### Architecture
|
||||
|
||||
Nouveau module `src-tauri/src/commands/token_store.rs` qui centralise le stockage :
|
||||
|
||||
- `save(app, &StoredTokens)` — tente le keychain, retombe sur `tokens.json` chiffré par permissions (`0600` Unix) si keychain indisponible.
|
||||
- `load(app) -> Option<StoredTokens>` — lit depuis le keychain, migre un `tokens.json` résiduel à la volée.
|
||||
- `delete(app)` — efface les deux stores (idempotent).
|
||||
- `store_mode(app) -> Option<StoreMode>` — exposé au frontend pour afficher une bannière quand le fallback est actif.
|
||||
|
||||
### Choix du backend `keyring`
|
||||
|
||||
Le crate `keyring` v3 demande la sélection explicite des backends :
|
||||
|
||||
- **Linux** : `sync-secret-service` + `crypto-rust` → passe par le crate `dbus-secret-service` → crate `dbus` → lib C `libdbus-1`. Requiert `libdbus-1-dev` au build time et `libdbus-1-3` au runtime (ce dernier est universellement présent sur les distributions desktop Linux).
|
||||
- **Windows** : `windows-native` → bind direct sur `windows-sys`, pas de dépendance externe.
|
||||
|
||||
L'option `async-secret-service` (via `zbus`, pur Rust) a été envisagée pour éviter la dépendance `libdbus-1-dev`, mais elle force une API asynchrone sur toutes les plateformes, ce qui ne matche pas le design sync de `license_commands::current_edition` (appelé depuis `check_entitlement`, sync). Le compromis accepté : un paquet apt de plus dans la CI, une API sync partout.
|
||||
|
||||
### Identité du trousseau
|
||||
|
||||
`service = "com.simpl.resultat"` (identifiant canonique de l'app dans `tauri.conf.json`), `user = "oauth-tokens"`. Ce choix aligne l'entrée keychain avec l'identité installée de l'app pour que les outils de management de credentials du système puissent la scoper correctement.
|
||||
|
||||
### Garde anti-downgrade
|
||||
|
||||
Un flag persistant `store_mode` (valeurs `keychain` ou `file`) est écrit dans `<app_data>/auth/store_mode` après chaque opération. Une fois qu'un `store_mode = keychain` a été enregistré, toute tentative ultérieure de sauvegarde qui échoue sur le keychain retourne une erreur au lieu de silenter-dégrader vers le fichier. Cela empêche un attaquant local de forcer la dégradation en bloquant temporairement D-Bus pour capturer les tokens en clair au prochain refresh.
|
||||
|
||||
### Migration transparente
|
||||
|
||||
Au premier `load()` après upgrade depuis v0.7.x, le module détecte `tokens.json` résiduel, copie son contenu dans le keychain, puis **overwrite le fichier avec des zéros + `fsync()` avant `remove_file()`**. C'est un mitigation best-effort contre la récupération des bits sur unallocated sectors (CWE-212). Pas un substitut à un disk encryption : les backups antérieurs à la migration conservent évidemment le vieux fichier. Documenté dans le CHANGELOG comme recommandation de rotation de session post-upgrade pour les utilisateurs inquiets.
|
||||
|
||||
### Scope : `tokens.json` migré, `account.json` signé
|
||||
|
||||
`account.json` **n'est pas** migré dans le keychain pour limiter le blast radius du changement et garder `write_restricted()` en place pour les fichiers non-sensibles. Toutefois, le champ `subscription_status` de ce cache servait au gating de licence Premium, ce qui créait un trou de tampering : un malware local pouvait écrire `"active"` dans le cache pour bypass le paywall sans jamais toucher le keychain.
|
||||
|
||||
**Corrigé via un second module `account_cache.rs`** : le cache est désormais encapsulé dans un wrapper `{"data": {...}, "sig": "<HMAC-SHA256>"}`. La clé HMAC 32 bytes est stockée dans le keychain (`user = "account-hmac-key"`), parallèlement aux tokens. Le chemin de gating (`license_commands::check_account_edition`) appelle `account_cache::load_verified` qui refuse tout payload non-signé ou avec signature invalide, et **fail-closed** (retourne None → Premium reste gated) si la clé HMAC est inaccessible.
|
||||
|
||||
Le chemin d'affichage UI (`get_account_info` → `load_unverified`) accepte encore les anciens payloads non-signés pour que les utilisateurs upgradés voient leur profil immédiatement. La distinction display/verified est explicite dans l'API.
|
||||
|
||||
## Conséquences
|
||||
|
||||
### Positives
|
||||
|
||||
- **Protection cryptographique native** : sous Windows, les tokens sont maintenant protégés par Credential Manager (DPAPI sous le capot). Sous Linux, par le keyring daemon avec une master password de session.
|
||||
- **Anti-tampering du gating licence** : écrire `account.json` ne débloque plus Premium.
|
||||
- **Fail-closed par défaut** : tous les chemins qui échouent sur le keychain retournent des erreurs au lieu de dégrader silencieusement.
|
||||
- **Migration transparente** : zéro action utilisateur pour les upgrades depuis v0.7.x.
|
||||
- **Anti-downgrade** : un attaquant ne peut pas forcer la dégradation vers le fichier pour capturer les tokens au prochain refresh.
|
||||
|
||||
### Négatives
|
||||
|
||||
- **Dépendance Linux** : `libdbus-1-dev` est requis au build time (ajouté à `check.yml` et `release.yml`). Au runtime, `libdbus-1-3` est déjà présent sur toutes les distros desktop, mais une session headless sans D-Bus déclenche le fallback fichier (signalé à l'utilisateur par la bannière Settings).
|
||||
- **Surface d'attaque supply-chain accrue** : `keyring` + `dbus-secret-service` + `zbus` + `dbus` représentent une chaîne transitive nouvelle. Mitigé par un step `cargo audit` non-bloquant dans `check.yml`.
|
||||
- **Logs plus verbeux** : chaque fallback imprime un warning sur stderr pour qu'un dev puisse diagnostiquer. Pas de télémétrie.
|
||||
- **Sous Linux, la première utilisation peut demander un déverrouillage** : GNOME Keyring peut prompt l'utilisateur pour déverrouiller sa session keyring si elle ne l'est pas déjà. Ce comportement est natif du trousseau, pas de Simpl'Résultat.
|
||||
|
||||
### Tests
|
||||
|
||||
- Tests unitaires : 9 nouveaux tests (serde round-trip de `StoredTokens`, parse/encode de `StoreMode`, zéroïfication, HMAC sign/verify, tamper detection, wrong key, envelope serde).
|
||||
- Tests manuels : matrice de 5 scénarios sur pop-os (Linux) et Windows, documentée dans l'issue #82.
|
||||
- Pas de mock du keychain en CI : la matrice manuelle couvre les chemins où une lib externe est requise.
|
||||
|
||||
## Références
|
||||
|
||||
- Issue parente : maximus/simpl-resultat#66
|
||||
- PRs : #83 (core), #84 (CI/packaging), #85 (subscription HMAC), #86 (UI banner), #87 (wrap-up)
|
||||
- CWE-212 : Improper Removal of Sensitive Information Before Storage or Transfer
|
||||
- CWE-312 : Cleartext Storage of Sensitive Information
|
||||
- CWE-345 : Insufficient Verification of Data Authenticity
|
||||
- CWE-757 : Selection of Less-Secure Algorithm During Negotiation
|
||||
- [keyring crate v3.6 documentation](https://docs.rs/keyring/3.6.3/keyring/)
|
||||
- [Secret Service API specification](https://specifications.freedesktop.org/secret-service-spec/latest/)
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
# ADR 0007 — Reports hub refactor
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-04-14
|
||||
- Milestone: `spec-refonte-rapports`
|
||||
|
||||
## Context
|
||||
|
||||
The original `/reports` page exposed five tabs (`trends`, `byCategory`, `overTime`, `budgetVsActual`, `dynamic`) as independent analytic views backed by a single monolithic `useReports` hook. Three problems built up over time:
|
||||
|
||||
1. **No narrative.** None of the tabs answered "what's important to know about my finances this month?". Users had to navigate several tabs and reconstruct the story themselves.
|
||||
2. **Oversized pivot.** The dynamic pivot table (`DynamicReport*`) was powerful but complex. In practice ~90 % of its actual usage boiled down to zooming into a single category. It added visual and cognitive debt without proportional value.
|
||||
3. **Disconnected classification.** Keywords could only be edited from `/categories`. Spotting a mis-classified transaction in a report meant leaving the report, editing a rule, and navigating back — a context break that discouraged hygiene.
|
||||
|
||||
## Decision
|
||||
|
||||
Refactor `/reports` into a **hub + four dedicated sub-routes**, wired to a shared bookmarkable period and per-domain hooks, with contextual keyword editing via right-click.
|
||||
|
||||
### Routing
|
||||
|
||||
```
|
||||
/reports → hub (highlights panel + nav cards)
|
||||
/reports/highlights → detailed highlights
|
||||
/reports/trends → global flow + by-category evolution
|
||||
/reports/compare → month vs month / year vs year / actual vs budget
|
||||
/reports/category → single-category zoom with rollup
|
||||
```
|
||||
|
||||
All pages share the reporting period through the URL query string (`?from=YYYY-MM-DD&to=YYYY-MM-DD&period=...`), resolved by a pure `resolveReportsPeriod()` helper. Default: current civil year. The query string approach is deliberately **not** a React context — it keeps the URL bookmarkable and stays consistent with the rest of the project, which does not use global React contexts for UI state.
|
||||
|
||||
### Per-domain hooks
|
||||
|
||||
The monolithic `useReports` was split into:
|
||||
|
||||
| Hook | Responsibility |
|
||||
|------|----------------|
|
||||
| `useReportsPeriod` | Read/write period via `useSearchParams` |
|
||||
| `useHighlights` | Fetch highlights snapshot + window-days state |
|
||||
| `useTrends` | Fetch global or by-category trends depending on sub-view |
|
||||
| `useCompare` | Fetch MoM / YoY; budget mode delegates to `BudgetVsActualTable` |
|
||||
| `useCategoryZoom` | Fetch zoom data with rollup toggle |
|
||||
|
||||
Each page mounts only the hook it needs; no hook carries state for reports the user is not currently viewing.
|
||||
|
||||
### Dynamic pivot removal
|
||||
|
||||
Removed outright rather than hidden behind a feature flag. A runtime flag would leave `getDynamicReportData` and its dynamic `FIELD_SQL` in the shipped bundle as a dead-but-live attack surface (OWASP A05:2021). Git history preserves the previous implementation if it ever needs to come back.
|
||||
|
||||
### Contextual keyword editing
|
||||
|
||||
Right-clicking a transaction row anywhere transaction-level (category zoom, highlights top transactions, main transactions page) opens an `AddKeywordDialog` that:
|
||||
|
||||
1. Validates the keyword is 2–64 characters after trim (anti-ReDoS, CWE-1333).
|
||||
2. Previews matching transactions via a parameterised `LIKE $1` query, then filters in memory with the existing `buildKeywordRegex` helper (anti-SQL-injection, CWE-89).
|
||||
3. Caps the visible preview at 50 rows; an explicit opt-in checkbox lets the user extend the apply to N−50 non-displayed matches.
|
||||
4. Runs INSERT (or UPDATE-reassign) + per-transaction UPDATEs inside a single SQL transaction (`BEGIN`/`COMMIT`/`ROLLBACK`), so a crash mid-apply can never leave a keyword orphaned from its transactions (CWE-662).
|
||||
5. Renders transaction descriptions as React children — never `dangerouslySetInnerHTML` — with CSS-only truncation (CWE-79).
|
||||
6. Recategorises only the rows the user explicitly checked; never retroactive on the entire history.
|
||||
|
||||
Reassigning an existing keyword across categories requires an explicit confirmation step and leaves the existing keyword's historical matches alone.
|
||||
|
||||
### Category zoom cycle guard
|
||||
|
||||
`getCategoryZoom` aggregates via a **bounded** recursive CTE (`WITH RECURSIVE ... WHERE ct.depth < 5`) so a corrupted `parent_id` loop (A → B → A) can never spin forever (CWE-835). A unit test with a canned cyclic fixture asserts termination.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Reports now tell a story ("what moved") before offering analytic depth.
|
||||
- Each sub-route is independently code-splittable and testable.
|
||||
- Period state is bookmarkable and shareable (copy URL → same view).
|
||||
- Keyword hygiene happens inside the report, with a preview that's impossible in the old flow.
|
||||
- The dialog's security guarantees are covered by 13 vitest cases (validation boundaries, parameterised LIKE, regex word-boundary filter, BEGIN/COMMIT wrap, ROLLBACK on failure, reassignment policy).
|
||||
- The cycle guard is covered by its own test with the depth assertion.
|
||||
|
||||
### Negative / trade-offs
|
||||
|
||||
- Adds five new hooks and ~10 new components. Cognitive surface goes up but each piece is smaller and single-purpose.
|
||||
- Aggregate tables in the compare and highlights sections intentionally skip the right-click menu (the row represents a category/month, not a transaction, so "add as keyword" is meaningless there). Users looking for consistency may be briefly confused.
|
||||
- Right-clicking inside the main transactions page now offers two ways to add a keyword: the existing inline Tag button (no preview) and the new contextual dialog (with preview). Documented as complementary — the inline path is for quick manual classification, the dialog for preview-backed rule authoring.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep the five-tab layout and only improve the pivot.** Rejected — it doesn't fix the "no narrative" issue and leaves the oversized pivot problem.
|
||||
- **Hide the pivot behind a feature flag.** Rejected — the code stays in the bundle, runtime flag cannot be tree-shaken, and the i18n `reports.pivot.*` keys would have to linger indefinitely. Outright removal with git as the escape hatch was cheaper and cleaner.
|
||||
- **React context for the shared period.** Rejected — the project does not use global React contexts for UI state. Query-string persistence is simpler, bookmarkable, and consistent with the rest of the codebase.
|
||||
- **A single `ContextMenu` implementation shared across reports and charts.** Chose to generalise the existing `ChartContextMenu` into a `ContextMenu` shell; `ChartContextMenu` now composes the shared shell. Avoids duplicating click-outside + Escape handling.
|
||||
|
||||
## References
|
||||
|
||||
- Spec: `spec-refonte-rapports.md`
|
||||
- Issues: #69 (foundation), #70 (hooks), #71 (highlights + hub), #72 (trends), #73 (compare), #74 (category zoom + AddKeywordDialog), #75 (right-click propagation), #76 (polish)
|
||||
- OWASP A03:2021 (injection), A05:2021 (security misconfiguration)
|
||||
- CWE-79 (XSS), CWE-89 (SQL injection), CWE-662 (improper synchronization), CWE-835 (infinite loop), CWE-1333 (ReDoS)
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Architecture technique — Simpl'Résultat
|
||||
|
||||
> Document mis à jour le 2026-04-13 — Version 0.7.3
|
||||
> Document mis à jour le 2026-04-10 — Version 0.6.7
|
||||
|
||||
## Stack technique
|
||||
|
||||
|
|
@ -34,13 +34,13 @@ simpl-resultat/
|
|||
│ │ ├── import/ # 13 composants (wizard d'import)
|
||||
│ │ ├── layout/ # AppShell, Sidebar
|
||||
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
||||
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie)
|
||||
│ │ ├── reports/ # 10 composants (graphiques + rapports tabulaires + rapport dynamique)
|
||||
│ │ ├── settings/ # 5 composants (+ LogViewerCard, LicenseCard, AccountCard)
|
||||
│ │ ├── shared/ # 6 composants réutilisables
|
||||
│ │ └── transactions/ # 5 composants
|
||||
│ ├── contexts/ # ProfileContext (état global profil)
|
||||
│ ├── hooks/ # 18+ hooks custom (useReducer, 5 hooks rapports par domaine)
|
||||
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
|
||||
│ ├── hooks/ # 14 hooks custom (useReducer)
|
||||
│ ├── pages/ # 10 pages
|
||||
│ ├── services/ # 14 services métier
|
||||
│ ├── shared/ # Types et constantes partagés
|
||||
│ ├── utils/ # 4 utilitaires (parsing, CSV, charts)
|
||||
|
|
@ -121,11 +121,11 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
|
|||
| `importSourceService.ts` | Configuration des sources d'import |
|
||||
| `importedFileService.ts` | Suivi des fichiers importés |
|
||||
| `importConfigTemplateService.ts` | Modèles de configuration d'import |
|
||||
| `categorizationService.ts` | Catégorisation automatique + helpers édition de mot-clé (`validateKeyword`, `previewKeywordMatches`, `applyKeywordWithReassignment`) |
|
||||
| `categorizationService.ts` | Catégorisation automatique |
|
||||
| `adjustmentService.ts` | Gestion des ajustements |
|
||||
| `budgetService.ts` | Gestion budgétaire |
|
||||
| `dashboardService.ts` | Agrégation données tableau de bord |
|
||||
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) |
|
||||
| `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) |
|
||||
|
|
@ -146,19 +146,14 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
|||
| `useAdjustments` | Ajustements |
|
||||
| `useBudget` | Budget |
|
||||
| `useDashboard` | Métriques du tableau de bord |
|
||||
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) |
|
||||
| `useHighlights` | Panneau de faits saillants du hub rapports |
|
||||
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
|
||||
| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |
|
||||
| `useCategoryZoom` | Rapport Zoom catégorie avec rollup sous-catégories |
|
||||
| `useCartes` | Rapport Cartes (snapshot KPI + sparklines + top movers + budget + saisonnalité via `getCartesSnapshot`) |
|
||||
| `useReports` | Données analytiques |
|
||||
| `useDataExport` | Export de données |
|
||||
| `useTheme` | Thème clair/sombre |
|
||||
| `useUpdater` | Mise à jour de l'application (gated par entitlement licence) |
|
||||
| `useLicense` | État de la licence et entitlements |
|
||||
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
|
||||
|
||||
## Commandes Tauri (35)
|
||||
## Commandes Tauri (25)
|
||||
|
||||
### `fs_commands.rs` — Système de fichiers (6)
|
||||
|
||||
|
|
@ -183,11 +178,11 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
|||
- `save_profiles` — Sauvegarde de la configuration
|
||||
- `delete_profile_db` — Suppression du fichier de base de données
|
||||
- `get_new_profile_init_sql` — Récupération du schéma consolidé
|
||||
- `hash_pin` — Hachage Argon2id du PIN (format `argon2id:salt:hash`)
|
||||
- `verify_pin` — Vérification du PIN (supporte Argon2id et legacy SHA-256 pour rétrocompatibilité)
|
||||
- `hash_pin` — Hachage Argon2 du PIN
|
||||
- `verify_pin` — Vérification du PIN
|
||||
- `repair_migrations` — Réparation des checksums de migration (rusqlite)
|
||||
|
||||
### `license_commands.rs` — Licence et activation machine (10)
|
||||
### `license_commands.rs` — Licence (6)
|
||||
|
||||
- `validate_license_key` — Validation offline d'une clé de licence (JWT Ed25519)
|
||||
- `store_license` — Stockage de la clé dans le répertoire app data
|
||||
|
|
@ -195,74 +190,10 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
|||
- `read_license` — Lecture de la licence stockée
|
||||
- `get_edition` — Détection de l'édition active (free/base/premium)
|
||||
- `get_machine_id` — Génération d'un identifiant machine unique
|
||||
- `activate_machine` — Activation en ligne (appel API serveur de licences, issue #49)
|
||||
- `deactivate_machine` — Désactivation d'une machine enregistrée
|
||||
- `list_activated_machines` — Liste des machines activées pour la licence
|
||||
- `get_activation_status` — État d'activation de la machine courante
|
||||
|
||||
### `auth_commands.rs` — Compte Maximus / OAuth2 PKCE (5)
|
||||
|
||||
- `start_oauth` — Génère un code verifier PKCE et retourne l'URL d'authentification Logto
|
||||
- `refresh_auth_token` — Rafraîchit l'access token via le refresh token
|
||||
- `get_account_info` — Lecture du cache d'affichage (via `account_cache::load_unverified`, accepte les payloads legacy)
|
||||
- `check_subscription_status` — Vérifie l'abonnement (max 1×/jour, fallback cache gracieux). Déclenche aussi la migration `tokens.json` → keychain via `token_store::load`
|
||||
- `logout` — Efface tokens (`token_store`) + cache signé (`account_cache`) + clé HMAC du keychain
|
||||
|
||||
Note : `handle_auth_callback` n'est PAS exposée comme commande — elle est appelée depuis le handler deep-link `on_open_url` dans `lib.rs`. Voir section "OAuth2 et deep-link" plus bas.
|
||||
|
||||
### `token_store.rs` — Stockage des tokens OAuth (1)
|
||||
|
||||
- `get_token_store_mode` — Retourne `"keychain"`, `"file"` ou `null`. Utilisé par la bannière de sécurité `TokenStoreFallbackBanner` dans Settings pour alerter l'utilisateur quand les tokens sont dans le fallback fichier.
|
||||
|
||||
Module non-command : `save`, `load`, `delete`, `store_mode` — toute la logique de persistance passe par ce module, `auth_commands.rs` ne touche jamais directement `tokens.json`. Voir l'ADR 0006 pour la conception complète.
|
||||
|
||||
### `account_cache.rs` — Cache d'abonnement signé (aucune commande)
|
||||
|
||||
Module privé appelé uniquement par `auth_commands.rs` et `license_commands.rs`. Expose :
|
||||
- `save(app, &AccountInfo)` — écrit l'enveloppe signée `{data, sig}` dans `account.json`, avec clé HMAC-SHA256 stockée dans le keychain.
|
||||
- `load_unverified(app)` — lecture pour affichage UI (accepte legacy et signé).
|
||||
- `load_verified(app)` — lecture pour gating licence (refuse legacy, tampering, absence de clé). Utilisé par `license_commands::check_account_edition`.
|
||||
- `delete(app)` — efface le fichier et la clé HMAC du keychain.
|
||||
|
||||
### `entitlements.rs` — Entitlements (1)
|
||||
|
||||
- `check_entitlement` — Vérifie si une feature est autorisée selon l'édition
|
||||
- Source de vérité : `FEATURE_TIERS` dans `entitlements.rs`. Modifier cette constante pour changer les gates, jamais ailleurs dans le code
|
||||
- Temporaire : `auto-update` est ouvert à `free` en attendant le serveur de licences (issue #49). À re-gater à `[base, premium]` quand l'activation payante sera live
|
||||
|
||||
## Plugins Tauri
|
||||
|
||||
Ordre d'initialisation dans `lib.rs` (certains plugins ont des contraintes d'ordre) :
|
||||
|
||||
| Plugin | Rôle | Contrainte |
|
||||
|--------|------|-----------|
|
||||
| `tauri-plugin-single-instance` | Empêche les doubles lancements et forwarde les URLs deep-link au processus existant | **Doit être le premier plugin** ; feature `deep-link` requise pour le forwarding d'URL |
|
||||
| `tauri-plugin-opener` | Ouverture d'URLs externes et de fichiers | — |
|
||||
| `tauri-plugin-dialog` | Dialogues de sélection de fichier/dossier | — |
|
||||
| `tauri-plugin-process` | Relaunch après mise à jour | — |
|
||||
| `tauri-plugin-deep-link` | Gère le scheme custom `simpl-resultat://` | Doit être initialisé avant `setup()` pour que `on_open_url` soit disponible |
|
||||
| `tauri-plugin-updater` | Mise à jour auto (gated par entitlement `auto-update`) | Initialisé dans `setup()` derrière `#[cfg(desktop)]` |
|
||||
| `tauri-plugin-sql` | SQLite + migrations | Doit être initialisé avec les migrations pour que le schéma soit prêt |
|
||||
|
||||
## OAuth2 et deep-link (Compte Maximus)
|
||||
|
||||
Flow complet (v0.7.3+) :
|
||||
|
||||
1. Frontend appelle `start_oauth` → génère un code verifier PKCE (64 chars), le stocke dans `OAuthState` (Mutex en mémoire du processus), retourne l'URL Logto
|
||||
2. Frontend ouvre l'URL via `tauri-plugin-opener` → le navigateur système affiche la page Logto
|
||||
3. L'utilisateur s'authentifie (ou Logto auto-consent si session existante) → redirection 303 vers `simpl-resultat://auth/callback?code=...`
|
||||
4. L'OS route le custom scheme vers une nouvelle instance de l'app → `tauri-plugin-single-instance` (feature `deep-link`) détecte l'instance existante, **ne démarre PAS un nouveau processus**, et forwarde l'URL à l'instance vivante
|
||||
5. Le callback `app.deep_link().on_open_url(...)` enregistré via `DeepLinkExt` reçoit les URLs. Pour chaque URL :
|
||||
- Si un param `code` est présent → appelle `handle_auth_callback` (token exchange vers `/oidc/token`, fetch `/oidc/me`, écriture des tokens via `token_store::save` (keychain OS, fallback fichier 0600) + cache signé via `account_cache::save` (HMAC-SHA256), émission de l'event `auth-callback-success`)
|
||||
- Si un param `error` est présent → émission de l'event `auth-callback-error` avec `error: error_description`
|
||||
6. Le hook `useAuth` (frontend) écoute `auth-callback-success` / `auth-callback-error` et met à jour l'état
|
||||
|
||||
Pourquoi cet enchaînement est critique :
|
||||
- **Sans `tauri-plugin-single-instance`** : une nouvelle instance démarre à chaque callback, le `OAuthState` est vide (pas de verifier), le token exchange échoue
|
||||
- **Sans `on_open_url`** : l'ancien listener `app.listen("deep-link://new-url", ...)` ne recevait pas les URLs forwardées par single-instance. L'API canonique v2 via `DeepLinkExt` est nécessaire
|
||||
- **Sans gestion des erreurs** : un callback `?error=...` laissait l'UI bloquée en état "loading" infini
|
||||
|
||||
Fichiers : `src-tauri/src/lib.rs` (wiring), `src-tauri/src/commands/auth_commands.rs` (PKCE + token exchange), `src-tauri/src/commands/token_store.rs` (persistance keychain + fallback), `src-tauri/src/commands/account_cache.rs` (cache signé HMAC), `src/hooks/useAuth.ts` (frontend), `src/components/settings/TokenStoreFallbackBanner.tsx` (UI de l'état dégradé).
|
||||
|
||||
## Pages et routing
|
||||
|
||||
|
|
@ -285,12 +216,7 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
|
|||
| `/categories` | `CategoriesPage` | Gestion hiérarchique |
|
||||
| `/adjustments` | `AdjustmentsPage` | Ajustements manuels |
|
||||
| `/budget` | `BudgetPage` | Planification budgétaire |
|
||||
| `/reports` | `ReportsPage` | Hub des rapports : panneau faits saillants + 4 cartes de navigation |
|
||||
| `/reports/highlights` | `ReportsHighlightsPage` | Faits saillants détaillés (soldes, top mouvements, top transactions) |
|
||||
| `/reports/trends` | `ReportsTrendsPage` | Tendances (flux global + par catégorie) |
|
||||
| `/reports/compare` | `ReportsComparePage` | Comparables (MoM / YoY / Réel vs budget) |
|
||||
| `/reports/category` | `ReportsCategoryPage` | Zoom catégorie avec rollup + édition contextuelle de mots-clés |
|
||||
| `/reports/cartes` | `ReportsCartesPage` | Tableau de bord KPI avec sparklines, top movers, budget et saisonnalité |
|
||||
| `/reports` | `ReportsPage` | Analytique et rapports |
|
||||
| `/settings` | `SettingsPage` | Paramètres |
|
||||
| `/docs` | `DocsPage` | Documentation in-app |
|
||||
| `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) |
|
||||
|
|
@ -307,24 +233,12 @@ Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est acti
|
|||
|
||||
## CI/CD
|
||||
|
||||
Deux workflows Forgejo Actions (avec miroir GitHub) dans `.forgejo/workflows/` :
|
||||
Workflow GitHub Actions (`release.yml`) déclenché par les tags `v*` :
|
||||
|
||||
### `check.yml` — Vérifications sur branches et PR
|
||||
|
||||
Déclenché sur chaque push de branche (sauf `main`) et chaque PR vers `main`. Lance en parallèle :
|
||||
- `cargo check` + `cargo test` (Rust)
|
||||
- `npm run build` (tsc + vite)
|
||||
- `npm test` (vitest)
|
||||
|
||||
Doit être vert avant tout merge. Évite de découvrir des régressions au moment du tag de release.
|
||||
|
||||
### `release.yml` — Build et publication
|
||||
|
||||
Déclenché par les tags `v*`. Deux jobs :
|
||||
1. **build-windows** (windows-latest) → Installeur `.exe` (NSIS)
|
||||
2. **build-linux** (ubuntu-22.04) → `.deb` + `.rpm`
|
||||
2. **build-linux** (ubuntu-22.04) → `.deb` + `.AppImage`
|
||||
|
||||
Fonctionnalités :
|
||||
- Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY)
|
||||
- JSON d'updater publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json`
|
||||
- Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md
|
||||
- JSON d'updater pour mises à jour automatiques
|
||||
- Release GitHub automatique avec notes d'installation
|
||||
|
|
|
|||
|
|
@ -246,56 +246,51 @@ Planifiez votre budget mensuel pour chaque catégorie et suivez le prévu par ra
|
|||
|
||||
## 9. Rapports
|
||||
|
||||
`/reports` est un **hub** qui répond à quatre questions : *qu'est-ce qui a bougé ce mois ?*, *où je vais sur 12 mois ?*, *comment je me situe vs période précédente ou vs budget ?*, *que se passe-t-il dans cette catégorie ?*
|
||||
Visualisez vos données financières avec des graphiques interactifs et comparez votre plan budgétaire au réel.
|
||||
|
||||
### Le hub
|
||||
### Fonctionnalités
|
||||
|
||||
En haut, un **panneau de faits saillants** condensé : solde net du mois courant + solde cumul annuel (YTD) avec sparkline 12 mois, top mouvements vs mois précédent et top 5 des plus grosses transactions récentes. En bas, quatre cartes mènent aux quatre sous-rapports dédiés.
|
||||
- Tendances mensuelles : revenus vs dépenses dans le temps (graphique en barres)
|
||||
- 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 barres empilées), avec filtre par type (dépense/revenu/transfert)
|
||||
- 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
|
||||
- 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
|
||||
|
||||
Le sélecteur de période en haut à droite est **partagé** entre toutes les pages via l'URL : `?from=YYYY-MM-DD&to=YYYY-MM-DD`. Copiez l'URL pour revenir plus tard au même état ou la partager.
|
||||
### Comment faire
|
||||
|
||||
### Rapport Faits saillants (`/reports/highlights`)
|
||||
|
||||
- Tuiles de soldes mois courant + YTD avec sparklines 12 mois
|
||||
- Tableau triable des **top mouvements** (catégories avec la plus forte variation vs mois précédent), ou graphique en barres divergentes centré sur zéro (toggle graphique/tableau)
|
||||
- Liste des **plus grosses transactions récentes** avec fenêtre configurable 30 / 60 / 90 jours
|
||||
|
||||
### Rapport Tendances (`/reports/trends`)
|
||||
|
||||
- **Flux global** : revenus vs dépenses vs solde net sur la période, en graphique d'aires ou tableau
|
||||
- **Par catégorie** : évolution de chaque catégorie, en lignes ou tableau pivot
|
||||
|
||||
### Rapport Comparables (`/reports/compare`)
|
||||
|
||||
Trois modes accessibles via un tab bar :
|
||||
|
||||
- **Mois vs mois précédent** — tableau catégories × 2 colonnes + écart $ et %
|
||||
- **Année vs année précédente** — même logique sur 12 mois vs 12 mois
|
||||
- **Réel vs budget** — reprend la vue Budget vs Réel avec ses totaux mensuels et cumul annuel
|
||||
|
||||
### Rapport Zoom catégorie (`/reports/category`)
|
||||
|
||||
Choisissez une catégorie dans la combobox en haut. Par défaut le rapport inclut automatiquement les sous-catégories (toggle *Directe seulement* pour les exclure). Vous voyez :
|
||||
|
||||
- Un **donut chart** de la répartition par sous-catégorie avec le total au centre
|
||||
- Un graphique d'évolution mensuelle de la catégorie
|
||||
- Un tableau triable des transactions
|
||||
|
||||
### Édition contextuelle des mots-clés
|
||||
|
||||
**Clic droit** sur n'importe quelle transaction (dans le zoom catégorie, la liste des faits saillants, ou la page Transactions) ouvre un menu *Ajouter comme mot-clé*. Un dialog affiche :
|
||||
|
||||
1. Une **prévisualisation** des transactions qui seront recatégorisées (jusqu'à 50 visibles avec cases à cocher individuelles — les matches au-delà de 50 peuvent être appliqués via une case explicite)
|
||||
2. Un sélecteur de catégorie cible
|
||||
3. Un bouton **Appliquer et recatégoriser**
|
||||
|
||||
L'application est atomique : soit toutes les transactions cochées sont recatégorisées et le mot-clé enregistré, soit rien n'est fait. Si le mot-clé existait déjà pour une autre catégorie, un prompt vous demande si vous voulez le réassigner — cela ne touche **pas** l'historique, seulement les transactions visibles cochées.
|
||||
1. Utilisez les onglets pour basculer entre Tendances, Par catégorie, Dans le temps et Budget vs Réel
|
||||
2. Ajustez la période avec le sélecteur de période
|
||||
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
|
||||
|
||||
- Le toggle **graphique / tableau** est mémorisé par sous-rapport (vos préférences restent même après redémarrage)
|
||||
- Les mots-clés doivent faire entre 2 et 64 caractères (protection contre les regex explosives)
|
||||
- Le zoom catégorie est **protégé contre les arborescences cycliques** : un éventuel `parent_id` malformé ne fait pas planter l'app
|
||||
- Les catégories masquées sont mémorisées tant que vous restez sur la page — cliquez sur Tout afficher pour réinitialiser
|
||||
- Le sélecteur de période s'applique à tous les onglets de graphiques simultanément
|
||||
- Budget vs Réel affiche l'écart en dollars et en pourcentage pour chaque catégorie
|
||||
- Les motifs SVG aident les personnes daltoniennes à distinguer les catégories dans les graphiques
|
||||
|
||||
### Rapport dynamique
|
||||
|
||||
Le rapport dynamique fonctionne comme un tableau croisé dynamique (pivot table). Vous composez votre propre rapport en assignant des dimensions et des mesures.
|
||||
|
||||
**Dimensions disponibles :** Année, Mois, Type (dépense/revenu/transfert), Catégorie Niveau 1 (parent), Catégorie Niveau 2 (enfant).
|
||||
|
||||
**Mesures :** Montant périodique (somme), Cumul annuel (YTD).
|
||||
|
||||
1. Cliquez sur un champ disponible dans le panneau de droite
|
||||
2. Choisissez où le placer : Lignes, Colonnes, Filtres ou Valeurs
|
||||
3. Le tableau et/ou le graphique se mettent à jour automatiquement
|
||||
4. Utilisez les filtres pour restreindre les données (ex : Type = dépense uniquement)
|
||||
5. Basculez entre les vues Tableau, Graphique ou Les deux
|
||||
6. Cliquez sur le X pour retirer un champ d'une zone
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -309,7 +304,6 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
|
|||
- 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
|
||||
- Envoi de feedback optionnel vers `feedback.lacompagniemaximus.com` (exception explicite au fonctionnement 100 % local — déclenche une demande de consentement avant le premier envoi)
|
||||
- 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
|
||||
|
|
@ -319,10 +313,9 @@ 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. Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez
|
||||
4. Pour partager une suggestion ou signaler un problème, cliquez sur Envoyer un feedback dans la carte Journaux ; les cases d'identification et d'ajout du contexte/logs sont décochées par défaut
|
||||
5. Utilisez la section Gestion des données pour exporter ou importer vos données
|
||||
6. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement
|
||||
7. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe
|
||||
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
|
||||
|
||||
|
|
@ -331,5 +324,4 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
|
|||
- 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
|
||||
- Le feedback est la seule fonctionnalité qui communique avec un serveur en dehors des mises à jour et de la connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique
|
||||
- En cas de problème, cliquez Envoyer un feedback et cochez « Inclure les derniers logs d'erreur » pour joindre les journaux récents automatiquement
|
||||
- En cas de problème, copiez les journaux et joignez-les à votre signalement
|
||||
|
|
|
|||
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "simpl_result_scaffold",
|
||||
"version": "0.7.3",
|
||||
"version": "0.6.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "simpl_result_scaffold",
|
||||
"version": "0.7.3",
|
||||
"version": "0.6.6",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -3297,11 +3297,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "simpl_result_scaffold",
|
||||
"private": true,
|
||||
"version": "0.8.2",
|
||||
"version": "0.6.7",
|
||||
"license": "GPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
311
src-tauri/Cargo.lock
generated
311
src-tauri/Cargo.lock
generated
|
|
@ -347,15 +347,6 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
|
|
@ -493,15 +484,6 @@ dependencies = [
|
|||
"toml 0.9.11+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.55"
|
||||
|
|
@ -545,12 +527,6 @@ version = "1.0.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.43"
|
||||
|
|
@ -866,35 +842,6 @@ dependencies = [
|
|||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libdbus-sys",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbus-secret-service"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"block-padding",
|
||||
"cbc",
|
||||
"dbus",
|
||||
"fastrand",
|
||||
"hkdf",
|
||||
"num",
|
||||
"once_cell",
|
||||
"sha2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
|
|
@ -2229,7 +2176,6 @@ version = "0.1.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
|
|
@ -2377,20 +2323,6 @@ dependencies = [
|
|||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"dbus-secret-service",
|
||||
"log",
|
||||
"secret-service",
|
||||
"windows-sys 0.60.2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kuchikiki"
|
||||
version = "0.8.8-speedreader"
|
||||
|
|
@ -2442,15 +2374,6 @@ version = "0.2.180"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
|
||||
[[package]]
|
||||
name = "libdbus-sys"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.7.4"
|
||||
|
|
@ -2696,39 +2619,12 @@ version = "1.0.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodrop"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
|
|
@ -2755,15 +2651,6 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
|
|
@ -2790,17 +2677,6 @@ dependencies = [
|
|||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
|
|
@ -4127,25 +4003,6 @@ version = "1.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "secret-service"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
"futures-util",
|
||||
"generic-array",
|
||||
"hkdf",
|
||||
"num",
|
||||
"once_cell",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"sha2",
|
||||
"zbus 4.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.5.1"
|
||||
|
|
@ -4423,17 +4280,15 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
|||
|
||||
[[package]]
|
||||
name = "simpl-result"
|
||||
version = "0.8.2"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
"encoding_rs",
|
||||
"hmac",
|
||||
"hostname",
|
||||
"jsonwebtoken",
|
||||
"keyring",
|
||||
"libsqlite3-sys",
|
||||
"machine-uid",
|
||||
"rand 0.8.5",
|
||||
|
|
@ -4442,20 +4297,17 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-sql",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
"urlencoding",
|
||||
"walkdir",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4776,12 +4628,6 @@ version = "1.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
|
|
@ -5196,7 +5042,7 @@ dependencies = [
|
|||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"windows",
|
||||
"zbus 5.13.2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5209,22 +5055,6 @@ dependencies = [
|
|||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a33a5b7d78f0dec4406b003ea87c40bf928d801b6fd9323a556172c91d8712c1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin-deep-link",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"zbus 5.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-sql"
|
||||
version = "2.3.2"
|
||||
|
|
@ -6850,16 +6680,6 @@ dependencies = [
|
|||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xdg-home"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.1"
|
||||
|
|
@ -6883,38 +6703,6 @@ dependencies = [
|
|||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "4.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-trait",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"nix",
|
||||
"ordered-stream",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"sha1",
|
||||
"static_assertions",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"windows-sys 0.52.0",
|
||||
"xdg-home",
|
||||
"zbus_macros 4.4.0",
|
||||
"zbus_names 3.0.0",
|
||||
"zvariant 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.13.2"
|
||||
|
|
@ -6945,22 +6733,9 @@ dependencies = [
|
|||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 0.7.14",
|
||||
"zbus_macros 5.13.2",
|
||||
"zbus_names 4.3.1",
|
||||
"zvariant 5.9.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "4.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"zvariant_utils 2.1.0",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6973,20 +6748,9 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"zbus_names 4.3.1",
|
||||
"zvariant 5.9.2",
|
||||
"zvariant_utils 3.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant 4.2.0",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6997,7 +6761,7 @@ checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
|
|||
dependencies = [
|
||||
"serde",
|
||||
"winnow 0.7.14",
|
||||
"zvariant 5.9.2",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -7046,20 +6810,6 @@ name = "zeroize"
|
|||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
|
|
@ -7112,19 +6862,6 @@ version = "1.0.19"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant_derive 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.9.2"
|
||||
|
|
@ -7135,21 +6872,8 @@ dependencies = [
|
|||
"enumflags2",
|
||||
"serde",
|
||||
"winnow 0.7.14",
|
||||
"zvariant_derive 5.9.2",
|
||||
"zvariant_utils 3.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"zvariant_utils 2.1.0",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -7162,18 +6886,7 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"zvariant_utils 3.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "simpl-result"
|
||||
version = "0.8.2"
|
||||
version = "0.6.7"
|
||||
description = "Personal finance management app"
|
||||
license = "GPL-3.0-only"
|
||||
authors = ["you"]
|
||||
|
|
@ -26,7 +26,6 @@ tauri-plugin-dialog = "2"
|
|||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
libsqlite3-sys = { version = "0.30", features = ["bundled"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
|
@ -36,7 +35,6 @@ encoding_rs = "0.8"
|
|||
walkdir = "2"
|
||||
aes-gcm = "0.10"
|
||||
argon2 = "0.5"
|
||||
subtle = "2"
|
||||
rand = "0.8"
|
||||
jsonwebtoken = "9"
|
||||
machine-uid = "0.5"
|
||||
|
|
@ -45,14 +43,6 @@ tokio = { version = "1", features = ["macros"] }
|
|||
hostname = "0.4"
|
||||
urlencoding = "2"
|
||||
base64 = "0.22"
|
||||
# OAuth token storage in OS keychain (Credential Manager on Windows,
|
||||
# Secret Service on Linux). We use sync-secret-service to get sync
|
||||
# methods that are safe to call from async Tauri commands without
|
||||
# tokio runtime entanglement. Requires libdbus-1-dev at build time
|
||||
# on Linux (libdbus-1-3 is present on every desktop Linux at runtime).
|
||||
keyring = { version = "3.6", default-features = false, features = ["sync-secret-service", "crypto-rust", "windows-native"] }
|
||||
zeroize = "1"
|
||||
hmac = "0.12"
|
||||
|
||||
[dev-dependencies]
|
||||
# Used in license_commands.rs tests to sign test JWTs. We avoid the `pem`
|
||||
|
|
|
|||
|
|
@ -1,292 +0,0 @@
|
|||
// Integrity-protected cache for cached account info.
|
||||
//
|
||||
// The user's subscription tier is used by `license_commands::current_edition`
|
||||
// to unlock Premium features. Until this module, the subscription_status
|
||||
// claim was read directly from a plaintext `account.json` on disk, which
|
||||
// meant any local process (malware, nosy user, curl) could write
|
||||
// `{"subscription_status": "active"}` and bypass the paywall without
|
||||
// ever touching the Logto session. This module closes that trap.
|
||||
//
|
||||
// Approach: an HMAC-SHA256 signature is computed over the serialized
|
||||
// AccountInfo bytes using a per-install key stored in the OS keychain
|
||||
// (via the `keyring` crate, same backend as token_store). The signed
|
||||
// payload is wrapped as `{"data": {...}, "sig": "<base64>"}`. On read,
|
||||
// verification requires the same key; tampering with either `data` or
|
||||
// `sig` invalidates the cache.
|
||||
//
|
||||
// Trust chain:
|
||||
// - Key lives in the keychain, scoped to service "com.simpl.resultat",
|
||||
// user "account-hmac-key". Compromising it requires compromising the
|
||||
// keychain, which is the existing trust boundary for OAuth tokens.
|
||||
// - A legacy unsigned `account.json` (from v0.7.x) is still readable
|
||||
// for display purposes (email, name, picture), but the gating path
|
||||
// uses `load_verified` which returns None for legacy payloads —
|
||||
// Premium features stay locked until the next token refresh rewrites
|
||||
// the file with a signature.
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use std::fs;
|
||||
|
||||
use super::auth_commands::AccountInfo;
|
||||
use super::token_store::{auth_dir, write_restricted};
|
||||
|
||||
const KEYCHAIN_SERVICE: &str = "com.simpl.resultat";
|
||||
const KEYCHAIN_USER_HMAC: &str = "account-hmac-key";
|
||||
const ACCOUNT_FILE: &str = "account.json";
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct SignedAccount {
|
||||
data: AccountInfo,
|
||||
sig: String,
|
||||
}
|
||||
|
||||
/// Read the HMAC key from the keychain, creating a fresh random key on
|
||||
/// first use. The key is never persisted to disk — if the keychain is
|
||||
/// unreachable, the whole cache signing/verification path falls back
|
||||
/// to "not signed" which means gating stays locked until the keychain
|
||||
/// is available again. This is intentional (fail-closed).
|
||||
fn get_or_create_key() -> Result<[u8; 32], String> {
|
||||
let entry = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_HMAC)
|
||||
.map_err(|e| format!("Keychain entry init failed: {}", e))?;
|
||||
match entry.get_password() {
|
||||
Ok(b64) => decode_key(&b64),
|
||||
Err(keyring::Error::NoEntry) => {
|
||||
use rand::RngCore;
|
||||
let mut key = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut key);
|
||||
let encoded = encode_key(&key);
|
||||
entry
|
||||
.set_password(&encoded)
|
||||
.map_err(|e| format!("Keychain HMAC key write failed: {}", e))?;
|
||||
Ok(key)
|
||||
}
|
||||
Err(e) => Err(format!("Keychain HMAC key read failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_key(key: &[u8]) -> String {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
STANDARD.encode(key)
|
||||
}
|
||||
|
||||
fn decode_key(raw: &str) -> Result<[u8; 32], String> {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
let bytes = STANDARD
|
||||
.decode(raw.trim())
|
||||
.map_err(|e| format!("Invalid HMAC key encoding: {}", e))?;
|
||||
if bytes.len() != 32 {
|
||||
return Err(format!(
|
||||
"HMAC key must be 32 bytes, got {}",
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
let mut arr = [0u8; 32];
|
||||
arr.copy_from_slice(&bytes);
|
||||
Ok(arr)
|
||||
}
|
||||
|
||||
fn sign(key: &[u8; 32], payload: &[u8]) -> String {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can take any key length");
|
||||
mac.update(payload);
|
||||
STANDARD.encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
fn verify(key: &[u8; 32], payload: &[u8], sig_b64: &str) -> bool {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
let Ok(sig_bytes) = STANDARD.decode(sig_b64) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(mut mac) = HmacSha256::new_from_slice(key) else {
|
||||
return false;
|
||||
};
|
||||
mac.update(payload);
|
||||
mac.verify_slice(&sig_bytes).is_ok()
|
||||
}
|
||||
|
||||
/// Write the account cache as `{"data": {...}, "sig": "..."}`. The key
|
||||
/// is fetched from (or created in) the keychain. Writes fall back to an
|
||||
/// unsigned legacy-shape payload only when the keychain is unreachable
|
||||
/// — this keeps the UI functional on keychain-less systems but means
|
||||
/// the gating path will refuse to grant Premium until the keychain
|
||||
/// comes back.
|
||||
pub fn save(app: &tauri::AppHandle, account: &AccountInfo) -> Result<(), String> {
|
||||
let dir = auth_dir(app)?;
|
||||
let path = dir.join(ACCOUNT_FILE);
|
||||
|
||||
match get_or_create_key() {
|
||||
Ok(key) => {
|
||||
// Serialise the AccountInfo alone (compact, deterministic
|
||||
// for a given struct layout), sign the bytes, then re-wrap
|
||||
// into the signed envelope. We write the signed envelope
|
||||
// as pretty-printed JSON for readability in the audit log.
|
||||
let data_bytes =
|
||||
serde_json::to_vec(account).map_err(|e| format!("Serialize error: {}", e))?;
|
||||
let sig = sign(&key, &data_bytes);
|
||||
let envelope = SignedAccount {
|
||||
data: account.clone(),
|
||||
sig,
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&envelope)
|
||||
.map_err(|e| format!("Serialize envelope error: {}", e))?;
|
||||
write_restricted(&path, &json)
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"account_cache: keychain HMAC key unavailable, writing unsigned legacy payload ({})",
|
||||
err
|
||||
);
|
||||
// Fallback: unsigned payload. UI still works, but
|
||||
// `load_verified` will reject this file for gating.
|
||||
let json = serde_json::to_string_pretty(account)
|
||||
.map_err(|e| format!("Serialize error: {}", e))?;
|
||||
write_restricted(&path, &json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the cached account for **display purposes**. Accepts both the
|
||||
/// new signed envelope and legacy plaintext AccountInfo files. Does
|
||||
/// NOT verify the signature — suitable for showing the user's name /
|
||||
/// email / picture in the UI, but never for gating decisions.
|
||||
pub fn load_unverified(app: &tauri::AppHandle) -> Result<Option<AccountInfo>, String> {
|
||||
let dir = auth_dir(app)?;
|
||||
let path = dir.join(ACCOUNT_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let raw = fs::read_to_string(&path).map_err(|e| format!("Cannot read account: {}", e))?;
|
||||
|
||||
// Prefer the signed envelope shape; fall back to legacy flat
|
||||
// AccountInfo so upgraded users see their account info immediately
|
||||
// rather than a blank card until the next token refresh.
|
||||
if let Ok(envelope) = serde_json::from_str::<SignedAccount>(&raw) {
|
||||
return Ok(Some(envelope.data));
|
||||
}
|
||||
if let Ok(flat) = serde_json::from_str::<AccountInfo>(&raw) {
|
||||
return Ok(Some(flat));
|
||||
}
|
||||
Err("Invalid account cache payload".to_string())
|
||||
}
|
||||
|
||||
/// Load the cached account and verify the HMAC signature. Used by the
|
||||
/// license gating path (`current_edition`). Returns Ok(None) when:
|
||||
/// - no cache exists,
|
||||
/// - the cache is in legacy unsigned shape (pre-v0.8 or post-fallback),
|
||||
/// - the keychain HMAC key is unreachable,
|
||||
/// - the signature does not verify.
|
||||
///
|
||||
/// Any of these states must cause Premium features to stay locked —
|
||||
/// never accept an unverifiable payload for a gating decision.
|
||||
pub fn load_verified(app: &tauri::AppHandle) -> Result<Option<AccountInfo>, String> {
|
||||
let dir = auth_dir(app)?;
|
||||
let path = dir.join(ACCOUNT_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let raw = fs::read_to_string(&path).map_err(|e| format!("Cannot read account: {}", e))?;
|
||||
|
||||
// Only signed envelopes are acceptable here. Legacy flat payloads
|
||||
// are treated as "no verified account".
|
||||
let Ok(envelope) = serde_json::from_str::<SignedAccount>(&raw) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Ok(key) = get_or_create_key() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let data_bytes = match serde_json::to_vec(&envelope.data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
if !verify(&key, &data_bytes, &envelope.sig) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(envelope.data))
|
||||
}
|
||||
|
||||
/// Delete the cached account file AND the keychain HMAC key. Called on
|
||||
/// logout so the next login generates a fresh key bound to the new
|
||||
/// session.
|
||||
pub fn delete(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
let dir = auth_dir(app)?;
|
||||
let path = dir.join(ACCOUNT_FILE);
|
||||
if path.exists() {
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
if let Ok(entry) = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_HMAC) {
|
||||
let _ = entry.delete_credential();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_account() -> AccountInfo {
|
||||
AccountInfo {
|
||||
email: "user@example.com".into(),
|
||||
name: Some("Test User".into()),
|
||||
picture: None,
|
||||
subscription_status: Some("active".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_then_verify_same_key() {
|
||||
let key = [7u8; 32];
|
||||
let payload = b"hello world";
|
||||
let sig = sign(&key, payload);
|
||||
assert!(verify(&key, payload, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_tampered_payload() {
|
||||
let key = [7u8; 32];
|
||||
let sig = sign(&key, b"original");
|
||||
assert!(!verify(&key, b"tampered", &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_wrong_key() {
|
||||
let key_a = [7u8; 32];
|
||||
let key_b = [8u8; 32];
|
||||
let sig = sign(&key_a, b"payload");
|
||||
assert!(!verify(&key_b, b"payload", &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_roundtrip_serde() {
|
||||
let account = sample_account();
|
||||
let key = [3u8; 32];
|
||||
let data = serde_json::to_vec(&account).unwrap();
|
||||
let sig = sign(&key, &data);
|
||||
let env = SignedAccount {
|
||||
data: account.clone(),
|
||||
sig,
|
||||
};
|
||||
let json = serde_json::to_string(&env).unwrap();
|
||||
let decoded: SignedAccount = serde_json::from_str(&json).unwrap();
|
||||
let decoded_data = serde_json::to_vec(&decoded.data).unwrap();
|
||||
assert!(verify(&key, &decoded_data, &decoded.sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_decode_key_roundtrip() {
|
||||
let key = [42u8; 32];
|
||||
let encoded = encode_key(&key);
|
||||
let decoded = decode_key(&encoded).unwrap();
|
||||
assert_eq!(decoded, key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_key_rejects_wrong_length() {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
let short = STANDARD.encode([1u8; 16]);
|
||||
assert!(decode_key(&short).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +1,25 @@
|
|||
// OAuth2 PKCE flow for Compte Maximus (Logto) integration.
|
||||
//
|
||||
// Architecture:
|
||||
// - The desktop app is registered as a "Native App" in Logto (public
|
||||
// client, no secret).
|
||||
// - The desktop app is registered as a "Native App" in Logto (public client, no secret).
|
||||
// - OAuth2 Authorization Code + PKCE flow via the system browser.
|
||||
// - Deep-link callback: simpl-resultat://auth/callback?code=...
|
||||
// - Tokens are persisted through `token_store` which prefers the OS
|
||||
// keychain (Credential Manager / Secret Service) and falls back to a
|
||||
// restricted file only when no prior keychain success has been
|
||||
// recorded. See `token_store.rs` for details.
|
||||
// - Tokens are stored as files in app_data_dir/auth/ (encrypted at rest in a future
|
||||
// iteration via OS keychain). For now, plain JSON — acceptable because:
|
||||
// (a) the app data dir has user-only permissions,
|
||||
// (b) the access token is short-lived (1h default in Logto),
|
||||
// (c) the refresh token is rotated on each use.
|
||||
//
|
||||
// The PKCE verifier is held in memory via Tauri managed state, so it
|
||||
// cannot be intercepted by another process. It is cleared after the
|
||||
// callback exchange.
|
||||
// The PKCE verifier is held in memory via Tauri managed state, so it cannot be
|
||||
// intercepted by another process. It is cleared after the callback exchange.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
|
||||
use super::account_cache;
|
||||
use super::token_store::{
|
||||
self, auth_dir, chrono_now, write_restricted, StoredTokens,
|
||||
};
|
||||
|
||||
// Logto endpoint — overridable via env var for development.
|
||||
fn logto_endpoint() -> String {
|
||||
std::env::var("LOGTO_ENDPOINT")
|
||||
|
|
@ -32,10 +28,13 @@ fn logto_endpoint() -> String {
|
|||
|
||||
// Logto app ID for the desktop native app.
|
||||
fn logto_app_id() -> String {
|
||||
std::env::var("LOGTO_APP_ID").unwrap_or_else(|_| "sr-desktop-native".to_string())
|
||||
std::env::var("LOGTO_APP_ID").unwrap_or_else(|_| "simpl-resultat-desktop".to_string())
|
||||
}
|
||||
|
||||
const REDIRECT_URI: &str = "simpl-resultat://auth/callback";
|
||||
const AUTH_DIR: &str = "auth";
|
||||
const TOKENS_FILE: &str = "tokens.json";
|
||||
const ACCOUNT_FILE: &str = "account.json";
|
||||
const LAST_CHECK_FILE: &str = "last_check";
|
||||
const CHECK_INTERVAL_SECS: i64 = 86400; // 24 hours
|
||||
|
||||
|
|
@ -53,6 +52,50 @@ pub struct AccountInfo {
|
|||
pub subscription_status: Option<String>,
|
||||
}
|
||||
|
||||
/// Stored tokens (written to auth/tokens.json).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct StoredTokens {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
id_token: Option<String>,
|
||||
expires_at: i64,
|
||||
}
|
||||
|
||||
fn auth_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("Cannot get app data dir: {}", e))?
|
||||
.join(AUTH_DIR);
|
||||
if !dir.exists() {
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Cannot create auth dir: {}", e))?;
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Write a file with restricted permissions (0600 on Unix) for sensitive data like tokens.
|
||||
fn write_restricted(path: &Path, contents: &str) -> Result<(), String> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
|
||||
file.write_all(contents.as_bytes())
|
||||
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
fs::write(path, contents)
|
||||
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_pkce() -> (String, String) {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
|
|
@ -146,22 +189,25 @@ pub async fn handle_auth_callback(app: tauri::AppHandle, code: String) -> Result
|
|||
let expires_in = token_resp["expires_in"].as_i64().unwrap_or(3600);
|
||||
let expires_at = chrono_now() + expires_in;
|
||||
|
||||
// Persist tokens through token_store (prefers keychain over file).
|
||||
// Store tokens
|
||||
let tokens = StoredTokens {
|
||||
access_token: access_token.clone(),
|
||||
refresh_token,
|
||||
id_token,
|
||||
expires_at,
|
||||
};
|
||||
token_store::save(&app, &tokens)?;
|
||||
let dir = auth_dir(&app)?;
|
||||
let tokens_json =
|
||||
serde_json::to_string_pretty(&tokens).map_err(|e| format!("Serialize error: {}", e))?;
|
||||
write_restricted(&dir.join(TOKENS_FILE), &tokens_json)?;
|
||||
|
||||
// Fetch user info
|
||||
let account = fetch_userinfo(&endpoint, &access_token).await?;
|
||||
|
||||
// Store account info with an HMAC signature so the license gating
|
||||
// path can trust the cached subscription_status without re-calling
|
||||
// Logto on every entitlement check.
|
||||
account_cache::save(&app, &account)?;
|
||||
// Store account info
|
||||
let account_json =
|
||||
serde_json::to_string_pretty(&account).map_err(|e| format!("Serialize error: {}", e))?;
|
||||
write_restricted(&dir.join(ACCOUNT_FILE), &account_json)?;
|
||||
|
||||
Ok(account)
|
||||
}
|
||||
|
|
@ -169,7 +215,16 @@ pub async fn handle_auth_callback(app: tauri::AppHandle, code: String) -> Result
|
|||
/// Refresh the access token using the stored refresh token.
|
||||
#[tauri::command]
|
||||
pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result<AccountInfo, String> {
|
||||
let tokens = token_store::load(&app)?.ok_or_else(|| "Not authenticated".to_string())?;
|
||||
let dir = auth_dir(&app)?;
|
||||
let tokens_path = dir.join(TOKENS_FILE);
|
||||
if !tokens_path.exists() {
|
||||
return Err("Not authenticated".to_string());
|
||||
}
|
||||
|
||||
let tokens_raw =
|
||||
fs::read_to_string(&tokens_path).map_err(|e| format!("Cannot read tokens: {}", e))?;
|
||||
let tokens: StoredTokens =
|
||||
serde_json::from_str(&tokens_raw).map_err(|e| format!("Invalid tokens file: {}", e))?;
|
||||
|
||||
let refresh_token = tokens
|
||||
.refresh_token
|
||||
|
|
@ -192,9 +247,9 @@ pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result<AccountInfo, St
|
|||
.map_err(|e| format!("Token refresh failed: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
// Clear stored tokens on refresh failure.
|
||||
let _ = token_store::delete(&app);
|
||||
let _ = account_cache::delete(&app);
|
||||
// Clear stored tokens on refresh failure
|
||||
let _ = fs::remove_file(&tokens_path);
|
||||
let _ = fs::remove_file(dir.join(ACCOUNT_FILE));
|
||||
return Err("Session expired, please sign in again".to_string());
|
||||
}
|
||||
|
||||
|
|
@ -217,30 +272,38 @@ pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result<AccountInfo, St
|
|||
expires_at: chrono_now() + expires_in,
|
||||
};
|
||||
|
||||
token_store::save(&app, &new_tokens)?;
|
||||
let tokens_json = serde_json::to_string_pretty(&new_tokens)
|
||||
.map_err(|e| format!("Serialize error: {}", e))?;
|
||||
write_restricted(&tokens_path, &tokens_json)?;
|
||||
|
||||
let account = fetch_userinfo(&endpoint, &new_access).await?;
|
||||
account_cache::save(&app, &account)?;
|
||||
let account_json =
|
||||
serde_json::to_string_pretty(&account).map_err(|e| format!("Serialize error: {}", e))?;
|
||||
write_restricted(&dir.join(ACCOUNT_FILE), &account_json)?;
|
||||
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
/// Read cached account info without network call. Used for UI display
|
||||
/// only — accepts both signed (v0.8+) and legacy (v0.7.x) payloads so
|
||||
/// upgraded users still see their name/email immediately. The license
|
||||
/// gating path uses `account_cache::load_verified` instead.
|
||||
/// Read cached account info without network call.
|
||||
#[tauri::command]
|
||||
pub fn get_account_info(app: tauri::AppHandle) -> Result<Option<AccountInfo>, String> {
|
||||
account_cache::load_unverified(&app)
|
||||
let dir = auth_dir(&app)?;
|
||||
let path = dir.join(ACCOUNT_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let raw = fs::read_to_string(&path).map_err(|e| format!("Cannot read account: {}", e))?;
|
||||
let account: AccountInfo =
|
||||
serde_json::from_str(&raw).map_err(|e| format!("Invalid account file: {}", e))?;
|
||||
Ok(Some(account))
|
||||
}
|
||||
|
||||
/// Log out: clear all stored tokens and account info, including the
|
||||
/// HMAC key so the next session starts with a fresh cryptographic
|
||||
/// anchor.
|
||||
/// Log out: clear all stored tokens and account info.
|
||||
#[tauri::command]
|
||||
pub fn logout(app: tauri::AppHandle) -> Result<(), String> {
|
||||
token_store::delete(&app)?;
|
||||
account_cache::delete(&app)?;
|
||||
let dir = auth_dir(&app)?;
|
||||
let _ = fs::remove_file(dir.join(TOKENS_FILE));
|
||||
let _ = fs::remove_file(dir.join(ACCOUNT_FILE));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -251,14 +314,13 @@ pub fn logout(app: tauri::AppHandle) -> Result<(), String> {
|
|||
pub async fn check_subscription_status(
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<Option<AccountInfo>, String> {
|
||||
// Not authenticated — nothing to check. This also triggers migration
|
||||
// from a legacy tokens.json file into the keychain when present,
|
||||
// because token_store::load() performs the migration eagerly.
|
||||
if token_store::load(&app)?.is_none() {
|
||||
let dir = auth_dir(&app)?;
|
||||
|
||||
// Not authenticated — nothing to check
|
||||
if !dir.join(TOKENS_FILE).exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let dir = auth_dir(&app)?;
|
||||
let last_check_path = dir.join(LAST_CHECK_FILE);
|
||||
let now = chrono_now();
|
||||
|
||||
|
|
@ -321,3 +383,10 @@ async fn fetch_userinfo(endpoint: &str, access_token: &str) -> Result<AccountInf
|
|||
.map(|s| s.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn chrono_now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,7 @@ pub const EDITION_PREMIUM: &str = "premium";
|
|||
/// Maps feature name → list of editions allowed to use it.
|
||||
/// A feature absent from this list is denied for all editions.
|
||||
const FEATURE_TIERS: &[(&str, &[&str])] = &[
|
||||
// auto-update is temporarily open to FREE until the license server (issue #49)
|
||||
// is live. Re-gate to [BASE, PREMIUM] once paid activation works end-to-end.
|
||||
("auto-update", &[EDITION_FREE, EDITION_BASE, EDITION_PREMIUM]),
|
||||
("auto-update", &[EDITION_BASE, EDITION_PREMIUM]),
|
||||
("web-sync", &[EDITION_PREMIUM]),
|
||||
("cloud-backup", &[EDITION_PREMIUM]),
|
||||
("advanced-reports", &[EDITION_PREMIUM]),
|
||||
|
|
@ -40,9 +38,8 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn free_allows_auto_update_temporarily() {
|
||||
// Temporary: auto-update is open to FREE until the license server is live.
|
||||
assert!(is_feature_allowed("auto-update", EDITION_FREE));
|
||||
fn free_blocks_auto_update() {
|
||||
assert!(!is_feature_allowed("auto-update", EDITION_FREE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,159 +0,0 @@
|
|||
// Feedback Hub client — forwards user-submitted feedback to the central
|
||||
// feedback-api service. Routed through Rust (not direct fetch) so that:
|
||||
// - CORS is bypassed (Tauri origin is not whitelisted server-side by design)
|
||||
// - The exact payload leaving the machine is auditable in a single place
|
||||
// - The pattern matches the other outbound calls (OAuth, license, updater)
|
||||
//
|
||||
// The feedback-api contract is documented in
|
||||
// `la-compagnie-maximus/docs/feedback-hub-ops.md`. The server silently drops
|
||||
// any context key outside its whitelist, so this module only sends the
|
||||
// fields declared in `Context` below.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
fn feedback_endpoint() -> String {
|
||||
std::env::var("FEEDBACK_HUB_URL")
|
||||
.unwrap_or_else(|_| "https://feedback.lacompagniemaximus.com".to_string())
|
||||
}
|
||||
|
||||
/// Context payload sent with a feedback submission. Keys MUST match the
|
||||
/// server whitelist in `feedback-api/index.js` — unknown keys are dropped
|
||||
/// silently. Each field is capped at 500 chars server-side.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct FeedbackContext {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub page: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub locale: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub theme: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub viewport: Option<String>,
|
||||
#[serde(rename = "userAgent", skip_serializing_if = "Option::is_none")]
|
||||
pub user_agent: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timestamp: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct FeedbackPayload<'a> {
|
||||
app_id: &'a str,
|
||||
content: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
user_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
context: Option<FeedbackContext>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FeedbackSuccess {
|
||||
pub id: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FeedbackResponse {
|
||||
id: String,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
/// Return a composed User-Agent string for the context payload, e.g.
|
||||
/// `"Simpl'Résultat/0.8.1 (linux)"`. Uses std::env::consts::OS so we don't
|
||||
/// pull in an extra Tauri plugin just for this.
|
||||
#[tauri::command]
|
||||
pub fn get_feedback_user_agent(app: tauri::AppHandle) -> String {
|
||||
let version = app.package_info().version.to_string();
|
||||
let os = std::env::consts::OS;
|
||||
format!("Simpl'Résultat/{} ({})", version, os)
|
||||
}
|
||||
|
||||
/// Submit a feedback to the Feedback Hub. Error strings are stable codes
|
||||
/// ("invalid", "rate_limit", "server_error", "network_error") that the
|
||||
/// frontend maps to i18n messages.
|
||||
#[tauri::command]
|
||||
pub async fn send_feedback(
|
||||
content: String,
|
||||
user_id: Option<String>,
|
||||
context: Option<FeedbackContext>,
|
||||
) -> Result<FeedbackSuccess, String> {
|
||||
let trimmed = content.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("invalid".to_string());
|
||||
}
|
||||
|
||||
let payload = FeedbackPayload {
|
||||
app_id: "simpl-resultat",
|
||||
content: trimmed,
|
||||
user_id,
|
||||
context,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|_| "network_error".to_string())?;
|
||||
|
||||
let url = format!("{}/api/feedback", feedback_endpoint());
|
||||
let res = client
|
||||
.post(&url)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| "network_error".to_string())?;
|
||||
|
||||
match res.status().as_u16() {
|
||||
201 => {
|
||||
let body: FeedbackResponse = res
|
||||
.json()
|
||||
.await
|
||||
.map_err(|_| "server_error".to_string())?;
|
||||
Ok(FeedbackSuccess {
|
||||
id: body.id,
|
||||
created_at: body.created_at,
|
||||
})
|
||||
}
|
||||
400 => Err("invalid".to_string()),
|
||||
429 => Err("rate_limit".to_string()),
|
||||
_ => Err("server_error".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn context_skips_none_fields() {
|
||||
let ctx = FeedbackContext {
|
||||
page: Some("/settings".to_string()),
|
||||
locale: Some("fr".to_string()),
|
||||
theme: None,
|
||||
viewport: None,
|
||||
user_agent: None,
|
||||
timestamp: None,
|
||||
};
|
||||
let json = serde_json::to_value(&ctx).unwrap();
|
||||
let obj = json.as_object().unwrap();
|
||||
assert_eq!(obj.len(), 2);
|
||||
assert!(obj.contains_key("page"));
|
||||
assert!(obj.contains_key("locale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_serializes_user_agent_camelcase() {
|
||||
let ctx = FeedbackContext {
|
||||
user_agent: Some("Simpl'Résultat/0.8.1 (linux)".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_string(&ctx).unwrap();
|
||||
assert!(json.contains("\"userAgent\""));
|
||||
assert!(!json.contains("\"user_agent\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_content_is_rejected_locally() {
|
||||
let res = send_feedback(" \n\t".to_string(), None, None).await;
|
||||
assert_eq!(res.unwrap_err(), "invalid");
|
||||
}
|
||||
}
|
||||
|
|
@ -267,16 +267,16 @@ pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
|
|||
info.edition
|
||||
}
|
||||
|
||||
/// Read the HMAC-verified account cache to check for an active Premium
|
||||
/// subscription. Legacy unsigned caches (from v0.7.x) and tampered
|
||||
/// payloads return None — Premium features stay locked until the user
|
||||
/// re-authenticates or the next token refresh re-signs the cache.
|
||||
///
|
||||
/// This is intentional: before HMAC signing, any local process could
|
||||
/// write `{"subscription_status": "active"}` to account.json and
|
||||
/// bypass the paywall. Fail-closed is the correct posture here.
|
||||
/// Read the cached account.json to check for an active Premium subscription.
|
||||
/// Returns None if no account file exists or the file is invalid.
|
||||
fn check_account_edition(app: &tauri::AppHandle) -> Option<String> {
|
||||
let account = super::account_cache::load_verified(app).ok().flatten()?;
|
||||
let dir = app_data_dir(app).ok()?.join("auth");
|
||||
let account_path = dir.join("account.json");
|
||||
if !account_path.exists() {
|
||||
return None;
|
||||
}
|
||||
let raw = fs::read_to_string(&account_path).ok()?;
|
||||
let account: super::auth_commands::AccountInfo = serde_json::from_str(&raw).ok()?;
|
||||
match account.subscription_status.as_deref() {
|
||||
Some("active") => Some(EDITION_PREMIUM.to_string()),
|
||||
_ => None,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,13 @@
|
|||
pub mod account_cache;
|
||||
pub mod auth_commands;
|
||||
pub mod entitlements;
|
||||
pub mod export_import_commands;
|
||||
pub mod feedback_commands;
|
||||
pub mod fs_commands;
|
||||
pub mod license_commands;
|
||||
pub mod profile_commands;
|
||||
pub mod token_store;
|
||||
|
||||
pub use auth_commands::*;
|
||||
pub use entitlements::*;
|
||||
pub use export_import_commands::*;
|
||||
pub use feedback_commands::*;
|
||||
pub use fs_commands::*;
|
||||
pub use license_commands::*;
|
||||
pub use profile_commands::*;
|
||||
pub use token_store::*;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256, Sha384};
|
||||
use std::fs;
|
||||
use subtle::ConstantTimeEq;
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::database;
|
||||
|
|
@ -120,103 +118,44 @@ pub fn get_new_profile_init_sql() -> Result<Vec<String>, String> {
|
|||
])
|
||||
}
|
||||
|
||||
// Argon2id parameters for PIN hashing (same as export_import_commands.rs)
|
||||
const ARGON2_M_COST: u32 = 65536; // 64 MiB
|
||||
const ARGON2_T_COST: u32 = 3;
|
||||
const ARGON2_P_COST: u32 = 1;
|
||||
const ARGON2_OUTPUT_LEN: usize = 32;
|
||||
const ARGON2_SALT_LEN: usize = 16;
|
||||
|
||||
fn argon2_hash(pin: &str, salt: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let params = Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(ARGON2_OUTPUT_LEN))
|
||||
.map_err(|e| format!("Argon2 params error: {}", e))?;
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||
let mut hash = vec![0u8; ARGON2_OUTPUT_LEN];
|
||||
argon2
|
||||
.hash_password_into(pin.as_bytes(), salt, &mut hash)
|
||||
.map_err(|e| format!("Argon2 hash error: {}", e))?;
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn hash_pin(pin: String) -> Result<String, String> {
|
||||
let mut salt = [0u8; ARGON2_SALT_LEN];
|
||||
let mut salt = [0u8; 16];
|
||||
rand::rngs::OsRng.fill_bytes(&mut salt);
|
||||
let salt_hex = hex_encode(&salt);
|
||||
|
||||
let hash = argon2_hash(&pin, &salt)?;
|
||||
let hash_hex = hex_encode(&hash);
|
||||
|
||||
// Store as "argon2id:salt:hash" to distinguish from legacy SHA-256 "salt:hash"
|
||||
Ok(format!("argon2id:{}:{}", salt_hex, hash_hex))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VerifyPinResult {
|
||||
pub valid: bool,
|
||||
/// New Argon2id hash when a legacy SHA-256 PIN was successfully verified and re-hashed
|
||||
pub rehashed: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn verify_pin(pin: String, stored_hash: String) -> Result<VerifyPinResult, String> {
|
||||
// Argon2id format: "argon2id:salt_hex:hash_hex"
|
||||
if let Some(rest) = stored_hash.strip_prefix("argon2id:") {
|
||||
let parts: Vec<&str> = rest.split(':').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err("Invalid Argon2id hash format".to_string());
|
||||
}
|
||||
let salt = hex_decode(parts[0])?;
|
||||
let expected_hash = hex_decode(parts[1])?;
|
||||
|
||||
let computed = argon2_hash(&pin, &salt)?;
|
||||
|
||||
let valid = computed.ct_eq(&expected_hash).into();
|
||||
return Ok(VerifyPinResult { valid, rehashed: None });
|
||||
}
|
||||
|
||||
// Legacy SHA-256 format: "salt_hex:hash_hex"
|
||||
let parts: Vec<&str> = stored_hash.split(':').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err("Invalid stored hash format".to_string());
|
||||
}
|
||||
let salt_hex = parts[0];
|
||||
let expected_hash = hex_decode(parts[1])?;
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(salt_hex.as_bytes());
|
||||
hasher.update(pin.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
let hash_hex = hex_encode(&result);
|
||||
|
||||
let valid: bool = result.as_slice().ct_eq(&expected_hash).into();
|
||||
// Store as "salt:hash"
|
||||
Ok(format!("{}:{}", salt_hex, hash_hex))
|
||||
}
|
||||
|
||||
if valid {
|
||||
// Re-hash with Argon2id so this legacy PIN is upgraded.
|
||||
// If rehash fails, still allow login — don't block the user.
|
||||
let rehashed = hash_pin(pin).ok();
|
||||
Ok(VerifyPinResult { valid: true, rehashed })
|
||||
} else {
|
||||
Ok(VerifyPinResult { valid: false, rehashed: None })
|
||||
#[tauri::command]
|
||||
pub fn verify_pin(pin: String, stored_hash: String) -> Result<bool, String> {
|
||||
let parts: Vec<&str> = stored_hash.split(':').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err("Invalid stored hash format".to_string());
|
||||
}
|
||||
let salt_hex = parts[0];
|
||||
let expected_hash = parts[1];
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(salt_hex.as_bytes());
|
||||
hasher.update(pin.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
let computed_hash = hex_encode(&result);
|
||||
|
||||
Ok(computed_hash == expected_hash)
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
|
||||
fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
|
||||
if hex.len() % 2 != 0 {
|
||||
return Err("Invalid hex string length".to_string());
|
||||
}
|
||||
(0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| {
|
||||
u8::from_str_radix(&hex[i..i + 2], 16)
|
||||
.map_err(|e| format!("Invalid hex character: {}", e))
|
||||
})
|
||||
.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).
|
||||
|
|
@ -278,98 +217,3 @@ pub fn repair_migrations(app: tauri::AppHandle, db_filename: String) -> Result<b
|
|||
|
||||
Ok(repaired)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_pin_produces_argon2id_format() {
|
||||
let hash = hash_pin("1234".to_string()).unwrap();
|
||||
assert!(hash.starts_with("argon2id:"), "Hash should start with 'argon2id:' prefix");
|
||||
let parts: Vec<&str> = hash.split(':').collect();
|
||||
assert_eq!(parts.len(), 3, "Hash should have 3 parts: prefix:salt:hash");
|
||||
assert_eq!(parts[1].len(), ARGON2_SALT_LEN * 2, "Salt should be {} hex chars", ARGON2_SALT_LEN * 2);
|
||||
assert_eq!(parts[2].len(), ARGON2_OUTPUT_LEN * 2, "Hash should be {} hex chars", ARGON2_OUTPUT_LEN * 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_pin_different_salts() {
|
||||
let h1 = hash_pin("1234".to_string()).unwrap();
|
||||
let h2 = hash_pin("1234".to_string()).unwrap();
|
||||
assert_ne!(h1, h2, "Two hashes of the same PIN should use different salts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_argon2id_pin_correct() {
|
||||
let hash = hash_pin("5678".to_string()).unwrap();
|
||||
let result = verify_pin("5678".to_string(), hash).unwrap();
|
||||
assert!(result.valid, "Correct PIN should verify");
|
||||
assert!(result.rehashed.is_none(), "Argon2id PIN should not be rehashed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_argon2id_pin_wrong() {
|
||||
let hash = hash_pin("5678".to_string()).unwrap();
|
||||
let result = verify_pin("0000".to_string(), hash).unwrap();
|
||||
assert!(!result.valid, "Wrong PIN should not verify");
|
||||
assert!(result.rehashed.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_legacy_sha256_correct_and_rehash() {
|
||||
// Create a legacy SHA-256 hash: "salt_hex:sha256(salt_hex + pin)"
|
||||
let salt_hex = "abcdef0123456789";
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(salt_hex.as_bytes());
|
||||
hasher.update(b"4321");
|
||||
let hash_bytes = hasher.finalize();
|
||||
let hash_hex = hex_encode(&hash_bytes);
|
||||
let stored = format!("{}:{}", salt_hex, hash_hex);
|
||||
|
||||
let result = verify_pin("4321".to_string(), stored).unwrap();
|
||||
assert!(result.valid, "Correct legacy PIN should verify");
|
||||
assert!(result.rehashed.is_some(), "Legacy PIN should be rehashed to Argon2id");
|
||||
|
||||
// Verify the rehashed value is a valid Argon2id hash
|
||||
let new_hash = result.rehashed.unwrap();
|
||||
assert!(new_hash.starts_with("argon2id:"));
|
||||
|
||||
// Verify the rehashed value works for future verification
|
||||
let result2 = verify_pin("4321".to_string(), new_hash).unwrap();
|
||||
assert!(result2.valid, "Rehashed PIN should verify");
|
||||
assert!(result2.rehashed.is_none(), "Already Argon2id, no rehash needed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_legacy_sha256_wrong() {
|
||||
let salt_hex = "abcdef0123456789";
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(salt_hex.as_bytes());
|
||||
hasher.update(b"4321");
|
||||
let hash_bytes = hasher.finalize();
|
||||
let hash_hex = hex_encode(&hash_bytes);
|
||||
let stored = format!("{}:{}", salt_hex, hash_hex);
|
||||
|
||||
let result = verify_pin("9999".to_string(), stored).unwrap();
|
||||
assert!(!result.valid, "Wrong legacy PIN should not verify");
|
||||
assert!(result.rehashed.is_none(), "Failed verification should not rehash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_invalid_format() {
|
||||
let result = verify_pin("1234".to_string(), "invalid".to_string());
|
||||
assert!(result.is_err(), "Single-part hash should fail");
|
||||
|
||||
let result = verify_pin("1234".to_string(), "argon2id:bad".to_string());
|
||||
assert!(result.is_err(), "Argon2id with wrong part count should fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hex_roundtrip() {
|
||||
let original = vec![0u8, 127, 255, 1, 16];
|
||||
let encoded = hex_encode(&original);
|
||||
let decoded = hex_decode(&encoded).unwrap();
|
||||
assert_eq!(original, decoded);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,393 +0,0 @@
|
|||
// OAuth token storage abstraction.
|
||||
//
|
||||
// This module centralises how OAuth2 tokens are persisted. It tries the OS
|
||||
// keychain first (Credential Manager on Windows, Secret Service on Linux
|
||||
// via libdbus) and falls back to a restricted file on disk if the keychain
|
||||
// is unavailable.
|
||||
//
|
||||
// Security properties:
|
||||
// - The keychain service name matches the Tauri bundle identifier
|
||||
// (com.simpl.resultat) so OS tools and future macOS builds can scope
|
||||
// credentials correctly.
|
||||
// - A `store_mode` flag is persisted next to the fallback file. Once the
|
||||
// keychain has been used successfully, the store refuses to silently
|
||||
// downgrade to the file fallback: a subsequent keychain failure is
|
||||
// surfaced as an error so the caller can force re-authentication
|
||||
// instead of leaving the user with undetected plaintext tokens.
|
||||
// - Migration from an existing `tokens.json` file zeros the file contents
|
||||
// and fsyncs before unlinking, reducing the window where the refresh
|
||||
// token is recoverable from unallocated disk blocks.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::Manager;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
// Keychain identifiers. The service name matches tauri.conf.json's
|
||||
// `identifier` so credentials are scoped to the real app identity.
|
||||
const KEYCHAIN_SERVICE: &str = "com.simpl.resultat";
|
||||
const KEYCHAIN_USER_TOKENS: &str = "oauth-tokens";
|
||||
|
||||
pub(crate) const AUTH_DIR: &str = "auth";
|
||||
const TOKENS_FILE: &str = "tokens.json";
|
||||
const STORE_MODE_FILE: &str = "store_mode";
|
||||
|
||||
/// Where token material currently lives. Exposed via a Tauri command so
|
||||
/// the frontend can display a security banner when the app has fallen
|
||||
/// back to the file store.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StoreMode {
|
||||
Keychain,
|
||||
File,
|
||||
}
|
||||
|
||||
impl StoreMode {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
StoreMode::Keychain => "keychain",
|
||||
StoreMode::File => "file",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(raw: &str) -> Option<Self> {
|
||||
match raw.trim() {
|
||||
"keychain" => Some(StoreMode::Keychain),
|
||||
"file" => Some(StoreMode::File),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialised OAuth token bundle. Owned by `token_store` because this is
|
||||
/// the only module that should reach for the persisted bytes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoredTokens {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub id_token: Option<String>,
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
/// Resolve `<app_data_dir>/auth/`, creating it if needed. Shared with
|
||||
/// auth_commands.rs which still writes non-secret files (account info,
|
||||
/// last-check timestamp) in the same directory.
|
||||
pub(crate) fn auth_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("Cannot get app data dir: {}", e))?
|
||||
.join(AUTH_DIR);
|
||||
if !dir.exists() {
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Cannot create auth dir: {}", e))?;
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Write a file with 0600 permissions on Unix. Windows has no cheap
|
||||
/// equivalent here; callers that rely on this function for secrets should
|
||||
/// treat the Windows path as a last-resort fallback and surface the
|
||||
/// degraded state to the user (see StoreMode).
|
||||
pub(crate) fn write_restricted(path: &Path, contents: &str) -> Result<(), String> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
|
||||
file.write_all(contents.as_bytes())
|
||||
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
|
||||
file.sync_all()
|
||||
.map_err(|e| format!("Cannot fsync {}: {}", path.display(), e))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
fs::write(path, contents)
|
||||
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn chrono_now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
fn read_store_mode(dir: &Path) -> Option<StoreMode> {
|
||||
let path = dir.join(STORE_MODE_FILE);
|
||||
let raw = fs::read_to_string(&path).ok()?;
|
||||
StoreMode::parse(&raw)
|
||||
}
|
||||
|
||||
fn write_store_mode(dir: &Path, mode: StoreMode) -> Result<(), String> {
|
||||
write_restricted(&dir.join(STORE_MODE_FILE), mode.as_str())
|
||||
}
|
||||
|
||||
/// Overwrite the file contents with zeros, fsync, then unlink. Best-effort
|
||||
/// mitigation against recovery of the refresh token from unallocated
|
||||
/// blocks on copy-on-write filesystems where a plain unlink leaves the
|
||||
/// ciphertext recoverable. Not a substitute for proper disk encryption.
|
||||
fn zero_and_delete(path: &Path) -> Result<(), String> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let len = fs::metadata(path)
|
||||
.map(|m| m.len() as usize)
|
||||
.unwrap_or(0)
|
||||
.max(512);
|
||||
let mut zeros = vec![0u8; len];
|
||||
{
|
||||
let mut f = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.open(path)
|
||||
.map_err(|e| format!("Cannot open {} for wipe: {}", path.display(), e))?;
|
||||
f.write_all(&zeros)
|
||||
.map_err(|e| format!("Cannot zero {}: {}", path.display(), e))?;
|
||||
f.sync_all()
|
||||
.map_err(|e| format!("Cannot fsync {}: {}", path.display(), e))?;
|
||||
}
|
||||
zeros.zeroize();
|
||||
fs::remove_file(path).map_err(|e| format!("Cannot remove {}: {}", path.display(), e))
|
||||
}
|
||||
|
||||
fn tokens_to_json(tokens: &StoredTokens) -> Result<String, String> {
|
||||
serde_json::to_string(tokens).map_err(|e| format!("Serialize error: {}", e))
|
||||
}
|
||||
|
||||
fn tokens_from_json(raw: &str) -> Result<StoredTokens, String> {
|
||||
serde_json::from_str(raw).map_err(|e| format!("Invalid tokens payload: {}", e))
|
||||
}
|
||||
|
||||
fn keychain_entry() -> Result<keyring::Entry, keyring::Error> {
|
||||
keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_TOKENS)
|
||||
}
|
||||
|
||||
fn keychain_save(json: &str) -> Result<(), keyring::Error> {
|
||||
keychain_entry()?.set_password(json)
|
||||
}
|
||||
|
||||
fn keychain_load() -> Result<Option<String>, keyring::Error> {
|
||||
match keychain_entry()?.get_password() {
|
||||
Ok(v) => Ok(Some(v)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn keychain_delete() -> Result<(), keyring::Error> {
|
||||
match keychain_entry()?.delete_credential() {
|
||||
Ok(()) => Ok(()),
|
||||
Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the current OAuth token bundle.
|
||||
///
|
||||
/// Tries the OS keychain first. If the keychain write fails AND the
|
||||
/// persisted `store_mode` flag shows the keychain has worked before, the
|
||||
/// caller receives an error instead of a silent downgrade — this
|
||||
/// prevents a hostile local process from forcing the app into the
|
||||
/// weaker file-fallback path. On a fresh install (no prior flag), the
|
||||
/// fallback is allowed but recorded so subsequent calls know the app is
|
||||
/// running in degraded mode.
|
||||
pub fn save(app: &tauri::AppHandle, tokens: &StoredTokens) -> Result<(), String> {
|
||||
let json = tokens_to_json(tokens)?;
|
||||
let dir = auth_dir(app)?;
|
||||
let previous_mode = read_store_mode(&dir);
|
||||
|
||||
match keychain_save(&json) {
|
||||
Ok(()) => {
|
||||
// Keychain succeeded. Clean up any residual file from a prior
|
||||
// fallback or migration source so nothing stays readable.
|
||||
let residual = dir.join(TOKENS_FILE);
|
||||
if residual.exists() {
|
||||
let _ = zero_and_delete(&residual);
|
||||
}
|
||||
write_store_mode(&dir, StoreMode::Keychain)?;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
if previous_mode == Some(StoreMode::Keychain) {
|
||||
// Refuse to downgrade after a prior success — surface the
|
||||
// failure so the caller can force re-auth instead of
|
||||
// silently leaking tokens to disk.
|
||||
return Err(format!(
|
||||
"Keychain unavailable after prior success — refusing to downgrade. \
|
||||
Original error: {}",
|
||||
err
|
||||
));
|
||||
}
|
||||
eprintln!(
|
||||
"token_store: keychain unavailable, falling back to file store ({})",
|
||||
err
|
||||
);
|
||||
write_restricted(&dir.join(TOKENS_FILE), &json)?;
|
||||
write_store_mode(&dir, StoreMode::File)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the current OAuth token bundle.
|
||||
///
|
||||
/// Tries the keychain first. If the keychain is empty but a legacy
|
||||
/// `tokens.json` file exists, this is a first-run migration: the tokens
|
||||
/// are copied into the keychain, the file is zeroed and unlinked, and
|
||||
/// `store_mode` is updated. If the keychain itself is unreachable, the
|
||||
/// function falls back to reading the file — unless the `store_mode`
|
||||
/// flag indicates the keychain has worked before, in which case it
|
||||
/// returns an error to force re-auth.
|
||||
pub fn load(app: &tauri::AppHandle) -> Result<Option<StoredTokens>, String> {
|
||||
let dir = auth_dir(app)?;
|
||||
let previous_mode = read_store_mode(&dir);
|
||||
let residual = dir.join(TOKENS_FILE);
|
||||
|
||||
match keychain_load() {
|
||||
Ok(Some(raw)) => {
|
||||
let tokens = tokens_from_json(&raw)?;
|
||||
// Defensive: if a leftover file is still around (e.g. a prior
|
||||
// crash between keychain write and file delete), clean it up.
|
||||
if residual.exists() {
|
||||
let _ = zero_and_delete(&residual);
|
||||
}
|
||||
if previous_mode != Some(StoreMode::Keychain) {
|
||||
write_store_mode(&dir, StoreMode::Keychain)?;
|
||||
}
|
||||
Ok(Some(tokens))
|
||||
}
|
||||
Ok(None) => {
|
||||
// Keychain reachable but empty. Migrate from a legacy file if
|
||||
// one exists, otherwise report no stored session.
|
||||
if residual.exists() {
|
||||
let raw = fs::read_to_string(&residual)
|
||||
.map_err(|e| format!("Cannot read {}: {}", residual.display(), e))?;
|
||||
let tokens = tokens_from_json(&raw)?;
|
||||
// Push into keychain and wipe the file. If the keychain
|
||||
// push fails here, we keep the file rather than losing
|
||||
// the user's session.
|
||||
match keychain_save(&raw) {
|
||||
Ok(()) => {
|
||||
let _ = zero_and_delete(&residual);
|
||||
write_store_mode(&dir, StoreMode::Keychain)?;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("token_store: migration to keychain failed ({})", e);
|
||||
write_store_mode(&dir, StoreMode::File)?;
|
||||
}
|
||||
}
|
||||
Ok(Some(tokens))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if previous_mode == Some(StoreMode::Keychain) {
|
||||
return Err(format!(
|
||||
"Keychain unavailable after prior success: {}",
|
||||
err
|
||||
));
|
||||
}
|
||||
// No prior keychain success: honour the file fallback if any.
|
||||
eprintln!(
|
||||
"token_store: keychain unreachable, using file fallback ({})",
|
||||
err
|
||||
);
|
||||
if residual.exists() {
|
||||
let raw = fs::read_to_string(&residual)
|
||||
.map_err(|e| format!("Cannot read {}: {}", residual.display(), e))?;
|
||||
write_store_mode(&dir, StoreMode::File)?;
|
||||
Ok(Some(tokens_from_json(&raw)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the stored tokens from both the keychain and the file
|
||||
/// fallback. Both deletions are best-effort and ignore "no entry"
|
||||
/// errors to stay idempotent.
|
||||
pub fn delete(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
let dir = auth_dir(app)?;
|
||||
if let Err(err) = keychain_delete() {
|
||||
eprintln!("token_store: keychain delete failed ({})", err);
|
||||
}
|
||||
let residual = dir.join(TOKENS_FILE);
|
||||
if residual.exists() {
|
||||
let _ = zero_and_delete(&residual);
|
||||
}
|
||||
// Leave the store_mode flag alone: it still describes what the app
|
||||
// should trust the next time `save` is called.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Current store mode, derived from the persisted flag. Returns `None`
|
||||
/// if no tokens have ever been written (no flag file).
|
||||
pub fn store_mode(app: &tauri::AppHandle) -> Result<Option<StoreMode>, String> {
|
||||
let dir = auth_dir(app)?;
|
||||
Ok(read_store_mode(&dir))
|
||||
}
|
||||
|
||||
/// Tauri command: expose the current store mode to the frontend.
|
||||
/// Returns `"keychain"`, `"file"`, or `null` if the app has no stored
|
||||
/// session yet. Used by the settings UI to show a security banner when
|
||||
/// the fallback is active.
|
||||
#[tauri::command]
|
||||
pub fn get_token_store_mode(app: tauri::AppHandle) -> Result<Option<String>, String> {
|
||||
Ok(store_mode(&app)?.map(|m| m.as_str().to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn store_mode_roundtrip() {
|
||||
assert_eq!(StoreMode::parse("keychain"), Some(StoreMode::Keychain));
|
||||
assert_eq!(StoreMode::parse("file"), Some(StoreMode::File));
|
||||
assert_eq!(StoreMode::parse("other"), None);
|
||||
assert_eq!(StoreMode::Keychain.as_str(), "keychain");
|
||||
assert_eq!(StoreMode::File.as_str(), "file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_tokens_serde_roundtrip() {
|
||||
let tokens = StoredTokens {
|
||||
access_token: "at".into(),
|
||||
refresh_token: Some("rt".into()),
|
||||
id_token: Some("it".into()),
|
||||
expires_at: 42,
|
||||
};
|
||||
let json = tokens_to_json(&tokens).unwrap();
|
||||
let decoded = tokens_from_json(&json).unwrap();
|
||||
assert_eq!(decoded.access_token, "at");
|
||||
assert_eq!(decoded.refresh_token.as_deref(), Some("rt"));
|
||||
assert_eq!(decoded.id_token.as_deref(), Some("it"));
|
||||
assert_eq!(decoded.expires_at, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_and_delete_removes_file() {
|
||||
use std::io::Write as _;
|
||||
let tmp = std::env::temp_dir().join(format!(
|
||||
"simpl-resultat-token-store-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let mut f = fs::File::create(&tmp).unwrap();
|
||||
f.write_all(b"sensitive").unwrap();
|
||||
drop(f);
|
||||
assert!(tmp.exists());
|
||||
zero_and_delete(&tmp).unwrap();
|
||||
assert!(!tmp.exists());
|
||||
}
|
||||
}
|
||||
|
|
@ -2,8 +2,7 @@ mod commands;
|
|||
mod database;
|
||||
|
||||
use std::sync::Mutex;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
use tauri::{Emitter, Listener};
|
||||
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
|
|
@ -85,14 +84,6 @@ pub fn run() {
|
|||
];
|
||||
|
||||
tauri::Builder::default()
|
||||
// Single-instance plugin MUST be registered first. With the `deep-link`
|
||||
// feature, it forwards `simpl-resultat://` URLs to the running instance
|
||||
// so the OAuth2 callback reaches the process that holds the PKCE verifier.
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
.manage(commands::auth_commands::OAuthState {
|
||||
code_verifier: Mutex::new(None),
|
||||
})
|
||||
|
|
@ -104,23 +95,15 @@ pub fn run() {
|
|||
#[cfg(desktop)]
|
||||
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||
|
||||
// Register the custom scheme at runtime on Linux (the .desktop file
|
||||
// handles it in prod, but register_all is a no-op there and required
|
||||
// for AppImage/dev builds).
|
||||
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
|
||||
{
|
||||
let _ = app.deep_link().register_all();
|
||||
}
|
||||
|
||||
// Canonical Tauri v2 pattern: on_open_url fires for both initial-launch
|
||||
// URLs and subsequent URLs forwarded by tauri-plugin-single-instance
|
||||
// (with the `deep-link` feature).
|
||||
// Listen for deep-link events (simpl-resultat://auth/callback?code=...)
|
||||
let handle = app.handle().clone();
|
||||
app.deep_link().on_open_url(move |event| {
|
||||
for url in event.urls() {
|
||||
let url_str = url.as_str();
|
||||
app.listen("deep-link://new-url", move |event| {
|
||||
let payload = event.payload();
|
||||
// payload is a JSON-serialized array of URL strings
|
||||
if let Ok(urls) = serde_json::from_str::<Vec<String>>(payload) {
|
||||
for url in urls {
|
||||
if let Some(code) = extract_auth_code(&url) {
|
||||
let h = handle.clone();
|
||||
if let Some(code) = extract_auth_code(url_str) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match commands::handle_auth_callback(h.clone(), code).await {
|
||||
Ok(account) => {
|
||||
|
|
@ -131,13 +114,7 @@ pub fn run() {
|
|||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// No `code` param — likely an OAuth error response. Surface
|
||||
// it to the frontend instead of leaving the UI stuck in
|
||||
// "loading" forever.
|
||||
let err_msg = extract_auth_error(url_str)
|
||||
.unwrap_or_else(|| "OAuth callback did not include a code".to_string());
|
||||
let _ = h.emit("auth-callback-error", &err_msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -184,9 +161,6 @@ pub fn run() {
|
|||
commands::get_account_info,
|
||||
commands::check_subscription_status,
|
||||
commands::logout,
|
||||
commands::get_token_store_mode,
|
||||
commands::send_feedback,
|
||||
commands::get_feedback_user_agent,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
@ -195,20 +169,6 @@ pub fn run() {
|
|||
/// Extract the `code` query parameter from a deep-link callback URL.
|
||||
/// e.g. "simpl-resultat://auth/callback?code=abc123&state=xyz" → Some("abc123")
|
||||
fn extract_auth_code(url: &str) -> Option<String> {
|
||||
extract_query_param(url, "code")
|
||||
}
|
||||
|
||||
/// Extract an OAuth error description from a callback URL. Returns a
|
||||
/// formatted string combining `error` and `error_description` when present.
|
||||
fn extract_auth_error(url: &str) -> Option<String> {
|
||||
let error = extract_query_param(url, "error")?;
|
||||
match extract_query_param(url, "error_description") {
|
||||
Some(desc) => Some(format!("{}: {}", error, desc)),
|
||||
None => Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_query_param(url: &str, key: &str) -> Option<String> {
|
||||
let url = url.trim();
|
||||
if !url.starts_with("simpl-resultat://auth/callback") {
|
||||
return None;
|
||||
|
|
@ -216,7 +176,7 @@ fn extract_query_param(url: &str, key: &str) -> Option<String> {
|
|||
let query = url.split('?').nth(1)?;
|
||||
for pair in query.split('&') {
|
||||
let mut kv = pair.splitn(2, '=');
|
||||
if kv.next()? == key {
|
||||
if kv.next()? == "code" {
|
||||
return kv.next().map(|v| {
|
||||
urlencoding::decode(v).map(|s| s.into_owned()).unwrap_or_else(|_| v.to_string())
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Simpl Resultat",
|
||||
"version": "0.8.2",
|
||||
"version": "0.6.7",
|
||||
"identifier": "com.simpl.resultat",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
|
@ -18,12 +18,12 @@
|
|||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; connect-src 'self' https://api.lacompagniemaximus.com https://auth.lacompagniemaximus.com https://feedback.lacompagniemaximus.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
|
||||
"csp": "default-src 'self'; script-src 'self'; connect-src 'self' https://api.lacompagniemaximus.com https://auth.lacompagniemaximus.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis", "deb", "rpm"],
|
||||
"targets": ["nsis", "deb", "rpm", "appimage"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
|
|
|||
10
src/App.tsx
10
src/App.tsx
|
|
@ -10,11 +10,6 @@ import CategoriesPage from "./pages/CategoriesPage";
|
|||
import AdjustmentsPage from "./pages/AdjustmentsPage";
|
||||
import BudgetPage from "./pages/BudgetPage";
|
||||
import ReportsPage from "./pages/ReportsPage";
|
||||
import ReportsHighlightsPage from "./pages/ReportsHighlightsPage";
|
||||
import ReportsTrendsPage from "./pages/ReportsTrendsPage";
|
||||
import ReportsComparePage from "./pages/ReportsComparePage";
|
||||
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
|
||||
import ReportsCartesPage from "./pages/ReportsCartesPage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import DocsPage from "./pages/DocsPage";
|
||||
import ChangelogPage from "./pages/ChangelogPage";
|
||||
|
|
@ -106,11 +101,6 @@ export default function App() {
|
|||
<Route path="/adjustments" element={<AdjustmentsPage />} />
|
||||
<Route path="/budget" element={<BudgetPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/reports/highlights" element={<ReportsHighlightsPage />} />
|
||||
<Route path="/reports/trends" element={<ReportsTrendsPage />} />
|
||||
<Route path="/reports/compare" element={<ReportsComparePage />} />
|
||||
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
||||
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/docs" element={<DocsPage />} />
|
||||
<Route path="/changelog" element={<ChangelogPage />} />
|
||||
|
|
|
|||
|
|
@ -1,278 +0,0 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { RecentTransaction } from "../../shared/types";
|
||||
import {
|
||||
KEYWORD_MAX_LENGTH,
|
||||
KEYWORD_MIN_LENGTH,
|
||||
KEYWORD_PREVIEW_LIMIT,
|
||||
applyKeywordWithReassignment,
|
||||
previewKeywordMatches,
|
||||
validateKeyword,
|
||||
} from "../../services/categorizationService";
|
||||
import { getAllCategoriesWithCounts } from "../../services/categoryService";
|
||||
|
||||
interface CategoryOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AddKeywordDialogProps {
|
||||
initialKeyword: string;
|
||||
initialCategoryId?: number | null;
|
||||
onClose: () => void;
|
||||
onApplied?: () => void;
|
||||
}
|
||||
|
||||
type PreviewState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "ready"; visible: RecentTransaction[]; totalMatches: number }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
export default function AddKeywordDialog({
|
||||
initialKeyword,
|
||||
initialCategoryId = null,
|
||||
onClose,
|
||||
onApplied,
|
||||
}: AddKeywordDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [keyword, setKeyword] = useState(initialKeyword);
|
||||
const [categoryId, setCategoryId] = useState<number | null>(initialCategoryId);
|
||||
const [categories, setCategories] = useState<CategoryOption[]>([]);
|
||||
const [checked, setChecked] = useState<Set<number>>(new Set());
|
||||
const [applyToHidden, setApplyToHidden] = useState(false);
|
||||
const [preview, setPreview] = useState<PreviewState>({ kind: "idle" });
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
const [applyError, setApplyError] = useState<string | null>(null);
|
||||
const [replacePrompt, setReplacePrompt] = useState<string | null>(null);
|
||||
const [allowReplaceExisting, setAllowReplaceExisting] = useState(false);
|
||||
|
||||
const validation = useMemo(() => validateKeyword(keyword), [keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
getAllCategoriesWithCounts()
|
||||
.then((rows) => setCategories(rows.map((r) => ({ id: r.id, name: r.name }))))
|
||||
.catch(() => setCategories([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!validation.ok) {
|
||||
setPreview({ kind: "idle" });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setPreview({ kind: "loading" });
|
||||
previewKeywordMatches(validation.value, KEYWORD_PREVIEW_LIMIT)
|
||||
.then(({ visible, totalMatches }) => {
|
||||
if (cancelled) return;
|
||||
setPreview({ kind: "ready", visible, totalMatches });
|
||||
setChecked(new Set(visible.map((tx) => tx.id)));
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (cancelled) return;
|
||||
setPreview({ kind: "error", message: e instanceof Error ? e.message : String(e) });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [validation]);
|
||||
|
||||
const toggleRow = (id: number) => {
|
||||
setChecked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const canApply =
|
||||
validation.ok &&
|
||||
categoryId !== null &&
|
||||
preview.kind === "ready" &&
|
||||
!isApplying &&
|
||||
checked.size > 0;
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!validation.ok || categoryId === null) return;
|
||||
setIsApplying(true);
|
||||
setApplyError(null);
|
||||
try {
|
||||
await applyKeywordWithReassignment({
|
||||
keyword: validation.value,
|
||||
categoryId,
|
||||
transactionIds: Array.from(checked),
|
||||
allowReplaceExisting,
|
||||
});
|
||||
if (onApplied) onApplied();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg === "keyword_already_exists") {
|
||||
setReplacePrompt(t("reports.keyword.alreadyExists", { category: "" }));
|
||||
} else {
|
||||
setApplyError(msg);
|
||||
}
|
||||
} finally {
|
||||
setIsApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const preventBackdropClose = (e: React.MouseEvent) => e.stopPropagation();
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-[200] bg-black/40 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
onClick={preventBackdropClose}
|
||||
className="bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-xl max-w-xl w-full max-h-[90vh] flex flex-col"
|
||||
>
|
||||
<header className="px-5 py-3 border-b border-[var(--border)]">
|
||||
<h2 className="text-base font-semibold">{t("reports.keyword.dialogTitle")}</h2>
|
||||
</header>
|
||||
|
||||
<div className="px-5 py-4 flex flex-col gap-4 overflow-y-auto">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
|
||||
{t("reports.keyword.dialogTitle")}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
maxLength={KEYWORD_MAX_LENGTH * 2 /* allow user to paste longer then see error */}
|
||||
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
{!validation.ok && (
|
||||
<span className="text-xs text-[var(--negative)]">
|
||||
{validation.reason === "tooShort"
|
||||
? t("reports.keyword.tooShort", { min: KEYWORD_MIN_LENGTH })
|
||||
: t("reports.keyword.tooLong", { max: KEYWORD_MAX_LENGTH })}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
|
||||
{t("reports.highlights.category")}
|
||||
</span>
|
||||
<select
|
||||
value={categoryId ?? ""}
|
||||
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide mb-2">
|
||||
{t("reports.keyword.willMatch")}
|
||||
</h3>
|
||||
{preview.kind === "idle" && (
|
||||
<p className="text-sm text-[var(--muted-foreground)] italic">—</p>
|
||||
)}
|
||||
{preview.kind === "loading" && (
|
||||
<p className="text-sm text-[var(--muted-foreground)] italic">{t("common.loading")}</p>
|
||||
)}
|
||||
{preview.kind === "error" && (
|
||||
<p className="text-sm text-[var(--negative)]">{preview.message}</p>
|
||||
)}
|
||||
{preview.kind === "ready" && (
|
||||
<>
|
||||
<p className="text-sm mb-2">
|
||||
{t("reports.keyword.nMatches", { count: preview.totalMatches })}
|
||||
</p>
|
||||
<ul className="divide-y divide-[var(--border)] max-h-[220px] overflow-y-auto border border-[var(--border)] rounded-lg">
|
||||
{preview.visible.map((tx) => (
|
||||
<li key={tx.id} className="flex items-center gap-2 px-3 py-1.5 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked.has(tx.id)}
|
||||
onChange={() => toggleRow(tx.id)}
|
||||
className="accent-[var(--primary)]"
|
||||
/>
|
||||
<span className="text-[var(--muted-foreground)] tabular-nums flex-shrink-0">
|
||||
{tx.date}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate">{tx.description}</span>
|
||||
<span className="tabular-nums font-medium flex-shrink-0">
|
||||
{new Intl.NumberFormat("en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
}).format(tx.amount)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{preview.totalMatches > preview.visible.length && (
|
||||
<label className="flex items-center gap-2 text-xs mt-2 text-[var(--muted-foreground)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={applyToHidden}
|
||||
onChange={(e) => setApplyToHidden(e.target.checked)}
|
||||
className="accent-[var(--primary)]"
|
||||
/>
|
||||
{t("reports.keyword.applyToHidden", {
|
||||
count: preview.totalMatches - preview.visible.length,
|
||||
})}
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{replacePrompt && (
|
||||
<div className="bg-[var(--muted)]/50 border border-[var(--border)] rounded-lg p-3 text-sm flex flex-col gap-2">
|
||||
<p>{replacePrompt}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAllowReplaceExisting(true);
|
||||
setReplacePrompt(null);
|
||||
void handleApply();
|
||||
}}
|
||||
className="self-start px-3 py-1.5 rounded-lg bg-[var(--primary)] text-white text-sm"
|
||||
>
|
||||
{t("common.confirm")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{applyError && (
|
||||
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-lg p-3 text-sm">
|
||||
{applyError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="px-5 py-3 border-t border-[var(--border)] flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-2 rounded-lg text-sm bg-[var(--card)] border border-[var(--border)] hover:bg-[var(--muted)]"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
disabled={!canApply}
|
||||
className="px-3 py-2 rounded-lg text-sm bg-[var(--primary)] text-white font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isApplying ? t("common.loading") : t("reports.keyword.applyAndRecategorize")}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import { verifyPin } from "../../services/profileService";
|
|||
interface Props {
|
||||
profileName: string;
|
||||
storedHash: string;
|
||||
onSuccess: (rehashed?: string | null) => void;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -41,9 +41,9 @@ export default function PinDialog({ profileName, storedHash, onSuccess, onCancel
|
|||
if (value && filledCount === index + 1) {
|
||||
setChecking(true);
|
||||
try {
|
||||
const result = await verifyPin(pin.replace(/\s/g, ""), storedHash);
|
||||
if (result.valid) {
|
||||
onSuccess(result.rehashed);
|
||||
const valid = await verifyPin(pin.replace(/\s/g, ""), storedHash);
|
||||
if (valid) {
|
||||
onSuccess();
|
||||
} else if (filledCount >= 6 || (filledCount >= 4 && index === filledCount - 1 && !value)) {
|
||||
setError(true);
|
||||
setDigits(["", "", "", "", "", ""]);
|
||||
|
|
@ -67,10 +67,10 @@ export default function PinDialog({ profileName, storedHash, onSuccess, onCancel
|
|||
const pin = digits.join("");
|
||||
if (pin.length >= 4) {
|
||||
setChecking(true);
|
||||
verifyPin(pin, storedHash).then((result) => {
|
||||
verifyPin(pin, storedHash).then((valid) => {
|
||||
setChecking(false);
|
||||
if (result.valid) {
|
||||
onSuccess(result.rehashed);
|
||||
if (valid) {
|
||||
onSuccess();
|
||||
} else {
|
||||
setError(true);
|
||||
setDigits(["", "", "", "", "", ""]);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type { Profile } from "../../services/profileService";
|
|||
|
||||
export default function ProfileSwitcher() {
|
||||
const { t } = useTranslation();
|
||||
const { profiles, activeProfile, switchProfile, updateProfile } = useProfile();
|
||||
const { profiles, activeProfile, switchProfile } = useProfile();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pinProfile, setPinProfile] = useState<Profile | null>(null);
|
||||
const [showManage, setShowManage] = useState(false);
|
||||
|
|
@ -36,15 +36,8 @@ export default function ProfileSwitcher() {
|
|||
}
|
||||
};
|
||||
|
||||
const handlePinSuccess = async (rehashed?: string | null) => {
|
||||
const handlePinSuccess = () => {
|
||||
if (pinProfile) {
|
||||
if (rehashed) {
|
||||
try {
|
||||
await updateProfile(pinProfile.id, { pin_hash: rehashed });
|
||||
} catch {
|
||||
// Best-effort rehash: don't block profile switch if persistence fails
|
||||
}
|
||||
}
|
||||
switchProfile(pinProfile.id);
|
||||
setPinProfile(null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
|
||||
import type { CategoryZoomChild } from "../../shared/types";
|
||||
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
|
||||
|
||||
export interface CategoryDonutChartProps {
|
||||
byChild: CategoryZoomChild[];
|
||||
centerLabel: string;
|
||||
centerValue: string;
|
||||
}
|
||||
|
||||
export default function CategoryDonutChart({
|
||||
byChild,
|
||||
centerLabel,
|
||||
centerValue,
|
||||
}: CategoryDonutChartProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (byChild.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
|
||||
{t("reports.empty.noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 relative">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<ChartPatternDefs
|
||||
prefix="cat-donut"
|
||||
categories={byChild.map((c, index) => ({ color: c.categoryColor, index }))}
|
||||
/>
|
||||
<Pie
|
||||
data={byChild}
|
||||
dataKey="total"
|
||||
nameKey="categoryName"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={55}
|
||||
outerRadius={95}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{byChild.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.categoryId}
|
||||
fill={getPatternFill("cat-donut", index, entry.categoryColor)}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) =>
|
||||
typeof value === "number"
|
||||
? new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(value)
|
||||
: String(value)
|
||||
}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.5rem",
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<span className="text-xs text-[var(--muted-foreground)]">{centerLabel}</span>
|
||||
<span className="text-lg font-bold">{centerValue}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import type { CategoryZoomEvolutionPoint } from "../../shared/types";
|
||||
|
||||
export interface CategoryEvolutionChartProps {
|
||||
data: CategoryZoomEvolutionPoint[];
|
||||
color?: string;
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
export default function CategoryEvolutionChart({
|
||||
data,
|
||||
color = "var(--primary)",
|
||||
}: CategoryEvolutionChartProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
|
||||
{t("reports.empty.noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold mb-2">{t("reports.category.evolution")}</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={data} margin={{ top: 10, right: 20, bottom: 10, left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickFormatter={formatMonth}
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={11}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={11}
|
||||
tickFormatter={(v: number) =>
|
||||
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(v)
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) =>
|
||||
typeof value === "number"
|
||||
? new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
}).format(value)
|
||||
: String(value)
|
||||
}
|
||||
labelFormatter={(label) => (typeof label === "string" ? formatMonth(label) : String(label ?? ""))}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.5rem",
|
||||
}}
|
||||
/>
|
||||
<Area type="monotone" dataKey="total" stroke={color} fill={color} fillOpacity={0.2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
import { useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tag } from "lucide-react";
|
||||
import type { RecentTransaction } from "../../shared/types";
|
||||
import ContextMenu from "../shared/ContextMenu";
|
||||
|
||||
export interface CategoryTransactionsTableProps {
|
||||
transactions: RecentTransaction[];
|
||||
onAddKeyword?: (transaction: RecentTransaction) => void;
|
||||
}
|
||||
|
||||
type SortKey = "date" | "description" | "amount";
|
||||
|
||||
function formatAmount(amount: number, language: string): string {
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export default function CategoryTransactionsTable({
|
||||
transactions,
|
||||
onAddKeyword,
|
||||
}: CategoryTransactionsTableProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [sortKey, setSortKey] = useState<SortKey>("amount");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; tx: RecentTransaction } | null>(null);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const copy = [...transactions];
|
||||
copy.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (sortKey) {
|
||||
case "date":
|
||||
cmp = a.date.localeCompare(b.date);
|
||||
break;
|
||||
case "description":
|
||||
cmp = a.description.localeCompare(b.description);
|
||||
break;
|
||||
case "amount":
|
||||
cmp = Math.abs(a.amount) - Math.abs(b.amount);
|
||||
break;
|
||||
}
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
return copy;
|
||||
}, [transactions, sortKey, sortDir]);
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else {
|
||||
setSortKey(key);
|
||||
setSortDir("desc");
|
||||
}
|
||||
}
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, tx: RecentTransaction) => {
|
||||
if (!onAddKeyword) return;
|
||||
e.preventDefault();
|
||||
setMenu({ x: e.clientX, y: e.clientY, tx });
|
||||
};
|
||||
|
||||
const header = (key: SortKey, label: string, align: "left" | "right") => (
|
||||
<th
|
||||
onClick={() => toggleSort(key)}
|
||||
className={`${align === "right" ? "text-right" : "text-left"} px-3 py-2 font-medium text-[var(--muted-foreground)] cursor-pointer hover:text-[var(--foreground)] select-none`}
|
||||
>
|
||||
{label}
|
||||
{sortKey === key && <span className="ml-1">{sortDir === "asc" ? "▲" : "▼"}</span>}
|
||||
</th>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-[var(--card)]">
|
||||
<tr className="border-b border-[var(--border)]">
|
||||
{header("date", t("transactions.date"), "left")}
|
||||
{header("description", t("transactions.description"), "left")}
|
||||
{header("amount", t("transactions.amount"), "right")}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
|
||||
{t("reports.empty.noData")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sorted.map((tx) => (
|
||||
<tr
|
||||
key={tx.id}
|
||||
onContextMenu={(e) => handleContextMenu(e, tx)}
|
||||
className="border-b border-[var(--border)] last:border-0 hover:bg-[var(--muted)]/40"
|
||||
>
|
||||
<td className="px-3 py-2 tabular-nums text-[var(--muted-foreground)]">{tx.date}</td>
|
||||
<td className="px-3 py-2 truncate max-w-[280px]">{tx.description}</td>
|
||||
<td
|
||||
className="px-3 py-2 text-right tabular-nums font-medium"
|
||||
style={{
|
||||
color: tx.amount >= 0 ? "var(--positive, #10b981)" : "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
{formatAmount(tx.amount, i18n.language)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{menu && onAddKeyword && (
|
||||
<ContextMenu
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
header={menu.tx.description}
|
||||
onClose={() => setMenu(null)}
|
||||
items={[
|
||||
{
|
||||
icon: <Tag size={14} />,
|
||||
label: t("reports.keyword.addFromTransaction"),
|
||||
onClick: () => {
|
||||
onAddKeyword(menu.tx);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getAllCategoriesWithCounts } from "../../services/categoryService";
|
||||
|
||||
interface CategoryOption {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string | null;
|
||||
parent_id: number | null;
|
||||
}
|
||||
|
||||
export interface CategoryZoomHeaderProps {
|
||||
categoryId: number | null;
|
||||
includeSubcategories: boolean;
|
||||
onCategoryChange: (id: number | null) => void;
|
||||
onIncludeSubcategoriesChange: (flag: boolean) => void;
|
||||
}
|
||||
|
||||
export default function CategoryZoomHeader({
|
||||
categoryId,
|
||||
includeSubcategories,
|
||||
onCategoryChange,
|
||||
onIncludeSubcategoriesChange,
|
||||
}: CategoryZoomHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const [categories, setCategories] = useState<CategoryOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getAllCategoriesWithCounts()
|
||||
.then((rows) =>
|
||||
setCategories(
|
||||
rows.map((r) => ({ id: r.id, name: r.name, color: r.color, parent_id: r.parent_id })),
|
||||
),
|
||||
)
|
||||
.catch(() => setCategories([]));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
|
||||
<label className="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
|
||||
{t("reports.category.selectCategory")}
|
||||
</span>
|
||||
<select
|
||||
value={categoryId ?? ""}
|
||||
onChange={(e) => onCategoryChange(e.target.value ? Number(e.target.value) : null)}
|
||||
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeSubcategories}
|
||||
onChange={(e) => onIncludeSubcategoriesChange(e.target.checked)}
|
||||
className="accent-[var(--primary)]"
|
||||
/>
|
||||
<span>
|
||||
{includeSubcategories
|
||||
? t("reports.category.includeSubcategories")
|
||||
: t("reports.category.directOnly")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import BudgetVsActualTable from "./BudgetVsActualTable";
|
||||
import { getBudgetVsActualData } from "../../services/budgetService";
|
||||
import type { BudgetVsActualRow } from "../../shared/types";
|
||||
|
||||
export interface CompareBudgetViewProps {
|
||||
year: number;
|
||||
month: number;
|
||||
}
|
||||
|
||||
export default function CompareBudgetView({ year, month }: CompareBudgetViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const [rows, setRows] = useState<BudgetVsActualRow[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
getBudgetVsActualData(year, month)
|
||||
.then((data) => {
|
||||
if (!cancelled) setRows(data);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [year, month]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4">{error}</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
|
||||
{t("reports.bva.noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <BudgetVsActualTable data={rows} />;
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import type { CompareMode } from "../../hooks/useCompare";
|
||||
|
||||
export interface CompareModeTabsProps {
|
||||
value: CompareMode;
|
||||
onChange: (mode: CompareMode) => void;
|
||||
}
|
||||
|
||||
export default function CompareModeTabs({ value, onChange }: CompareModeTabsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const modes: { id: CompareMode; labelKey: string }[] = [
|
||||
{ id: "actual", labelKey: "reports.compare.modeActual" },
|
||||
{ id: "budget", labelKey: "reports.compare.modeBudget" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="inline-flex gap-1" role="tablist">
|
||||
{modes.map(({ id, labelKey }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
onClick={() => onChange(id)}
|
||||
aria-selected={value === id}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
value === id
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||
}`}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
import type { CategoryDelta } from "../../shared/types";
|
||||
|
||||
export interface ComparePeriodChartProps {
|
||||
rows: CategoryDelta[];
|
||||
previousLabel: string;
|
||||
currentLabel: string;
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number, language: string): string {
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export default function ComparePeriodChart({
|
||||
rows,
|
||||
previousLabel,
|
||||
currentLabel,
|
||||
}: ComparePeriodChartProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
|
||||
{t("reports.empty.noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by current-period amount (largest spending first) so the user's eye
|
||||
// lands on the biggest categories, then reverse so the biggest appears at
|
||||
// the top of the vertical bar chart.
|
||||
const chartData = [...rows]
|
||||
.sort((a, b) => b.currentAmount - a.currentAmount)
|
||||
.map((r) => ({
|
||||
name: r.categoryName,
|
||||
previousAmount: r.previousAmount,
|
||||
currentAmount: r.currentAmount,
|
||||
color: r.categoryColor,
|
||||
}));
|
||||
|
||||
const previousFill = "var(--muted-foreground)";
|
||||
const currentFill = "var(--primary)";
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
|
||||
<ResponsiveContainer width="100%" height={Math.max(280, chartData.length * 44 + 60)}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{ top: 10, right: 24, bottom: 10, left: 10 }}
|
||||
barCategoryGap="20%"
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tickFormatter={(v) => formatCurrency(v, i18n.language)}
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={11}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={140}
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={11}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) =>
|
||||
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
|
||||
}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.5rem",
|
||||
}}
|
||||
cursor={{ fill: "var(--muted)", fillOpacity: 0.2 }}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ paddingTop: 8, fontSize: 12, color: "var(--muted-foreground)" }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="previousAmount"
|
||||
name={previousLabel}
|
||||
fill={previousFill}
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="currentAmount"
|
||||
name={currentLabel}
|
||||
fill={currentFill}
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import type { CategoryDelta } from "../../shared/types";
|
||||
|
||||
export interface ComparePeriodTableProps {
|
||||
rows: CategoryDelta[];
|
||||
previousLabel: string;
|
||||
currentLabel: string;
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number, language: string): string {
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatSignedCurrency(amount: number, language: string): string {
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
signDisplay: "always",
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatPct(pct: number | null, language: string): string {
|
||||
if (pct === null) return "—";
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
signDisplay: "always",
|
||||
}).format(pct / 100);
|
||||
}
|
||||
|
||||
export default function ComparePeriodTable({
|
||||
rows,
|
||||
previousLabel,
|
||||
currentLabel,
|
||||
}: ComparePeriodTableProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border)]">
|
||||
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)]">
|
||||
{t("reports.highlights.category")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
|
||||
{previousLabel}
|
||||
</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
|
||||
{currentLabel}
|
||||
</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
|
||||
{t("reports.highlights.variationAbs")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
|
||||
{t("reports.highlights.variationPct")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
|
||||
{t("reports.empty.noData")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<tr
|
||||
key={`${row.categoryId ?? "uncat"}-${row.categoryName}`}
|
||||
className="border-b border-[var(--border)] last:border-0 hover:bg-[var(--muted)]/40"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: row.categoryColor }}
|
||||
/>
|
||||
{row.categoryName}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{formatCurrency(row.previousAmount, i18n.language)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{formatCurrency(row.currentAmount, i18n.language)}
|
||||
</td>
|
||||
<td
|
||||
className="px-3 py-2 text-right tabular-nums font-medium"
|
||||
style={{
|
||||
color:
|
||||
row.deltaAbs >= 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)",
|
||||
}}
|
||||
>
|
||||
{formatSignedCurrency(row.deltaAbs, i18n.language)}
|
||||
</td>
|
||||
<td
|
||||
className="px-3 py-2 text-right tabular-nums"
|
||||
style={{
|
||||
color:
|
||||
row.deltaAbs >= 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)",
|
||||
}}
|
||||
>
|
||||
{formatPct(row.deltaPct, i18n.language)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface CompareReferenceMonthPickerProps {
|
||||
year: number;
|
||||
month: number;
|
||||
onChange: (year: number, month: number) => void;
|
||||
/** Number of recent months to show in the dropdown. Default: 24. */
|
||||
monthCount?: number;
|
||||
/** "today" override for tests. */
|
||||
today?: Date;
|
||||
}
|
||||
|
||||
interface Option {
|
||||
value: string; // "YYYY-MM"
|
||||
year: number;
|
||||
month: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function formatMonth(year: number, month: number, language: string): string {
|
||||
const date = new Date(year, month - 1, 1);
|
||||
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default function CompareReferenceMonthPicker({
|
||||
year,
|
||||
month,
|
||||
onChange,
|
||||
monthCount = 24,
|
||||
today = new Date(),
|
||||
}: CompareReferenceMonthPickerProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const options = useMemo<Option[]>(() => {
|
||||
const list: Option[] = [];
|
||||
let y = today.getFullYear();
|
||||
let m = today.getMonth() + 1;
|
||||
for (let i = 0; i < monthCount; i++) {
|
||||
list.push({
|
||||
value: `${y}-${String(m).padStart(2, "0")}`,
|
||||
year: y,
|
||||
month: m,
|
||||
label: formatMonth(y, m, i18n.language),
|
||||
});
|
||||
m -= 1;
|
||||
if (m === 0) {
|
||||
m = 12;
|
||||
y -= 1;
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}, [today, monthCount, i18n.language]);
|
||||
|
||||
const currentValue = `${year}-${String(month).padStart(2, "0")}`;
|
||||
const isKnown = options.some((o) => o.value === currentValue);
|
||||
const displayOptions = isKnown
|
||||
? options
|
||||
: [
|
||||
{
|
||||
value: currentValue,
|
||||
year,
|
||||
month,
|
||||
label: formatMonth(year, month, i18n.language),
|
||||
},
|
||||
...options,
|
||||
];
|
||||
|
||||
return (
|
||||
<label className="inline-flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--muted-foreground)]">
|
||||
{t("reports.compare.referenceMonth")}
|
||||
</span>
|
||||
<select
|
||||
value={currentValue}
|
||||
onChange={(e) => {
|
||||
const opt = displayOptions.find((o) => o.value === e.target.value);
|
||||
if (opt) onChange(opt.year, opt.month);
|
||||
}}
|
||||
className="rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] px-3 py-2 text-sm hover:bg-[var(--muted)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
||||
>
|
||||
{displayOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import type { CompareSubMode } from "../../hooks/useCompare";
|
||||
|
||||
export interface CompareSubModeToggleProps {
|
||||
value: CompareSubMode;
|
||||
onChange: (subMode: CompareSubMode) => void;
|
||||
}
|
||||
|
||||
export default function CompareSubModeToggle({ value, onChange }: CompareSubModeToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const items: { id: CompareSubMode; labelKey: string }[] = [
|
||||
{ id: "mom", labelKey: "reports.compare.subModeMoM" },
|
||||
{ id: "yoy", labelKey: "reports.compare.subModeYoY" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex rounded-lg border border-[var(--border)] bg-[var(--card)] p-0.5"
|
||||
role="group"
|
||||
aria-label={t("reports.compare.subModeAria")}
|
||||
>
|
||||
{items.map(({ id, labelKey }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onChange(id)}
|
||||
aria-pressed={value === id}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
value === id
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||
}`}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/components/reports/DynamicReport.tsx
Normal file
106
src/components/reports/DynamicReport.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Table, BarChart3, Columns, Maximize2, Minimize2 } from "lucide-react";
|
||||
import type { PivotConfig, PivotResult } from "../../shared/types";
|
||||
import DynamicReportPanel from "./DynamicReportPanel";
|
||||
import DynamicReportTable from "./DynamicReportTable";
|
||||
import DynamicReportChart from "./DynamicReportChart";
|
||||
|
||||
type ViewMode = "table" | "chart" | "both";
|
||||
|
||||
interface DynamicReportProps {
|
||||
config: PivotConfig;
|
||||
result: PivotResult;
|
||||
onConfigChange: (config: PivotConfig) => void;
|
||||
}
|
||||
|
||||
export default function DynamicReport({ config, result, onConfigChange }: DynamicReportProps) {
|
||||
const { t } = useTranslation();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("table");
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
const toggleFullscreen = useCallback(() => setFullscreen((prev) => !prev), []);
|
||||
|
||||
// Escape key exits fullscreen
|
||||
useEffect(() => {
|
||||
if (!fullscreen) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setFullscreen(false);
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [fullscreen]);
|
||||
|
||||
const hasConfig = (config.rows.length > 0 || config.columns.length > 0) && config.values.length > 0;
|
||||
|
||||
const viewButtons: { mode: ViewMode; icon: React.ReactNode; label: string }[] = [
|
||||
{ mode: "table", icon: <Table size={14} />, label: t("reports.pivot.viewTable") },
|
||||
{ mode: "chart", icon: <BarChart3 size={14} />, label: t("reports.pivot.viewChart") },
|
||||
{ mode: "both", icon: <Columns size={14} />, label: t("reports.pivot.viewBoth") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
fullscreen
|
||||
? "fixed inset-0 z-50 bg-[var(--background)] overflow-auto p-6"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div className="flex gap-4 items-start">
|
||||
{/* Content area */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1">
|
||||
{hasConfig && viewButtons.map(({ mode, icon, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs 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>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
title={fullscreen ? t("reports.pivot.exitFullscreen") : t("reports.pivot.fullscreen")}
|
||||
>
|
||||
{fullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
{fullscreen ? t("reports.pivot.exitFullscreen") : t("reports.pivot.fullscreen")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{!hasConfig && (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-12 text-center text-[var(--muted-foreground)]">
|
||||
{t("reports.pivot.noConfig")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{hasConfig && (viewMode === "table" || viewMode === "both") && (
|
||||
<DynamicReportTable config={config} result={result} />
|
||||
)}
|
||||
|
||||
{/* Chart */}
|
||||
{hasConfig && (viewMode === "chart" || viewMode === "both") && (
|
||||
<DynamicReportChart config={config} result={result} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Side panel */}
|
||||
<DynamicReportPanel
|
||||
config={config}
|
||||
onChange={onConfigChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
src/components/reports/DynamicReportChart.tsx
Normal file
143
src/components/reports/DynamicReportChart.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
import type { PivotConfig, PivotResult } from "../../shared/types";
|
||||
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
|
||||
|
||||
const cadFormatter = (value: number) =>
|
||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
// Generate distinct colors for series
|
||||
const SERIES_COLORS = [
|
||||
"#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6",
|
||||
"#ec4899", "#14b8a6", "#f97316", "#06b6d4", "#84cc16",
|
||||
"#d946ef", "#0ea5e9", "#eab308", "#22c55e", "#e11d48",
|
||||
];
|
||||
|
||||
interface DynamicReportChartProps {
|
||||
config: PivotConfig;
|
||||
result: PivotResult;
|
||||
}
|
||||
|
||||
export default function DynamicReportChart({ config, result }: DynamicReportChartProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { chartData, seriesKeys, seriesColors } = useMemo(() => {
|
||||
if (result.rows.length === 0) {
|
||||
return { chartData: [], seriesKeys: [], seriesColors: {} };
|
||||
}
|
||||
|
||||
const colDims = config.columns;
|
||||
const rowDim = config.rows[0];
|
||||
const measure = config.values[0] || "periodic";
|
||||
|
||||
// X-axis = composite column key (or first row dimension if no columns)
|
||||
const hasColDims = colDims.length > 0;
|
||||
if (!hasColDims && !rowDim) return { chartData: [], seriesKeys: [], seriesColors: {} };
|
||||
|
||||
// Build composite column key per row
|
||||
const getColKey = (r: typeof result.rows[0]) =>
|
||||
colDims.map((d) => r.keys[d] || "").join(" — ");
|
||||
|
||||
// Series = first row dimension (or no stacking if no rows, or first row if columns exist)
|
||||
const seriesDim = hasColDims ? rowDim : undefined;
|
||||
|
||||
// Collect unique x and series values
|
||||
const xValues = hasColDims
|
||||
? [...new Set(result.rows.map(getColKey))].sort()
|
||||
: [...new Set(result.rows.map((r) => r.keys[rowDim]))].sort();
|
||||
const seriesVals = seriesDim
|
||||
? [...new Set(result.rows.map((r) => r.keys[seriesDim]))].sort()
|
||||
: [measure];
|
||||
|
||||
// Build chart data: one entry per x value
|
||||
const data = xValues.map((xVal) => {
|
||||
const entry: Record<string, string | number> = { name: xVal };
|
||||
if (seriesDim) {
|
||||
for (const sv of seriesVals) {
|
||||
const matchingRows = result.rows.filter(
|
||||
(r) => (hasColDims ? getColKey(r) : r.keys[rowDim]) === xVal && r.keys[seriesDim] === sv
|
||||
);
|
||||
entry[sv] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0);
|
||||
}
|
||||
} else {
|
||||
const matchingRows = result.rows.filter((r) =>
|
||||
hasColDims ? getColKey(r) === xVal : r.keys[rowDim] === xVal
|
||||
);
|
||||
entry[measure] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0);
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
const colors: Record<string, string> = {};
|
||||
seriesVals.forEach((sv, i) => {
|
||||
colors[sv] = SERIES_COLORS[i % SERIES_COLORS.length];
|
||||
});
|
||||
|
||||
return { chartData: data, seriesKeys: seriesVals, seriesColors: colors };
|
||||
}, [config, result]);
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||
<p className="text-center text-[var(--muted-foreground)] py-8">{t("reports.pivot.noData")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const categoryEntries = seriesKeys.map((key, index) => ({
|
||||
color: seriesColors[key],
|
||||
index,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={chartData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<ChartPatternDefs prefix="pivot-chart" categories={categoryEntries} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v) => cadFormatter(v)}
|
||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||
stroke="var(--border)"
|
||||
width={80}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
labelStyle={{ color: "var(--foreground)" }}
|
||||
itemStyle={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<Legend />
|
||||
{seriesKeys.map((key, index) => (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
stackId="stack"
|
||||
fill={getPatternFill("pivot-chart", index, seriesColors[key])}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
306
src/components/reports/DynamicReportPanel.tsx
Normal file
306
src/components/reports/DynamicReportPanel.tsx
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import type { PivotConfig, PivotFieldId, PivotFilterEntry, PivotMeasureId, PivotZone } from "../../shared/types";
|
||||
import { getDynamicFilterValues } from "../../services/reportService";
|
||||
|
||||
const ALL_FIELDS: PivotFieldId[] = ["year", "month", "type", "level1", "level2", "level3"];
|
||||
const ALL_MEASURES: PivotMeasureId[] = ["periodic", "ytd"];
|
||||
|
||||
interface DynamicReportPanelProps {
|
||||
config: PivotConfig;
|
||||
onChange: (config: PivotConfig) => void;
|
||||
}
|
||||
|
||||
export default function DynamicReportPanel({ config, onChange }: DynamicReportPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [menuTarget, setMenuTarget] = useState<{ id: string; type: "field" | "measure"; x: number; y: number } | null>(null);
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string[]>>({});
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// A field is only "exhausted" if it's in all 3 zones (rows + columns + filters)
|
||||
const inRows = new Set(config.rows);
|
||||
const inColumns = new Set(config.columns);
|
||||
const inFilters = new Set(Object.keys(config.filters) as PivotFieldId[]);
|
||||
const assignedFields = new Set(
|
||||
ALL_FIELDS.filter((f) => inRows.has(f) && inColumns.has(f) && inFilters.has(f))
|
||||
);
|
||||
const assignedMeasures = new Set(config.values);
|
||||
const availableFields = ALL_FIELDS.filter((f) => !assignedFields.has(f));
|
||||
const availableMeasures = ALL_MEASURES.filter((m) => !assignedMeasures.has(m));
|
||||
|
||||
// Load filter values when a field is added to filters
|
||||
const filterFieldIds = Object.keys(config.filters) as PivotFieldId[];
|
||||
useEffect(() => {
|
||||
for (const fieldId of filterFieldIds) {
|
||||
if (!filterValues[fieldId]) {
|
||||
getDynamicFilterValues(fieldId as PivotFieldId).then((vals) => {
|
||||
setFilterValues((prev) => ({ ...prev, [fieldId]: vals }));
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [filterFieldIds.join(",")]);
|
||||
|
||||
// Close menu on outside click
|
||||
useEffect(() => {
|
||||
if (!menuTarget) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuTarget(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [menuTarget]);
|
||||
|
||||
const handleFieldClick = (id: string, type: "field" | "measure", e: React.MouseEvent) => {
|
||||
setMenuTarget({ id, type, x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
const assignTo = useCallback((zone: PivotZone) => {
|
||||
if (!menuTarget) return;
|
||||
const next = { ...config, rows: [...config.rows], columns: [...config.columns], filters: { ...config.filters }, values: [...config.values] };
|
||||
|
||||
if (menuTarget.type === "measure") {
|
||||
if (zone === "values") {
|
||||
next.values = [...next.values, menuTarget.id as PivotMeasureId];
|
||||
}
|
||||
} else {
|
||||
const fieldId = menuTarget.id as PivotFieldId;
|
||||
if (zone === "rows") next.rows = [...next.rows, fieldId];
|
||||
else if (zone === "columns") next.columns = [...next.columns, fieldId];
|
||||
else if (zone === "filters") next.filters = { ...next.filters, [fieldId]: { include: [], exclude: [] } };
|
||||
}
|
||||
|
||||
setMenuTarget(null);
|
||||
onChange(next);
|
||||
}, [menuTarget, config, onChange]);
|
||||
|
||||
const removeFrom = (zone: PivotZone, id: string) => {
|
||||
const next = { ...config, rows: [...config.rows], columns: [...config.columns], filters: { ...config.filters }, values: [...config.values] };
|
||||
if (zone === "rows") next.rows = next.rows.filter((f) => f !== id);
|
||||
else if (zone === "columns") next.columns = next.columns.filter((f) => f !== id);
|
||||
else if (zone === "filters") {
|
||||
const { [id]: _, ...rest } = next.filters;
|
||||
next.filters = rest;
|
||||
} else if (zone === "values") next.values = next.values.filter((m) => m !== id);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const toggleFilterInclude = (fieldId: string, value: string) => {
|
||||
const entry: PivotFilterEntry = config.filters[fieldId] || { include: [], exclude: [] };
|
||||
const isIncluded = entry.include.includes(value);
|
||||
const newInclude = isIncluded ? entry.include.filter((v) => v !== value) : [...entry.include, value];
|
||||
// Remove from exclude if adding to include
|
||||
const newExclude = isIncluded ? entry.exclude : entry.exclude.filter((v) => v !== value);
|
||||
onChange({ ...config, filters: { ...config.filters, [fieldId]: { include: newInclude, exclude: newExclude } } });
|
||||
};
|
||||
|
||||
const toggleFilterExclude = (fieldId: string, value: string) => {
|
||||
const entry: PivotFilterEntry = config.filters[fieldId] || { include: [], exclude: [] };
|
||||
const isExcluded = entry.exclude.includes(value);
|
||||
const newExclude = isExcluded ? entry.exclude.filter((v) => v !== value) : [...entry.exclude, value];
|
||||
// Remove from include if adding to exclude
|
||||
const newInclude = isExcluded ? entry.include : entry.include.filter((v) => v !== value);
|
||||
onChange({ ...config, filters: { ...config.filters, [fieldId]: { include: newInclude, exclude: newExclude } } });
|
||||
};
|
||||
|
||||
const fieldLabel = (id: string) => t(`reports.pivot.${id === "level1" ? "level1" : id === "level2" ? "level2" : id === "level3" ? "level3" : id === "type" ? "categoryType" : id}`);
|
||||
const measureLabel = (id: string) => t(`reports.pivot.${id}`);
|
||||
|
||||
// Context menu only shows zones where the field is NOT already assigned
|
||||
const getAvailableZones = (fieldId: string): PivotZone[] => {
|
||||
const zones: PivotZone[] = [];
|
||||
if (!inRows.has(fieldId as PivotFieldId)) zones.push("rows");
|
||||
if (!inColumns.has(fieldId as PivotFieldId)) zones.push("columns");
|
||||
if (!inFilters.has(fieldId as PivotFieldId)) zones.push("filters");
|
||||
return zones;
|
||||
};
|
||||
|
||||
const zoneLabels: Record<PivotZone, string> = {
|
||||
rows: t("reports.pivot.rows"),
|
||||
columns: t("reports.pivot.columns"),
|
||||
filters: t("reports.pivot.filters"),
|
||||
values: t("reports.pivot.values"),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-64 shrink-0 bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 space-y-4 text-sm h-fit sticky top-4">
|
||||
{/* Available Fields */}
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--muted-foreground)] mb-2">{t("reports.pivot.availableFields")}</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{availableFields.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={(e) => handleFieldClick(f, "field", e)}
|
||||
className="px-2.5 py-1 rounded-lg bg-[var(--muted)] text-[var(--foreground)] hover:bg-[var(--border)] transition-colors text-xs"
|
||||
>
|
||||
{fieldLabel(f)}
|
||||
</button>
|
||||
))}
|
||||
{availableMeasures.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={(e) => handleFieldClick(m, "measure", e)}
|
||||
className="px-2.5 py-1 rounded-lg bg-[var(--primary)]/10 text-[var(--primary)] hover:bg-[var(--primary)]/20 transition-colors text-xs"
|
||||
>
|
||||
{measureLabel(m)}
|
||||
</button>
|
||||
))}
|
||||
{availableFields.length === 0 && availableMeasures.length === 0 && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<ZoneSection
|
||||
label={t("reports.pivot.rows")}
|
||||
items={config.rows}
|
||||
getLabel={fieldLabel}
|
||||
onRemove={(id) => removeFrom("rows", id)}
|
||||
/>
|
||||
|
||||
{/* Columns */}
|
||||
<ZoneSection
|
||||
label={t("reports.pivot.columns")}
|
||||
items={config.columns}
|
||||
getLabel={fieldLabel}
|
||||
onRemove={(id) => removeFrom("columns", id)}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--muted-foreground)] mb-1">{t("reports.pivot.filters")}</h3>
|
||||
{filterFieldIds.length === 0 ? (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">—</span>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filterFieldIds.map((fieldId) => {
|
||||
const entry = config.filters[fieldId] || { include: [], exclude: [] };
|
||||
const hasActive = entry.include.length > 0 || entry.exclude.length > 0;
|
||||
return (
|
||||
<div key={fieldId}>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<span className="text-xs font-medium">{fieldLabel(fieldId)}</span>
|
||||
<button onClick={() => removeFrom("filters", fieldId)} className="text-[var(--muted-foreground)] hover:text-[var(--negative)]">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(filterValues[fieldId] || []).map((val) => {
|
||||
const isIncluded = entry.include.includes(val);
|
||||
const isExcluded = entry.exclude.includes(val);
|
||||
return (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => toggleFilterInclude(fieldId, val)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
toggleFilterExclude(fieldId, val);
|
||||
}}
|
||||
className={`px-2 py-0.5 rounded text-xs transition-colors ${
|
||||
isIncluded
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: isExcluded
|
||||
? "bg-[var(--negative)] text-white line-through"
|
||||
: hasActive
|
||||
? "bg-[var(--muted)] text-[var(--muted-foreground)] opacity-50"
|
||||
: "bg-[var(--muted)] text-[var(--foreground)]"
|
||||
}`}
|
||||
title={t("reports.pivot.rightClickExclude")}
|
||||
>
|
||||
{val}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Values */}
|
||||
<ZoneSection
|
||||
label={t("reports.pivot.values")}
|
||||
items={config.values}
|
||||
getLabel={measureLabel}
|
||||
onRemove={(id) => removeFrom("values", id)}
|
||||
accent
|
||||
/>
|
||||
|
||||
{/* Context menu */}
|
||||
{menuTarget && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1 min-w-[140px]"
|
||||
style={{ left: menuTarget.x, top: menuTarget.y }}
|
||||
>
|
||||
<div className="px-3 py-1 text-xs text-[var(--muted-foreground)]">{t("reports.pivot.addTo")}</div>
|
||||
{menuTarget.type === "measure" ? (
|
||||
<button
|
||||
onClick={() => assignTo("values")}
|
||||
className="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
{zoneLabels.values}
|
||||
</button>
|
||||
) : (
|
||||
getAvailableZones(menuTarget.id).map((zone) => (
|
||||
<button
|
||||
key={zone}
|
||||
onClick={() => assignTo(zone)}
|
||||
className="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
{zoneLabels[zone]}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoneSection({
|
||||
label,
|
||||
items,
|
||||
getLabel,
|
||||
onRemove,
|
||||
accent,
|
||||
}: {
|
||||
label: string;
|
||||
items: string[];
|
||||
getLabel: (id: string) => string;
|
||||
onRemove: (id: string) => void;
|
||||
accent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--muted-foreground)] mb-1">{label}</h3>
|
||||
{items.length === 0 ? (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">—</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.map((id) => (
|
||||
<span
|
||||
key={id}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-xs ${
|
||||
accent
|
||||
? "bg-[var(--primary)]/10 text-[var(--primary)]"
|
||||
: "bg-[var(--muted)] text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
{getLabel(id)}
|
||||
<button onClick={() => onRemove(id)} className="hover:text-[var(--negative)]">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
295
src/components/reports/DynamicReportTable.tsx
Normal file
295
src/components/reports/DynamicReportTable.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
import { Fragment, useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUpDown } from "lucide-react";
|
||||
import type { PivotConfig, PivotResult, PivotResultRow } from "../../shared/types";
|
||||
|
||||
const cadFormatter = (value: number) =>
|
||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
const STORAGE_KEY = "pivot-subtotals-position";
|
||||
|
||||
interface DynamicReportTableProps {
|
||||
config: PivotConfig;
|
||||
result: PivotResult;
|
||||
}
|
||||
|
||||
/** A pivoted row: one per unique combination of row dimensions */
|
||||
interface PivotedRow {
|
||||
rowKeys: Record<string, string>; // row-dimension values
|
||||
cells: Record<string, Record<string, number>>; // colValue → measure → value
|
||||
}
|
||||
|
||||
/** Pivot raw result rows into one PivotedRow per unique row-key combination */
|
||||
function pivotRows(
|
||||
rows: PivotResultRow[],
|
||||
rowDims: string[],
|
||||
colDims: string[],
|
||||
measures: string[],
|
||||
): PivotedRow[] {
|
||||
const map = new Map<string, PivotedRow>();
|
||||
|
||||
for (const row of rows) {
|
||||
const rowKey = rowDims.map((d) => row.keys[d] || "").join("\0");
|
||||
let pivoted = map.get(rowKey);
|
||||
if (!pivoted) {
|
||||
const rowKeys: Record<string, string> = {};
|
||||
for (const d of rowDims) rowKeys[d] = row.keys[d] || "";
|
||||
pivoted = { rowKeys, cells: {} };
|
||||
map.set(rowKey, pivoted);
|
||||
}
|
||||
|
||||
const colKey = colDims.length > 0
|
||||
? colDims.map((d) => row.keys[d] || "").join("\0")
|
||||
: "__all__";
|
||||
if (!pivoted.cells[colKey]) pivoted.cells[colKey] = {};
|
||||
for (const m of measures) {
|
||||
pivoted.cells[colKey][m] = (pivoted.cells[colKey][m] || 0) + (row.measures[m] || 0);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
interface GroupNode {
|
||||
key: string;
|
||||
label: string;
|
||||
pivotedRows: PivotedRow[];
|
||||
children: GroupNode[];
|
||||
}
|
||||
|
||||
function buildGroups(rows: PivotedRow[], rowDims: string[], depth: number): GroupNode[] {
|
||||
if (depth >= rowDims.length) return [];
|
||||
const dim = rowDims[depth];
|
||||
const map = new Map<string, PivotedRow[]>();
|
||||
for (const row of rows) {
|
||||
const key = row.rowKeys[dim] || "";
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(row);
|
||||
}
|
||||
const groups: GroupNode[] = [];
|
||||
for (const [key, groupRows] of map) {
|
||||
groups.push({
|
||||
key,
|
||||
label: key,
|
||||
pivotedRows: groupRows,
|
||||
children: buildGroups(groupRows, rowDims, depth + 1),
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function computeSubtotals(
|
||||
rows: PivotedRow[],
|
||||
measures: string[],
|
||||
colValues: string[],
|
||||
): Record<string, Record<string, number>> {
|
||||
const result: Record<string, Record<string, number>> = {};
|
||||
for (const colVal of colValues) {
|
||||
result[colVal] = {};
|
||||
for (const m of measures) {
|
||||
result[colVal][m] = rows.reduce((sum, r) => sum + (r.cells[colVal]?.[m] || 0), 0);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function DynamicReportTable({ config, result }: DynamicReportTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored === null ? true : stored === "top";
|
||||
});
|
||||
|
||||
const toggleSubtotals = () => {
|
||||
setSubtotalsOnTop((prev) => {
|
||||
const next = !prev;
|
||||
localStorage.setItem(STORAGE_KEY, next ? "top" : "bottom");
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const rowDims = config.rows;
|
||||
const colDims = config.columns;
|
||||
const colValues = colDims.length > 0 ? result.columnValues : ["__all__"];
|
||||
const measures = config.values;
|
||||
|
||||
// Display label for a composite column key (joined with \0)
|
||||
const colLabel = (compositeKey: string) => compositeKey.split("\0").join(" — ");
|
||||
|
||||
// Pivot the flat SQL rows into one PivotedRow per unique row-key combo
|
||||
const pivotedRows = useMemo(
|
||||
() => pivotRows(result.rows, rowDims, colDims, measures),
|
||||
[result.rows, rowDims, colDims, measures],
|
||||
);
|
||||
|
||||
if (pivotedRows.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
||||
{t("reports.pivot.noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const groups = rowDims.length > 0 ? buildGroups(pivotedRows, rowDims, 0) : [];
|
||||
const grandTotals = computeSubtotals(pivotedRows, measures, colValues);
|
||||
|
||||
const fieldLabel = (id: string) => t(`reports.pivot.${id === "level1" ? "level1" : id === "level2" ? "level2" : id === "type" ? "categoryType" : id}`);
|
||||
const measureLabel = (id: string) => t(`reports.pivot.${id}`);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||
{rowDims.length > 1 && (
|
||||
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
|
||||
<button
|
||||
onClick={toggleSubtotals}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<ArrowUpDown size={13} />
|
||||
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<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)]">
|
||||
{rowDims.map((dim) => (
|
||||
<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)] bg-[var(--card)]">
|
||||
{colDims.length > 0 ? (measures.length > 1 ? `${colLabel(colVal)} — ${measureLabel(m)}` : colLabel(colVal)) : measureLabel(m)}
|
||||
</th>
|
||||
))
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rowDims.length === 0 ? (
|
||||
<tr className="border-b border-[var(--border)]/50">
|
||||
{colValues.map((colVal) =>
|
||||
measures.map((m) => (
|
||||
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
|
||||
</td>
|
||||
))
|
||||
)}
|
||||
</tr>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<GroupRows
|
||||
key={group.key}
|
||||
group={group}
|
||||
colValues={colValues}
|
||||
measures={measures}
|
||||
rowDims={rowDims}
|
||||
depth={0}
|
||||
subtotalsOnTop={subtotalsOnTop}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{/* Grand total */}
|
||||
<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-3 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
|
||||
</td>
|
||||
))
|
||||
)}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRows({
|
||||
group,
|
||||
colValues,
|
||||
measures,
|
||||
rowDims,
|
||||
depth,
|
||||
subtotalsOnTop,
|
||||
}: {
|
||||
group: GroupNode;
|
||||
colValues: string[];
|
||||
measures: string[];
|
||||
rowDims: string[];
|
||||
depth: number;
|
||||
subtotalsOnTop: boolean;
|
||||
}) {
|
||||
const isLeafLevel = depth === rowDims.length - 1;
|
||||
const subtotals = computeSubtotals(group.pivotedRows, measures, colValues);
|
||||
|
||||
const subtotalRow = rowDims.length > 1 && !isLeafLevel ? (
|
||||
<tr className="bg-[var(--muted)]/30 font-semibold border-b border-[var(--border)]/50">
|
||||
<td className="px-3 py-1.5" style={{ paddingLeft: `${depth * 16 + 12}px` }}>
|
||||
{group.label}
|
||||
</td>
|
||||
{depth < rowDims.length - 1 && <td colSpan={rowDims.length - depth - 1} />}
|
||||
{colValues.map((colVal) =>
|
||||
measures.map((m) => (
|
||||
<td key={`sub-${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(subtotals[colVal]?.[m] || 0)}
|
||||
</td>
|
||||
))
|
||||
)}
|
||||
</tr>
|
||||
) : null;
|
||||
|
||||
if (isLeafLevel) {
|
||||
// Render one table row per pivoted row (already deduplicated by row keys)
|
||||
return (
|
||||
<>
|
||||
{group.pivotedRows.map((pRow, i) => (
|
||||
<tr key={i} className="border-b border-[var(--border)]/50">
|
||||
{rowDims.map((dim, di) => (
|
||||
<td
|
||||
key={dim}
|
||||
className="px-3 py-1.5"
|
||||
style={di === 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined}
|
||||
>
|
||||
{pRow.rowKeys[dim] || ""}
|
||||
</td>
|
||||
))}
|
||||
{colValues.map((colVal) =>
|
||||
measures.map((m) => (
|
||||
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(pRow.cells[colVal]?.[m] || 0)}
|
||||
</td>
|
||||
))
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const childContent = group.children.map((child) => (
|
||||
<GroupRows
|
||||
key={child.key}
|
||||
group={child}
|
||||
colValues={colValues}
|
||||
measures={measures}
|
||||
rowDims={rowDims}
|
||||
depth={depth + 1}
|
||||
subtotalsOnTop={subtotalsOnTop}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{subtotalsOnTop && subtotalRow}
|
||||
{childContent}
|
||||
{!subtotalsOnTop && subtotalRow}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { BarChart, Bar, XAxis, YAxis, Cell, ReferenceLine, Tooltip, ResponsiveContainer } from "recharts";
|
||||
import type { HighlightMover } from "../../shared/types";
|
||||
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
|
||||
|
||||
export interface HighlightsTopMoversChartProps {
|
||||
movers: HighlightMover[];
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number, language: string): string {
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export default function HighlightsTopMoversChart({ movers }: HighlightsTopMoversChartProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
if (movers.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
|
||||
{t("reports.empty.noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const chartData = movers
|
||||
.map((m, i) => ({
|
||||
name: m.categoryName,
|
||||
color: m.categoryColor,
|
||||
delta: m.deltaAbs,
|
||||
index: i,
|
||||
}))
|
||||
.sort((a, b) => a.delta - b.delta);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
|
||||
<ResponsiveContainer width="100%" height={Math.max(200, chartData.length * 36 + 40)}>
|
||||
<BarChart data={chartData} layout="vertical" margin={{ top: 10, right: 20, bottom: 10, left: 10 }}>
|
||||
<ChartPatternDefs
|
||||
prefix="highlights-movers"
|
||||
categories={chartData.map((d) => ({ color: d.color, index: d.index }))}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
tickFormatter={(v) => formatCurrency(v, i18n.language)}
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={11}
|
||||
/>
|
||||
<YAxis type="category" dataKey="name" width={120} stroke="var(--muted-foreground)" fontSize={11} />
|
||||
<ReferenceLine x={0} stroke="var(--border)" />
|
||||
<Tooltip
|
||||
formatter={(value) =>
|
||||
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
|
||||
}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.5rem",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="delta">
|
||||
{chartData.map((entry) => (
|
||||
<Cell
|
||||
key={entry.name}
|
||||
fill={getPatternFill("highlights-movers", entry.index, entry.color)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { HighlightMover } from "../../shared/types";
|
||||
|
||||
type SortKey = "categoryName" | "previous" | "current" | "deltaAbs" | "deltaPct";
|
||||
|
||||
export interface HighlightsTopMoversTableProps {
|
||||
movers: HighlightMover[];
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number, language: string): string {
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatSignedCurrency(amount: number, language: string): string {
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
signDisplay: "always",
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatPct(pct: number | null, language: string): string {
|
||||
if (pct === null) return "—";
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
signDisplay: "always",
|
||||
}).format(pct / 100);
|
||||
}
|
||||
|
||||
export default function HighlightsTopMoversTable({ movers }: HighlightsTopMoversTableProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [sortKey, setSortKey] = useState<SortKey>("deltaAbs");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
|
||||
const sorted = [...movers].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (sortKey) {
|
||||
case "categoryName":
|
||||
cmp = a.categoryName.localeCompare(b.categoryName);
|
||||
break;
|
||||
case "previous":
|
||||
cmp = a.previousAmount - b.previousAmount;
|
||||
break;
|
||||
case "current":
|
||||
cmp = a.currentAmount - b.currentAmount;
|
||||
break;
|
||||
case "deltaAbs":
|
||||
cmp = Math.abs(a.deltaAbs) - Math.abs(b.deltaAbs);
|
||||
break;
|
||||
case "deltaPct":
|
||||
cmp = (a.deltaPct ?? 0) - (b.deltaPct ?? 0);
|
||||
break;
|
||||
}
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir("desc");
|
||||
}
|
||||
}
|
||||
|
||||
const headerCell = (key: SortKey, label: string, align: "left" | "right") => (
|
||||
<th
|
||||
onClick={() => toggleSort(key)}
|
||||
className={`${align === "right" ? "text-right" : "text-left"} px-3 py-2 font-medium text-[var(--muted-foreground)] cursor-pointer hover:text-[var(--foreground)] select-none`}
|
||||
>
|
||||
{label}
|
||||
{sortKey === key && <span className="ml-1">{sortDir === "asc" ? "▲" : "▼"}</span>}
|
||||
</th>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border)]">
|
||||
{headerCell("categoryName", t("reports.highlights.category"), "left")}
|
||||
{headerCell("previous", t("reports.highlights.previousAmount"), "right")}
|
||||
{headerCell("current", t("reports.highlights.currentAmount"), "right")}
|
||||
{headerCell("deltaAbs", t("reports.highlights.variationAbs"), "right")}
|
||||
{headerCell("deltaPct", t("reports.highlights.variationPct"), "right")}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
|
||||
{t("reports.empty.noData")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sorted.map((mover) => (
|
||||
<tr
|
||||
key={`${mover.categoryId ?? "uncat"}-${mover.categoryName}`}
|
||||
className="border-b border-[var(--border)] last:border-0 hover:bg-[var(--muted)]/40"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: mover.categoryColor }}
|
||||
/>
|
||||
{mover.categoryName}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{formatCurrency(mover.previousAmount, i18n.language)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{formatCurrency(mover.currentAmount, i18n.language)}
|
||||
</td>
|
||||
<td
|
||||
className="px-3 py-2 text-right tabular-nums font-medium"
|
||||
style={{
|
||||
color:
|
||||
mover.deltaAbs >= 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)",
|
||||
}}
|
||||
>
|
||||
{formatSignedCurrency(mover.deltaAbs, i18n.language)}
|
||||
</td>
|
||||
<td
|
||||
className="px-3 py-2 text-right tabular-nums"
|
||||
style={{
|
||||
color:
|
||||
mover.deltaAbs >= 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)",
|
||||
}}
|
||||
>
|
||||
{formatPct(mover.deltaPct, i18n.language)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import type { RecentTransaction } from "../../shared/types";
|
||||
|
||||
export interface HighlightsTopTransactionsListProps {
|
||||
transactions: RecentTransaction[];
|
||||
windowDays: 30 | 60 | 90;
|
||||
onWindowChange: (days: 30 | 60 | 90) => void;
|
||||
onContextMenuRow?: (event: React.MouseEvent, transaction: RecentTransaction) => void;
|
||||
}
|
||||
|
||||
function formatAmount(amount: number, language: string): string {
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export default function HighlightsTopTransactionsList({
|
||||
transactions,
|
||||
windowDays,
|
||||
onWindowChange,
|
||||
onContextMenuRow,
|
||||
}: HighlightsTopTransactionsListProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
|
||||
<h3 className="text-sm font-semibold">{t("reports.highlights.topTransactions")}</h3>
|
||||
<div className="flex gap-1">
|
||||
{([30, 60, 90] as const).map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
onClick={() => onWindowChange(days)}
|
||||
aria-pressed={windowDays === days}
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
windowDays === days
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
{t(`reports.highlights.windowDays${days}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{transactions.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-sm text-[var(--muted-foreground)] italic">
|
||||
{t("reports.empty.noData")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-[var(--border)]">
|
||||
{transactions.map((tx) => (
|
||||
<li
|
||||
key={tx.id}
|
||||
onContextMenu={onContextMenuRow ? (e) => onContextMenuRow(e, tx) : undefined}
|
||||
className="flex items-center gap-3 px-4 py-2 text-sm"
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: tx.category_color ?? "#9ca3af" }}
|
||||
/>
|
||||
<span className="text-[var(--muted-foreground)] tabular-nums flex-shrink-0">
|
||||
{tx.date}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate">{tx.description}</span>
|
||||
{tx.category_name && (
|
||||
<span className="text-xs text-[var(--muted-foreground)] flex-shrink-0">
|
||||
{tx.category_name}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="tabular-nums font-medium flex-shrink-0"
|
||||
style={{ color: tx.amount >= 0 ? "var(--positive, #10b981)" : "var(--foreground)" }}
|
||||
>
|
||||
{formatAmount(tx.amount, i18n.language)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import type { HighlightsData } from "../../shared/types";
|
||||
import HubNetBalanceTile from "./HubNetBalanceTile";
|
||||
import HubTopMoversTile from "./HubTopMoversTile";
|
||||
import HubTopTransactionsTile from "./HubTopTransactionsTile";
|
||||
|
||||
export interface HubHighlightsPanelProps {
|
||||
data: HighlightsData | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export default function HubHighlightsPanel({ data, isLoading, error }: HubHighlightsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 mb-6 text-center text-[var(--muted-foreground)]">
|
||||
{isLoading ? t("common.loading") : t("reports.empty.noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const series = data.monthlyBalanceSeries.map((m) => m.netBalance);
|
||||
|
||||
return (
|
||||
<section className={`mb-6 ${isLoading ? "opacity-60" : ""}`} aria-busy={isLoading}>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
|
||||
{t("reports.hub.highlights")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<HubNetBalanceTile
|
||||
label={t("reports.highlights.netBalanceCurrent")}
|
||||
amount={data.netBalanceCurrent}
|
||||
series={series}
|
||||
/>
|
||||
<HubNetBalanceTile
|
||||
label={t("reports.highlights.netBalanceYtd")}
|
||||
amount={data.netBalanceYtd}
|
||||
series={series}
|
||||
/>
|
||||
<HubTopMoversTile movers={data.topMovers} />
|
||||
<HubTopTransactionsTile transactions={data.topTransactions} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import Sparkline from "./Sparkline";
|
||||
|
||||
export interface HubNetBalanceTileProps {
|
||||
label: string;
|
||||
amount: number;
|
||||
series: number[];
|
||||
}
|
||||
|
||||
function formatSigned(amount: number, language: string): string {
|
||||
const formatted = new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
signDisplay: "always",
|
||||
}).format(amount);
|
||||
return formatted;
|
||||
}
|
||||
|
||||
export default function HubNetBalanceTile({ label, amount, series }: HubNetBalanceTileProps) {
|
||||
const { i18n } = useTranslation();
|
||||
const positive = amount >= 0;
|
||||
const color = positive ? "var(--positive, #10b981)" : "var(--negative, #ef4444)";
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-2">
|
||||
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-2xl font-bold" style={{ color }}>
|
||||
{formatSigned(amount, i18n.language)}
|
||||
</span>
|
||||
<Sparkline data={series} color={color} height={28} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export interface HubReportNavCardProps {
|
||||
to: string;
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function HubReportNavCard({ to, icon, title, description }: HubReportNavCardProps) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="group bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 flex flex-col gap-2 hover:border-[var(--primary)] hover:shadow-sm transition-all"
|
||||
>
|
||||
<div className="text-[var(--primary)]">{icon}</div>
|
||||
<h3 className="text-base font-semibold text-[var(--foreground)] group-hover:text-[var(--primary)]">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUpRight, ArrowDownRight } from "lucide-react";
|
||||
import type { HighlightMover } from "../../shared/types";
|
||||
|
||||
export interface HubTopMoversTileProps {
|
||||
movers: HighlightMover[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number, language: string): string {
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
signDisplay: "always",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatPct(pct: number | null, language: string): string {
|
||||
if (pct === null) return "—";
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 0,
|
||||
signDisplay: "always",
|
||||
}).format(pct / 100);
|
||||
}
|
||||
|
||||
export default function HubTopMoversTile({ movers, limit = 3 }: HubTopMoversTileProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [mode, setMode] = useState<"abs" | "pct">("abs");
|
||||
const visible = movers.slice(0, limit);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
|
||||
{t("reports.highlights.topMovers")}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("abs")}
|
||||
aria-pressed={mode === "abs"}
|
||||
className={`text-xs px-2 py-0.5 rounded ${
|
||||
mode === "abs"
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
$
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("pct")}
|
||||
aria-pressed={mode === "pct"}
|
||||
className={`text-xs px-2 py-0.5 rounded ${
|
||||
mode === "pct"
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
%
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{visible.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)] italic">{t("reports.empty.noData")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{visible.map((mover) => {
|
||||
const isUp = mover.deltaAbs >= 0;
|
||||
const Icon = isUp ? ArrowUpRight : ArrowDownRight;
|
||||
const color = isUp ? "var(--negative, #ef4444)" : "var(--positive, #10b981)";
|
||||
return (
|
||||
<li
|
||||
key={`${mover.categoryId ?? "uncat"}-${mover.categoryName}`}
|
||||
className="flex items-center justify-between gap-2 text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: mover.categoryColor }}
|
||||
/>
|
||||
<span className="truncate">{mover.categoryName}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1 flex-shrink-0" style={{ color }}>
|
||||
<Icon size={14} />
|
||||
<span className="tabular-nums font-medium">
|
||||
{mode === "abs"
|
||||
? formatCurrency(mover.deltaAbs, i18n.language)
|
||||
: formatPct(mover.deltaPct, i18n.language)}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-[10px] text-[var(--muted-foreground)]">
|
||||
{t("reports.highlights.vsLastMonth")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import type { RecentTransaction } from "../../shared/types";
|
||||
|
||||
export interface HubTopTransactionsTileProps {
|
||||
transactions: RecentTransaction[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
function formatAmount(amount: number, language: string): string {
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export default function HubTopTransactionsTile({
|
||||
transactions,
|
||||
limit = 5,
|
||||
}: HubTopTransactionsTileProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const visible = transactions.slice(0, limit);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-2">
|
||||
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
|
||||
{t("reports.highlights.topTransactions")}
|
||||
</span>
|
||||
{visible.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)] italic">{t("reports.empty.noData")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{visible.map((tx) => (
|
||||
<li key={tx.id} className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: tx.category_color ?? "#9ca3af" }}
|
||||
/>
|
||||
<span className="text-[var(--muted-foreground)] tabular-nums flex-shrink-0">
|
||||
{tx.date}
|
||||
</span>
|
||||
<span className="truncate flex-1 min-w-0">{tx.description}</span>
|
||||
<span
|
||||
className="tabular-nums font-medium flex-shrink-0"
|
||||
style={{ color: tx.amount >= 0 ? "var(--positive, #10b981)" : "var(--foreground)" }}
|
||||
>
|
||||
{formatAmount(tx.amount, i18n.language)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ export default function MonthlyTrendsTable({ data }: MonthlyTrendsTableProps) {
|
|||
<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.month")}
|
||||
{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")}
|
||||
|
|
|
|||
166
src/components/reports/ReportFilterPanel.tsx
Normal file
166
src/components/reports/ReportFilterPanel.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Filter, Search } from "lucide-react";
|
||||
import type { ImportSource } from "../../shared/types";
|
||||
import type { CategoryTypeFilter } from "../../hooks/useReports";
|
||||
|
||||
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;
|
||||
categoryType?: CategoryTypeFilter;
|
||||
onCategoryTypeChange?: (type: CategoryTypeFilter) => void;
|
||||
}
|
||||
|
||||
export default function ReportFilterPanel({
|
||||
categories,
|
||||
hiddenCategories,
|
||||
onToggleHidden,
|
||||
onShowAll,
|
||||
sources,
|
||||
selectedSourceId,
|
||||
onSourceChange,
|
||||
categoryType,
|
||||
onCategoryTypeChange,
|
||||
}: 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>
|
||||
)}
|
||||
|
||||
{/* Type filter */}
|
||||
{onCategoryTypeChange && (
|
||||
<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("categories.type")}
|
||||
</div>
|
||||
<div className="border-t border-[var(--border)] px-2 py-2">
|
||||
<select
|
||||
value={categoryType ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
const valid: CategoryTypeFilter[] = ["expense", "income", "transfer"];
|
||||
onCategoryTypeChange(valid.includes(v as CategoryTypeFilter) ? (v as CategoryTypeFilter) : 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("reports.filters.allTypes")}</option>
|
||||
<option value="expense">{t("categories.expense")}</option>
|
||||
<option value="income">{t("categories.income")}</option>
|
||||
<option value="transfer">{t("categories.transfer")}</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { LineChart, Line, ResponsiveContainer, YAxis } from "recharts";
|
||||
|
||||
export interface SparklineProps {
|
||||
data: number[];
|
||||
color?: string;
|
||||
width?: number | `${number}%`;
|
||||
height?: number;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export default function Sparkline({
|
||||
data,
|
||||
color = "var(--primary)",
|
||||
width = "100%",
|
||||
height = 32,
|
||||
strokeWidth = 1.5,
|
||||
}: SparklineProps) {
|
||||
if (data.length === 0) {
|
||||
return <div style={{ width, height }} />;
|
||||
}
|
||||
|
||||
const chartData = data.map((value, index) => ({ index, value }));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width={width} height={height}>
|
||||
<LineChart data={chartData} margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
|
||||
<YAxis hide domain={["dataMin", "dataMax"]} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { readViewMode } from "./ViewModeToggle";
|
||||
|
||||
describe("readViewMode", () => {
|
||||
const store = new Map<string, string>();
|
||||
const mockLocalStorage = {
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
store.delete(key);
|
||||
}),
|
||||
clear: vi.fn(() => store.clear()),
|
||||
key: vi.fn(),
|
||||
length: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
vi.stubGlobal("localStorage", mockLocalStorage);
|
||||
});
|
||||
|
||||
it("returns fallback when key is missing", () => {
|
||||
expect(readViewMode("reports-viewmode-highlights")).toBe("chart");
|
||||
});
|
||||
|
||||
it("returns 'chart' when stored value is 'chart'", () => {
|
||||
store.set("reports-viewmode-highlights", "chart");
|
||||
expect(readViewMode("reports-viewmode-highlights")).toBe("chart");
|
||||
});
|
||||
|
||||
it("returns 'table' when stored value is 'table'", () => {
|
||||
store.set("reports-viewmode-highlights", "table");
|
||||
expect(readViewMode("reports-viewmode-highlights")).toBe("table");
|
||||
});
|
||||
|
||||
it("ignores invalid stored values and returns fallback", () => {
|
||||
store.set("reports-viewmode-highlights", "bogus");
|
||||
expect(readViewMode("reports-viewmode-highlights", "table")).toBe("table");
|
||||
});
|
||||
|
||||
it("respects custom fallback when provided", () => {
|
||||
expect(readViewMode("reports-viewmode-compare", "table")).toBe("table");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BarChart3, Table } from "lucide-react";
|
||||
|
||||
export type ViewMode = "chart" | "table";
|
||||
|
||||
export interface ViewModeToggleProps {
|
||||
value: ViewMode;
|
||||
onChange: (mode: ViewMode) => void;
|
||||
/** localStorage key used to persist the preference per section. */
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
export function readViewMode(storageKey: string, fallback: ViewMode = "chart"): ViewMode {
|
||||
if (typeof localStorage === "undefined") return fallback;
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
return saved === "chart" || saved === "table" ? saved : fallback;
|
||||
}
|
||||
|
||||
export default function ViewModeToggle({ value, onChange, storageKey }: ViewModeToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (storageKey) localStorage.setItem(storageKey, value);
|
||||
}, [value, storageKey]);
|
||||
|
||||
const options: { mode: ViewMode; icon: React.ReactNode; label: string }[] = [
|
||||
{ mode: "chart", icon: <BarChart3 size={14} />, label: t("reports.viewMode.chart") },
|
||||
{ mode: "table", icon: <Table size={14} />, label: t("reports.viewMode.table") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="inline-flex gap-1" role="group" aria-label={t("reports.viewMode.chart")}>
|
||||
{options.map(({ mode, icon, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => onChange(mode)}
|
||||
aria-pressed={value === mode}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
value === mode
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Target } from "lucide-react";
|
||||
import type { CartesBudgetAdherence } from "../../../shared/types";
|
||||
|
||||
export interface BudgetAdherenceCardProps {
|
||||
adherence: CartesBudgetAdherence;
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number, language: string): string {
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatPct(pct: number | null, language: string): string {
|
||||
if (pct === null) return "—";
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 0,
|
||||
signDisplay: "always",
|
||||
}).format(pct / 100);
|
||||
}
|
||||
|
||||
export default function BudgetAdherenceCard({ adherence }: BudgetAdherenceCardProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { categoriesInTarget, categoriesTotal, worstOverruns } = adherence;
|
||||
const score = categoriesTotal === 0 ? null : (categoriesInTarget / categoriesTotal) * 100;
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target size={16} className="text-[var(--primary)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--foreground)]">
|
||||
{t("reports.cartes.budgetAdherenceTitle")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{categoriesTotal === 0 ? (
|
||||
<div className="text-xs italic text-[var(--muted-foreground)] py-2">
|
||||
{t("reports.cartes.budgetAdherenceEmpty")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<div className="text-2xl font-bold tabular-nums text-[var(--foreground)]">
|
||||
{categoriesInTarget}
|
||||
<span className="text-sm text-[var(--muted-foreground)] font-normal">
|
||||
{" / "}
|
||||
{categoriesTotal}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{t("reports.cartes.budgetAdherenceSubtitle", {
|
||||
score:
|
||||
score !== null
|
||||
? new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(score / 100)
|
||||
: "—",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{worstOverruns.length > 0 && (
|
||||
<div className="flex flex-col gap-2 pt-2 border-t border-[var(--border)]">
|
||||
<div className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
{t("reports.cartes.budgetAdherenceWorst")}
|
||||
</div>
|
||||
{worstOverruns.map((r) => {
|
||||
const progressPct = r.budget > 0 ? Math.min((r.actual / r.budget) * 100, 200) : 0;
|
||||
return (
|
||||
<div key={r.categoryId} className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: r.categoryColor }}
|
||||
/>
|
||||
<span className="truncate text-[var(--foreground)]">{r.categoryName}</span>
|
||||
</span>
|
||||
<span className="tabular-nums text-[var(--muted-foreground)]">
|
||||
{formatCurrency(r.actual, i18n.language)}
|
||||
{" / "}
|
||||
{formatCurrency(r.budget, i18n.language)}
|
||||
<span className="text-[var(--negative)] ml-1 font-medium">
|
||||
{formatPct(r.overrunPct, i18n.language)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-[var(--muted)] overflow-hidden"
|
||||
aria-hidden
|
||||
>
|
||||
<div
|
||||
className="h-full bg-[var(--negative)]"
|
||||
style={{ width: `${Math.min(progressPct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ComposedChart,
|
||||
Bar,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
Legend,
|
||||
CartesianGrid,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import type { CartesMonthFlow } from "../../../shared/types";
|
||||
|
||||
export interface IncomeExpenseOverlayChartProps {
|
||||
flow: CartesMonthFlow[];
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number, language: string): string {
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatMonthShort(month: string, language: string): string {
|
||||
const [y, m] = month.split("-").map(Number);
|
||||
if (!Number.isFinite(y) || !Number.isFinite(m)) return month;
|
||||
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
month: "short",
|
||||
year: "2-digit",
|
||||
}).format(new Date(y, m - 1, 1));
|
||||
}
|
||||
|
||||
export default function IncomeExpenseOverlayChart({ flow }: IncomeExpenseOverlayChartProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
if (flow.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
|
||||
{t("reports.empty.noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const data = flow.map((p) => ({
|
||||
...p,
|
||||
label: formatMonthShort(p.month, i18n.language),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
|
||||
<div className="text-sm font-medium text-[var(--foreground)] mb-3">
|
||||
{t("reports.cartes.flowChartTitle")}
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<ComposedChart data={data} margin={{ top: 10, right: 20, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
||||
<XAxis dataKey="label" stroke="var(--muted-foreground)" fontSize={11} />
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={11}
|
||||
tickFormatter={(v) => formatCurrency(v, i18n.language)}
|
||||
width={80}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) =>
|
||||
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
|
||||
}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.5rem",
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ paddingTop: 8, fontSize: 12, color: "var(--muted-foreground)" }}
|
||||
/>
|
||||
<ReferenceLine y={0} stroke="var(--border)" />
|
||||
<Bar dataKey="income" name={t("reports.cartes.income")} fill="var(--positive)" />
|
||||
<Bar dataKey="expenses" name={t("reports.cartes.expenses")} fill="var(--negative)" />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="net"
|
||||
name={t("reports.cartes.net")}
|
||||
stroke="var(--primary)"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 2 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import KpiSparkline from "./KpiSparkline";
|
||||
import type { CartesKpi, CartesKpiId } from "../../../shared/types";
|
||||
|
||||
export interface KpiCardProps {
|
||||
id: CartesKpiId;
|
||||
title: string;
|
||||
kpi: CartesKpi;
|
||||
format: "currency" | "percent";
|
||||
/** When true, positive deltas are rendered in red (e.g. rising expenses). */
|
||||
deltaIsBadWhenUp?: boolean;
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number, language: string): string {
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatPercent(value: number, language: string, signed = false): string {
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
signDisplay: signed ? "always" : "auto",
|
||||
}).format(value / 100);
|
||||
}
|
||||
|
||||
function formatValue(value: number, format: "currency" | "percent", language: string): string {
|
||||
return format === "currency" ? formatCurrency(value, language) : formatPercent(value, language);
|
||||
}
|
||||
|
||||
function formatDeltaAbs(
|
||||
value: number,
|
||||
format: "currency" | "percent",
|
||||
language: string,
|
||||
): string {
|
||||
if (format === "currency") {
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
maximumFractionDigits: 0,
|
||||
signDisplay: "always",
|
||||
}).format(value);
|
||||
}
|
||||
// Savings rate delta in percentage points — not a % of %
|
||||
const formatted = new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
maximumFractionDigits: 1,
|
||||
signDisplay: "always",
|
||||
}).format(value);
|
||||
return `${formatted} pt`;
|
||||
}
|
||||
|
||||
interface DeltaBadgeProps {
|
||||
abs: number | null;
|
||||
pct: number | null;
|
||||
label: string;
|
||||
format: "currency" | "percent";
|
||||
language: string;
|
||||
deltaIsBadWhenUp: boolean;
|
||||
}
|
||||
|
||||
function DeltaBadge({ abs, pct, label, format, language, deltaIsBadWhenUp }: DeltaBadgeProps) {
|
||||
if (abs === null) {
|
||||
return (
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--muted-foreground)] italic">—</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const isUp = abs >= 0;
|
||||
const isBad = deltaIsBadWhenUp ? isUp : !isUp;
|
||||
// Treat near-zero as neutral
|
||||
const isNeutral = abs === 0;
|
||||
const colorClass = isNeutral
|
||||
? "text-[var(--muted-foreground)]"
|
||||
: isBad
|
||||
? "text-[var(--negative)]"
|
||||
: "text-[var(--positive)]";
|
||||
const absText = formatDeltaAbs(abs, format, language);
|
||||
const pctText = pct === null ? "" : ` (${formatPercent(pct, language, true)})`;
|
||||
return (
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
{label}
|
||||
</span>
|
||||
<span className={`text-xs font-medium tabular-nums ${colorClass}`}>
|
||||
{absText}
|
||||
{pctText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KpiCard({
|
||||
id,
|
||||
title,
|
||||
kpi,
|
||||
format,
|
||||
deltaIsBadWhenUp = false,
|
||||
}: KpiCardProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const language = i18n.language;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-kpi={id}
|
||||
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3"
|
||||
>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">{title}</div>
|
||||
<div className="text-2xl font-bold tabular-nums text-[var(--foreground)]">
|
||||
{formatValue(kpi.current, format, language)}
|
||||
</div>
|
||||
<KpiSparkline data={kpi.sparkline} />
|
||||
<div className="flex items-start justify-between gap-2 pt-1 border-t border-[var(--border)]">
|
||||
<DeltaBadge
|
||||
abs={kpi.deltaMoMAbs}
|
||||
pct={kpi.deltaMoMPct}
|
||||
label={t("reports.cartes.deltaMoMLabel")}
|
||||
format={format}
|
||||
language={language}
|
||||
deltaIsBadWhenUp={deltaIsBadWhenUp}
|
||||
/>
|
||||
<DeltaBadge
|
||||
abs={kpi.deltaYoYAbs}
|
||||
pct={kpi.deltaYoYPct}
|
||||
label={t("reports.cartes.deltaYoYLabel")}
|
||||
format={format}
|
||||
language={language}
|
||||
deltaIsBadWhenUp={deltaIsBadWhenUp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { LineChart, Line, ResponsiveContainer, YAxis, ReferenceDot } from "recharts";
|
||||
import type { CartesSparklinePoint } from "../../../shared/types";
|
||||
|
||||
export interface KpiSparklineProps {
|
||||
data: CartesSparklinePoint[];
|
||||
color?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact line chart with the reference month (the last point) highlighted
|
||||
* by a filled dot. Rendered inside the KPI cards on the Cartes page.
|
||||
*/
|
||||
export default function KpiSparkline({
|
||||
data,
|
||||
color = "var(--primary)",
|
||||
height = 40,
|
||||
}: KpiSparklineProps) {
|
||||
if (data.length === 0) {
|
||||
return <div style={{ width: "100%", height }} />;
|
||||
}
|
||||
|
||||
const chartData = data.map((p, index) => ({ index, value: p.value, month: p.month }));
|
||||
const lastIndex = chartData.length - 1;
|
||||
const lastValue = chartData[lastIndex]?.value ?? 0;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<LineChart data={chartData} margin={{ top: 4, right: 6, bottom: 2, left: 2 }}>
|
||||
<YAxis hide domain={["dataMin", "dataMax"]} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={color}
|
||||
strokeWidth={1.75}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<ReferenceDot
|
||||
x={lastIndex}
|
||||
y={lastValue}
|
||||
r={3.5}
|
||||
fill={color}
|
||||
stroke="var(--card)"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import type { CartesSeasonality } from "../../../shared/types";
|
||||
|
||||
export interface SeasonalityCardProps {
|
||||
seasonality: CartesSeasonality;
|
||||
referenceYear: number;
|
||||
referenceMonth: number;
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number, language: string): string {
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatPct(pct: number, language: string, signed = true): string {
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
signDisplay: signed ? "always" : "auto",
|
||||
}).format(pct / 100);
|
||||
}
|
||||
|
||||
function formatMonthYear(year: number, month: number, language: string): string {
|
||||
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(new Date(year, month - 1, 1));
|
||||
}
|
||||
|
||||
export default function SeasonalityCard({
|
||||
seasonality,
|
||||
referenceYear,
|
||||
referenceMonth,
|
||||
}: SeasonalityCardProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const language = i18n.language;
|
||||
const { referenceAmount, historicalYears, historicalAverage, deviationPct } = seasonality;
|
||||
|
||||
const refLabel = formatMonthYear(referenceYear, referenceMonth, language);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarClock size={16} className="text-[var(--primary)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--foreground)]">
|
||||
{t("reports.cartes.seasonalityTitle")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{historicalYears.length === 0 ? (
|
||||
<div className="text-xs italic text-[var(--muted-foreground)] py-2">
|
||||
{t("reports.cartes.seasonalityEmpty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-xs text-[var(--muted-foreground)]">{refLabel}</span>
|
||||
<span className="text-lg font-bold tabular-nums text-[var(--foreground)]">
|
||||
{formatCurrency(referenceAmount, language)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
{historicalYears.map((y) => (
|
||||
<div
|
||||
key={y.year}
|
||||
className="flex items-center justify-between text-[var(--muted-foreground)]"
|
||||
>
|
||||
<span>{y.year}</span>
|
||||
<span className="tabular-nums">{formatCurrency(y.amount, language)}</span>
|
||||
</div>
|
||||
))}
|
||||
{historicalAverage !== null && (
|
||||
<div className="flex items-center justify-between border-t border-[var(--border)] pt-1 mt-1 text-[var(--foreground)]">
|
||||
<span>{t("reports.cartes.seasonalityAverage")}</span>
|
||||
<span className="tabular-nums font-medium">
|
||||
{formatCurrency(historicalAverage, language)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deviationPct !== null && (
|
||||
<div
|
||||
className={`text-xs font-medium ${
|
||||
deviationPct > 5
|
||||
? "text-[var(--negative)]"
|
||||
: deviationPct < -5
|
||||
? "text-[var(--positive)]"
|
||||
: "text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
>
|
||||
{t("reports.cartes.seasonalityDeviation", {
|
||||
pct: formatPct(deviationPct, language),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { TrendingUp, TrendingDown } from "lucide-react";
|
||||
import type { CartesTopMover } from "../../../shared/types";
|
||||
|
||||
export interface TopMoversListProps {
|
||||
movers: CartesTopMover[];
|
||||
direction: "up" | "down";
|
||||
}
|
||||
|
||||
function formatSignedCurrency(amount: number, language: string): string {
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
maximumFractionDigits: 0,
|
||||
signDisplay: "always",
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatPct(pct: number | null, language: string): string {
|
||||
if (pct === null) return "—";
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
signDisplay: "always",
|
||||
}).format(pct / 100);
|
||||
}
|
||||
|
||||
function categoryHref(categoryId: number | null): string {
|
||||
if (categoryId === null) return "/transactions";
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("cat", String(categoryId));
|
||||
return `/reports/category?${params.toString()}`;
|
||||
}
|
||||
|
||||
export default function TopMoversList({ movers, direction }: TopMoversListProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const title =
|
||||
direction === "up"
|
||||
? t("reports.cartes.topMoversUp")
|
||||
: t("reports.cartes.topMoversDown");
|
||||
const Icon = direction === "up" ? TrendingUp : TrendingDown;
|
||||
const accentClass = direction === "up" ? "text-[var(--negative)]" : "text-[var(--positive)]";
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={16} className={accentClass} />
|
||||
<h3 className="text-sm font-medium text-[var(--foreground)]">{title}</h3>
|
||||
</div>
|
||||
{movers.length === 0 ? (
|
||||
<div className="text-xs italic text-[var(--muted-foreground)] py-2">
|
||||
{t("reports.empty.noData")}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{movers.map((m) => (
|
||||
<li key={`${m.categoryId ?? "uncat"}-${m.categoryName}`}>
|
||||
<Link
|
||||
to={categoryHref(m.categoryId)}
|
||||
className="flex items-center justify-between gap-3 px-2 py-1.5 rounded-md hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: m.categoryColor }}
|
||||
/>
|
||||
<span className="truncate text-sm text-[var(--foreground)]">
|
||||
{m.categoryName}
|
||||
</span>
|
||||
</span>
|
||||
<span className={`text-xs font-medium tabular-nums ${accentClass}`}>
|
||||
{formatSignedCurrency(m.deltaAbs, i18n.language)}
|
||||
<span className="text-[var(--muted-foreground)] ml-1">
|
||||
{formatPct(m.deltaPct, i18n.language)}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Globe } from "lucide-react";
|
||||
|
||||
interface FeedbackConsentDialogProps {
|
||||
onAccept: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function FeedbackConsentDialog({
|
||||
onAccept,
|
||||
onCancel,
|
||||
}: FeedbackConsentDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [onCancel]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[210] flex items-center justify-center bg-black/50"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onCancel();
|
||||
}}
|
||||
>
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-2xl w-full max-w-md mx-4">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border)]">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Globe size={18} />
|
||||
{t("feedback.consent.title")}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
aria-label={t("feedback.dialog.cancel")}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-3 text-sm text-[var(--muted-foreground)]">
|
||||
<p>{t("feedback.consent.body")}</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 px-6 py-4 border-t border-[var(--border)]">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors"
|
||||
>
|
||||
{t("feedback.dialog.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={onAccept}
|
||||
className="px-4 py-2 text-sm bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t("feedback.consent.accept")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { X, MessageSquarePlus, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import { useFeedback } from "../../hooks/useFeedback";
|
||||
import { useAuth } from "../../hooks/useAuth";
|
||||
import { getRecentErrorLogs } from "../../services/logService";
|
||||
import {
|
||||
getFeedbackUserAgent,
|
||||
type FeedbackContext,
|
||||
} from "../../services/feedbackService";
|
||||
|
||||
const MAX_CONTENT_LENGTH = 2000;
|
||||
const LOGS_SUFFIX_MAX = 800;
|
||||
const RECENT_ERROR_LOGS_N = 20;
|
||||
const AUTO_CLOSE_DELAY_MS = 2000;
|
||||
|
||||
interface FeedbackDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function FeedbackDialog({ onClose }: FeedbackDialogProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const location = useLocation();
|
||||
const { state: authState } = useAuth();
|
||||
const { state: feedbackState, submit, reset } = useFeedback();
|
||||
|
||||
const [content, setContent] = useState("");
|
||||
const [includeContext, setIncludeContext] = useState(false);
|
||||
const [includeLogs, setIncludeLogs] = useState(false);
|
||||
const [identify, setIdentify] = useState(false);
|
||||
|
||||
const isAuthenticated = authState.status === "authenticated";
|
||||
const userEmail = authState.account?.email ?? null;
|
||||
|
||||
const trimmed = content.trim();
|
||||
const isSending = feedbackState.status === "sending";
|
||||
const isSuccess = feedbackState.status === "success";
|
||||
const canSubmit = trimmed.length > 0 && !isSending && !isSuccess;
|
||||
|
||||
useEffect(() => {
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && !isSending) onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose, isSending]);
|
||||
|
||||
// Auto-close after success
|
||||
useEffect(() => {
|
||||
if (!isSuccess) return;
|
||||
const timer = setTimeout(() => {
|
||||
onClose();
|
||||
reset();
|
||||
}, AUTO_CLOSE_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isSuccess, onClose, reset]);
|
||||
|
||||
const errorMessage = useMemo(() => {
|
||||
if (feedbackState.status !== "error" || !feedbackState.errorCode) return null;
|
||||
switch (feedbackState.errorCode) {
|
||||
case "rate_limit":
|
||||
return t("feedback.toast.error.429");
|
||||
case "invalid":
|
||||
return t("feedback.toast.error.400");
|
||||
case "network_error":
|
||||
case "server_error":
|
||||
default:
|
||||
return t("feedback.toast.error.generic");
|
||||
}
|
||||
}, [feedbackState, t]);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit) return;
|
||||
|
||||
// Compose content with optional logs suffix, keeping total ≤ 2000 chars
|
||||
let body = trimmed;
|
||||
if (includeLogs) {
|
||||
const rawLogs = getRecentErrorLogs(RECENT_ERROR_LOGS_N);
|
||||
if (rawLogs.length > 0) {
|
||||
const trimmedLogs = rawLogs.slice(-LOGS_SUFFIX_MAX);
|
||||
const suffix = `\n\n---\n${t("feedback.logsHeading")}\n${trimmedLogs}`;
|
||||
const available = MAX_CONTENT_LENGTH - body.length;
|
||||
if (available > suffix.length) {
|
||||
body = body + suffix;
|
||||
} else if (available > 50) {
|
||||
body = body + suffix.slice(0, available);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compose context if opted in
|
||||
let context: FeedbackContext | undefined;
|
||||
if (includeContext) {
|
||||
let userAgent = "Simpl'Résultat";
|
||||
try {
|
||||
userAgent = await getFeedbackUserAgent();
|
||||
} catch {
|
||||
// fall back to a basic string if the Rust helper fails
|
||||
}
|
||||
context = {
|
||||
page: location.pathname,
|
||||
locale: i18n.language,
|
||||
theme: document.documentElement.classList.contains("dark") ? "dark" : "light",
|
||||
viewport: `${window.innerWidth}x${window.innerHeight}`,
|
||||
userAgent,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const userId = identify && isAuthenticated ? userEmail : null;
|
||||
|
||||
await submit({ content: body, userId, context });
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && !isSending) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-2xl w-full max-w-lg mx-4">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border)]">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<MessageSquarePlus size={18} />
|
||||
{t("feedback.dialog.title")}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSending}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] disabled:opacity-50"
|
||||
aria-label={t("feedback.dialog.cancel")}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
{isSuccess ? (
|
||||
<div className="flex items-center gap-2 text-[var(--positive)] py-8 justify-center">
|
||||
<CheckCircle size={20} />
|
||||
<span>{t("feedback.toast.success")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) =>
|
||||
setContent(e.target.value.slice(0, MAX_CONTENT_LENGTH))
|
||||
}
|
||||
placeholder={t("feedback.dialog.placeholder")}
|
||||
rows={6}
|
||||
disabled={isSending}
|
||||
className="w-full px-3 py-2 rounded-lg bg-[var(--background)] border border-[var(--border)] text-sm resize-y focus:outline-none focus:ring-2 focus:ring-[var(--primary)] disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex justify-end text-xs text-[var(--muted-foreground)]">
|
||||
{content.length}/{MAX_CONTENT_LENGTH}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeContext}
|
||||
onChange={(e) => setIncludeContext(e.target.checked)}
|
||||
disabled={isSending}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span>{t("feedback.checkbox.context")}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeLogs}
|
||||
onChange={(e) => setIncludeLogs(e.target.checked)}
|
||||
disabled={isSending}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span>{t("feedback.checkbox.logs")}</span>
|
||||
</label>
|
||||
{isAuthenticated && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={identify}
|
||||
onChange={(e) => setIdentify(e.target.checked)}
|
||||
disabled={isSending}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span>
|
||||
{t("feedback.checkbox.identify")}
|
||||
{userEmail && (
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{" "}
|
||||
({userEmail})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
|
||||
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isSuccess && (
|
||||
<div className="flex justify-end gap-2 px-6 py-4 border-t border-[var(--border)]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSending}
|
||||
className="px-4 py-2 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t("feedback.dialog.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="px-4 py-2 text-sm bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{isSending
|
||||
? t("feedback.dialog.sending")
|
||||
: t("feedback.dialog.submit")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,37 +1,16 @@
|
|||
import { useState, useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollText, Trash2, Copy, Check, MessageSquarePlus } from "lucide-react";
|
||||
import { ScrollText, Trash2, Copy, Check } from "lucide-react";
|
||||
import { getLogs, clearLogs, subscribe, type LogLevel } from "../../services/logService";
|
||||
import FeedbackDialog from "./FeedbackDialog";
|
||||
import FeedbackConsentDialog from "./FeedbackConsentDialog";
|
||||
|
||||
type Filter = "all" | LogLevel;
|
||||
|
||||
const FEEDBACK_CONSENT_KEY = "feedbackConsentAccepted";
|
||||
|
||||
export default function LogViewerCard() {
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [consentOpen, setConsentOpen] = useState(false);
|
||||
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const openFeedback = () => {
|
||||
const accepted = localStorage.getItem(FEEDBACK_CONSENT_KEY) === "true";
|
||||
if (accepted) {
|
||||
setFeedbackOpen(true);
|
||||
} else {
|
||||
setConsentOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const acceptConsent = () => {
|
||||
localStorage.setItem(FEEDBACK_CONSENT_KEY, "true");
|
||||
setConsentOpen(false);
|
||||
setFeedbackOpen(true);
|
||||
};
|
||||
|
||||
const logs = useSyncExternalStore(subscribe, getLogs, getLogs);
|
||||
|
||||
const filtered = filter === "all" ? logs : logs.filter((l) => l.level === filter);
|
||||
|
|
@ -75,13 +54,6 @@ export default function LogViewerCard() {
|
|||
{t("settings.logs.title")}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={openFeedback}
|
||||
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"
|
||||
>
|
||||
<MessageSquarePlus size={14} />
|
||||
{t("feedback.button")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={filtered.length === 0}
|
||||
|
|
@ -139,14 +111,6 @@ export default function LogViewerCard() {
|
|||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{consentOpen && (
|
||||
<FeedbackConsentDialog
|
||||
onAccept={acceptConsent}
|
||||
onCancel={() => setConsentOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{feedbackOpen && <FeedbackDialog onClose={() => setFeedbackOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShieldAlert, X } from "lucide-react";
|
||||
import { getTokenStoreMode, TokenStoreMode } from "../../services/authService";
|
||||
|
||||
// Per-session dismissal flag. Kept in sessionStorage so the banner
|
||||
// returns on the next app launch if the fallback condition still
|
||||
// holds — this matches the acceptance criteria from issue #81.
|
||||
const DISMISS_KEY = "tokenStoreFallbackBannerDismissed";
|
||||
|
||||
export default function TokenStoreFallbackBanner() {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState<TokenStoreMode | null>(null);
|
||||
const [dismissed, setDismissed] = useState<boolean>(() => {
|
||||
try {
|
||||
return sessionStorage.getItem(DISMISS_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getTokenStoreMode()
|
||||
.then((m) => {
|
||||
if (!cancelled) setMode(m);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setMode(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (mode !== "file" || dismissed) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
try {
|
||||
sessionStorage.setItem(DISMISS_KEY, "1");
|
||||
} catch {
|
||||
// Ignore storage errors — the banner will simply hide for the
|
||||
// remainder of this render cycle via state.
|
||||
}
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 p-4">
|
||||
<ShieldAlert size={20} className="mt-0.5 shrink-0 text-amber-500" />
|
||||
<div className="flex-1 space-y-1 text-sm">
|
||||
<p className="font-semibold text-[var(--foreground)]">
|
||||
{t("account.tokenStore.fallback.title")}
|
||||
</p>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
{t("account.tokenStore.fallback.description")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
aria-label={t("common.close")}
|
||||
className="shrink-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EyeOff, List } from "lucide-react";
|
||||
import ContextMenu from "./ContextMenu";
|
||||
|
||||
export interface ChartContextMenuProps {
|
||||
x: number;
|
||||
|
|
@ -20,25 +20,60 @@ export default function ChartContextMenu({
|
|||
onClose,
|
||||
}: ChartContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// Adjust position to stay within viewport
|
||||
useEffect(() => {
|
||||
if (!menuRef.current) return;
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth) {
|
||||
menuRef.current.style.left = `${x - rect.width}px`;
|
||||
}
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
menuRef.current.style.top = `${y - rect.height}px`;
|
||||
}
|
||||
}, [x, y]);
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
x={x}
|
||||
y={y}
|
||||
header={categoryName}
|
||||
onClose={onClose}
|
||||
items={[
|
||||
{
|
||||
icon: <List size={14} />,
|
||||
label: t("charts.viewTransactions"),
|
||||
onClick: onViewDetails,
|
||||
},
|
||||
{
|
||||
icon: <EyeOff size={14} />,
|
||||
label: t("charts.hideCategory"),
|
||||
onClick: onHide,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[100] min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-[var(--muted-foreground)] truncate border-b border-[var(--border)]">
|
||||
{categoryName}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { onViewDetails(); onClose(); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<List size={14} />
|
||||
{t("charts.viewTransactions")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onHide(); onClose(); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<EyeOff size={14} />
|
||||
{t("charts.hideCategory")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
icon?: ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
header?: ReactNode;
|
||||
items: ContextMenuItem[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ContextMenu({ x, y, header, items, onClose }: ContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuRef.current) return;
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth) {
|
||||
menuRef.current.style.left = `${x - rect.width}px`;
|
||||
}
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
menuRef.current.style.top = `${y - rect.height}px`;
|
||||
}
|
||||
}, [x, y]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[100] min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
{header && (
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-[var(--muted-foreground)] truncate border-b border-[var(--border)]">
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
if (item.disabled) return;
|
||||
item.onClick();
|
||||
onClose();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,7 +21,6 @@ interface TransactionTableProps {
|
|||
onLoadSplitChildren: (parentId: number) => Promise<SplitChild[]>;
|
||||
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
|
||||
onDeleteSplit: (parentId: number) => Promise<void>;
|
||||
onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void;
|
||||
}
|
||||
|
||||
function SortIcon({
|
||||
|
|
@ -51,7 +50,6 @@ export default function TransactionTable({
|
|||
onLoadSplitChildren,
|
||||
onSaveSplit,
|
||||
onDeleteSplit,
|
||||
onRowContextMenu,
|
||||
}: TransactionTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
|
@ -137,7 +135,6 @@ export default function TransactionTable({
|
|||
{rows.map((row) => (
|
||||
<Fragment key={row.id}>
|
||||
<tr
|
||||
onContextMenu={onRowContextMenu ? (e) => onRowContextMenu(e, row) : undefined}
|
||||
className="hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ interface ProfileContextValue {
|
|||
error: string | null;
|
||||
switchProfile: (id: string) => Promise<void>;
|
||||
createProfile: (name: string, color: string, pin?: string) => Promise<void>;
|
||||
updateProfile: (id: string, updates: Partial<Pick<Profile, "name" | "color" | "pin_hash">>) => Promise<void>;
|
||||
updateProfile: (id: string, updates: Partial<Pick<Profile, "name" | "color">>) => Promise<void>;
|
||||
deleteProfile: (id: string) => Promise<void>;
|
||||
setPin: (id: string, pin: string | null) => Promise<void>;
|
||||
connectActiveProfile: () => Promise<void>;
|
||||
|
|
@ -151,7 +151,7 @@ export function ProfileProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
}, [state.config]);
|
||||
|
||||
const updateProfile = useCallback(async (id: string, updates: Partial<Pick<Profile, "name" | "color" | "pin_hash">>) => {
|
||||
const updateProfile = useCallback(async (id: string, updates: Partial<Pick<Profile, "name" | "color">>) => {
|
||||
if (!state.config) return;
|
||||
|
||||
const newProfiles = state.config.profiles.map((p) =>
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { defaultCartesReferencePeriod } from "./useCartes";
|
||||
|
||||
describe("defaultCartesReferencePeriod", () => {
|
||||
it("returns the month before the given date", () => {
|
||||
expect(defaultCartesReferencePeriod(new Date(2026, 3, 15))).toEqual({
|
||||
year: 2026,
|
||||
month: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps around January to December of the previous year", () => {
|
||||
expect(defaultCartesReferencePeriod(new Date(2026, 0, 10))).toEqual({
|
||||
year: 2025,
|
||||
month: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles the last day of a month", () => {
|
||||
expect(defaultCartesReferencePeriod(new Date(2026, 5, 30))).toEqual({
|
||||
year: 2026,
|
||||
month: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||
import type { CartesSnapshot } from "../shared/types";
|
||||
import { getCartesSnapshot } from "../services/reportService";
|
||||
import { useReportsPeriod } from "./useReportsPeriod";
|
||||
|
||||
interface State {
|
||||
year: number;
|
||||
month: number;
|
||||
snapshot: CartesSnapshot | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
|
||||
| { type: "SET_LOADING"; payload: boolean }
|
||||
| { type: "SET_SNAPSHOT"; payload: CartesSnapshot }
|
||||
| { type: "SET_ERROR"; payload: string };
|
||||
|
||||
/**
|
||||
* Default reference period for the Cartes report: the month preceding `today`.
|
||||
* January wraps around to December of the previous year. Exported for tests.
|
||||
*/
|
||||
export function defaultCartesReferencePeriod(
|
||||
today: Date = new Date(),
|
||||
): { year: number; month: number } {
|
||||
const y = today.getFullYear();
|
||||
const m = today.getMonth() + 1;
|
||||
if (m === 1) return { year: y - 1, month: 12 };
|
||||
return { year: y, month: m - 1 };
|
||||
}
|
||||
|
||||
const defaultRef = defaultCartesReferencePeriod();
|
||||
const initialState: State = {
|
||||
year: defaultRef.year,
|
||||
month: defaultRef.month,
|
||||
snapshot: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_REFERENCE_PERIOD":
|
||||
return { ...state, year: action.payload.year, month: action.payload.month };
|
||||
case "SET_LOADING":
|
||||
return { ...state, isLoading: action.payload };
|
||||
case "SET_SNAPSHOT":
|
||||
return { ...state, snapshot: action.payload, isLoading: false, error: null };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.payload, isLoading: false };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useCartes() {
|
||||
const { from, to, period, setPeriod, setCustomDates } = useReportsPeriod();
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
const fetch = useCallback(async (year: number, month: number) => {
|
||||
const id = ++fetchIdRef.current;
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
try {
|
||||
const snapshot = await getCartesSnapshot(year, month);
|
||||
if (id !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_SNAPSHOT", payload: snapshot });
|
||||
} catch (e) {
|
||||
if (id !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(state.year, state.month);
|
||||
}, [fetch, state.year, state.month]);
|
||||
|
||||
// Keep the reference month in sync with the URL `to` date, so navigating
|
||||
// via PeriodSelector works as expected.
|
||||
useEffect(() => {
|
||||
const [y, m] = to.split("-").map(Number);
|
||||
if (!Number.isFinite(y) || !Number.isFinite(m)) return;
|
||||
if (y !== state.year || m !== state.month) {
|
||||
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year: y, month: m } });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [to]);
|
||||
|
||||
const setReferencePeriod = useCallback((year: number, month: number) => {
|
||||
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
setReferencePeriod,
|
||||
from,
|
||||
to,
|
||||
period,
|
||||
setPeriod,
|
||||
setCustomDates,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import { useReducer, useEffect, useRef, useCallback } from "react";
|
||||
import type { CategoryZoomData } from "../shared/types";
|
||||
import { getCategoryZoom } from "../services/reportService";
|
||||
import { useReportsPeriod } from "./useReportsPeriod";
|
||||
|
||||
interface State {
|
||||
zoomedCategoryId: number | null;
|
||||
rollupChildren: boolean;
|
||||
data: CategoryZoomData | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: "SET_CATEGORY"; payload: number | null }
|
||||
| { type: "TOGGLE_ROLLUP"; payload: boolean }
|
||||
| { type: "SET_LOADING"; payload: boolean }
|
||||
| { type: "SET_DATA"; payload: CategoryZoomData }
|
||||
| { type: "SET_ERROR"; payload: string };
|
||||
|
||||
const initialState: State = {
|
||||
zoomedCategoryId: null,
|
||||
rollupChildren: true,
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_CATEGORY":
|
||||
return { ...state, zoomedCategoryId: action.payload, data: null };
|
||||
case "TOGGLE_ROLLUP":
|
||||
return { ...state, rollupChildren: action.payload };
|
||||
case "SET_LOADING":
|
||||
return { ...state, isLoading: action.payload };
|
||||
case "SET_DATA":
|
||||
return { ...state, data: action.payload, isLoading: false, error: null };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.payload, isLoading: false };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useCategoryZoom() {
|
||||
const { from, to } = useReportsPeriod();
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
const fetch = useCallback(
|
||||
async (categoryId: number | null, includeChildren: boolean, dateFrom: string, dateTo: string) => {
|
||||
if (categoryId === null) return;
|
||||
const id = ++fetchIdRef.current;
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
try {
|
||||
const data = await getCategoryZoom(categoryId, dateFrom, dateTo, includeChildren);
|
||||
if (id !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_DATA", payload: data });
|
||||
} catch (e) {
|
||||
if (id !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(state.zoomedCategoryId, state.rollupChildren, from, to);
|
||||
}, [fetch, state.zoomedCategoryId, state.rollupChildren, from, to]);
|
||||
|
||||
const setCategory = useCallback((id: number | null) => {
|
||||
dispatch({ type: "SET_CATEGORY", payload: id });
|
||||
}, []);
|
||||
|
||||
const setRollupChildren = useCallback((flag: boolean) => {
|
||||
dispatch({ type: "TOGGLE_ROLLUP", payload: flag });
|
||||
}, []);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
fetch(state.zoomedCategoryId, state.rollupChildren, from, to);
|
||||
}, [fetch, state.zoomedCategoryId, state.rollupChildren, from, to]);
|
||||
|
||||
return { ...state, setCategory, setRollupChildren, refetch, from, to };
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { previousMonth, defaultReferencePeriod, comparisonMeta } from "./useCompare";
|
||||
|
||||
describe("useCompare helpers", () => {
|
||||
describe("previousMonth", () => {
|
||||
it("goes back one month within the same year", () => {
|
||||
expect(previousMonth(2026, 3)).toEqual({ year: 2026, month: 2 });
|
||||
expect(previousMonth(2026, 12)).toEqual({ year: 2026, month: 11 });
|
||||
});
|
||||
|
||||
it("wraps around January to December of previous year", () => {
|
||||
expect(previousMonth(2026, 1)).toEqual({ year: 2025, month: 12 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultReferencePeriod", () => {
|
||||
it("returns the month before the given date", () => {
|
||||
expect(defaultReferencePeriod(new Date(2026, 3, 15))).toEqual({ year: 2026, month: 3 });
|
||||
});
|
||||
|
||||
it("wraps around when today is in January", () => {
|
||||
expect(defaultReferencePeriod(new Date(2026, 0, 10))).toEqual({ year: 2025, month: 12 });
|
||||
});
|
||||
|
||||
it("handles the last day of a month", () => {
|
||||
expect(defaultReferencePeriod(new Date(2026, 6, 31))).toEqual({ year: 2026, month: 6 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("comparisonMeta", () => {
|
||||
it("MoM returns the previous month", () => {
|
||||
expect(comparisonMeta("mom", 2026, 3)).toEqual({ previousYear: 2026, previousMonth: 2 });
|
||||
});
|
||||
|
||||
it("MoM wraps around January", () => {
|
||||
expect(comparisonMeta("mom", 2026, 1)).toEqual({ previousYear: 2025, previousMonth: 12 });
|
||||
});
|
||||
|
||||
it("YoY returns the same month in the previous year", () => {
|
||||
expect(comparisonMeta("yoy", 2026, 3)).toEqual({ previousYear: 2025, previousMonth: 3 });
|
||||
});
|
||||
|
||||
it("YoY for January stays on January of previous year", () => {
|
||||
expect(comparisonMeta("yoy", 2026, 1)).toEqual({ previousYear: 2025, previousMonth: 1 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||
import type { CategoryDelta } from "../shared/types";
|
||||
import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService";
|
||||
import { useReportsPeriod } from "./useReportsPeriod";
|
||||
|
||||
export type CompareMode = "actual" | "budget";
|
||||
export type CompareSubMode = "mom" | "yoy";
|
||||
|
||||
interface State {
|
||||
mode: CompareMode;
|
||||
subMode: CompareSubMode;
|
||||
year: number;
|
||||
month: number;
|
||||
rows: CategoryDelta[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: "SET_MODE"; payload: CompareMode }
|
||||
| { type: "SET_SUB_MODE"; payload: CompareSubMode }
|
||||
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
|
||||
| { type: "SET_LOADING"; payload: boolean }
|
||||
| { type: "SET_ROWS"; payload: CategoryDelta[] }
|
||||
| { type: "SET_ERROR"; payload: string };
|
||||
|
||||
/**
|
||||
* Wrap-around helper: returns (year, month) shifted back by one month.
|
||||
* Example: previousMonth(2026, 1) -> { year: 2025, month: 12 }.
|
||||
*/
|
||||
export function previousMonth(year: number, month: number): { year: number; month: number } {
|
||||
if (month === 1) return { year: year - 1, month: 12 };
|
||||
return { year, month: month - 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Default reference period for the Compare report: the month preceding `today`.
|
||||
* Exported for unit tests.
|
||||
*/
|
||||
export function defaultReferencePeriod(today: Date = new Date()): { year: number; month: number } {
|
||||
return previousMonth(today.getFullYear(), today.getMonth() + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the comparison meta for a given subMode + reference period.
|
||||
* - MoM: previous month vs current month
|
||||
* - YoY: same month previous year vs current year
|
||||
*/
|
||||
export function comparisonMeta(
|
||||
subMode: CompareSubMode,
|
||||
year: number,
|
||||
month: number,
|
||||
): { previousYear: number; previousMonth: number } {
|
||||
if (subMode === "mom") {
|
||||
const prev = previousMonth(year, month);
|
||||
return { previousYear: prev.year, previousMonth: prev.month };
|
||||
}
|
||||
return { previousYear: year - 1, previousMonth: month };
|
||||
}
|
||||
|
||||
const defaultRef = defaultReferencePeriod();
|
||||
const initialState: State = {
|
||||
mode: "actual",
|
||||
subMode: "mom",
|
||||
year: defaultRef.year,
|
||||
month: defaultRef.month,
|
||||
rows: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_MODE":
|
||||
return { ...state, mode: action.payload };
|
||||
case "SET_SUB_MODE":
|
||||
return { ...state, subMode: action.payload };
|
||||
case "SET_REFERENCE_PERIOD":
|
||||
return { ...state, year: action.payload.year, month: action.payload.month };
|
||||
case "SET_LOADING":
|
||||
return { ...state, isLoading: action.payload };
|
||||
case "SET_ROWS":
|
||||
return { ...state, rows: action.payload, isLoading: false, error: null };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.payload, isLoading: false };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useCompare() {
|
||||
const { from, to } = useReportsPeriod();
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
const fetch = useCallback(
|
||||
async (mode: CompareMode, subMode: CompareSubMode, year: number, month: number) => {
|
||||
if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly
|
||||
const id = ++fetchIdRef.current;
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
try {
|
||||
const rows =
|
||||
subMode === "mom"
|
||||
? await getCompareMonthOverMonth(year, month)
|
||||
: await getCompareYearOverYear(year);
|
||||
if (id !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_ROWS", payload: rows });
|
||||
} catch (e) {
|
||||
if (id !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(state.mode, state.subMode, state.year, state.month);
|
||||
}, [fetch, state.mode, state.subMode, state.year, state.month]);
|
||||
|
||||
// When the URL period changes, align the reference month with `to`.
|
||||
// The explicit dropdown remains the primary selector — this effect only
|
||||
// keeps the two in sync when the user navigates via PeriodSelector.
|
||||
useEffect(() => {
|
||||
const [y, m] = to.split("-").map(Number);
|
||||
if (!Number.isFinite(y) || !Number.isFinite(m)) return;
|
||||
if (y !== state.year || m !== state.month) {
|
||||
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year: y, month: m } });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [to]);
|
||||
|
||||
const setMode = useCallback((m: CompareMode) => {
|
||||
dispatch({ type: "SET_MODE", payload: m });
|
||||
}, []);
|
||||
|
||||
const setSubMode = useCallback((s: CompareSubMode) => {
|
||||
dispatch({ type: "SET_SUB_MODE", payload: s });
|
||||
}, []);
|
||||
|
||||
const setReferencePeriod = useCallback((year: number, month: number) => {
|
||||
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
|
||||
}, []);
|
||||
|
||||
return { ...state, setMode, setSubMode, setReferencePeriod, from, to };
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
feedbackReducer,
|
||||
initialFeedbackState,
|
||||
type FeedbackState,
|
||||
} from "./useFeedback";
|
||||
|
||||
describe("feedbackReducer", () => {
|
||||
it("starts in idle with no error", () => {
|
||||
expect(initialFeedbackState).toEqual({ status: "idle", errorCode: null });
|
||||
});
|
||||
|
||||
it("transitions idle → sending on SEND_START", () => {
|
||||
const next = feedbackReducer(initialFeedbackState, { type: "SEND_START" });
|
||||
expect(next).toEqual({ status: "sending", errorCode: null });
|
||||
});
|
||||
|
||||
it("clears a previous error code when re-sending", () => {
|
||||
const prev: FeedbackState = { status: "error", errorCode: "rate_limit" };
|
||||
const next = feedbackReducer(prev, { type: "SEND_START" });
|
||||
expect(next.errorCode).toBeNull();
|
||||
});
|
||||
|
||||
it("transitions sending → success", () => {
|
||||
const prev: FeedbackState = { status: "sending", errorCode: null };
|
||||
const next = feedbackReducer(prev, { type: "SEND_SUCCESS" });
|
||||
expect(next).toEqual({ status: "success", errorCode: null });
|
||||
});
|
||||
|
||||
it("transitions sending → error and records the code", () => {
|
||||
const prev: FeedbackState = { status: "sending", errorCode: null };
|
||||
const next = feedbackReducer(prev, {
|
||||
type: "SEND_ERROR",
|
||||
code: "network_error",
|
||||
});
|
||||
expect(next).toEqual({ status: "error", errorCode: "network_error" });
|
||||
});
|
||||
|
||||
it("RESET returns to the initial state regardless of prior state", () => {
|
||||
const prev: FeedbackState = { status: "error", errorCode: "invalid" };
|
||||
const next = feedbackReducer(prev, { type: "RESET" });
|
||||
expect(next).toEqual(initialFeedbackState);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import { useCallback, useReducer } from "react";
|
||||
import {
|
||||
sendFeedback,
|
||||
type FeedbackContext,
|
||||
type FeedbackErrorCode,
|
||||
isFeedbackErrorCode,
|
||||
} from "../services/feedbackService";
|
||||
|
||||
export type FeedbackStatus = "idle" | "sending" | "success" | "error";
|
||||
|
||||
export interface FeedbackState {
|
||||
status: FeedbackStatus;
|
||||
errorCode: FeedbackErrorCode | null;
|
||||
}
|
||||
|
||||
export type FeedbackAction =
|
||||
| { type: "SEND_START" }
|
||||
| { type: "SEND_SUCCESS" }
|
||||
| { type: "SEND_ERROR"; code: FeedbackErrorCode }
|
||||
| { type: "RESET" };
|
||||
|
||||
export const initialFeedbackState: FeedbackState = {
|
||||
status: "idle",
|
||||
errorCode: null,
|
||||
};
|
||||
|
||||
export function feedbackReducer(
|
||||
_state: FeedbackState,
|
||||
action: FeedbackAction,
|
||||
): FeedbackState {
|
||||
switch (action.type) {
|
||||
case "SEND_START":
|
||||
return { status: "sending", errorCode: null };
|
||||
case "SEND_SUCCESS":
|
||||
return { status: "success", errorCode: null };
|
||||
case "SEND_ERROR":
|
||||
return { status: "error", errorCode: action.code };
|
||||
case "RESET":
|
||||
return initialFeedbackState;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SubmitArgs {
|
||||
content: string;
|
||||
userId?: string | null;
|
||||
context?: FeedbackContext;
|
||||
}
|
||||
|
||||
export function useFeedback() {
|
||||
const [state, dispatch] = useReducer(feedbackReducer, initialFeedbackState);
|
||||
|
||||
const submit = useCallback(async (args: SubmitArgs) => {
|
||||
dispatch({ type: "SEND_START" });
|
||||
try {
|
||||
await sendFeedback(args);
|
||||
dispatch({ type: "SEND_SUCCESS" });
|
||||
} catch (e) {
|
||||
const code: FeedbackErrorCode = isFeedbackErrorCode(e)
|
||||
? e
|
||||
: "network_error";
|
||||
dispatch({ type: "SEND_ERROR", code });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => dispatch({ type: "RESET" }), []);
|
||||
|
||||
return { state, submit, reset };
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import { useReducer, useEffect, useRef, useCallback } from "react";
|
||||
import type { HighlightsData } from "../shared/types";
|
||||
import { getHighlights } from "../services/reportService";
|
||||
import { useReportsPeriod } from "./useReportsPeriod";
|
||||
|
||||
interface State {
|
||||
data: HighlightsData | null;
|
||||
windowDays: 30 | 60 | 90;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: "SET_LOADING"; payload: boolean }
|
||||
| { type: "SET_DATA"; payload: HighlightsData }
|
||||
| { type: "SET_ERROR"; payload: string }
|
||||
| { type: "SET_WINDOW_DAYS"; payload: 30 | 60 | 90 };
|
||||
|
||||
const initialState: State = {
|
||||
data: null,
|
||||
windowDays: 30,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_LOADING":
|
||||
return { ...state, isLoading: action.payload };
|
||||
case "SET_DATA":
|
||||
return { ...state, data: action.payload, isLoading: false, error: null };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.payload, isLoading: false };
|
||||
case "SET_WINDOW_DAYS":
|
||||
return { ...state, windowDays: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useHighlights() {
|
||||
const { from, to } = useReportsPeriod();
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
const fetch = useCallback(async (windowDays: 30 | 60 | 90, referenceDate: string) => {
|
||||
const id = ++fetchIdRef.current;
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
try {
|
||||
const data = await getHighlights(windowDays, referenceDate);
|
||||
if (id !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_DATA", payload: data });
|
||||
} catch (e) {
|
||||
if (id !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(state.windowDays, to);
|
||||
}, [fetch, state.windowDays, to]);
|
||||
|
||||
const setWindowDays = useCallback((d: 30 | 60 | 90) => {
|
||||
dispatch({ type: "SET_WINDOW_DAYS", payload: d });
|
||||
}, []);
|
||||
|
||||
return { ...state, setWindowDays, from, to };
|
||||
}
|
||||
213
src/hooks/useReports.ts
Normal file
213
src/hooks/useReports.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||
import type {
|
||||
ReportTab,
|
||||
DashboardPeriod,
|
||||
MonthlyTrendItem,
|
||||
CategoryBreakdownItem,
|
||||
CategoryOverTimeData,
|
||||
BudgetVsActualRow,
|
||||
PivotConfig,
|
||||
PivotResult,
|
||||
} from "../shared/types";
|
||||
import { getMonthlyTrends, getCategoryOverTime, getDynamicReportData } from "../services/reportService";
|
||||
import { getExpensesByCategory } from "../services/dashboardService";
|
||||
import { getBudgetVsActualData } from "../services/budgetService";
|
||||
import { computeDateRange } from "../utils/dateRange";
|
||||
|
||||
export type CategoryTypeFilter = "expense" | "income" | "transfer" | null;
|
||||
|
||||
interface ReportsState {
|
||||
tab: ReportTab;
|
||||
period: DashboardPeriod;
|
||||
customDateFrom: string;
|
||||
customDateTo: string;
|
||||
sourceId: number | null;
|
||||
categoryType: CategoryTypeFilter;
|
||||
monthlyTrends: MonthlyTrendItem[];
|
||||
categorySpending: CategoryBreakdownItem[];
|
||||
categoryOverTime: CategoryOverTimeData;
|
||||
budgetYear: number;
|
||||
budgetMonth: number;
|
||||
budgetVsActual: BudgetVsActualRow[];
|
||||
pivotConfig: PivotConfig;
|
||||
pivotResult: PivotResult;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type ReportsAction =
|
||||
| { type: "SET_TAB"; payload: ReportTab }
|
||||
| { type: "SET_PERIOD"; payload: DashboardPeriod }
|
||||
| { type: "SET_LOADING"; payload: boolean }
|
||||
| { type: "SET_ERROR"; payload: string | null }
|
||||
| { type: "SET_MONTHLY_TRENDS"; payload: MonthlyTrendItem[] }
|
||||
| { type: "SET_CATEGORY_SPENDING"; payload: CategoryBreakdownItem[] }
|
||||
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
|
||||
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
|
||||
| { 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_SOURCE_ID"; payload: number | null }
|
||||
| { type: "SET_CATEGORY_TYPE"; payload: CategoryTypeFilter };
|
||||
|
||||
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 initialState: ReportsState = {
|
||||
tab: "trends",
|
||||
period: "6months",
|
||||
customDateFrom: monthStartStr,
|
||||
customDateTo: todayStr,
|
||||
sourceId: null,
|
||||
categoryType: "expense",
|
||||
monthlyTrends: [],
|
||||
categorySpending: [],
|
||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||
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: {} },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function reducer(state: ReportsState, action: ReportsAction): ReportsState {
|
||||
switch (action.type) {
|
||||
case "SET_TAB":
|
||||
return { ...state, tab: action.payload };
|
||||
case "SET_PERIOD":
|
||||
return { ...state, period: action.payload };
|
||||
case "SET_LOADING":
|
||||
return { ...state, isLoading: action.payload };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.payload, isLoading: false };
|
||||
case "SET_MONTHLY_TRENDS":
|
||||
return { ...state, monthlyTrends: action.payload, isLoading: false };
|
||||
case "SET_CATEGORY_SPENDING":
|
||||
return { ...state, categorySpending: action.payload, isLoading: false };
|
||||
case "SET_CATEGORY_OVER_TIME":
|
||||
return { ...state, categoryOverTime: action.payload, isLoading: false };
|
||||
case "SET_BUDGET_MONTH":
|
||||
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
|
||||
case "SET_BUDGET_VS_ACTUAL":
|
||||
return { ...state, budgetVsActual: action.payload, isLoading: false };
|
||||
case "SET_PIVOT_CONFIG":
|
||||
return { ...state, pivotConfig: action.payload };
|
||||
case "SET_PIVOT_RESULT":
|
||||
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 };
|
||||
case "SET_CATEGORY_TYPE":
|
||||
return { ...state, categoryType: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useReports() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
const fetchData = useCallback(async (
|
||||
tab: ReportTab,
|
||||
period: DashboardPeriod,
|
||||
budgetYear: number,
|
||||
budgetMonth: number,
|
||||
customFrom?: string,
|
||||
customTo?: string,
|
||||
pivotCfg?: PivotConfig,
|
||||
srcId?: number | null,
|
||||
catType?: CategoryTypeFilter,
|
||||
) => {
|
||||
const fetchId = ++fetchIdRef.current;
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
dispatch({ type: "SET_ERROR", payload: null });
|
||||
|
||||
try {
|
||||
switch (tab) {
|
||||
case "trends": {
|
||||
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||
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, 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, undefined, srcId ?? undefined, catType ?? undefined);
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
||||
break;
|
||||
}
|
||||
case "budgetVsActual": {
|
||||
const data = await getBudgetVsActualData(budgetYear, budgetMonth);
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_BUDGET_VS_ACTUAL", payload: data });
|
||||
break;
|
||||
}
|
||||
case "dynamic": {
|
||||
if (!pivotCfg || (pivotCfg.rows.length === 0 && pivotCfg.columns.length === 0) || pivotCfg.values.length === 0) {
|
||||
dispatch({ type: "SET_PIVOT_RESULT", payload: { rows: [], columnValues: [], dimensionLabels: {} } });
|
||||
break;
|
||||
}
|
||||
const data = await getDynamicReportData(pivotCfg);
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_PIVOT_RESULT", payload: data });
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, state.categoryType);
|
||||
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, state.categoryType, fetchData]);
|
||||
|
||||
const setTab = useCallback((tab: ReportTab) => {
|
||||
dispatch({ type: "SET_TAB", payload: tab });
|
||||
}, []);
|
||||
|
||||
const setPeriod = useCallback((period: DashboardPeriod) => {
|
||||
dispatch({ type: "SET_PERIOD", payload: period });
|
||||
}, []);
|
||||
|
||||
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 } });
|
||||
}, []);
|
||||
|
||||
const setPivotConfig = useCallback((config: PivotConfig) => {
|
||||
dispatch({ type: "SET_PIVOT_CONFIG", payload: config });
|
||||
}, []);
|
||||
|
||||
const setSourceId = useCallback((id: number | null) => {
|
||||
dispatch({ type: "SET_SOURCE_ID", payload: id });
|
||||
}, []);
|
||||
|
||||
const setCategoryType = useCallback((catType: CategoryTypeFilter) => {
|
||||
dispatch({ type: "SET_CATEGORY_TYPE", payload: catType });
|
||||
}, []);
|
||||
|
||||
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId, setCategoryType };
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { resolveReportsPeriod } from "./useReportsPeriod";
|
||||
|
||||
describe("resolveReportsPeriod", () => {
|
||||
const fixedToday = new Date("2026-04-14T12:00:00Z");
|
||||
|
||||
it("defaults to current civil year when no URL params are set", () => {
|
||||
const result = resolveReportsPeriod(null, null, null, fixedToday);
|
||||
expect(result.from).toBe("2026-01-01");
|
||||
expect(result.to).toBe("2026-12-31");
|
||||
expect(result.period).toBe("custom");
|
||||
});
|
||||
|
||||
it("restores state from bookmarked from/to params", () => {
|
||||
const result = resolveReportsPeriod("2025-03-01", "2025-06-30", null, fixedToday);
|
||||
expect(result.from).toBe("2025-03-01");
|
||||
expect(result.to).toBe("2025-06-30");
|
||||
expect(result.period).toBe("custom");
|
||||
});
|
||||
|
||||
it("keeps period=yearly alongside explicit from/to", () => {
|
||||
const result = resolveReportsPeriod("2024-01-01", "2024-12-31", "year", fixedToday);
|
||||
expect(result.period).toBe("year");
|
||||
});
|
||||
|
||||
it("ignores malformed dates and falls back to the civil year", () => {
|
||||
const result = resolveReportsPeriod("not-a-date", "also-not", null, fixedToday);
|
||||
expect(result.from).toBe("2026-01-01");
|
||||
expect(result.to).toBe("2026-12-31");
|
||||
expect(result.period).toBe("custom");
|
||||
});
|
||||
|
||||
it("resolves preset period values without from/to", () => {
|
||||
const result = resolveReportsPeriod(null, null, "6months", fixedToday);
|
||||
expect(result.period).toBe("6months");
|
||||
expect(result.from).toBeTruthy();
|
||||
expect(result.to).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects an invalid period string and falls back to civil year custom", () => {
|
||||
const result = resolveReportsPeriod(null, null, "bogus", fixedToday);
|
||||
expect(result.period).toBe("custom");
|
||||
expect(result.from).toBe("2026-01-01");
|
||||
});
|
||||
|
||||
it("treats `all` as a preset with empty range (service handles the clauses)", () => {
|
||||
const result = resolveReportsPeriod(null, null, "all", fixedToday);
|
||||
expect(result.period).toBe("all");
|
||||
// Fallback civil year when computeDateRange returns empty
|
||||
expect(result.from).toBe("2026-01-01");
|
||||
expect(result.to).toBe("2026-12-31");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { DashboardPeriod } from "../shared/types";
|
||||
import { computeDateRange } from "../utils/dateRange";
|
||||
|
||||
const VALID_PERIODS: readonly DashboardPeriod[] = [
|
||||
"month",
|
||||
"3months",
|
||||
"6months",
|
||||
"year",
|
||||
"12months",
|
||||
"all",
|
||||
"custom",
|
||||
];
|
||||
|
||||
function isValidPeriod(p: string | null): p is DashboardPeriod {
|
||||
return p !== null && (VALID_PERIODS as readonly string[]).includes(p);
|
||||
}
|
||||
|
||||
function isValidIsoDate(s: string | null): s is string {
|
||||
return !!s && /^\d{4}-\d{2}-\d{2}$/.test(s);
|
||||
}
|
||||
|
||||
function currentYearRange(today: Date = new Date()): { from: string; to: string } {
|
||||
const year = today.getFullYear();
|
||||
return { from: `${year}-01-01`, to: `${year}-12-31` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure resolver used by the hook and unit tests. Exposed to keep the core
|
||||
* logic hookless and testable without rendering a router.
|
||||
*/
|
||||
export function resolveReportsPeriod(
|
||||
rawFrom: string | null,
|
||||
rawTo: string | null,
|
||||
rawPeriod: string | null,
|
||||
today: Date = new Date(),
|
||||
): { from: string; to: string; period: DashboardPeriod } {
|
||||
if (isValidIsoDate(rawFrom) && isValidIsoDate(rawTo)) {
|
||||
const p = isValidPeriod(rawPeriod) ? rawPeriod : "custom";
|
||||
return { from: rawFrom, to: rawTo, period: p };
|
||||
}
|
||||
if (isValidPeriod(rawPeriod) && rawPeriod !== "custom") {
|
||||
const range = computeDateRange(rawPeriod);
|
||||
const { from: defaultFrom, to: defaultTo } = currentYearRange(today);
|
||||
return {
|
||||
from: range.dateFrom ?? defaultFrom,
|
||||
to: range.dateTo ?? defaultTo,
|
||||
period: rawPeriod,
|
||||
};
|
||||
}
|
||||
const { from, to } = currentYearRange(today);
|
||||
return { from, to, period: "custom" };
|
||||
}
|
||||
|
||||
export interface UseReportsPeriodResult {
|
||||
from: string;
|
||||
to: string;
|
||||
period: DashboardPeriod;
|
||||
setPeriod: (period: DashboardPeriod) => void;
|
||||
setCustomDates: (from: string, to: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads/writes the active reporting period via the URL query string so it is
|
||||
* bookmarkable and shared across the four report sub-routes.
|
||||
*
|
||||
* Defaults to the current civil year (Jan 1 → Dec 31).
|
||||
*/
|
||||
export function useReportsPeriod(): UseReportsPeriodResult {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const rawPeriod = searchParams.get("period");
|
||||
const rawFrom = searchParams.get("from");
|
||||
const rawTo = searchParams.get("to");
|
||||
|
||||
const { from, to, period } = useMemo(
|
||||
() => resolveReportsPeriod(rawFrom, rawTo, rawPeriod),
|
||||
[rawPeriod, rawFrom, rawTo],
|
||||
);
|
||||
|
||||
const setPeriod = useCallback(
|
||||
(next: DashboardPeriod) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
if (next === "custom") {
|
||||
params.set("period", "custom");
|
||||
} else {
|
||||
params.set("period", next);
|
||||
params.delete("from");
|
||||
params.delete("to");
|
||||
}
|
||||
return params;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const setCustomDates = useCallback(
|
||||
(nextFrom: string, nextTo: string) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
params.set("period", "custom");
|
||||
params.set("from", nextFrom);
|
||||
params.set("to", nextTo);
|
||||
return params;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
return { from, to, period, setPeriod, setCustomDates };
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import { useReducer, useEffect, useRef, useCallback } from "react";
|
||||
import type { MonthlyTrendItem, CategoryOverTimeData } from "../shared/types";
|
||||
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
|
||||
import { useReportsPeriod } from "./useReportsPeriod";
|
||||
|
||||
export type TrendsSubView = "global" | "byCategory";
|
||||
|
||||
interface State {
|
||||
subView: TrendsSubView;
|
||||
monthlyTrends: MonthlyTrendItem[];
|
||||
categoryOverTime: CategoryOverTimeData;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: "SET_SUBVIEW"; payload: TrendsSubView }
|
||||
| { type: "SET_LOADING"; payload: boolean }
|
||||
| { type: "SET_TRENDS"; payload: MonthlyTrendItem[] }
|
||||
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
|
||||
| { type: "SET_ERROR"; payload: string };
|
||||
|
||||
const initialState: State = {
|
||||
subView: "global",
|
||||
monthlyTrends: [],
|
||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_SUBVIEW":
|
||||
return { ...state, subView: action.payload };
|
||||
case "SET_LOADING":
|
||||
return { ...state, isLoading: action.payload };
|
||||
case "SET_TRENDS":
|
||||
return { ...state, monthlyTrends: action.payload, isLoading: false, error: null };
|
||||
case "SET_CATEGORY_OVER_TIME":
|
||||
return { ...state, categoryOverTime: action.payload, isLoading: false, error: null };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.payload, isLoading: false };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useTrends() {
|
||||
const { from, to } = useReportsPeriod();
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
const fetch = useCallback(async (subView: TrendsSubView, dateFrom: string, dateTo: string) => {
|
||||
const id = ++fetchIdRef.current;
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
try {
|
||||
if (subView === "global") {
|
||||
const data = await getMonthlyTrends(dateFrom, dateTo);
|
||||
if (id !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_TRENDS", payload: data });
|
||||
} else {
|
||||
const data = await getCategoryOverTime(dateFrom, dateTo);
|
||||
if (id !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
||||
}
|
||||
} catch (e) {
|
||||
if (id !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(state.subView, from, to);
|
||||
}, [fetch, state.subView, from, to]);
|
||||
|
||||
const setSubView = useCallback((sv: TrendsSubView) => {
|
||||
dispatch({ type: "SET_SUBVIEW", payload: sv });
|
||||
}, []);
|
||||
|
||||
return { ...state, setSubView, from, to };
|
||||
}
|
||||
|
|
@ -358,10 +358,7 @@
|
|||
"period": "Period",
|
||||
"byCategory": "Expenses by Category",
|
||||
"overTime": "Category Over Time",
|
||||
"trends": {
|
||||
"subviewGlobal": "Global flow",
|
||||
"subviewByCategory": "By category"
|
||||
},
|
||||
"trends": "Monthly Trends",
|
||||
"budgetVsActual": "Budget vs Actual",
|
||||
"subtotalsOnTop": "Subtotals on top",
|
||||
"subtotalsOnBottom": "Subtotals on bottom",
|
||||
|
|
@ -384,93 +381,33 @@
|
|||
"noData": "No budget or transaction data for this period.",
|
||||
"titlePrefix": "Budget vs Actual for"
|
||||
},
|
||||
"dynamic": "Dynamic Report",
|
||||
"export": "Export",
|
||||
"pivot": {
|
||||
"availableFields": "Available Fields",
|
||||
"rows": "Rows",
|
||||
"columns": "Columns",
|
||||
"filters": "Filters",
|
||||
"values": "Values",
|
||||
"addTo": "Add to...",
|
||||
"year": "Year",
|
||||
"month": "Month",
|
||||
"viewMode": {
|
||||
"chart": "Chart",
|
||||
"table": "Table"
|
||||
},
|
||||
"hub": {
|
||||
"title": "Reports",
|
||||
"explore": "Explore",
|
||||
"highlights": "Highlights",
|
||||
"highlightsDescription": "What moved this month",
|
||||
"trends": "Trends",
|
||||
"trendsDescription": "Where you're heading over 12 months",
|
||||
"compare": "Compare",
|
||||
"compareDescription": "Compare a reference month against previous month, previous year, or budget",
|
||||
"categoryZoom": "Category Analysis",
|
||||
"categoryZoomDescription": "Zoom in on a single category",
|
||||
"cartes": "Cards",
|
||||
"cartesDescription": "KPI dashboard with sparklines, top movers, budget adherence, and seasonality"
|
||||
},
|
||||
"compare": {
|
||||
"modeActual": "Actual vs actual",
|
||||
"modeBudget": "Actual vs budget",
|
||||
"subModeMoM": "Previous month",
|
||||
"subModeYoY": "Previous year",
|
||||
"subModeAria": "Comparison period",
|
||||
"referenceMonth": "Reference month"
|
||||
},
|
||||
"cartes": {
|
||||
"kpiSectionAria": "Key indicators for the reference month",
|
||||
"income": "Income",
|
||||
"expenses": "Expenses",
|
||||
"net": "Net balance",
|
||||
"savingsRate": "Savings rate",
|
||||
"deltaMoMLabel": "vs last month",
|
||||
"deltaYoYLabel": "vs last year",
|
||||
"flowChartTitle": "Income vs expenses — last 12 months",
|
||||
"topMoversUp": "Biggest increases",
|
||||
"topMoversDown": "Biggest decreases",
|
||||
"budgetAdherenceTitle": "Budget adherence",
|
||||
"budgetAdherenceSubtitle": "{{score}} of budgeted categories on target",
|
||||
"budgetAdherenceEmpty": "No budgeted categories this month",
|
||||
"budgetAdherenceWorst": "Worst overruns",
|
||||
"seasonalityTitle": "Seasonality",
|
||||
"seasonalityEmpty": "Not enough history for this month",
|
||||
"seasonalityAverage": "Average",
|
||||
"seasonalityDeviation": "{{pct}} vs average"
|
||||
},
|
||||
"category": {
|
||||
"selectCategory": "Select a category",
|
||||
"includeSubcategories": "Include subcategories",
|
||||
"directOnly": "Direct only",
|
||||
"breakdown": "Total",
|
||||
"evolution": "Evolution",
|
||||
"transactions": "Transactions"
|
||||
},
|
||||
"keyword": {
|
||||
"addFromTransaction": "Add as keyword",
|
||||
"dialogTitle": "New keyword",
|
||||
"willMatch": "Will also match",
|
||||
"nMatches_one": "{{count}} transaction matched",
|
||||
"nMatches_other": "{{count}} transactions matched",
|
||||
"applyAndRecategorize": "Apply and recategorize",
|
||||
"applyToHidden": "Also apply to {{count}} non-displayed transactions",
|
||||
"tooShort": "Minimum {{min}} characters",
|
||||
"tooLong": "Maximum {{max}} characters",
|
||||
"alreadyExists": "This keyword already exists for another category. Reassign?"
|
||||
},
|
||||
"highlights": {
|
||||
"balances": "Balances",
|
||||
"netBalanceCurrent": "This month",
|
||||
"netBalanceYtd": "Year to date",
|
||||
"topMovers": "Top movers",
|
||||
"topTransactions": "Top recent transactions",
|
||||
"category": "Category",
|
||||
"previousAmount": "Previous",
|
||||
"currentAmount": "Current",
|
||||
"variationAbs": "Delta ($)",
|
||||
"variationPct": "Delta (%)",
|
||||
"vsLastMonth": "vs. last month",
|
||||
"windowDays30": "30 days",
|
||||
"windowDays60": "60 days",
|
||||
"windowDays90": "90 days"
|
||||
},
|
||||
"empty": {
|
||||
"noData": "No data for this period",
|
||||
"importCta": "Import a statement"
|
||||
"categoryType": "Type",
|
||||
"level1": "Category (Level 1)",
|
||||
"level2": "Category (Level 2)",
|
||||
"level3": "Category (Level 3)",
|
||||
"periodic": "Periodic Amount",
|
||||
"ytd": "Year-to-Date (YTD)",
|
||||
"subtotal": "Subtotal",
|
||||
"total": "Total",
|
||||
"viewTable": "Table",
|
||||
"viewChart": "Chart",
|
||||
"viewBoth": "Both",
|
||||
"noConfig": "Add fields to generate the report",
|
||||
"noData": "No data for this configuration",
|
||||
"fullscreen": "Full screen",
|
||||
"exitFullscreen": "Exit full screen",
|
||||
"rightClickExclude": "Right-click to exclude"
|
||||
},
|
||||
"help": {
|
||||
"title": "How to use Reports",
|
||||
|
|
@ -478,7 +415,8 @@
|
|||
"Switch between Trends, By Category, and Over Time views using the tabs",
|
||||
"Use the period selector to adjust the time range for all charts",
|
||||
"Monthly Trends shows your income and expenses over time",
|
||||
"Category Over Time tracks how spending in each category evolves"
|
||||
"Category Over Time tracks how spending in each category evolves",
|
||||
"Dynamic Report lets you build custom pivot tables by assigning dimensions to rows, columns, and filters"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -801,29 +739,32 @@
|
|||
},
|
||||
"reports": {
|
||||
"title": "Reports",
|
||||
"overview": "A hub with a live highlights panel plus four dedicated sub-reports (Highlights, Trends, Compare, Category Zoom). Every page shares a bookmarkable period via the URL query string.",
|
||||
"overview": "Visualize your financial data with interactive charts and compare your budget plan against actual spending.",
|
||||
"features": [
|
||||
"Hub: compact highlights panel + 4 navigation cards",
|
||||
"Highlights: current month and YTD balances with sparklines, top movers vs. last month, top recent transactions (30/60/90 day window)",
|
||||
"Trends: global flow (income vs. expenses) and by-category evolution with a chart/table toggle",
|
||||
"Compare: Month vs. Previous Month, Year vs. Previous Year, and Actual vs. Budget",
|
||||
"Category Zoom: single-category drill-down with donut, monthly evolution, and filterable transaction table; auto-rollup of subcategories",
|
||||
"Contextual keyword editing: right-click a transaction row to add its description as a keyword with a live preview of the matches",
|
||||
"Monthly Trends: income vs. expenses over time (bar chart)",
|
||||
"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",
|
||||
"View mode preference (chart vs. table) persisted per report section"
|
||||
"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": [
|
||||
"Open /reports to see the highlights panel and four navigation cards",
|
||||
"Adjust the period with the period selector — it is mirrored in the URL and shared with every sub-report",
|
||||
"Click a card or a sub-route link to open the corresponding report",
|
||||
"Toggle chart vs. table on any sub-report — your choice is remembered",
|
||||
"Right-click any transaction row in the category zoom, highlights list, or transactions page to add a keyword",
|
||||
"In the keyword dialog, review the preview of matching transactions and confirm to apply"
|
||||
"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 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": [
|
||||
"Copy the URL to share a specific period + report state",
|
||||
"Keywords must be 2–64 characters long",
|
||||
"The Category Zoom is protected against malformed category trees: a parent_id cycle cannot freeze the app"
|
||||
"Hidden categories are remembered while you stay on the page — click Show All to reset",
|
||||
"The period selector applies to all chart tabs simultaneously",
|
||||
"Budget vs Actual shows dollar and percentage variance for each category",
|
||||
"SVG patterns help colorblind users distinguish categories in charts"
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
|
|
@ -836,8 +777,7 @@
|
|||
"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",
|
||||
"Optional feedback submission to feedback.lacompagniemaximus.com (explicit exception to the 100% local operation — prompts for consent)"
|
||||
"Optional AES-256-GCM encryption for exported files"
|
||||
],
|
||||
"steps": [
|
||||
"Click User Guide to access the full documentation",
|
||||
|
|
@ -845,8 +785,7 @@
|
|||
"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",
|
||||
"Click Send feedback in the Logs section to share a suggestion, comment, or issue — the identify and context/logs checkboxes are unchecked by default"
|
||||
"When importing, select a previously exported file — encrypted files will prompt for the password"
|
||||
],
|
||||
"tips": [
|
||||
"Updates only replace the app binary — your database is never modified",
|
||||
|
|
@ -854,8 +793,7 @@
|
|||
"Export regularly to keep a backup of your data",
|
||||
"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",
|
||||
"Feedback is the only feature that talks to a server besides updates and Maximus sign-in — every submission is explicit, no automatic telemetry"
|
||||
"If you encounter an issue, copy the logs and attach them to your report"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -909,9 +847,7 @@
|
|||
"language": "Language",
|
||||
"total": "Total",
|
||||
"darkMode": "Dark mode",
|
||||
"lightMode": "Light mode",
|
||||
"close": "Close",
|
||||
"underConstruction": "Under construction"
|
||||
"lightMode": "Light mode"
|
||||
},
|
||||
"license": {
|
||||
"title": "License",
|
||||
|
|
@ -946,41 +882,6 @@
|
|||
"description": "Sign in to access Premium features (web version, sync). The account is only required for Premium features.",
|
||||
"signIn": "Sign in",
|
||||
"signOut": "Sign out",
|
||||
"connected": "Connected",
|
||||
"tokenStore": {
|
||||
"fallback": {
|
||||
"title": "Tokens stored in plaintext fallback",
|
||||
"description": "Your authentication tokens are currently stored in a local file protected by filesystem permissions. For stronger protection via the OS keychain, make sure a keyring service is running (GNOME Keyring, KWallet, or equivalent)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"button": "Send feedback",
|
||||
"logsHeading": "Recent logs:",
|
||||
"dialog": {
|
||||
"title": "Your feedback",
|
||||
"placeholder": "Describe your suggestion, comment, or issue...",
|
||||
"submit": "Send",
|
||||
"sending": "Sending...",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"checkbox": {
|
||||
"context": "Include navigation context (page, theme, viewport, version)",
|
||||
"logs": "Include recent error logs",
|
||||
"identify": "Identify me with my Maximus account"
|
||||
},
|
||||
"toast": {
|
||||
"success": "Thank you for your feedback",
|
||||
"error": {
|
||||
"429": "Too many feedbacks sent recently. Try again later.",
|
||||
"400": "Invalid feedback. Check the content.",
|
||||
"generic": "Error while sending. Try again later."
|
||||
}
|
||||
},
|
||||
"consent": {
|
||||
"title": "Feedback submission",
|
||||
"body": "Your feedback will be sent to feedback.lacompagniemaximus.com so we can improve the app. This requires an internet connection and is an exception to the app's 100% local operation.",
|
||||
"accept": "I agree"
|
||||
}
|
||||
"connected": "Connected"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -358,11 +358,8 @@
|
|||
"period": "Période",
|
||||
"byCategory": "Dépenses par catégorie",
|
||||
"overTime": "Catégories dans le temps",
|
||||
"trends": {
|
||||
"subviewGlobal": "Flux global",
|
||||
"subviewByCategory": "Par catégorie"
|
||||
},
|
||||
"budgetVsActual": "Budget vs Réel",
|
||||
"trends": "Tendances mensuelles",
|
||||
"budgetVsActual": "Budget vs R\u00e9el",
|
||||
"subtotalsOnTop": "Sous-totaux en haut",
|
||||
"subtotalsOnBottom": "Sous-totaux en bas",
|
||||
"detail": {
|
||||
|
|
@ -379,98 +376,38 @@
|
|||
"bva": {
|
||||
"monthly": "Mensuel",
|
||||
"ytd": "Cumul annuel",
|
||||
"dollarVar": "$ Écart",
|
||||
"pctVar": "% Écart",
|
||||
"noData": "Aucune donnée de budget ou de transaction pour cette période.",
|
||||
"dollarVar": "$ \u00c9cart",
|
||||
"pctVar": "% \u00c9cart",
|
||||
"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",
|
||||
"pivot": {
|
||||
"availableFields": "Champs disponibles",
|
||||
"rows": "Lignes",
|
||||
"columns": "Colonnes",
|
||||
"filters": "Filtres",
|
||||
"values": "Valeurs",
|
||||
"addTo": "Ajouter à...",
|
||||
"year": "Année",
|
||||
"month": "Mois",
|
||||
"viewMode": {
|
||||
"chart": "Graphique",
|
||||
"table": "Tableau"
|
||||
},
|
||||
"hub": {
|
||||
"title": "Rapports",
|
||||
"explore": "Explorer",
|
||||
"highlights": "Faits saillants",
|
||||
"highlightsDescription": "Ce qui a bougé ce mois-ci",
|
||||
"trends": "Tendances",
|
||||
"trendsDescription": "Où vous allez sur 12 mois",
|
||||
"compare": "Comparables",
|
||||
"compareDescription": "Comparer un mois de référence au précédent, à l'année passée ou au budget",
|
||||
"categoryZoom": "Analyse par catégorie",
|
||||
"categoryZoomDescription": "Zoom sur une catégorie",
|
||||
"cartes": "Cartes",
|
||||
"cartesDescription": "Tableau de bord KPI, sparklines, top mouvements, budget et saisonnalité"
|
||||
},
|
||||
"compare": {
|
||||
"modeActual": "Réel vs réel",
|
||||
"modeBudget": "Réel vs budget",
|
||||
"subModeMoM": "Mois précédent",
|
||||
"subModeYoY": "Année précédente",
|
||||
"subModeAria": "Période de comparaison",
|
||||
"referenceMonth": "Mois de référence"
|
||||
},
|
||||
"cartes": {
|
||||
"kpiSectionAria": "Indicateurs clés du mois de référence",
|
||||
"income": "Revenus",
|
||||
"expenses": "Dépenses",
|
||||
"net": "Solde net",
|
||||
"savingsRate": "Taux d'épargne",
|
||||
"deltaMoMLabel": "vs mois précédent",
|
||||
"deltaYoYLabel": "vs l'an dernier",
|
||||
"flowChartTitle": "Revenus vs dépenses — 12 derniers mois",
|
||||
"topMoversUp": "Catégories en hausse",
|
||||
"topMoversDown": "Catégories en baisse",
|
||||
"budgetAdherenceTitle": "Respect du budget",
|
||||
"budgetAdherenceSubtitle": "{{score}} des catégories avec budget sont dans la cible",
|
||||
"budgetAdherenceEmpty": "Aucune catégorie avec budget ce mois-ci",
|
||||
"budgetAdherenceWorst": "Pires dépassements",
|
||||
"seasonalityTitle": "Saisonnalité",
|
||||
"seasonalityEmpty": "Pas assez d'historique pour ce mois",
|
||||
"seasonalityAverage": "Moyenne",
|
||||
"seasonalityDeviation": "{{pct}} par rapport à la moyenne"
|
||||
},
|
||||
"category": {
|
||||
"selectCategory": "Choisir une catégorie",
|
||||
"includeSubcategories": "Inclure les sous-catégories",
|
||||
"directOnly": "Directe seulement",
|
||||
"breakdown": "Total",
|
||||
"evolution": "Évolution",
|
||||
"transactions": "Transactions"
|
||||
},
|
||||
"keyword": {
|
||||
"addFromTransaction": "Ajouter comme mot-clé",
|
||||
"dialogTitle": "Nouveau mot-clé",
|
||||
"willMatch": "Matchera aussi",
|
||||
"nMatches_one": "{{count}} transaction matchée",
|
||||
"nMatches_other": "{{count}} transactions matchées",
|
||||
"applyAndRecategorize": "Appliquer et recatégoriser",
|
||||
"applyToHidden": "Appliquer aussi aux {{count}} transactions non affichées",
|
||||
"tooShort": "Minimum {{min}} caractères",
|
||||
"tooLong": "Maximum {{max}} caractères",
|
||||
"alreadyExists": "Ce mot-clé existe déjà pour une autre catégorie. Remplacer ?"
|
||||
},
|
||||
"highlights": {
|
||||
"balances": "Soldes",
|
||||
"netBalanceCurrent": "Ce mois-ci",
|
||||
"netBalanceYtd": "Cumul annuel",
|
||||
"topMovers": "Top mouvements",
|
||||
"topTransactions": "Plus grosses transactions récentes",
|
||||
"category": "Catégorie",
|
||||
"previousAmount": "Précédent",
|
||||
"currentAmount": "Courant",
|
||||
"variationAbs": "Écart ($)",
|
||||
"variationPct": "Écart (%)",
|
||||
"vsLastMonth": "vs mois précédent",
|
||||
"windowDays30": "30 jours",
|
||||
"windowDays60": "60 jours",
|
||||
"windowDays90": "90 jours"
|
||||
},
|
||||
"empty": {
|
||||
"noData": "Aucune donnée pour cette période",
|
||||
"importCta": "Importer un relevé"
|
||||
"categoryType": "Type",
|
||||
"level1": "Catégorie (Niveau 1)",
|
||||
"level2": "Catégorie (Niveau 2)",
|
||||
"level3": "Catégorie (Niveau 3)",
|
||||
"periodic": "Montant périodique",
|
||||
"ytd": "Cumul annuel (YTD)",
|
||||
"subtotal": "Sous-total",
|
||||
"total": "Total",
|
||||
"viewTable": "Tableau",
|
||||
"viewChart": "Graphique",
|
||||
"viewBoth": "Les deux",
|
||||
"noConfig": "Ajoutez des champs pour générer le rapport",
|
||||
"noData": "Aucune donnée pour cette configuration",
|
||||
"fullscreen": "Plein écran",
|
||||
"exitFullscreen": "Quitter plein écran",
|
||||
"rightClickExclude": "Clic-droit pour exclure"
|
||||
},
|
||||
"help": {
|
||||
"title": "Comment utiliser les Rapports",
|
||||
|
|
@ -478,7 +415,8 @@
|
|||
"Basculez entre les vues Tendances, Par catégorie et Dans le temps via les onglets",
|
||||
"Utilisez le sélecteur de période pour ajuster la plage de dates de tous les graphiques",
|
||||
"Les tendances mensuelles montrent vos revenus et dépenses au fil du temps",
|
||||
"Catégories dans le temps suit l'évolution des dépenses par catégorie"
|
||||
"Catégories dans le temps suit l'évolution des dépenses par catégorie",
|
||||
"Le Rapport dynamique permet de créer des tableaux croisés personnalisés en assignant des dimensions aux lignes, colonnes et filtres"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -801,29 +739,32 @@
|
|||
},
|
||||
"reports": {
|
||||
"title": "Rapports",
|
||||
"overview": "Un hub qui affiche un panneau de faits saillants en direct plus quatre sous-rapports dédiés (Faits saillants, Tendances, Comparables, Zoom catégorie). Chaque page partage une période bookmarkable via la query string de l'URL.",
|
||||
"overview": "Visualisez vos données financières avec des graphiques interactifs et comparez votre plan budgétaire au réel.",
|
||||
"features": [
|
||||
"Hub : panneau de faits saillants condensé + 4 cartes de navigation",
|
||||
"Faits saillants : soldes mois courant et cumul annuel avec sparklines, top mouvements vs mois précédent, plus grosses transactions récentes (fenêtre 30/60/90 jours)",
|
||||
"Tendances : flux global (revenus vs dépenses) et évolution par catégorie avec toggle graphique/tableau",
|
||||
"Comparables : Mois vs Mois précédent, Année vs Année précédente, et Réel vs Budget",
|
||||
"Zoom catégorie : analyse d'une seule catégorie avec donut, évolution mensuelle et tableau de transactions filtrable ; rollup automatique des sous-catégories",
|
||||
"Édition contextuelle des mots-clés : clic droit sur une ligne de transaction pour ajouter sa description comme mot-clé avec prévisualisation en direct des matches",
|
||||
"Tendances mensuelles : revenus vs dépenses dans le temps (graphique en barres)",
|
||||
"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",
|
||||
"Préférence chart/table mémorisée par section de rapport"
|
||||
"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": [
|
||||
"Ouvrez /reports pour voir le panneau de faits saillants et les quatre cartes de navigation",
|
||||
"Ajustez la période avec le sélecteur — elle est reflétée dans l'URL et partagée avec tous les sous-rapports",
|
||||
"Cliquez sur une carte ou un lien pour ouvrir le sous-rapport correspondant",
|
||||
"Basculez graphique/tableau sur n'importe quel sous-rapport — votre choix est mémorisé",
|
||||
"Cliquez droit sur une ligne de transaction dans le zoom catégorie, la liste des faits saillants, ou la page transactions pour ajouter un mot-clé",
|
||||
"Dans le dialog de mot-clé, passez en revue la prévisualisation des transactions qui matchent et confirmez pour appliquer"
|
||||
"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 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": [
|
||||
"Copiez l'URL pour partager une période et un rapport spécifiques",
|
||||
"Les mots-clés doivent faire entre 2 et 64 caractères",
|
||||
"Le Zoom catégorie est protégé contre les arborescences malformées : un cycle parent_id ne peut pas figer l'app"
|
||||
"Les catégories masquées sont mémorisées tant que vous restez sur la page — cliquez sur Tout afficher pour réinitialiser",
|
||||
"Le sélecteur de période s'applique à tous les onglets de graphiques simultanément",
|
||||
"Budget vs Réel affiche l'écart en dollars et en pourcentage pour chaque catégorie",
|
||||
"Les motifs SVG aident les personnes daltoniennes à distinguer les catégories dans les graphiques"
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
|
|
@ -836,8 +777,7 @@
|
|||
"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",
|
||||
"Envoi de feedback optionnel vers feedback.lacompagniemaximus.com (exception explicite au fonctionnement 100% local — déclenche une demande de consentement)"
|
||||
"Chiffrement AES-256-GCM optionnel pour les fichiers exportés"
|
||||
],
|
||||
"steps": [
|
||||
"Cliquez sur Guide d'utilisation pour accéder à la documentation complète",
|
||||
|
|
@ -845,8 +785,7 @@
|
|||
"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",
|
||||
"Cliquez sur Envoyer un feedback dans la section Journaux pour partager une suggestion, un commentaire ou un problème — la case d'identification et d'envoi du contexte/logs sont décochées par défaut"
|
||||
"Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe"
|
||||
],
|
||||
"tips": [
|
||||
"Les mises à jour ne remplacent que le programme — votre base de données n'est jamais modifiée",
|
||||
|
|
@ -854,8 +793,7 @@
|
|||
"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",
|
||||
"Le feedback est la seule fonctionnalité qui communique avec un serveur hors mises à jour et connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique"
|
||||
"En cas de problème, copiez les journaux et joignez-les à votre signalement"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -909,9 +847,7 @@
|
|||
"language": "Langue",
|
||||
"total": "Total",
|
||||
"darkMode": "Mode sombre",
|
||||
"lightMode": "Mode clair",
|
||||
"close": "Fermer",
|
||||
"underConstruction": "En construction"
|
||||
"lightMode": "Mode clair"
|
||||
},
|
||||
"license": {
|
||||
"title": "Licence",
|
||||
|
|
@ -946,41 +882,6 @@
|
|||
"description": "Connectez-vous pour accéder aux fonctionnalités Premium (version web, synchronisation). Le compte est requis uniquement pour les fonctionnalités Premium.",
|
||||
"signIn": "Se connecter",
|
||||
"signOut": "Se déconnecter",
|
||||
"connected": "Connecté",
|
||||
"tokenStore": {
|
||||
"fallback": {
|
||||
"title": "Stockage des tokens en clair",
|
||||
"description": "Vos jetons d'authentification sont stockés dans un fichier local protégé par les permissions du système. Pour une protection renforcée via le trousseau du système d'exploitation, vérifiez que le service de trousseau est disponible (GNOME Keyring, KWallet, ou équivalent)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"button": "Envoyer un feedback",
|
||||
"logsHeading": "Logs récents :",
|
||||
"dialog": {
|
||||
"title": "Votre avis",
|
||||
"placeholder": "Décrivez votre suggestion, commentaire ou problème...",
|
||||
"submit": "Envoyer",
|
||||
"sending": "Envoi...",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"checkbox": {
|
||||
"context": "Inclure le contexte de navigation (page, thème, écran, version)",
|
||||
"logs": "Inclure les derniers logs d'erreur",
|
||||
"identify": "M'identifier avec mon compte Maximus"
|
||||
},
|
||||
"toast": {
|
||||
"success": "Merci pour votre feedback",
|
||||
"error": {
|
||||
"429": "Trop de feedbacks envoyés récemment. Réessayez plus tard.",
|
||||
"400": "Feedback invalide. Vérifiez le contenu.",
|
||||
"generic": "Erreur lors de l'envoi. Réessayez plus tard."
|
||||
}
|
||||
},
|
||||
"consent": {
|
||||
"title": "Envoi de feedback",
|
||||
"body": "Votre feedback sera envoyé à feedback.lacompagniemaximus.com pour que nous puissions améliorer l'application. Cette opération nécessite une connexion Internet et constitue une exception au fonctionnement 100 % local de l'application.",
|
||||
"accept": "J'accepte"
|
||||
}
|
||||
"connected": "Connecté"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import ProfileFormModal from "../components/profile/ProfileFormModal";
|
|||
|
||||
export default function ProfileSelectionPage() {
|
||||
const { t } = useTranslation();
|
||||
const { profiles, switchProfile, updateProfile } = useProfile();
|
||||
const { profiles, switchProfile } = useProfile();
|
||||
const [pinProfileId, setPinProfileId] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
|
|
@ -23,15 +23,8 @@ export default function ProfileSelectionPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handlePinSuccess = async (rehashed?: string | null) => {
|
||||
const handlePinSuccess = () => {
|
||||
if (pinProfileId) {
|
||||
if (rehashed) {
|
||||
try {
|
||||
await updateProfile(pinProfileId, { pin_hash: rehashed });
|
||||
} catch {
|
||||
// Best-effort rehash: don't block profile switch if persistence fails
|
||||
}
|
||||
}
|
||||
switchProfile(pinProfileId);
|
||||
setPinProfileId(null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
|
||||
import KpiCard from "../components/reports/cards/KpiCard";
|
||||
import IncomeExpenseOverlayChart from "../components/reports/cards/IncomeExpenseOverlayChart";
|
||||
import TopMoversList from "../components/reports/cards/TopMoversList";
|
||||
import BudgetAdherenceCard from "../components/reports/cards/BudgetAdherenceCard";
|
||||
import SeasonalityCard from "../components/reports/cards/SeasonalityCard";
|
||||
import { useCartes } from "../hooks/useCartes";
|
||||
|
||||
export default function ReportsCartesPage() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
year,
|
||||
month,
|
||||
snapshot,
|
||||
isLoading,
|
||||
error,
|
||||
setReferencePeriod,
|
||||
period,
|
||||
setPeriod,
|
||||
from,
|
||||
to,
|
||||
setCustomDates,
|
||||
} = useCartes();
|
||||
|
||||
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
||||
|
||||
return (
|
||||
<div className={isLoading ? "opacity-60" : ""}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Link
|
||||
to={`/reports${preserveSearch}`}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
|
||||
aria-label={t("reports.hub.title")}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">{t("reports.hub.cartes")}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
|
||||
<PeriodSelector
|
||||
value={period}
|
||||
onChange={setPeriod}
|
||||
customDateFrom={from}
|
||||
customDateTo={to}
|
||||
onCustomDateChange={setCustomDates}
|
||||
/>
|
||||
<CompareReferenceMonthPicker year={year} month={month} onChange={setReferencePeriod} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!snapshot ? (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
|
||||
{t("reports.empty.noData")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<section
|
||||
className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3"
|
||||
aria-label={t("reports.cartes.kpiSectionAria")}
|
||||
>
|
||||
<KpiCard
|
||||
id="income"
|
||||
title={t("reports.cartes.income")}
|
||||
kpi={snapshot.kpis.income}
|
||||
format="currency"
|
||||
deltaIsBadWhenUp={false}
|
||||
/>
|
||||
<KpiCard
|
||||
id="expenses"
|
||||
title={t("reports.cartes.expenses")}
|
||||
kpi={snapshot.kpis.expenses}
|
||||
format="currency"
|
||||
deltaIsBadWhenUp={true}
|
||||
/>
|
||||
<KpiCard
|
||||
id="net"
|
||||
title={t("reports.cartes.net")}
|
||||
kpi={snapshot.kpis.net}
|
||||
format="currency"
|
||||
deltaIsBadWhenUp={false}
|
||||
/>
|
||||
<KpiCard
|
||||
id="savingsRate"
|
||||
title={t("reports.cartes.savingsRate")}
|
||||
kpi={snapshot.kpis.savingsRate}
|
||||
format="percent"
|
||||
deltaIsBadWhenUp={false}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<IncomeExpenseOverlayChart flow={snapshot.flow12Months} />
|
||||
|
||||
<section className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<TopMoversList movers={snapshot.topMoversUp} direction="up" />
|
||||
<TopMoversList movers={snapshot.topMoversDown} direction="down" />
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<BudgetAdherenceCard adherence={snapshot.budgetAdherence} />
|
||||
<SeasonalityCard
|
||||
seasonality={snapshot.seasonality}
|
||||
referenceYear={year}
|
||||
referenceMonth={month}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||
import CategoryZoomHeader from "../components/reports/CategoryZoomHeader";
|
||||
import CategoryDonutChart from "../components/reports/CategoryDonutChart";
|
||||
import CategoryEvolutionChart from "../components/reports/CategoryEvolutionChart";
|
||||
import CategoryTransactionsTable from "../components/reports/CategoryTransactionsTable";
|
||||
import AddKeywordDialog from "../components/categories/AddKeywordDialog";
|
||||
import { useCategoryZoom } from "../hooks/useCategoryZoom";
|
||||
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
||||
import type { RecentTransaction } from "../shared/types";
|
||||
|
||||
export default function ReportsCategoryPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
|
||||
const {
|
||||
zoomedCategoryId,
|
||||
rollupChildren,
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
setCategory,
|
||||
setRollupChildren,
|
||||
refetch,
|
||||
} = useCategoryZoom();
|
||||
const [pending, setPending] = useState<RecentTransaction | null>(null);
|
||||
|
||||
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
||||
|
||||
const centerLabel = t("reports.category.breakdown");
|
||||
const centerValue = data
|
||||
? new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(data.rollupTotal)
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<div className={isLoading ? "opacity-60" : ""}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Link
|
||||
to={`/reports${preserveSearch}`}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
|
||||
aria-label={t("reports.hub.title")}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">{t("reports.hub.categoryZoom")}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<PeriodSelector
|
||||
value={period}
|
||||
onChange={setPeriod}
|
||||
customDateFrom={from}
|
||||
customDateTo={to}
|
||||
onCustomDateChange={setCustomDates}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<CategoryZoomHeader
|
||||
categoryId={zoomedCategoryId}
|
||||
includeSubcategories={rollupChildren}
|
||||
onCategoryChange={setCategory}
|
||||
onIncludeSubcategoriesChange={setRollupChildren}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{zoomedCategoryId === null ? (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
||||
{t("reports.category.selectCategory")}
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||
<CategoryDonutChart
|
||||
byChild={data.byChild}
|
||||
centerLabel={centerLabel}
|
||||
centerValue={centerValue}
|
||||
/>
|
||||
<CategoryEvolutionChart data={data.monthlyEvolution} />
|
||||
<div className="lg:col-span-2">
|
||||
<h3 className="text-sm font-semibold mb-2">{t("reports.category.transactions")}</h3>
|
||||
<CategoryTransactionsTable
|
||||
transactions={data.transactions}
|
||||
onAddKeyword={(tx) => setPending(tx)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pending && (
|
||||
<AddKeywordDialog
|
||||
initialKeyword={pending.description.split(/\s+/)[0] ?? ""}
|
||||
initialCategoryId={zoomedCategoryId}
|
||||
onClose={() => setPending(null)}
|
||||
onApplied={() => {
|
||||
setPending(null);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||
import CompareModeTabs from "../components/reports/CompareModeTabs";
|
||||
import CompareSubModeToggle from "../components/reports/CompareSubModeToggle";
|
||||
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
|
||||
import ComparePeriodTable from "../components/reports/ComparePeriodTable";
|
||||
import ComparePeriodChart from "../components/reports/ComparePeriodChart";
|
||||
import CompareBudgetView from "../components/reports/CompareBudgetView";
|
||||
import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle";
|
||||
import { useCompare, comparisonMeta } from "../hooks/useCompare";
|
||||
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
||||
|
||||
const STORAGE_KEY = "reports-viewmode-compare";
|
||||
|
||||
function formatMonthLabel(year: number, month: number, language: string): string {
|
||||
const date = new Date(year, month - 1, 1);
|
||||
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default function ReportsComparePage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
|
||||
const {
|
||||
mode,
|
||||
subMode,
|
||||
setMode,
|
||||
setSubMode,
|
||||
setReferencePeriod,
|
||||
year,
|
||||
month,
|
||||
rows,
|
||||
isLoading,
|
||||
error,
|
||||
} = useCompare();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
|
||||
|
||||
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
||||
|
||||
const { previousYear, previousMonth: prevMonth } = comparisonMeta(subMode, year, month);
|
||||
const currentLabel =
|
||||
subMode === "mom" ? formatMonthLabel(year, month, i18n.language) : String(year);
|
||||
const previousLabel =
|
||||
subMode === "mom"
|
||||
? formatMonthLabel(previousYear, prevMonth, i18n.language)
|
||||
: String(previousYear);
|
||||
|
||||
const showActualControls = mode === "actual";
|
||||
|
||||
return (
|
||||
<div className={isLoading ? "opacity-60" : ""}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Link
|
||||
to={`/reports${preserveSearch}`}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
|
||||
aria-label={t("reports.hub.title")}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">{t("reports.hub.compare")}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4 flex-wrap">
|
||||
<PeriodSelector
|
||||
value={period}
|
||||
onChange={setPeriod}
|
||||
customDateFrom={from}
|
||||
customDateTo={to}
|
||||
onCustomDateChange={setCustomDates}
|
||||
/>
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<CompareModeTabs value={mode} onChange={setMode} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<CompareReferenceMonthPicker
|
||||
year={year}
|
||||
month={month}
|
||||
onChange={setReferencePeriod}
|
||||
/>
|
||||
{showActualControls && (
|
||||
<CompareSubModeToggle value={subMode} onChange={setSubMode} />
|
||||
)}
|
||||
</div>
|
||||
{showActualControls && (
|
||||
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "budget" ? (
|
||||
<CompareBudgetView year={year} month={month} />
|
||||
) : viewMode === "chart" ? (
|
||||
<ComparePeriodChart
|
||||
rows={rows}
|
||||
previousLabel={previousLabel}
|
||||
currentLabel={currentLabel}
|
||||
/>
|
||||
) : (
|
||||
<ComparePeriodTable rows={rows} previousLabel={previousLabel} currentLabel={currentLabel} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowLeft, Tag } from "lucide-react";
|
||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||
import HubNetBalanceTile from "../components/reports/HubNetBalanceTile";
|
||||
import HighlightsTopMoversTable from "../components/reports/HighlightsTopMoversTable";
|
||||
import HighlightsTopMoversChart from "../components/reports/HighlightsTopMoversChart";
|
||||
import HighlightsTopTransactionsList from "../components/reports/HighlightsTopTransactionsList";
|
||||
import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle";
|
||||
import ContextMenu from "../components/shared/ContextMenu";
|
||||
import AddKeywordDialog from "../components/categories/AddKeywordDialog";
|
||||
import { useHighlights } from "../hooks/useHighlights";
|
||||
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
||||
import type { RecentTransaction } from "../shared/types";
|
||||
|
||||
const STORAGE_KEY = "reports-viewmode-highlights";
|
||||
|
||||
export default function ReportsHighlightsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
|
||||
const { data, isLoading, error, windowDays, setWindowDays } = useHighlights();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; tx: RecentTransaction } | null>(null);
|
||||
const [pending, setPending] = useState<RecentTransaction | null>(null);
|
||||
|
||||
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, tx: RecentTransaction) => {
|
||||
e.preventDefault();
|
||||
setMenu({ x: e.clientX, y: e.clientY, tx });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={isLoading ? "opacity-60" : ""}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Link
|
||||
to={`/reports${preserveSearch}`}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
|
||||
aria-label={t("reports.hub.title")}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">{t("reports.hub.highlights")}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<PeriodSelector
|
||||
value={period}
|
||||
onChange={setPeriod}
|
||||
customDateFrom={from}
|
||||
customDateTo={to}
|
||||
onCustomDateChange={setCustomDates}
|
||||
/>
|
||||
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
|
||||
{t("reports.highlights.balances")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<HubNetBalanceTile
|
||||
label={t("reports.highlights.netBalanceCurrent")}
|
||||
amount={data.netBalanceCurrent}
|
||||
series={data.monthlyBalanceSeries.map((m) => m.netBalance)}
|
||||
/>
|
||||
<HubNetBalanceTile
|
||||
label={t("reports.highlights.netBalanceYtd")}
|
||||
amount={data.netBalanceYtd}
|
||||
series={data.monthlyBalanceSeries.map((m) => m.netBalance)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
|
||||
{t("reports.highlights.topMovers")}
|
||||
</h2>
|
||||
{viewMode === "chart" ? (
|
||||
<HighlightsTopMoversChart movers={data.topMovers} />
|
||||
) : (
|
||||
<HighlightsTopMoversTable movers={data.topMovers} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<HighlightsTopTransactionsList
|
||||
transactions={data.topTransactions}
|
||||
windowDays={windowDays}
|
||||
onWindowChange={setWindowDays}
|
||||
onContextMenuRow={handleContextMenu}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{menu && (
|
||||
<ContextMenu
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
header={menu.tx.description}
|
||||
onClose={() => setMenu(null)}
|
||||
items={[
|
||||
{
|
||||
icon: <Tag size={14} />,
|
||||
label: t("reports.keyword.addFromTransaction"),
|
||||
onClick: () => setPending(menu.tx),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pending && (
|
||||
<AddKeywordDialog
|
||||
initialKeyword={pending.description.split(/\s+/)[0] ?? ""}
|
||||
onClose={() => setPending(null)}
|
||||
onApplied={() => setPending(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,79 +1,257 @@
|
|||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sparkles, TrendingUp, Scale, Search, LayoutDashboard } from "lucide-react";
|
||||
import { Hash, Table, BarChart3 } from "lucide-react";
|
||||
import { useReports } from "../hooks/useReports";
|
||||
import { PageHelp } from "../components/shared/PageHelp";
|
||||
import type { ReportTab, CategoryBreakdownItem, ImportSource } from "../shared/types";
|
||||
import { getAllSources } from "../services/importSourceService";
|
||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||
import HubHighlightsPanel from "../components/reports/HubHighlightsPanel";
|
||||
import HubReportNavCard from "../components/reports/HubReportNavCard";
|
||||
import { useHighlights } from "../hooks/useHighlights";
|
||||
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
||||
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"];
|
||||
|
||||
export default function ReportsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
|
||||
const { data, isLoading, error } = useHighlights();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId, setCategoryType } = useReports();
|
||||
const [sources, setSources] = useState<ImportSource[]>([]);
|
||||
|
||||
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
||||
const navCards = [
|
||||
{
|
||||
to: `/reports/highlights${preserveSearch}`,
|
||||
icon: <Sparkles size={24} />,
|
||||
title: t("reports.hub.highlights"),
|
||||
description: t("reports.hub.highlightsDescription"),
|
||||
},
|
||||
{
|
||||
to: `/reports/trends${preserveSearch}`,
|
||||
icon: <TrendingUp size={24} />,
|
||||
title: t("reports.hub.trends"),
|
||||
description: t("reports.hub.trendsDescription"),
|
||||
},
|
||||
{
|
||||
to: `/reports/compare${preserveSearch}`,
|
||||
icon: <Scale size={24} />,
|
||||
title: t("reports.hub.compare"),
|
||||
description: t("reports.hub.compareDescription"),
|
||||
},
|
||||
{
|
||||
to: `/reports/category${preserveSearch}`,
|
||||
icon: <Search size={24} />,
|
||||
title: t("reports.hub.categoryZoom"),
|
||||
description: t("reports.hub.categoryZoomDescription"),
|
||||
},
|
||||
{
|
||||
to: `/reports/cartes${preserveSearch}`,
|
||||
icon: <LayoutDashboard size={24} />,
|
||||
title: t("reports.hub.cartes"),
|
||||
description: t("reports.hub.cartesDescription"),
|
||||
},
|
||||
];
|
||||
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) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const showAll = useCallback(() => setHiddenCategories(new Set()), []);
|
||||
|
||||
const viewDetails = useCallback((item: CategoryBreakdownItem) => {
|
||||
setDetailModal(item);
|
||||
}, []);
|
||||
|
||||
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>
|
||||
<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.hub.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" && (
|
||||
<PeriodSelector
|
||||
value={period}
|
||||
value={state.period}
|
||||
onChange={setPeriod}
|
||||
customDateFrom={from}
|
||||
customDateTo={to}
|
||||
customDateFrom={state.customDateFrom}
|
||||
customDateTo={state.customDateTo}
|
||||
onCustomDateChange={setCustomDates}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<HubHighlightsPanel data={data} isLoading={isLoading} error={error} />
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
|
||||
{t("reports.hub.explore")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3">
|
||||
{navCards.map((card) => (
|
||||
<HubReportNavCard key={card.to} {...card} />
|
||||
<div className="flex gap-2 mb-6 flex-wrap items-center">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setTab(tab)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
tab === state.tab
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
</section>
|
||||
|
||||
{state.error && (
|
||||
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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}
|
||||
categoryType={state.tab === "overTime" ? state.categoryType : undefined}
|
||||
onCategoryTypeChange={state.tab === "overTime" ? setCategoryType : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detailModal && (
|
||||
<TransactionDetailModal
|
||||
categoryId={detailModal.category_id}
|
||||
categoryName={detailModal.category_name}
|
||||
categoryColor={detailModal.category_color}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
onClose={() => setDetailModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
||||
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
|
||||
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
||||
import CategoryOverTimeTable from "../components/reports/CategoryOverTimeTable";
|
||||
import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle";
|
||||
import { useTrends } from "../hooks/useTrends";
|
||||
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
||||
import type { CategoryBreakdownItem } from "../shared/types";
|
||||
|
||||
const STORAGE_KEY = "reports-viewmode-trends";
|
||||
|
||||
export default function ReportsTrendsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
|
||||
const { subView, setSubView, monthlyTrends, categoryOverTime, isLoading, error } = useTrends();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
|
||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||
|
||||
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
||||
|
||||
const toggleHidden = useCallback((name: string) => {
|
||||
setHiddenCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const showAll = useCallback(() => setHiddenCategories(new Set()), []);
|
||||
// viewDetails not used in trends view — transactions details are accessed from category zoom.
|
||||
const noOpDetails = useCallback((_item: CategoryBreakdownItem) => {}, []);
|
||||
|
||||
return (
|
||||
<div className={isLoading ? "opacity-60" : ""}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Link
|
||||
to={`/reports${preserveSearch}`}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
|
||||
aria-label={t("reports.hub.title")}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">{t("reports.hub.trends")}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 flex-wrap">
|
||||
<PeriodSelector
|
||||
value={period}
|
||||
onChange={setPeriod}
|
||||
customDateFrom={from}
|
||||
customDateTo={to}
|
||||
onCustomDateChange={setCustomDates}
|
||||
/>
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<div className="inline-flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubView("global")}
|
||||
aria-pressed={subView === "global"}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
subView === "global"
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||
}`}
|
||||
>
|
||||
{t("reports.trends.subviewGlobal")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubView("byCategory")}
|
||||
aria-pressed={subView === "byCategory"}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
subView === "byCategory"
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||
}`}
|
||||
>
|
||||
{t("reports.trends.subviewByCategory")}
|
||||
</button>
|
||||
</div>
|
||||
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subView === "global" ? (
|
||||
viewMode === "chart" ? (
|
||||
<MonthlyTrendsChart data={monthlyTrends} />
|
||||
) : (
|
||||
<MonthlyTrendsTable data={monthlyTrends} />
|
||||
)
|
||||
) : viewMode === "chart" ? (
|
||||
<CategoryOverTimeChart
|
||||
data={categoryOverTime}
|
||||
hiddenCategories={hiddenCategories}
|
||||
onToggleHidden={toggleHidden}
|
||||
onShowAll={showAll}
|
||||
onViewDetails={noOpDetails}
|
||||
/>
|
||||
) : (
|
||||
<CategoryOverTimeTable data={categoryOverTime} hiddenCategories={hiddenCategories} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,7 +22,6 @@ import DataManagementCard from "../components/settings/DataManagementCard";
|
|||
import LicenseCard from "../components/settings/LicenseCard";
|
||||
import AccountCard from "../components/settings/AccountCard";
|
||||
import LogViewerCard from "../components/settings/LogViewerCard";
|
||||
import TokenStoreFallbackBanner from "../components/settings/TokenStoreFallbackBanner";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
|
@ -81,10 +80,6 @@ export default function SettingsPage() {
|
|||
{/* Account card */}
|
||||
<AccountCard />
|
||||
|
||||
{/* Security banner — renders only when OAuth tokens are in the
|
||||
file fallback instead of the OS keychain */}
|
||||
<TokenStoreFallbackBanner />
|
||||
|
||||
{/* About card */}
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
|
|||
|
|
@ -1,28 +1,18 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Wand2, Tag } from "lucide-react";
|
||||
import { Wand2 } from "lucide-react";
|
||||
import { PageHelp } from "../components/shared/PageHelp";
|
||||
import { useTransactions } from "../hooks/useTransactions";
|
||||
import TransactionFilterBar from "../components/transactions/TransactionFilterBar";
|
||||
import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar";
|
||||
import TransactionTable from "../components/transactions/TransactionTable";
|
||||
import TransactionPagination from "../components/transactions/TransactionPagination";
|
||||
import ContextMenu from "../components/shared/ContextMenu";
|
||||
import AddKeywordDialog from "../components/categories/AddKeywordDialog";
|
||||
import type { TransactionRow } from "../shared/types";
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory, loadSplitChildren, saveSplit, deleteSplit } =
|
||||
useTransactions();
|
||||
const [resultMessage, setResultMessage] = useState<string | null>(null);
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null);
|
||||
const [pending, setPending] = useState<TransactionRow | null>(null);
|
||||
|
||||
const handleRowContextMenu = (e: React.MouseEvent, row: TransactionRow) => {
|
||||
e.preventDefault();
|
||||
setMenu({ x: e.clientX, y: e.clientY, row });
|
||||
};
|
||||
|
||||
const handleAutoCategorize = async () => {
|
||||
setResultMessage(null);
|
||||
|
|
@ -94,7 +84,6 @@ export default function TransactionsPage() {
|
|||
onLoadSplitChildren={loadSplitChildren}
|
||||
onSaveSplit={saveSplit}
|
||||
onDeleteSplit={deleteSplit}
|
||||
onRowContextMenu={handleRowContextMenu}
|
||||
/>
|
||||
|
||||
<TransactionPagination
|
||||
|
|
@ -105,31 +94,6 @@ export default function TransactionsPage() {
|
|||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{menu && (
|
||||
<ContextMenu
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
header={menu.row.description}
|
||||
onClose={() => setMenu(null)}
|
||||
items={[
|
||||
{
|
||||
icon: <Tag size={14} />,
|
||||
label: t("reports.keyword.addFromTransaction"),
|
||||
onClick: () => setPending(menu.row),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pending && (
|
||||
<AddKeywordDialog
|
||||
initialKeyword={pending.description.split(/\s+/)[0] ?? ""}
|
||||
initialCategoryId={pending.category_id}
|
||||
onClose={() => setPending(null)}
|
||||
onApplied={() => setPending(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,9 +26,3 @@ export async function checkSubscriptionStatus(): Promise<AccountInfo | null> {
|
|||
export async function logoutAccount(): Promise<void> {
|
||||
return invoke<void>("logout");
|
||||
}
|
||||
|
||||
export type TokenStoreMode = "keychain" | "file";
|
||||
|
||||
export async function getTokenStoreMode(): Promise<TokenStoreMode | null> {
|
||||
return invoke<TokenStoreMode | null>("get_token_store_mode");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,174 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
validateKeyword,
|
||||
previewKeywordMatches,
|
||||
applyKeywordWithReassignment,
|
||||
KEYWORD_MAX_LENGTH,
|
||||
} from "./categorizationService";
|
||||
|
||||
vi.mock("./db", () => ({
|
||||
getDb: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getDb } from "./db";
|
||||
|
||||
const mockSelect = vi.fn();
|
||||
const mockExecute = vi.fn();
|
||||
const mockDb = { select: mockSelect, execute: mockExecute };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getDb).mockResolvedValue(mockDb as never);
|
||||
mockSelect.mockReset();
|
||||
mockExecute.mockReset();
|
||||
});
|
||||
|
||||
describe("validateKeyword", () => {
|
||||
it("rejects whitespace-only input", () => {
|
||||
expect(validateKeyword(" ")).toEqual({ ok: false, reason: "tooShort" });
|
||||
});
|
||||
|
||||
it("rejects single-character keywords", () => {
|
||||
expect(validateKeyword("a")).toEqual({ ok: false, reason: "tooShort" });
|
||||
});
|
||||
|
||||
it("accepts a minimal two-character keyword after trim", () => {
|
||||
expect(validateKeyword(" ab ")).toEqual({ ok: true, value: "ab" });
|
||||
});
|
||||
|
||||
it("rejects keywords longer than 64 characters (ReDoS cap)", () => {
|
||||
const long = "a".repeat(KEYWORD_MAX_LENGTH + 1);
|
||||
expect(validateKeyword(long)).toEqual({ ok: false, reason: "tooLong" });
|
||||
});
|
||||
|
||||
it("accepts keywords at exactly 64 characters", () => {
|
||||
const borderline = "a".repeat(KEYWORD_MAX_LENGTH);
|
||||
expect(validateKeyword(borderline)).toEqual({ ok: true, value: borderline });
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewKeywordMatches", () => {
|
||||
it("binds the LIKE pattern as a parameter (no interpolation)", async () => {
|
||||
mockSelect.mockResolvedValueOnce([]);
|
||||
|
||||
await previewKeywordMatches("METRO");
|
||||
|
||||
expect(mockSelect).toHaveBeenCalledTimes(1);
|
||||
const sql = mockSelect.mock.calls[0][0] as string;
|
||||
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||
expect(sql).toContain("LIKE $1");
|
||||
expect(sql).not.toContain("'metro'");
|
||||
expect(sql).not.toContain("'%metro%'");
|
||||
expect(params[0]).toBe("%metro%");
|
||||
});
|
||||
|
||||
it("returns an empty preview when the keyword is invalid", async () => {
|
||||
const result = await previewKeywordMatches("a");
|
||||
expect(result).toEqual({ visible: [], totalMatches: 0 });
|
||||
expect(mockSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters candidates with the regex and respects the visible cap", async () => {
|
||||
// 3 rows: 2 true matches, 1 substring-only (should be dropped by word-boundary)
|
||||
mockSelect.mockResolvedValueOnce([
|
||||
{ id: 1, date: "2026-03-15", description: "METRO #123", amount: -45, category_name: null, category_color: null },
|
||||
{ id: 2, date: "2026-03-02", description: "METRO PLUS", amount: -67.2, category_name: null, category_color: null },
|
||||
{ id: 3, date: "2026-02-18", description: "METROPOLITAIN", amount: -12, category_name: null, category_color: null },
|
||||
]);
|
||||
|
||||
const result = await previewKeywordMatches("METRO", 2);
|
||||
|
||||
expect(result.totalMatches).toBe(2);
|
||||
expect(result.visible).toHaveLength(2);
|
||||
expect(result.visible[0].id).toBe(1);
|
||||
expect(result.visible[1].id).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyKeywordWithReassignment", () => {
|
||||
it("wraps INSERT + UPDATEs in a BEGIN/COMMIT transaction", async () => {
|
||||
mockSelect.mockResolvedValueOnce([]); // existing keyword lookup → none
|
||||
mockExecute.mockResolvedValue({ lastInsertId: 77, rowsAffected: 1 });
|
||||
|
||||
await applyKeywordWithReassignment({
|
||||
keyword: "METRO",
|
||||
categoryId: 3,
|
||||
transactionIds: [1, 2],
|
||||
allowReplaceExisting: false,
|
||||
});
|
||||
|
||||
const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim());
|
||||
expect(commands[0]).toBe("BEGIN");
|
||||
expect(commands[commands.length - 1]).toBe("COMMIT");
|
||||
expect(commands.filter((c) => c.startsWith("INSERT INTO keywords"))).toHaveLength(1);
|
||||
expect(commands.filter((c) => c.startsWith("UPDATE transactions"))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rolls back when an UPDATE throws", async () => {
|
||||
mockSelect.mockResolvedValueOnce([]);
|
||||
mockExecute
|
||||
.mockResolvedValueOnce(undefined) // BEGIN
|
||||
.mockResolvedValueOnce({ lastInsertId: 77 }) // INSERT keywords
|
||||
.mockRejectedValueOnce(new Error("disk full")); // UPDATE transactions fails
|
||||
|
||||
await expect(
|
||||
applyKeywordWithReassignment({
|
||||
keyword: "METRO",
|
||||
categoryId: 3,
|
||||
transactionIds: [1],
|
||||
allowReplaceExisting: false,
|
||||
}),
|
||||
).rejects.toThrow("disk full");
|
||||
|
||||
const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim());
|
||||
expect(commands).toContain("BEGIN");
|
||||
expect(commands).toContain("ROLLBACK");
|
||||
expect(commands).not.toContain("COMMIT");
|
||||
});
|
||||
|
||||
it("blocks reassignment when keyword exists for another category without allowReplaceExisting", async () => {
|
||||
mockSelect.mockResolvedValueOnce([{ id: 10, category_id: 5 }]);
|
||||
|
||||
await expect(
|
||||
applyKeywordWithReassignment({
|
||||
keyword: "METRO",
|
||||
categoryId: 3,
|
||||
transactionIds: [1],
|
||||
allowReplaceExisting: false,
|
||||
}),
|
||||
).rejects.toThrow("keyword_already_exists");
|
||||
|
||||
const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim());
|
||||
expect(commands).toContain("BEGIN");
|
||||
expect(commands).toContain("ROLLBACK");
|
||||
});
|
||||
|
||||
it("reassigns existing keyword when allowReplaceExisting is true", async () => {
|
||||
mockSelect.mockResolvedValueOnce([{ id: 10, category_id: 5 }]);
|
||||
mockExecute.mockResolvedValue({ rowsAffected: 1 });
|
||||
|
||||
const result = await applyKeywordWithReassignment({
|
||||
keyword: "METRO",
|
||||
categoryId: 3,
|
||||
transactionIds: [1, 2],
|
||||
allowReplaceExisting: true,
|
||||
});
|
||||
|
||||
expect(result.replacedExisting).toBe(true);
|
||||
expect(result.updatedTransactions).toBe(2);
|
||||
const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim());
|
||||
expect(commands.some((c) => c.startsWith("UPDATE keywords SET category_id"))).toBe(true);
|
||||
expect(commands).toContain("COMMIT");
|
||||
});
|
||||
|
||||
it("rejects invalid keywords before touching the database", async () => {
|
||||
await expect(
|
||||
applyKeywordWithReassignment({
|
||||
keyword: "a",
|
||||
categoryId: 3,
|
||||
transactionIds: [1],
|
||||
allowReplaceExisting: false,
|
||||
}),
|
||||
).rejects.toThrow("invalid_keyword:tooShort");
|
||||
expect(mockExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { getDb } from "./db";
|
||||
import type { Keyword, RecentTransaction } from "../shared/types";
|
||||
import type { Keyword } from "../shared/types";
|
||||
|
||||
/**
|
||||
* Normalize a description for keyword matching:
|
||||
|
|
@ -7,7 +7,7 @@ import type { Keyword, RecentTransaction } from "../shared/types";
|
|||
* - strip accents via NFD decomposition
|
||||
* - collapse whitespace
|
||||
*/
|
||||
export function normalizeDescription(desc: string): string {
|
||||
function normalizeDescription(desc: string): string {
|
||||
return desc
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
|
|
@ -25,7 +25,7 @@ const WORD_CHAR = /\w/;
|
|||
* (e.g., brackets, parentheses, dashes). This ensures keywords like
|
||||
* "[VIREMENT]" or "(INTERAC)" can match correctly.
|
||||
*/
|
||||
export function buildKeywordRegex(normalizedKeyword: string): RegExp {
|
||||
function buildKeywordRegex(normalizedKeyword: string): RegExp {
|
||||
const escaped = normalizedKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const left = WORD_CHAR.test(normalizedKeyword[0])
|
||||
? "\\b"
|
||||
|
|
@ -50,7 +50,7 @@ interface CompiledKeyword {
|
|||
/**
|
||||
* Compile keywords into regex patterns once for reuse across multiple matches.
|
||||
*/
|
||||
export function compileKeywords(keywords: Keyword[]): CompiledKeyword[] {
|
||||
function compileKeywords(keywords: Keyword[]): CompiledKeyword[] {
|
||||
return keywords.map((kw) => ({
|
||||
regex: buildKeywordRegex(normalizeDescription(kw.keyword)),
|
||||
category_id: kw.category_id,
|
||||
|
|
@ -112,162 +112,3 @@ export async function categorizeBatch(
|
|||
return matchDescription(normalized, compiled);
|
||||
});
|
||||
}
|
||||
|
||||
// --- AddKeywordDialog support (Issue #74) ---
|
||||
|
||||
export const KEYWORD_MIN_LENGTH = 2;
|
||||
export const KEYWORD_MAX_LENGTH = 64;
|
||||
export const KEYWORD_PREVIEW_LIMIT = 50;
|
||||
|
||||
/**
|
||||
* Validate a keyword before it hits the regex engine.
|
||||
*
|
||||
* Rejects whitespace-only input and caps length at 64 chars to prevent
|
||||
* ReDoS (CWE-1333) when the compiled regex is replayed across many
|
||||
* transactions later.
|
||||
*/
|
||||
export function validateKeyword(raw: string): { ok: true; value: string } | { ok: false; reason: "tooShort" | "tooLong" } {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length < KEYWORD_MIN_LENGTH) return { ok: false, reason: "tooShort" };
|
||||
if (trimmed.length > KEYWORD_MAX_LENGTH) return { ok: false, reason: "tooLong" };
|
||||
return { ok: true, value: trimmed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview the transactions that would be recategorised if the user commits
|
||||
* the given keyword. Uses a parameterised `LIKE ?1` to scope the candidates,
|
||||
* then re-filters in memory with `buildKeywordRegex` for exact word-boundary
|
||||
* matching. Results are capped at `limit` visible rows — callers decide what
|
||||
* to do with the `totalMatches` (which may be greater than the returned list).
|
||||
*
|
||||
* SECURITY: the keyword is never interpolated into the SQL string. `LIKE ?1`
|
||||
* is the only parameterised binding, and the `%...%` wrapping happens inside
|
||||
* the bound parameter value.
|
||||
*/
|
||||
export async function previewKeywordMatches(
|
||||
keyword: string,
|
||||
limit: number = KEYWORD_PREVIEW_LIMIT,
|
||||
): Promise<{ visible: RecentTransaction[]; totalMatches: number }> {
|
||||
const validation = validateKeyword(keyword);
|
||||
if (!validation.ok) {
|
||||
return { visible: [], totalMatches: 0 };
|
||||
}
|
||||
const normalized = normalizeDescription(validation.value);
|
||||
const regex = buildKeywordRegex(normalized);
|
||||
const db = await getDb();
|
||||
|
||||
// Coarse pre-filter via parameterised LIKE (case-insensitive thanks to
|
||||
// normalize on the JS side). A small cap protects against catastrophic
|
||||
// backtracking across a huge candidate set — hard-capped to 1000 rows
|
||||
// before the in-memory filter.
|
||||
const likePattern = `%${normalized}%`;
|
||||
const candidates = await db.select<RecentTransaction[]>(
|
||||
`SELECT t.id, t.date, t.description, t.amount,
|
||||
c.name AS category_name,
|
||||
c.color AS category_color
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
WHERE LOWER(t.description) LIKE $1
|
||||
ORDER BY t.date DESC
|
||||
LIMIT 1000`,
|
||||
[likePattern],
|
||||
);
|
||||
|
||||
const matched: RecentTransaction[] = [];
|
||||
for (const tx of candidates) {
|
||||
const normDesc = normalizeDescription(tx.description);
|
||||
if (regex.test(normDesc)) matched.push(tx);
|
||||
}
|
||||
|
||||
return {
|
||||
visible: matched.slice(0, limit),
|
||||
totalMatches: matched.length,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApplyKeywordInput {
|
||||
keyword: string;
|
||||
categoryId: number;
|
||||
/** ids of transactions to recategorise (only those the user checked). */
|
||||
transactionIds: number[];
|
||||
/**
|
||||
* When true, and a keyword with the same spelling already exists for a
|
||||
* different category, that existing keyword is **reassigned** to the new
|
||||
* category rather than creating a duplicate. Matches the spec decision
|
||||
* that history is never touched — only the visible transactions are
|
||||
* recategorised.
|
||||
*/
|
||||
allowReplaceExisting: boolean;
|
||||
}
|
||||
|
||||
export interface ApplyKeywordResult {
|
||||
keywordId: number;
|
||||
updatedTransactions: number;
|
||||
replacedExisting: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERTs (or reassigns) a keyword and recategorises the given transaction
|
||||
* ids in a single SQL transaction. Either all writes commit or none do.
|
||||
*
|
||||
* SECURITY: every query is parameterised. The caller is expected to have
|
||||
* vetted `transactionIds` from a preview window that the user confirmed.
|
||||
*/
|
||||
export async function applyKeywordWithReassignment(
|
||||
input: ApplyKeywordInput,
|
||||
): Promise<ApplyKeywordResult> {
|
||||
const validation = validateKeyword(input.keyword);
|
||||
if (!validation.ok) {
|
||||
throw new Error(`invalid_keyword:${validation.reason}`);
|
||||
}
|
||||
const keyword = validation.value;
|
||||
|
||||
const db = await getDb();
|
||||
await db.execute("BEGIN");
|
||||
try {
|
||||
// Is there already a row for this keyword spelling?
|
||||
const existing = await db.select<Array<{ id: number; category_id: number }>>(
|
||||
`SELECT id, category_id FROM keywords WHERE keyword = $1 LIMIT 1`,
|
||||
[keyword],
|
||||
);
|
||||
|
||||
let keywordId: number;
|
||||
let replacedExisting = false;
|
||||
if (existing.length > 0) {
|
||||
if (!input.allowReplaceExisting && existing[0].category_id !== input.categoryId) {
|
||||
throw new Error("keyword_already_exists");
|
||||
}
|
||||
await db.execute(
|
||||
`UPDATE keywords SET category_id = $1, is_active = 1 WHERE id = $2`,
|
||||
[input.categoryId, existing[0].id],
|
||||
);
|
||||
keywordId = existing[0].id;
|
||||
replacedExisting = existing[0].category_id !== input.categoryId;
|
||||
} else {
|
||||
const result = await db.execute(
|
||||
`INSERT INTO keywords (keyword, category_id, priority) VALUES ($1, $2, $3)`,
|
||||
[keyword, input.categoryId, 100],
|
||||
);
|
||||
keywordId = Number(result.lastInsertId ?? 0);
|
||||
}
|
||||
|
||||
let updatedTransactions = 0;
|
||||
for (const txId of input.transactionIds) {
|
||||
await db.execute(
|
||||
`UPDATE transactions
|
||||
SET category_id = $1,
|
||||
is_manually_categorized = 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2`,
|
||||
[input.categoryId, txId],
|
||||
);
|
||||
updatedTransactions++;
|
||||
}
|
||||
|
||||
await db.execute("COMMIT");
|
||||
return { keywordId, updatedTransactions, replacedExisting };
|
||||
} catch (e) {
|
||||
await db.execute("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { isFeedbackErrorCode } from "./feedbackService";
|
||||
|
||||
describe("isFeedbackErrorCode", () => {
|
||||
it("recognizes the four stable error codes", () => {
|
||||
expect(isFeedbackErrorCode("invalid")).toBe(true);
|
||||
expect(isFeedbackErrorCode("rate_limit")).toBe(true);
|
||||
expect(isFeedbackErrorCode("server_error")).toBe(true);
|
||||
expect(isFeedbackErrorCode("network_error")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown strings and non-strings", () => {
|
||||
expect(isFeedbackErrorCode("boom")).toBe(false);
|
||||
expect(isFeedbackErrorCode("")).toBe(false);
|
||||
expect(isFeedbackErrorCode(404)).toBe(false);
|
||||
expect(isFeedbackErrorCode(null)).toBe(false);
|
||||
expect(isFeedbackErrorCode(undefined)).toBe(false);
|
||||
expect(isFeedbackErrorCode({ error: "rate_limit" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export type FeedbackErrorCode =
|
||||
| "invalid"
|
||||
| "rate_limit"
|
||||
| "server_error"
|
||||
| "network_error";
|
||||
|
||||
export interface FeedbackContext {
|
||||
page?: string;
|
||||
locale?: string;
|
||||
theme?: string;
|
||||
viewport?: string;
|
||||
userAgent?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface FeedbackSuccess {
|
||||
id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SendFeedbackInput {
|
||||
content: string;
|
||||
userId?: string | null;
|
||||
context?: FeedbackContext;
|
||||
}
|
||||
|
||||
export async function sendFeedback(
|
||||
input: SendFeedbackInput,
|
||||
): Promise<FeedbackSuccess> {
|
||||
return invoke<FeedbackSuccess>("send_feedback", {
|
||||
content: input.content,
|
||||
userId: input.userId ?? null,
|
||||
context: input.context ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getFeedbackUserAgent(): Promise<string> {
|
||||
return invoke<string>("get_feedback_user_agent");
|
||||
}
|
||||
|
||||
export function isFeedbackErrorCode(value: unknown): value is FeedbackErrorCode {
|
||||
return (
|
||||
value === "invalid" ||
|
||||
value === "rate_limit" ||
|
||||
value === "server_error" ||
|
||||
value === "network_error"
|
||||
);
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest";
|
||||
import { getRecentErrorLogs, clearLogs, initLogCapture } from "./logService";
|
||||
|
||||
beforeAll(() => {
|
||||
// Patch console.* so addEntry runs. Idempotent.
|
||||
initLogCapture();
|
||||
});
|
||||
|
||||
describe("getRecentErrorLogs", () => {
|
||||
beforeEach(() => {
|
||||
// Reset the in-memory buffer. clearLogs also clears sessionStorage
|
||||
// which jsdom provides in vitest.
|
||||
clearLogs();
|
||||
});
|
||||
|
||||
it("returns an empty string when the log buffer is empty", () => {
|
||||
expect(getRecentErrorLogs(5)).toBe("");
|
||||
});
|
||||
|
||||
it("returns an empty string when n <= 0", () => {
|
||||
console.error("boom");
|
||||
expect(getRecentErrorLogs(0)).toBe("");
|
||||
expect(getRecentErrorLogs(-3)).toBe("");
|
||||
});
|
||||
|
||||
it("filters out info-level entries", () => {
|
||||
// Freeze time so the ISO prefix is predictable
|
||||
vi.setSystemTime(new Date("2026-04-17T15:00:00.000Z"));
|
||||
console.log("just chatter");
|
||||
console.warn("low fuel");
|
||||
console.error("engine out");
|
||||
|
||||
const out = getRecentErrorLogs(10);
|
||||
expect(out).not.toContain("chatter");
|
||||
expect(out).toContain("WARN: low fuel");
|
||||
expect(out).toContain("ERROR: engine out");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps only the last N non-info entries in order", () => {
|
||||
for (let i = 0; i < 5; i++) console.warn(`w${i}`);
|
||||
const out = getRecentErrorLogs(2);
|
||||
const lines = out.split("\n");
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[0]).toContain("w3");
|
||||
expect(lines[1]).toContain("w4");
|
||||
});
|
||||
|
||||
it("formats each line as `[ISO] LEVEL: message`", () => {
|
||||
vi.setSystemTime(new Date("2026-04-17T15:23:45.000Z"));
|
||||
console.error("export failed");
|
||||
const out = getRecentErrorLogs(1);
|
||||
expect(out).toMatch(/^\[2026-04-17T15:23:45\.000Z\] ERROR: export failed$/);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
|
@ -86,21 +86,6 @@ export function getLogs(): readonly LogEntry[] {
|
|||
return logs;
|
||||
}
|
||||
|
||||
/// Extract the N most recent non-info entries formatted as a single string,
|
||||
/// suitable for appending to a feedback body. Empty string if no qualifying
|
||||
/// entries. Each line: `[ISO timestamp] LEVEL: message`.
|
||||
export function getRecentErrorLogs(n: number): string {
|
||||
if (n <= 0) return "";
|
||||
const errors = logs.filter((l) => l.level !== "info");
|
||||
const tail = errors.slice(Math.max(0, errors.length - n));
|
||||
return tail
|
||||
.map((l) => {
|
||||
const iso = new Date(l.timestamp).toISOString();
|
||||
return `[${iso}] ${l.level.toUpperCase()}: ${l.message}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function clearLogs() {
|
||||
logs.length = 0;
|
||||
saveToStorage();
|
||||
|
|
|
|||
|
|
@ -34,12 +34,6 @@ export async function hashPin(pin: string): Promise<string> {
|
|||
return invoke<string>("hash_pin", { pin });
|
||||
}
|
||||
|
||||
export interface VerifyPinResult {
|
||||
valid: boolean;
|
||||
/** New Argon2id hash when a legacy SHA-256 PIN was re-hashed on successful verification */
|
||||
rehashed: string | null;
|
||||
}
|
||||
|
||||
export async function verifyPin(pin: string, storedHash: string): Promise<VerifyPinResult> {
|
||||
return invoke<VerifyPinResult>("verify_pin", { pin, storedHash });
|
||||
export async function verifyPin(pin: string, storedHash: string): Promise<boolean> {
|
||||
return invoke<boolean>("verify_pin", { pin, storedHash });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,229 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { shiftMonth, getCartesSnapshot } from "./reportService";
|
||||
|
||||
vi.mock("./db", () => ({
|
||||
getDb: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getDb } from "./db";
|
||||
|
||||
const mockSelect = vi.fn();
|
||||
const mockDb = { select: mockSelect };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getDb).mockResolvedValue(mockDb as never);
|
||||
mockSelect.mockReset();
|
||||
});
|
||||
|
||||
describe("shiftMonth", () => {
|
||||
it("shifts forward within a year", () => {
|
||||
expect(shiftMonth(2026, 1, 2)).toEqual({ year: 2026, month: 3 });
|
||||
});
|
||||
|
||||
it("shifts backward within a year", () => {
|
||||
expect(shiftMonth(2026, 6, -3)).toEqual({ year: 2026, month: 3 });
|
||||
});
|
||||
|
||||
it("wraps around January to the previous year", () => {
|
||||
expect(shiftMonth(2026, 1, -1)).toEqual({ year: 2025, month: 12 });
|
||||
});
|
||||
|
||||
it("wraps past multiple years back", () => {
|
||||
expect(shiftMonth(2026, 4, -24)).toEqual({ year: 2024, month: 4 });
|
||||
});
|
||||
|
||||
it("wraps past year forward", () => {
|
||||
expect(shiftMonth(2025, 11, 3)).toEqual({ year: 2026, month: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Dispatch mock SELECT responses based on the SQL fragment being queried.
|
||||
* Each entry returns the canned rows for queries whose text contains `match`.
|
||||
*/
|
||||
function routeSelect(routes: { match: string; rows: unknown[] }[]): void {
|
||||
mockSelect.mockImplementation((sql: string) => {
|
||||
for (const r of routes) {
|
||||
if (sql.includes(r.match)) return Promise.resolve(r.rows);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
}
|
||||
|
||||
describe("getCartesSnapshot", () => {
|
||||
it("returns zero-filled KPIs when there is no data", async () => {
|
||||
routeSelect([]);
|
||||
const snapshot = await getCartesSnapshot(2026, 3);
|
||||
expect(snapshot.referenceYear).toBe(2026);
|
||||
expect(snapshot.referenceMonth).toBe(3);
|
||||
expect(snapshot.kpis.income.current).toBe(0);
|
||||
expect(snapshot.kpis.expenses.current).toBe(0);
|
||||
expect(snapshot.kpis.net.current).toBe(0);
|
||||
expect(snapshot.kpis.savingsRate.current).toBe(0);
|
||||
expect(snapshot.kpis.income.sparkline).toHaveLength(13);
|
||||
expect(snapshot.flow12Months).toHaveLength(12);
|
||||
expect(snapshot.topMoversUp).toHaveLength(0);
|
||||
expect(snapshot.topMoversDown).toHaveLength(0);
|
||||
expect(snapshot.budgetAdherence.categoriesTotal).toBe(0);
|
||||
expect(snapshot.seasonality.historicalYears).toHaveLength(0);
|
||||
expect(snapshot.seasonality.historicalAverage).toBeNull();
|
||||
expect(snapshot.seasonality.deviationPct).toBeNull();
|
||||
});
|
||||
|
||||
it("computes MoM and YoY deltas from a monthly flow stream", async () => {
|
||||
// Reference = 2026-03
|
||||
routeSelect([
|
||||
{
|
||||
match: "strftime('%Y-%m', date)",
|
||||
rows: [
|
||||
{ month: "2025-03", income: 3000, expenses: 1800 }, // YoY comparison
|
||||
{ month: "2026-02", income: 4000, expenses: 2000 }, // MoM comparison
|
||||
{ month: "2026-03", income: 5000, expenses: 2500 }, // Reference
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const snapshot = await getCartesSnapshot(2026, 3);
|
||||
|
||||
expect(snapshot.kpis.income.current).toBe(5000);
|
||||
expect(snapshot.kpis.income.previousMonth).toBe(4000);
|
||||
expect(snapshot.kpis.income.previousYear).toBe(3000);
|
||||
expect(snapshot.kpis.income.deltaMoMAbs).toBe(1000);
|
||||
expect(snapshot.kpis.income.deltaMoMPct).toBe(25);
|
||||
expect(snapshot.kpis.income.deltaYoYAbs).toBe(2000);
|
||||
expect(Math.round(snapshot.kpis.income.deltaYoYPct ?? 0)).toBe(67);
|
||||
|
||||
expect(snapshot.kpis.expenses.current).toBe(2500);
|
||||
expect(snapshot.kpis.net.current).toBe(2500);
|
||||
expect(snapshot.kpis.savingsRate.current).toBe(50);
|
||||
});
|
||||
|
||||
it("January reference month shifts MoM to December of previous year", async () => {
|
||||
routeSelect([
|
||||
{
|
||||
match: "strftime('%Y-%m', date)",
|
||||
rows: [
|
||||
{ month: "2025-12", income: 2000, expenses: 1000 },
|
||||
{ month: "2026-01", income: 3000, expenses: 1500 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const snapshot = await getCartesSnapshot(2026, 1);
|
||||
|
||||
expect(snapshot.kpis.income.current).toBe(3000);
|
||||
expect(snapshot.kpis.income.previousMonth).toBe(2000);
|
||||
// YoY for January 2026 = January 2025 = no data
|
||||
expect(snapshot.kpis.income.previousYear).toBeNull();
|
||||
expect(snapshot.kpis.income.deltaYoYAbs).toBeNull();
|
||||
});
|
||||
|
||||
it("savings rate stays at 0 when income is zero (no division by zero)", async () => {
|
||||
routeSelect([
|
||||
{
|
||||
match: "strftime('%Y-%m', date)",
|
||||
rows: [
|
||||
{ month: "2026-03", income: 0, expenses: 500 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const snapshot = await getCartesSnapshot(2026, 3);
|
||||
expect(snapshot.kpis.savingsRate.current).toBe(0);
|
||||
expect(snapshot.kpis.income.current).toBe(0);
|
||||
expect(snapshot.kpis.expenses.current).toBe(500);
|
||||
expect(snapshot.kpis.net.current).toBe(-500);
|
||||
});
|
||||
|
||||
it("handles less than 13 months of history by filling gaps with zero", async () => {
|
||||
routeSelect([
|
||||
{
|
||||
match: "strftime('%Y-%m', date)",
|
||||
rows: [
|
||||
{ month: "2026-03", income: 1000, expenses: 400 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const snapshot = await getCartesSnapshot(2026, 3);
|
||||
expect(snapshot.kpis.income.sparkline).toHaveLength(13);
|
||||
// First 12 points are zero, last one is 1000
|
||||
expect(snapshot.kpis.income.sparkline[12].value).toBe(1000);
|
||||
expect(snapshot.kpis.income.sparkline[0].value).toBe(0);
|
||||
// MoM comparison with a missing month returns null (no data for 2026-02)
|
||||
expect(snapshot.kpis.income.previousMonth).toBeNull();
|
||||
expect(snapshot.kpis.income.deltaMoMAbs).toBeNull();
|
||||
});
|
||||
|
||||
it("computes seasonality only when historical data exists", async () => {
|
||||
routeSelect([
|
||||
{
|
||||
match: "strftime('%Y-%m', date)",
|
||||
rows: [{ month: "2026-03", income: 3000, expenses: 1500 }],
|
||||
},
|
||||
{
|
||||
match: "CAST(strftime('%Y', date) AS INTEGER) AS year",
|
||||
rows: [
|
||||
{ year: 2025, amount: 1200 },
|
||||
{ year: 2024, amount: 1000 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const snapshot = await getCartesSnapshot(2026, 3);
|
||||
expect(snapshot.seasonality.historicalYears).toHaveLength(2);
|
||||
expect(snapshot.seasonality.historicalAverage).toBe(1100);
|
||||
expect(snapshot.seasonality.referenceAmount).toBe(1500);
|
||||
// (1500 - 1100) / 1100 * 100 ≈ 36.36
|
||||
expect(Math.round(snapshot.seasonality.deviationPct ?? 0)).toBe(36);
|
||||
});
|
||||
|
||||
it("seasonality deviation stays null when there is no historical average", async () => {
|
||||
routeSelect([
|
||||
{
|
||||
match: "strftime('%Y-%m', date)",
|
||||
rows: [{ month: "2026-03", income: 2000, expenses: 800 }],
|
||||
},
|
||||
]);
|
||||
|
||||
const snapshot = await getCartesSnapshot(2026, 3);
|
||||
expect(snapshot.seasonality.historicalYears).toHaveLength(0);
|
||||
expect(snapshot.seasonality.historicalAverage).toBeNull();
|
||||
expect(snapshot.seasonality.deviationPct).toBeNull();
|
||||
});
|
||||
|
||||
it("splits top movers by sign and caps each list at 5", async () => {
|
||||
// Seven up-movers, three down-movers — verify we get 5 up and 3 down.
|
||||
const momRows = [
|
||||
{ category_id: 1, category_name: "C1", category_color: "#000", current_total: 200, previous_total: 100 },
|
||||
{ category_id: 2, category_name: "C2", category_color: "#000", current_total: 400, previous_total: 100 },
|
||||
{ category_id: 3, category_name: "C3", category_color: "#000", current_total: 500, previous_total: 100 },
|
||||
{ category_id: 4, category_name: "C4", category_color: "#000", current_total: 700, previous_total: 100 },
|
||||
{ category_id: 5, category_name: "C5", category_color: "#000", current_total: 900, previous_total: 100 },
|
||||
{ category_id: 6, category_name: "C6", category_color: "#000", current_total: 1100, previous_total: 100 },
|
||||
{ category_id: 7, category_name: "C7", category_color: "#000", current_total: 1300, previous_total: 100 },
|
||||
{ category_id: 8, category_name: "D1", category_color: "#000", current_total: 100, previous_total: 500 },
|
||||
{ category_id: 9, category_name: "D2", category_color: "#000", current_total: 100, previous_total: 700 },
|
||||
{ category_id: 10, category_name: "D3", category_color: "#000", current_total: 100, previous_total: 900 },
|
||||
];
|
||||
routeSelect([
|
||||
{
|
||||
match: "strftime('%Y-%m', date)",
|
||||
rows: [{ month: "2026-03", income: 1000, expenses: 500 }],
|
||||
},
|
||||
{
|
||||
// Matches the getCompareMonthOverMonth SQL pattern.
|
||||
match: "ORDER BY ABS(current_total - previous_total) DESC",
|
||||
rows: momRows,
|
||||
},
|
||||
]);
|
||||
|
||||
const snapshot = await getCartesSnapshot(2026, 3);
|
||||
expect(snapshot.topMoversUp).toHaveLength(5);
|
||||
expect(snapshot.topMoversDown).toHaveLength(3);
|
||||
// Top up is the biggest delta (C7: +1200)
|
||||
expect(snapshot.topMoversUp[0].categoryName).toBe("C7");
|
||||
// Top down is the biggest negative delta (D3: -800)
|
||||
expect(snapshot.topMoversDown[0].categoryName).toBe("D3");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
getCategoryOverTime,
|
||||
getHighlights,
|
||||
getCompareMonthOverMonth,
|
||||
getCompareYearOverYear,
|
||||
getCategoryZoom,
|
||||
} from "./reportService";
|
||||
import { getCategoryOverTime } from "./reportService";
|
||||
|
||||
// Mock the db module
|
||||
vi.mock("./db", () => ({
|
||||
|
|
@ -150,245 +144,3 @@ describe("getCategoryOverTime", () => {
|
|||
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHighlights", () => {
|
||||
const REF = "2026-04-14";
|
||||
|
||||
function queueEmpty(n: number) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
mockSelect.mockResolvedValueOnce([]);
|
||||
}
|
||||
}
|
||||
|
||||
it("computes windows and returns zeroed data on an empty profile", async () => {
|
||||
queueEmpty(5); // currentBalance, ytd, series, movers, recent
|
||||
|
||||
const result = await getHighlights(30, REF);
|
||||
|
||||
expect(result.currentMonth).toBe("2026-04");
|
||||
expect(result.netBalanceCurrent).toBe(0);
|
||||
expect(result.netBalanceYtd).toBe(0);
|
||||
expect(result.monthlyBalanceSeries).toHaveLength(12);
|
||||
expect(result.monthlyBalanceSeries[11].month).toBe("2026-04");
|
||||
expect(result.monthlyBalanceSeries[0].month).toBe("2025-05");
|
||||
expect(result.topMovers).toEqual([]);
|
||||
expect(result.topTransactions).toEqual([]);
|
||||
});
|
||||
|
||||
it("parameterises every query with no inlined strings", async () => {
|
||||
queueEmpty(5);
|
||||
|
||||
await getHighlights(60, REF);
|
||||
|
||||
for (const call of mockSelect.mock.calls) {
|
||||
const sql = call[0] as string;
|
||||
const params = call[1] as unknown[];
|
||||
expect(sql).not.toContain(`'${REF}'`);
|
||||
expect(Array.isArray(params)).toBe(true);
|
||||
}
|
||||
// First call uses current month range parameters
|
||||
const firstParams = mockSelect.mock.calls[0][1] as unknown[];
|
||||
expect(firstParams[0]).toBe("2026-04-01");
|
||||
expect(firstParams[1]).toBe("2026-04-30");
|
||||
// YTD call uses year start
|
||||
const ytdParams = mockSelect.mock.calls[1][1] as unknown[];
|
||||
expect(ytdParams[0]).toBe("2026-01-01");
|
||||
expect(ytdParams[1]).toBe(REF);
|
||||
});
|
||||
|
||||
it("uses a 60-day window for top transactions when requested", async () => {
|
||||
queueEmpty(5);
|
||||
|
||||
await getHighlights(60, REF);
|
||||
|
||||
const recentParams = mockSelect.mock.calls[4][1] as unknown[];
|
||||
// 60-day window ending at REF: start = 2026-04-14 - 59 days = 2026-02-14
|
||||
expect(recentParams[0]).toBe("2026-02-14");
|
||||
expect(recentParams[1]).toBe(REF);
|
||||
expect(recentParams[2]).toBe(10);
|
||||
});
|
||||
|
||||
it("computes deltaAbs and deltaPct from movers rows", async () => {
|
||||
mockSelect
|
||||
.mockResolvedValueOnce([{ net: -500 }]) // current balance
|
||||
.mockResolvedValueOnce([{ net: -1800 }]) // ytd
|
||||
.mockResolvedValueOnce([
|
||||
{ month: "2026-04", net: -500 },
|
||||
{ month: "2026-03", net: -400 },
|
||||
]) // series
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
category_id: 1,
|
||||
category_name: "Restaurants",
|
||||
category_color: "#f97316",
|
||||
current_total: 240,
|
||||
previous_total: 200,
|
||||
},
|
||||
{
|
||||
category_id: 2,
|
||||
category_name: "Groceries",
|
||||
category_color: "#10b981",
|
||||
current_total: 85,
|
||||
previous_total: 170,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([]); // recent
|
||||
|
||||
const result = await getHighlights(30, REF);
|
||||
|
||||
expect(result.netBalanceCurrent).toBe(-500);
|
||||
expect(result.netBalanceYtd).toBe(-1800);
|
||||
expect(result.topMovers).toHaveLength(2);
|
||||
expect(result.topMovers[0]).toMatchObject({
|
||||
categoryName: "Restaurants",
|
||||
currentAmount: 240,
|
||||
previousAmount: 200,
|
||||
deltaAbs: 40,
|
||||
});
|
||||
expect(result.topMovers[0].deltaPct).toBeCloseTo(20, 4);
|
||||
expect(result.topMovers[1].deltaAbs).toBe(-85);
|
||||
expect(result.topMovers[1].deltaPct).toBeCloseTo(-50, 4);
|
||||
});
|
||||
|
||||
it("returns deltaPct=null when previous month total is zero", async () => {
|
||||
mockSelect
|
||||
.mockResolvedValueOnce([{ net: 0 }])
|
||||
.mockResolvedValueOnce([{ net: 0 }])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
category_id: 3,
|
||||
category_name: "New expense",
|
||||
category_color: "#3b82f6",
|
||||
current_total: 120,
|
||||
previous_total: 0,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getHighlights(30, REF);
|
||||
|
||||
expect(result.topMovers[0].deltaPct).toBeNull();
|
||||
expect(result.topMovers[0].deltaAbs).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCompareMonthOverMonth", () => {
|
||||
it("passes current and previous month boundaries as parameters", async () => {
|
||||
mockSelect.mockResolvedValueOnce([]);
|
||||
|
||||
await getCompareMonthOverMonth(2026, 4);
|
||||
|
||||
expect(mockSelect).toHaveBeenCalledTimes(1);
|
||||
const sql = mockSelect.mock.calls[0][0] as string;
|
||||
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||
expect(sql).toContain("$1");
|
||||
expect(sql).toContain("$4");
|
||||
expect(params).toEqual(["2026-04-01", "2026-04-30", "2026-03-01", "2026-03-31"]);
|
||||
expect(sql).not.toContain("'2026");
|
||||
});
|
||||
|
||||
it("wraps to december of previous year when target month is january", async () => {
|
||||
mockSelect.mockResolvedValueOnce([]);
|
||||
|
||||
await getCompareMonthOverMonth(2026, 1);
|
||||
|
||||
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||
expect(params).toEqual(["2026-01-01", "2026-01-31", "2025-12-01", "2025-12-31"]);
|
||||
});
|
||||
|
||||
it("converts raw rows into CategoryDelta with signed deltas", async () => {
|
||||
mockSelect.mockResolvedValueOnce([
|
||||
{
|
||||
category_id: 1,
|
||||
category_name: "Groceries",
|
||||
category_color: "#10b981",
|
||||
current_total: 500,
|
||||
previous_total: 400,
|
||||
},
|
||||
{
|
||||
category_id: 2,
|
||||
category_name: "Restaurants",
|
||||
category_color: "#f97316",
|
||||
current_total: 120,
|
||||
previous_total: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await getCompareMonthOverMonth(2026, 4);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toMatchObject({
|
||||
categoryName: "Groceries",
|
||||
deltaAbs: 100,
|
||||
});
|
||||
expect(result[0].deltaPct).toBeCloseTo(25, 4);
|
||||
expect(result[1].deltaPct).toBeNull(); // previous = 0
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCompareYearOverYear", () => {
|
||||
it("spans two full calendar years with parameterised boundaries", async () => {
|
||||
mockSelect.mockResolvedValueOnce([]);
|
||||
|
||||
await getCompareYearOverYear(2026);
|
||||
|
||||
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||
expect(params).toEqual(["2026-01-01", "2026-12-31", "2025-01-01", "2025-12-31"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCategoryZoom", () => {
|
||||
it("uses a bounded recursive CTE when including subcategories", async () => {
|
||||
mockSelect
|
||||
.mockResolvedValueOnce([]) // transactions
|
||||
.mockResolvedValueOnce([{ rollup: 0 }]) // rollup
|
||||
.mockResolvedValueOnce([]) // byChild
|
||||
.mockResolvedValueOnce([]); // evolution
|
||||
|
||||
await getCategoryZoom(42, "2026-01-01", "2026-12-31", true);
|
||||
|
||||
const txSql = mockSelect.mock.calls[0][0] as string;
|
||||
expect(txSql).toContain("WITH RECURSIVE cat_tree");
|
||||
expect(txSql).toContain("WHERE ct.depth < 5");
|
||||
const txParams = mockSelect.mock.calls[0][1] as unknown[];
|
||||
expect(txParams).toEqual([42, "2026-01-01", "2026-12-31"]);
|
||||
});
|
||||
|
||||
it("terminates on a cyclic category tree because of the depth cap", async () => {
|
||||
// Simulate a cyclic parent_id chain. Since the mocked db.select simply
|
||||
// returns our canned values, the real cycle guard (depth < 5) is what we
|
||||
// can assert: the CTE must include the bound.
|
||||
mockSelect
|
||||
.mockResolvedValueOnce([]) // transactions
|
||||
.mockResolvedValueOnce([{ rollup: 0 }]) // rollup
|
||||
.mockResolvedValueOnce([]) // byChild
|
||||
.mockResolvedValueOnce([]); // evolution
|
||||
|
||||
await expect(
|
||||
getCategoryZoom(1, "2026-01-01", "2026-01-31", true),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
// Every recursive query sent must contain the depth guard.
|
||||
for (const call of mockSelect.mock.calls) {
|
||||
const sql = call[0] as string;
|
||||
if (sql.includes("cat_tree")) {
|
||||
expect(sql).toContain("ct.depth < 5");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("issues a direct-only query when includeSubcategories is false", async () => {
|
||||
mockSelect
|
||||
.mockResolvedValueOnce([]) // transactions
|
||||
.mockResolvedValueOnce([{ rollup: 0 }]); // rollup — no byChild / evolution recursive here
|
||||
// evolution is still queried with categoryId = direct
|
||||
mockSelect.mockResolvedValueOnce([]);
|
||||
|
||||
await getCategoryZoom(7, "2026-01-01", "2026-12-31", false);
|
||||
|
||||
const txSql = mockSelect.mock.calls[0][0] as string;
|
||||
expect(txSql).not.toContain("cat_tree");
|
||||
expect(txSql).toContain("t.category_id = $1");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue