fix: onboarding phase 1 follow-ups: pins, Transactions card, Import messages, docs (#384) #474

Open
maximus wants to merge 7 commits from issue-384-onboarding-followups into issue-437-test-pins
7 changed files with 45 additions and 18 deletions
Showing only changes of commit a64ac3b8a8 - Show all commits

View file

@ -10,11 +10,13 @@
- Paramètres → Système : « Journaux et commentaires » vient désormais juste après « Mise à jour », avant l'historique des versions, qui ne repousse plus le Journal en bas de la page. L'historique des versions s'ouvre sur les notes de la version installée, les autres versions repliées : cliquez sur un numéro de version pour lire ses notes, ou sur « Tout déplier » pour ouvrir tout l'historique (#372). - Paramètres → Système : « Journaux et commentaires » vient désormais juste après « Mise à jour », avant l'historique des versions, qui ne repousse plus le Journal en bas de la page. L'historique des versions s'ouvre sur les notes de la version installée, les autres versions repliées : cliquez sur un numéro de version pour lire ses notes, ou sur « Tout déplier » pour ouvrir tout l'historique (#372).
- Ajustements : la catégorie d'une ligne d'ajustement accepte désormais la saisie, comme la cellule de catégorie de la page Transactions : tapez quelques lettres pour filtrer la liste, qui montre la hiérarchie et les noms des catégories standard dans votre langue. Elle propose les mêmes catégories que la page Transactions, actives et autorisées à la saisie, comme la fenêtre de répartition ouverte depuis la page Ajustements ; une ligne déjà enregistrée sur une catégorie masquée de la saisie la garde, son champ vide tant que vous n'en choisissez pas une autre. Le filtre de catégorie de la fenêtre « Lier transferts » du Bilan accepte aussi la saisie, avec « Toutes les catégories » en tête. Une liste de catégories ouverte n'est plus coupée par le bord d'une fenêtre ou du tableau des transactions, dans la fenêtre de répartition comme sur les dernières lignes du tableau, et elle s'ouvre vers le haut quand elle ne tient pas en dessous mais tient au-dessus (#429). - Ajustements : la catégorie d'une ligne d'ajustement accepte désormais la saisie, comme la cellule de catégorie de la page Transactions : tapez quelques lettres pour filtrer la liste, qui montre la hiérarchie et les noms des catégories standard dans votre langue. Elle propose les mêmes catégories que la page Transactions, actives et autorisées à la saisie, comme la fenêtre de répartition ouverte depuis la page Ajustements ; une ligne déjà enregistrée sur une catégorie masquée de la saisie la garde, son champ vide tant que vous n'en choisissez pas une autre. Le filtre de catégorie de la fenêtre « Lier transferts » du Bilan accepte aussi la saisie, avec « Toutes les catégories » en tête. Une liste de catégories ouverte n'est plus coupée par le bord d'une fenêtre ou du tableau des transactions, dans la fenêtre de répartition comme sur les dernières lignes du tableau, et elle s'ouvre vers le haut quand elle ne tient pas en dessous mais tient au-dessus (#429).
- Transactions : sur un profil sans aucune transaction, la carte « Aucune transaction pour l'instant » s'affiche dès que l'application sait que le profil est vide, au lieu des filtres, des totaux et de « Chargement... » jusqu'au retour de la première page de la liste (#384).
### Corrigé ### Corrigé
- Catégories : un nouveau parent est désormais vérifié à l'enregistrement d'une catégorie. L'une de ses propres sous-catégories (la boucle de parenté qui cachait toute une branche), un parent qui créerait un quatrième niveau et un parent qui a des transactions à son nom (devenu parent, il ne serait plus saisissable) sont refusés avec un message, et rien n'est enregistré. Une catégorie enregistrée sans changement de parent n'est jamais refusée par ces vérifications (#393). - Catégories : un nouveau parent est désormais vérifié à l'enregistrement d'une catégorie. L'une de ses propres sous-catégories (la boucle de parenté qui cachait toute une branche), un parent qui créerait un quatrième niveau et un parent qui a des transactions à son nom (devenu parent, il ne serait plus saisissable) sont refusés avec un message, et rien n'est enregistré. Une catégorie enregistrée sans changement de parent n'est jamais refusée par ces vérifications (#393).
- Budget : une catégorie dont le parent a été supprimé ou est introuvable ne disparaît plus de la grille Budget ni du rapport Budget vs réel : elle s'affiche au premier niveau, comme dans l'arbre des catégories (#393). - Budget : une catégorie dont le parent a été supprimé ou est introuvable ne disparaît plus de la grille Budget ni du rapport Budget vs réel : elle s'affiche au premier niveau, comme dans l'arbre des catégories (#393).
- Import : les messages de « Créer un dossier d'exemple » suivent désormais la langue de l'application : un dossier d'exemple déjà présent dans le dossier d'import (nommé, avec la marche à suivre : actualiser la liste ou le renommer), un dossier d'import introuvable, une date du jour illisible. Toute autre erreur reste affichée telle que le système l'a écrite. Une fois les dossiers d'exemple écrits, le message de réussite ne s'affiche que si le dossier d'import a pu être relu ; sinon le message d'erreur s'affiche seul, au lieu des deux à la fois (#384).
## [0.17.2] - 2026-10-18 ## [0.17.2] - 2026-10-18

View file

@ -10,11 +10,13 @@
- Settings → System: “Logs and feedback” now comes right after “Update”, before the version history, which no longer pushes the Journal to the bottom of the page. The version history opens on the notes of the installed version, with every other version folded: click a version number to read its notes, or “Expand all” to open the whole history (#372). - Settings → System: “Logs and feedback” now comes right after “Update”, before the version history, which no longer pushes the Journal to the bottom of the page. The version history opens on the notes of the installed version, with every other version folded: click a version number to read its notes, or “Expand all” to open the whole history (#372).
- Adjustments: the category of an adjustment line now takes typing, like the category cell of the Transactions page: type a few letters to filter the list, which shows the hierarchy and the names of the standard categories in your language. It offers the same categories as the Transactions page, active and allowed for input, as does the split window opened from the Adjustments page; a line already saved on a category hidden from input keeps it, its field empty until you choose another one. The category filter of the “Link transfers” window of the Balance sheet takes typing too, with “Any category” first. An open category list is no longer cut by the edge of a window or of the Transactions table, in the split window as on the last rows of the table, and it opens upward when it does not fit below but fits above (#429). - Adjustments: the category of an adjustment line now takes typing, like the category cell of the Transactions page: type a few letters to filter the list, which shows the hierarchy and the names of the standard categories in your language. It offers the same categories as the Transactions page, active and allowed for input, as does the split window opened from the Adjustments page; a line already saved on a category hidden from input keeps it, its field empty until you choose another one. The category filter of the “Link transfers” window of the Balance sheet takes typing too, with “Any category” first. An open category list is no longer cut by the edge of a window or of the Transactions table, in the split window as on the last rows of the table, and it opens upward when it does not fit below but fits above (#429).
- Transactions: on a profile without any transaction, the “No transactions yet” card now shows as soon as the app knows the profile is empty, instead of the filters, the totals and “Loading...” until the first page of the list comes back (#384).
### Fixed ### Fixed
- Categories: a new parent is now checked when a category is saved. One of the category's own subcategories (the parent loop that hid a whole branch), a parent that would make a fourth level, and a parent with transactions of its own (once a parent, it would no longer take input) are refused with a message, and nothing is saved. A category saved with its parent unchanged is never refused by these checks (#393). - Categories: a new parent is now checked when a category is saved. One of the category's own subcategories (the parent loop that hid a whole branch), a parent that would make a fourth level, and a parent with transactions of its own (once a parent, it would no longer take input) are refused with a message, and nothing is saved. A category saved with its parent unchanged is never refused by these checks (#393).
- Budget: a category whose parent was deleted or cannot be found no longer disappears from the Budget grid and the Budget vs Actual report: it now shows at the top level, as in the category tree (#393). - Budget: a category whose parent was deleted or cannot be found no longer disappears from the Budget grid and the Budget vs Actual report: it now shows at the top level, as in the category tree (#393).
- Import: the messages of “Create a sample folder” now follow the app's language: a sample folder already in the import folder (named, with what to do: refresh the list or rename it), an import folder that cannot be found, today's date unreadable. Any other error still shows as the system wrote it. Once the sample folders are written, the success message only shows when the import folder could be read again; otherwise the error message shows alone, instead of both at once (#384).
## [0.17.2] - 2026-10-18 ## [0.17.2] - 2026-10-18

View file

@ -51,7 +51,7 @@ src/
│ ├── shared/ # Composants réutilisables │ ├── shared/ # Composants réutilisables
│ └── transactions/ # Transactions │ └── transactions/ # Transactions
├── contexts/ # ProfileContext (état global profil) ├── contexts/ # ProfileContext (état global profil)
├── hooks/ # 13 hooks custom (useReducer) ├── hooks/ # 29 hooks custom (useReducer)
├── pages/ # 11 pages ├── pages/ # 11 pages
├── services/ # 14 services métier ├── services/ # 14 services métier
├── shared/ # Types et constantes partagés ├── shared/ # Types et constantes partagés

View file

@ -52,13 +52,17 @@ simpl-resultat/
│ └── main.tsx # Point d'entrée │ └── main.tsx # Point d'entrée
├── src-tauri/ # Backend Rust ├── src-tauri/ # Backend Rust
│ ├── src/ │ ├── src/
│ │ ├── commands/ # 6 modules de commandes Tauri │ │ ├── commands/ # 10 modules de commandes Tauri (45 commandes), plus account_cache.rs et return_calculator.rs, sans commande
│ │ │ ├── fs_commands.rs │ │ │ ├── fs_commands.rs
│ │ │ ├── export_import_commands.rs │ │ │ ├── export_import_commands.rs
│ │ │ ├── profile_commands.rs │ │ │ ├── profile_commands.rs
│ │ │ ├── license_commands.rs │ │ │ ├── license_commands.rs
│ │ │ ├── auth_commands.rs │ │ │ ├── auth_commands.rs
│ │ │ └── entitlements.rs │ │ │ ├── token_store.rs
│ │ │ ├── entitlements.rs
│ │ │ ├── feedback_commands.rs
│ │ │ ├── backup_commands.rs
│ │ │ └── balance_commands.rs
│ │ ├── database/ # Schémas SQL et migrations │ │ ├── database/ # Schémas SQL et migrations
│ │ │ ├── schema.sql │ │ │ ├── schema.sql
│ │ │ ├── seed_categories.sql │ │ │ ├── seed_categories.sql
@ -223,17 +227,19 @@ Un seul service par convention projet (1 service par domaine, splitter seulement
1. **CRUD catégories + comptes + titres** — `listBalanceCategories`, `createBalanceCategory`, `updateBalanceCategory`, `archiveBalanceCategory` (refus si comptes liés via FK RESTRICT, refus si `is_seed = 1`), `listBalanceAccounts`, `createBalanceAccount`, `updateBalanceAccount` (garde `detailed → simple` refusée si des holdings existent, erreur typée), `archiveBalanceAccount`. **Securities (Étape 2)** : `listSecurities`, `getSecurity`, `findOrCreateSecurity` (UPSERT sur symbol normalisé upper/trim, `asset_type` requis), `updateSecurity`. Le service garde une `BalanceServiceError` typée (`BalanceErrorCode`) pour des messages i18n distincts (`currency_unsupported`, `category_seed_protected`, `category_has_accounts`, `account_kind_detailed_has_holdings`, etc.). 1. **CRUD catégories + comptes + titres** — `listBalanceCategories`, `createBalanceCategory`, `updateBalanceCategory`, `archiveBalanceCategory` (refus si comptes liés via FK RESTRICT, refus si `is_seed = 1`), `listBalanceAccounts`, `createBalanceAccount`, `updateBalanceAccount` (garde `detailed → simple` refusée si des holdings existent, erreur typée), `archiveBalanceAccount`. **Securities (Étape 2)** : `listSecurities`, `getSecurity`, `findOrCreateSecurity` (UPSERT sur symbol normalisé upper/trim, `asset_type` requis), `updateSecurity`. Le service garde une `BalanceServiceError` typée (`BalanceErrorCode`) pour des messages i18n distincts (`currency_unsupported`, `category_seed_protected`, `category_has_accounts`, `account_kind_detailed_has_holdings`, etc.).
2. **Snapshots + lines + holdings** — `listBalanceSnapshots`, `getBalanceSnapshotByDate`, `upsertSnapshot` (création + édition par date), `upsertSnapshotLines` (rewrite-all : DELETE WHERE snapshot_id puis INSERT par ligne). **Save détaillé (Étape 2)** : pour un compte `detailed`, la ligne agrégée (value = somme des holdings) **et** ses holdings sont écrits dans la **même transaction** (`BEGIN/COMMIT`), `value` recalculée = `SUM(holdings.value)` (chaque holding arrondi au cent, comparaison exacte). `validateLineKindInvariants` (simple, inchangé, tolérance `PRICED_VALUE_TOLERANCE = 0.01`) + nouvelle passe `validateDetailedSnapshot(account.kind, line, holdings)` (detailed + holdings ⇒ ligne agrégée ET `value = SUM` ; detailed pré-pivot ⇒ agrégé toléré). `getHoldingsForLatestSnapshot` (pré-remplissage : titres + qty + book_cost reportés, qty-0 exclus), `listHoldingsBySnapshotLine` (drill-down), `computeUnrealizedGain` (gain latent `value − book_cost` en valeur + %, garde-fou `book_cost = 0`/NULL → « N/A », agrégeable par classe/enveloppe). `deleteSnapshot`. 2. **Snapshots + lines + holdings** — `listBalanceSnapshots`, `getBalanceSnapshotByDate`, `upsertSnapshot` (création + édition par date), `upsertSnapshotLines` (rewrite-all : DELETE WHERE snapshot_id puis INSERT par ligne). **Save détaillé (Étape 2)** : pour un compte `detailed`, la ligne agrégée (value = somme des holdings) **et** ses holdings sont écrits dans la **même transaction** (`BEGIN/COMMIT`), `value` recalculée = `SUM(holdings.value)` (chaque holding arrondi au cent, comparaison exacte). `validateLineKindInvariants` (simple, inchangé, tolérance `PRICED_VALUE_TOLERANCE = 0.01`) + nouvelle passe `validateDetailedSnapshot(account.kind, line, holdings)` (detailed + holdings ⇒ ligne agrégée ET `value = SUM` ; detailed pré-pivot ⇒ agrégé toléré). `getHoldingsForLatestSnapshot` (pré-remplissage : titres + qty + book_cost reportés, qty-0 exclus), `listHoldingsBySnapshotLine` (drill-down), `computeUnrealizedGain` (gain latent `value − book_cost` en valeur + %, garde-fou `book_cost = 0`/NULL → « N/A », agrégeable par classe/enveloppe). `deleteSnapshot`.
3. **Returns + transfers** — `linkTransfer`, `unlinkTransfer`, `listAccountTransfers`, `listAllLinkedTransfersForTooltip` (un coup pour la `Map.has(txId)` consommée par l'icône d'attribution dans `TransactionTable`), `computeAccountReturn` (wrapper sur la commande Tauri `compute_account_return` qui lit `db_filename` du profil actif via `loadProfiles()`). 3. **Returns + transfers** — `linkTransfer`, `unlinkTransfer`, `listAccountTransfers`, `listAllLinkedTransfersForTooltip` (un coup pour la `Map.has(txId)` consommée par l'icône d'attribution dans `TransactionTable`), `computeAccountReturn` (wrapper sur la commande Tauri `compute_account_return` qui lit `db_filename` du profil actif via `loadProfiles()`).
4. **Prices** — *(Phase 5, livraison reportée à l'Issue #143)*. La forme prévue : `fetchPrice(symbol, date)` invoquant `fetch_price` (Tauri), avec rate-limit client (1/2s), backoff exponentiel et dedup in-flight. Voir [ADR 0009](adr/0009-proxy-price-fetching-via-maximus-api.md) pour l'architecture proxy. 4. **Prices** — `prices.fetchPrice(symbol, date)` invoque `fetch_price` (Tauri, voir « Commandes Tauri »), avec rate-limit client (1/2s), dedup in-flight, backoff exponentiel sur les erreurs de classe 5xx et plafond de 100 cours réussis par session ; un refus `license_revoked` est écrit au Journal (`ACTIVATION_TOKEN_LOG`). Voir [ADR 0009](adr/0009-proxy-price-fetching-via-maximus-api.md) pour l'architecture proxy.
Le CRUD passe par `getDb()` + `tauri-plugin-sql` direct, **jamais** via une commande Tauri — convention projet. Les commandes Rust sont réservées au filesystem, OAuth, license, profils, feedback et au seul calcul Modified Dietz (qui a besoin d'arithmétique de dates `chrono`). Le CRUD passe par `getDb()` + `tauri-plugin-sql` direct, **jamais** via une commande Tauri — convention projet. Les commandes Rust sont réservées au filesystem, OAuth, license, profils, feedback, aux cours (`fetch_price`, appel réseau qui porte le jeton d'activation) et au seul calcul Modified Dietz (qui a besoin d'arithmétique de dates `chrono`).
## Hooks (25+) ## Hooks (29)
Chaque hook encapsule la logique d'état via `useReducer` : Les 29 fichiers `.ts` de `src/hooks/`, hors tests (`ls src/hooks/*.ts | grep -v '\.test\.ts$' | wc -l`, #384) : les 28 du tableau et `useCollapsibleGroups`, décrit plus bas. Chaque hook de domaine encapsule la logique d'état via `useReducer` :
| Hook | Domaine | | Hook | Domaine |
|------|---------| |------|---------|
| `useCategories` | Catégories avec hiérarchie. Erreurs typées `CategoriesError` (`{ key }` d'un refus de la hiérarchie, `{ message }` sinon, `categoriesErrorOf`). Fenêtre « Vérifier la hiérarchie » : `hierarchyCheck`, `checkHierarchy`, `reattachOrphan` (une ligne de Journal, puis rechargement de la liste et de l'arbre), `closeHierarchyCheck` (#393) | | `useCategories` | Catégories avec hiérarchie. Erreurs typées `CategoriesError` (`{ key }` d'un refus de la hiérarchie, `{ message }` sinon, `categoriesErrorOf`). Fenêtre « Vérifier la hiérarchie » : `hierarchyCheck`, `checkHierarchy`, `reattachOrphan` (une ligne de Journal, puis rechargement de la liste et de l'arbre), `closeHierarchyCheck` (#393) |
| `useCategoryMigration` | Migration vers la taxonomie standard (`/settings/categories/migrate`, `CategoriesMigrationPage`) : machine d'états des étapes découvrir, simuler et consentir, puis exécution et résultat. Reducer sans appel de service : la page calcule le plan, écrit la sauvegarde préalable (SREF) et applique la migration, puis dispatche |
| `useCategoryTaxonomy` | Taxonomie standard v1 en lecture (`getTaxonomyV1`, `findById`, `findByPath`, `getLeaves`, `getParentById` de `categoryTaxonomyService`), mémorisée une fois : guide des catégories standard (`CategoriesStandardGuidePage`) et étapes de la migration |
| `useTransactions` | Transactions et filtrage. `useTransactions(initialFilters?)` part de filtres de départ posés sur les filtres par défaut et lus au montage seulement (`initTransactionsState`) : l'entrée guidée de l'étape « Classer une transaction à la main » passe `GUIDED_ENTRY_FILTERS` (non catégorisées, sans borne de date). Si sa première page revient vide, le reducer retire le filtre « non catégorisées » une seule fois (`guidedFallback`) et la page relit (#399) | | `useTransactions` | Transactions et filtrage. `useTransactions(initialFilters?)` part de filtres de départ posés sur les filtres par défaut et lus au montage seulement (`initTransactionsState`) : l'entrée guidée de l'étape « Classer une transaction à la main » passe `GUIDED_ENTRY_FILTERS` (non catégorisées, sans borne de date). Si sa première page revient vide, le reducer retire le filtre « non catégorisées » une seule fois (`guidedFallback`) et la page relit (#399) |
| `useDataImport` | Import de données | | `useDataImport` | Import de données |
| `useImportWizard` | Assistant d'import multi-étapes. Restaure le format enregistré **tel quel** via `formatFromRow` (aucune ré-inférence), déclenche la détection seule sur une source sans format (garde `!existing`), traverse l'étape `file-preview` à **chaque** import, et n'écrit la configuration qu'à `executeImport` (point d'écriture unique) — voir la section « Import CSV » et l'[ADR 0019](adr/0019-format-import-persiste.md). Expose `importFolderLoaded`, posé dans le `finally` de la lecture du dossier au montage (après son premier scan) et gardé par `RESET` : l'état vide de la page Import et sa carte de démarrage l'attendent (#384, #398). Expose aussi `rootFiles`, les CSV/TXT posés à la racine du dossier d'import que le scan (`ImportFolderScan`) rend à côté des sources : posé avec elles par la même action `SET_SCANNED_SOURCES`, gardé par `RESET` comme elles, nommé à l'écran par `RootFilesWarning` (au plus cinq noms, `formatRootFiles`) et jamais écrit au Journal (#427). `executeImport` enregistre la source sous `selectedSource.folder_name`, à la création comme à la mise à jour, jamais sous `config.name` : la source et ses fichiers importés sont retrouvés par le nom du sous-dossier, que `SourceNameDisplay` affiche en lecture seule (#426) | | `useImportWizard` | Assistant d'import multi-étapes. Restaure le format enregistré **tel quel** via `formatFromRow` (aucune ré-inférence), déclenche la détection seule sur une source sans format (garde `!existing`), traverse l'étape `file-preview` à **chaque** import, et n'écrit la configuration qu'à `executeImport` (point d'écriture unique) — voir la section « Import CSV » et l'[ADR 0019](adr/0019-format-import-persiste.md). Expose `importFolderLoaded`, posé dans le `finally` de la lecture du dossier au montage (après son premier scan) et gardé par `RESET` : l'état vide de la page Import et sa carte de démarrage l'attendent (#384, #398). Expose aussi `rootFiles`, les CSV/TXT posés à la racine du dossier d'import que le scan (`ImportFolderScan`) rend à côté des sources : posé avec elles par la même action `SET_SCANNED_SOURCES`, gardé par `RESET` comme elles, nommé à l'écran par `RootFilesWarning` (au plus cinq noms, `formatRootFiles`) et jamais écrit au Journal (#427). `executeImport` enregistre la source sous `selectedSource.folder_name`, à la création comme à la mise à jour, jamais sous `config.name` : la source et ses fichiers importés sont retrouvés par le nom du sous-dossier, que `SourceNameDisplay` affiche en lecture seule (#426) |
@ -258,6 +264,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useIsPremium` | Raccourci d'affichage `edition === "premium"` au-dessus de `LicenseContext` (remplace l'ancien `useLicense` supprimé, qui invoquait `get_edition` à chaque appel) | | `useIsPremium` | Raccourci d'affichage `edition === "premium"` au-dessus de `LicenseContext` (remplace l'ancien `useLicense` supprimé, qui invoquait `get_edition` à chaque appel) |
| `useLicenseMachines` | Carte Licence — machines activées et activation de la machine courante (#270). Reducer exporté (`licenseMachinesReducer`) : l'erreur d'activation est distincte de l'erreur de liste, et aucun chargement de la liste ne l'efface. Le serveur limite à 5 requêtes/min/IP partagées : sur `machine_limit_reached` la liste s'ouvre et se charge (verify) ; une désactivation retire la machine localement sans relancer verify ; la liste ne se charge que depuis des gestionnaires explicites, jamais depuis un effet. Un `license_revoked` reçu de l'un des trois appels a déjà été enregistré par Rust (#365) : le hook recharge alors le `LicenseContext` (`refresh`), qui repasse en édition Gratuite ; il en relit `revoked`, et `selectCanActivate` bloque « Activer ». `refresh()` du `LicenseContext` suit chaque activation ou désactivation réussie, pour resynchroniser le contexte : depuis #347 ni l'une ni l'autre ne change l'édition. Il porte aussi le retrait de la clé (#370) : `performRemoveLicense` (dépendances injectées, dont `confirm` et `t` passés par la carte) choisit la confirmation selon l'état (`removeLicenseConfirmKey` : valide, activée, révoquée) et demande la libération de la place d'une machine activée dont la clé n'est pas révoquée (`releasesSlot`) ; sur `network`, `server`, `rate_limit` ou `invalid_response`, Rust n'a rien supprimé et une seconde confirmation, qui nomme la raison, propose de retirer la clé quand même (`RELEASE_RETRY_CODES`). Une ligne au Journal par retrait, par libération ou retrait échoués (`REMOVE_LOG`), puis `refresh()`. `removing` verrouille la saisie d'une clé, « Activer » et « Désactiver » le temps du retrait. Après chaque chargement réussi de la liste, `loadMachines` relit l'état d'activation (lecture locale, aucune requête) : Rust a pu supprimer un jeton qu'aucune ligne ne représente, et « Activer » revient sans recharger la page ; une ligne au Journal à code fixe le dit quand `token_dropped` vaut `not_listed` (#367) | | `useLicenseMachines` | Carte Licence — machines activées et activation de la machine courante (#270). Reducer exporté (`licenseMachinesReducer`) : l'erreur d'activation est distincte de l'erreur de liste, et aucun chargement de la liste ne l'efface. Le serveur limite à 5 requêtes/min/IP partagées : sur `machine_limit_reached` la liste s'ouvre et se charge (verify) ; une désactivation retire la machine localement sans relancer verify ; la liste ne se charge que depuis des gestionnaires explicites, jamais depuis un effet. Un `license_revoked` reçu de l'un des trois appels a déjà été enregistré par Rust (#365) : le hook recharge alors le `LicenseContext` (`refresh`), qui repasse en édition Gratuite ; il en relit `revoked`, et `selectCanActivate` bloque « Activer ». `refresh()` du `LicenseContext` suit chaque activation ou désactivation réussie, pour resynchroniser le contexte : depuis #347 ni l'une ni l'autre ne change l'édition. Il porte aussi le retrait de la clé (#370) : `performRemoveLicense` (dépendances injectées, dont `confirm` et `t` passés par la carte) choisit la confirmation selon l'état (`removeLicenseConfirmKey` : valide, activée, révoquée) et demande la libération de la place d'une machine activée dont la clé n'est pas révoquée (`releasesSlot`) ; sur `network`, `server`, `rate_limit` ou `invalid_response`, Rust n'a rien supprimé et une seconde confirmation, qui nomme la raison, propose de retirer la clé quand même (`RELEASE_RETRY_CODES`). Une ligne au Journal par retrait, par libération ou retrait échoués (`REMOVE_LOG`), puis `refresh()`. `removing` verrouille la saisie d'une clé, « Activer » et « Désactiver » le temps du retrait. Après chaque chargement réussi de la liste, `loadMachines` relit l'état d'activation (lecture locale, aucune requête) : Rust a pu supprimer un jeton qu'aucune ligne ne représente, et « Activer » revient sans recharger la page ; une ligne au Journal à code fixe le dit quand `token_dropped` vaut `not_listed` (#367) |
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) | | `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
| `useFeedback` | Envoi d'une rétroaction au Feedback Hub (`FeedbackDialog`) : `idle`, `sending`, puis `success` ou `error` avec le code stable rendu par `send_feedback` (`invalid`, `rate_limit`, `server_error`, `network_error`) ; toute autre erreur vaut `network_error` |
### Hook transverse — `useCollapsibleGroups` ### Hook transverse — `useCollapsibleGroups`
@ -342,9 +349,11 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
|---|---|---|---| |---|---|---|---|
| Tableau de bord `/` | `counters.transactions === 0`, compteur lu par `useOnboardingChecklist` | `DashboardEmptyState` (carte d'accueil + liste détaillée) | « Choisir un dossier d'import » → `/import` | | Tableau de bord `/` | `counters.transactions === 0`, compteur lu par `useOnboardingChecklist` | `DashboardEmptyState` (carte d'accueil + liste détaillée) | « Choisir un dossier d'import » → `/import` |
| Import `/import` | aucun dossier d'import, ou dossier sans aucun sous-dossier, une fois le dossier lu (`importFolderLoaded`, #384) | `ImportEmptyState` (deux variantes, chacune avec l'encadré « Structure attendue » d'`ImportFolderStructure` : dossier parent, un sous-dossier par compte, fichiers à la racine ignorés, #422) | « Choisir un dossier » ; ou « Créer un dossier d'exemple » + « Actualiser » ; lien vers le guide `/settings/users#import-export` dans les deux cas | | Import `/import` | aucun dossier d'import, ou dossier sans aucun sous-dossier, une fois le dossier lu (`importFolderLoaded`, #384) | `ImportEmptyState` (deux variantes, chacune avec l'encadré « Structure attendue » d'`ImportFolderStructure` : dossier parent, un sous-dossier par compte, fichiers à la racine ignorés, #422) | « Choisir un dossier » ; ou « Créer un dossier d'exemple » + « Actualiser » ; lien vers le guide `/settings/users#import-export` dans les deux cas |
| Transactions `/transactions` | `showsEmptyState(state)` = `totalInDb === 0 && !isLoading`, `totalInDb` via `getTransactionCount` | `EmptyState` dans la page | « Importer des transactions » → `/import` | | Transactions `/transactions` | `showsEmptyState(state)` = `totalInDb === 0`, `totalInDb` via `getTransactionCount`, lu au montage : la carte vient dès que le compte est lu, sans attendre la première page (#384, D1) | `EmptyState` dans la page | « Importer des transactions » → `/import` |
| Budget `/budget` | `showsEmptyState(state)` = `totalInDb === 0`, même compteur | `EmptyState` dans la page | « Importer des transactions » → `/import` | | Budget `/budget` | `showsEmptyState(state)` = `totalInDb === 0`, même compteur | `EmptyState` dans la page | « Importer des transactions » → `/import` |
Sur ces deux pages, le compte se lit au montage à côté des autres lectures de la page (catégories et sources de Transactions, comptes du filtre de Budget), chacune pour son compte (`Promise.allSettled`, #384) : un compte en échec laisse `totalInDb` à `null`, donc la mise en page normale, jamais l'état vide, sans priver la page de ses filtres ; le premier échec devient l'erreur de la page. Le compte n'est relu qu'au montage, aucun abonnement au canal d'événements de données : un import se fait sur `/import`, et revenir sur la page la remonte (un changement de profil remonte toutes les routes, `BrowserRouter key={refreshKey}`).
**La liste de démarrage** (`OnboardingChecklist`, dans la carte d'accueil du tableau de bord vide, et dépliée en place par « Voir la liste » du bandeau de démarrage `OnboardingBanner`, que `AppShell` pose en haut de chaque page, #397) a huit lignes dans un ordre fixe (#425). Les six étapes de données se cochent par une **donnée**, jamais par un clic ; les deux étapes de visite par « J'ai compris » (et, pour Rapports, l'ouverture de la carte Tendances mise en évidence). La source de vérité est `computeChecklist(counters, { importFolderSet, budgetAllowed, skipped, acknowledged, visitStepsInitialized })` (`utils/onboardingChecklist.ts`, pur) : **La liste de démarrage** (`OnboardingChecklist`, dans la carte d'accueil du tableau de bord vide, et dépliée en place par « Voir la liste » du bandeau de démarrage `OnboardingBanner`, que `AppShell` pose en haut de chaque page, #397) a huit lignes dans un ordre fixe (#425). Les six étapes de données se cochent par une **donnée**, jamais par un clic ; les deux étapes de visite par « J'ai compris » (et, pour Rapports, l'ouverture de la carte Tendances mise en évidence). La source de vérité est `computeChecklist(counters, { importFolderSet, budgetAllowed, skipped, acknowledged, visitStepsInitialized })` (`utils/onboardingChecklist.ts`, pur) :
| Case | Source de vérité | | Case | Source de vérité |
@ -360,7 +369,7 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
`computeChecklist` rend `{ items, done, total, complete }`. Chaque ligne porte `done` (la donnée), `skipped` (l'utilisateur l'a passée) et `locked` ; au niveau de la liste, `done` compte les lignes déverrouillées faites ou passées, une fois chacune, et `complete` vaut vrai quand toutes le sont. Une ligne verrouillée reste hors du total, quel que soit son état. Les deux lignes de visite sont verrouillées tant que le profil n'a pas initialisé ses étapes de visite : un profil au parcours fini ne passe pas un instant par « Étape suivante : Parcourir les catégories » ; la liste les montre alors « en attente », jamais « verrouillées » (le badge de l'édition Base ne les concerne pas), et son compteur attend `budgetReady && visitStepsInitialized`. Dans la liste, une ligne faite n'a plus de lien, une ligne passée montre le badge « Passée » et « Reprendre », une ligne verrouillée mène à la carte Licence ; chaque état sauf « à faire » est lu par un texte masqué visuellement (`onboarding.state.*`), les icônes étant cachées aux technologies d'assistance. `computeChecklist` rend `{ items, done, total, complete }`. Chaque ligne porte `done` (la donnée), `skipped` (l'utilisateur l'a passée) et `locked` ; au niveau de la liste, `done` compte les lignes déverrouillées faites ou passées, une fois chacune, et `complete` vaut vrai quand toutes le sont. Une ligne verrouillée reste hors du total, quel que soit son état. Les deux lignes de visite sont verrouillées tant que le profil n'a pas initialisé ses étapes de visite : un profil au parcours fini ne passe pas un instant par « Étape suivante : Parcourir les catégories » ; la liste les montre alors « en attente », jamais « verrouillées » (le badge de l'édition Base ne les concerne pas), et son compteur attend `budgetReady && visitStepsInitialized`. Dans la liste, une ligne faite n'a plus de lien, une ligne passée montre le badge « Passée » et « Reprendre », une ligne verrouillée mène à la carte Licence ; chaque état sauf « à faire » est lu par un texte masqué visuellement (`onboarding.state.*`), les icônes étant cachées aux technologies d'assistance.
**Le dossier d'exemple** : sur un dossier d'import sans sous-dossier, `ImportEmptyState` propose « Créer un dossier d'exemple ». `useImportWizard.createSampleFolders` invoque `create_sample_import_folders` avec le dossier et la date du jour, rescanne, puis affiche `import.empty.sampleCreated` avec les noms rendus par la commande ; ce bandeau de réussite tombe au changement de dossier. La commande refuse d'écraser (voir son contrat dans « Commandes Tauri ») ; les gabarits et leur test d'intégration sont décrits dans « Gabarits du dossier d'exemple ». **Le dossier d'exemple** : sur un dossier d'import sans sous-dossier, `ImportEmptyState` propose « Créer un dossier d'exemple ». `useImportWizard.createSampleFolders` invoque `create_sample_import_folders` avec le dossier et la date du jour, rescanne, puis affiche `import.empty.sampleCreated` avec les noms rendus par la commande ; ce bandeau de réussite tombe au changement de dossier. Il ne vient qu'après un rescan réussi : `scanFolderInternal` rend `false` sur un échec, dont le bandeau d'erreur reste seul affiché (#384). La commande refuse d'écraser (voir son contrat dans « Commandes Tauri ») ; ses refus sont des codes stables que `sampleFolderError` (pur) traduit en clés `import.empty.sampleErrors.*`, le nom du dossier d'exemple passé par `errorValues` de l'état, que la page interpole au rendu ; tout autre message reste affiché brut. Le message brut part aussi au Journal en `logWarn` : un code, ou une erreur d'écriture qui nomme un dossier d'exemple, son fichier et l'erreur du système, jamais un chemin ; l'erreur du rescan, qui peut porter le chemin, n'y va pas. Les gabarits et leur test d'intégration sont décrits dans « Gabarits du dossier d'exemple ».
### Parcours guidé ### Parcours guidé
@ -426,7 +435,9 @@ Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de l
Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Seule exception à l'intérieur d'une page Free : le bouton Répartition de la page Transactions, gaté par `adjustments` (#369). Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide. Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Seule exception à l'intérieur d'une page Free : le bouton Répartition de la page Transactions, gaté par `adjustments` (#369). Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide.
## Commandes Tauri (39) ## Commandes Tauri (45)
Les 45 commandes de `generate_handler!` (`src-tauri/src/lib.rs`), réparties par module ; chaque compteur de section est le nombre de ses entrées (recompté pour #384).
### `fs_commands.rs` — Système de fichiers (7) ### `fs_commands.rs` — Système de fichiers (7)
@ -436,7 +447,7 @@ Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transac
- `detect_encoding` — Détection auto (UTF-8, Windows-1252, ISO-8859-15) - `detect_encoding` — Détection auto (UTF-8, Windows-1252, ISO-8859-15)
- `get_file_preview` — Aperçu des N premières lignes - `get_file_preview` — Aperçu des N premières lignes
- `pick_folder` — Dialogue de sélection de dossier - `pick_folder` — Dialogue de sélection de dossier
- `create_sample_import_folders` — Dossier d'exemple (#354). Contrat : reçoit le dossier d'import et la date du jour (`YYYY-MM-DD`) ; écrit « Exemple Desjardins » et « Exemple Tangerine », un relevé `releve-<AAAA-MM>.csv` chacun daté des deux mois complets précédents ; rend les deux **noms** créés, pas les chemins. Refuse si le dossier d'import n'existe pas ou si l'un des deux sous-dossiers existe déjà — les deux existences sont vérifiées **avant** la première écriture, donc rien n'est écrasé ni créé à moitié. Gabarits embarqués par `include_str!` depuis `src/__fixtures__/samples/` - `create_sample_import_folders` — Dossier d'exemple (#354). Contrat : reçoit le dossier d'import et la date du jour (`YYYY-MM-DD`) ; écrit « Exemple Desjardins » et « Exemple Tangerine », un relevé `releve-<AAAA-MM>.csv` chacun daté des deux mois complets précédents ; rend les deux **noms** créés, pas les chemins. Refuse si le dossier d'import n'existe pas ou si l'un des deux sous-dossiers existe déjà — les deux existences sont vérifiées **avant** la première écriture, donc rien n'est écrasé ni créé à moitié. Ses refus sont des codes stables (constantes `SAMPLE_ERROR_*`, #384), traduits par le front : `invalid_date`, `import_folder_missing` (sans le chemin) et `sample_folder_exists:<nom du dossier d'exemple>` ; une erreur d'écriture garde son texte anglais (dossier d'exemple, fichier, erreur du système). Gabarits embarqués par `include_str!` depuis `src/__fixtures__/samples/`
### `export_import_commands.rs` — Export/Import de données (5) ### `export_import_commands.rs` — Export/Import de données (5)
@ -514,14 +525,26 @@ Module privé appelé uniquement par `auth_commands.rs` et `license_commands.rs`
- Édition et `features[]` sont résolus ensemble par `license_commands::current_entitlements` (interne, pas une commande) : même chemin que `get_edition`, depuis la seule clé (#347) et son état de révocation (#365), tout échec de validation retourne `("free", [])` - Édition et `features[]` sont résolus ensemble par `license_commands::current_entitlements` (interne, pas une commande) : même chemin que `get_edition`, depuis la seule clé (#347) et son état de révocation (#365), tout échec de validation retourne `("free", [])`
- Dev : la Cargo feature `dev-override` (off par défaut — feature explicite, pas `debug_assertions`, CWE-489) compile la lecture de `SR_DEV_EDITION` pour forcer l'édition résolue en test (`cargo test --features dev-override`) - Dev : la Cargo feature `dev-override` (off par défaut — feature explicite, pas `debug_assertions`, CWE-489) compile la lecture de `SR_DEV_EDITION` pour forcer l'édition résolue en test (`cargo test --features dev-override`)
### `balance_commands.rs` — Bilan (1) ### `feedback_commands.rs` — Feedback Hub (2)
- `send_feedback(content, user_id, context)` — Envoie une rétroaction au Feedback Hub (`POST /api/feedback` sur `feedback.lacompagniemaximus.com`, `FEEDBACK_HUB_URL` en développement), délai de 15 s : `app_id` `simpl-resultat`, le texte (celui saisi, suivi au choix des dernières erreurs du Journal, ajoutées par `FeedbackDialog`), refusé s'il est vide, un `user_id` facultatif et le contexte borné par `FeedbackContext` (page, langue, thème, fenêtre, user-agent, horodatage ; un champ absent n'est pas envoyé). Rend `{ id, created_at }`, ou un code stable que `useFeedback` traduit : `invalid`, `rate_limit`, `server_error`, `network_error`
- `get_feedback_user_agent` — Rend `Simpl'Résultat/<version> (<système>)` pour le contexte
### `backup_commands.rs` — Sauvegarde avant la migration des catégories (3)
Appelées par `categoryBackupService` (sauvegarde SREF vérifiée avant la migration vers la taxonomie standard) et `categoryRestoreService` (restauration) :
- `ensure_backup_dir` — Crée au besoin `Simpl-Resultat/backups/` dans le dossier Documents du système et rend son chemin ; un échec rend `permission_denied: <chemin>` ou `create_dir_failed: <chemin>: <erreur>`, que le service traduit en code
- `get_file_size(file_path)` — Taille en octets d'un fichier : celle de la sauvegarde qui vient d'être écrite
- `file_exists(file_path)` — Vrai si le chemin désigne un fichier existant, `false` sans erreur s'il n'existe pas. Lue avant la fenêtre de restauration : une sauvegarde déplacée ou supprimée renvoie au choix manuel d'un fichier
### `balance_commands.rs` — Bilan (2)
- `compute_account_return(account_id, period_start, period_end, db_filename)` — Calcul Modified Dietz d'un compte sur une période. Ouvre une connexion `rusqlite` courte sur le fichier DB du profil actif (résolu par `profile_db_path` ; sous Linux, l'ancien chemin `app_data_dir()` faisait échouer le calcul, #390), lit le snapshot ≤ `period_start`, le snapshot ≥ `period_end` et tous les `balance_account_transfers` JOIN `transactions` dans la fenêtre, puis appelle `return_calculator::modified_dietz`. Retourne `AccountReturn { value_start, value_end, net_contributions, return_pct, annualized_pct, is_partial, has_no_transfers_warning }`. Voir [ADR 0008](adr/0008-modified-dietz-pour-rendement.md). - `compute_account_return(account_id, period_start, period_end, db_filename)` — Calcul Modified Dietz d'un compte sur une période. Ouvre une connexion `rusqlite` courte sur le fichier DB du profil actif (résolu par `profile_db_path` ; sous Linux, l'ancien chemin `app_data_dir()` faisait échouer le calcul, #390), lit le snapshot ≤ `period_start`, le snapshot ≥ `period_end` et tous les `balance_account_transfers` JOIN `transactions` dans la fenêtre, puis appelle `return_calculator::modified_dietz`. Retourne `AccountReturn { value_start, value_end, net_contributions, return_pct, annualized_pct, is_partial, has_no_transfers_warning }`. Voir [ADR 0008](adr/0008-modified-dietz-pour-rendement.md).
- `fetch_price(symbol, date)` — Cours d'un titre à une date (`YYYY-MM-DD`) par `maximus-api` (`GET /v1/prices`, [ADR 0009](adr/0009-proxy-price-fetching-via-maximus-api.md)) : lit le jeton d'activation du dossier de données et l'envoie en `Authorization`, avec les seuls en-têtes `Accept` et `User-Agent` (posé sur le client). Rend `PriceResponse`, ou une `FetchPriceError` sérialisée en JSON (`{"code":"rate_limit","retry_after_s":42}`…) : `auth` (sans jeton, ou sur un 401), `premium_required`, `license_revoked` sur un 403 qui le dit (`error.code`), `symbol_not_found`, `rate_limit`, `provider_unavailable`, `network`, `internal`. Sa logique vit dans `fetch_price_in`, sans `AppHandle`, testée dans un dossier temporaire contre un serveur mockito : sur `license_revoked`, le jeton refusé est supprimé s'il n'a pas changé pendant la requête, sans écrire l'état de la clé (#367, voir « Révocation » dans la section licence). Le consentement aux cours n'est vérifié que côté front (`PriceFetchControl`, `PriceFetchConsentToggle`)
Le module privé `return_calculator.rs` (déclaré dans `commands/mod.rs` mais non exposé comme commande) contient la logique pure Modified Dietz et ses tests `#[cfg(test)] mod tests` co-localisés (TDD, 7 cas : nominal / pas de snapshot début / partial / créé en cours / vidé / sans transferts / annualisation). Le module privé `return_calculator.rs` (déclaré dans `commands/mod.rs` mais non exposé comme commande) contient la logique pure Modified Dietz et ses tests `#[cfg(test)] mod tests` co-localisés (TDD, 7 cas : nominal / pas de snapshot début / partial / créé en cours / vidé / sans transferts / annualisation).
**À venir Phase 5** (Issue #143, BLOCKED par maximus-api Phase 2) : commande `fetch_price(symbol, date)` pour le price-fetching premium via proxy maximus-api. L'architecture est documentée dans l'ADR 0009 ; la livraison est différée jusqu'à ce que le serveur de licences (`maximus-api`) expose l'endpoint `GET /v1/prices`.
## Plugins Tauri ## Plugins Tauri
Ordre d'initialisation dans `lib.rs` (certains plugins ont des contraintes d'ordre) : Ordre d'initialisation dans `lib.rs` (certains plugins ont des contraintes d'ordre) :
@ -637,7 +660,7 @@ Sémantique simulée, chaque point prouvé par `src/test/hookHarness.test.ts` :
Non simulé : le nouveau rendu après un setter ou un dispatch, les enfants (un composant dans l'arbre rendu reste un élément), le DOM, le concurrent, la boucle de mises à jour pendant le rendu (le `render()` suivant la lit), et un reducer qui change d'un rendu à l'autre : le `dispatch` d'un `useReducer` garde le reducer du premier rendu (il est créé une fois, avec l'emplacement), là où React applique celui du dernier rendu. Un reducer défini au niveau du module, seul cas utilisé, n'est pas touché. Non simulé : le nouveau rendu après un setter ou un dispatch, les enfants (un composant dans l'arbre rendu reste un élément), le DOM, le concurrent, la boucle de mises à jour pendant le rendu (le `render()` suivant la lit), et un reducer qui change d'un rendu à l'autre : le `dispatch` d'un `useReducer` garde le reducer du premier rendu (il est créé une fois, avec l'emplacement), là où React applique celui du dernier rendu. Un reducer défini au niveau du module, seul cas utilisé, n'est pas touché.
Utilisateurs : `useOnboardingChecklist.wiring.test.ts`, `UsersSettingsPage.test.tsx`, `BudgetTable.wiring.test.ts`, `BudgetPage.wiring.test.ts`, `AppShell.slot.test.tsx`, `OnboardingStepCard.test.tsx`, `PriceFetchConsentToggle.test.tsx` (#389). Hors périmètre de #409, `useImportWizard.mount.test.ts` et `useImportWizard.executeImport.test.ts` gardent leurs faux non persistants. Utilisateurs : `useOnboardingChecklist.wiring.test.ts`, `UsersSettingsPage.test.tsx`, `BudgetTable.wiring.test.ts`, `BudgetPage.wiring.test.ts`, `AppShell.slot.test.tsx`, `OnboardingStepCard.test.tsx`, `PriceFetchConsentToggle.test.tsx` (#389). Hors périmètre de #409, `useImportWizard.mount.test.ts` et `useImportWizard.executeImport.test.ts` gardent leurs faux non persistants ; `useImportWizard.samples.test.ts` (« Créer un dossier d'exemple », #384) passe par le harnais partagé.
### Faux `t` marqué — `src/test/i18nMock.ts` (#389) ### Faux `t` marqué — `src/test/i18nMock.ts` (#389)

View file

@ -27,7 +27,7 @@ Les huit étapes ci-dessous sont celles du parcours de démarrage. La carte d'ac
3. Configurer une source d'import — la première ouverture d'un sous-dossier détecte le format du fichier 3. Configurer une source d'import — la première ouverture d'un sous-dossier détecte le format du fichier
4. Importer un premier fichier — après avoir lu l'aperçu et son récapitulatif signé 4. Importer un premier fichier — après avoir lu l'aperçu et son récapitulatif signé
5. Parcourir les catégories et les mots-clés — ce que font les mots-clés, avant de classer 5. Parcourir les catégories et les mots-clés — ce que font les mots-clés, avant de classer
6. Classer une transaction à la main — le seed de mots-clés fait le gros du travail, une correction manuelle affine le reste 6. Classer une transaction à la main — les mots-clés fournis avec l'application font le gros du travail, une correction manuelle affine le reste
7. Saisir un premier budget — édition Base et plus ; suivez ensuite votre progression via les Rapports 7. Saisir un premier budget — édition Base et plus ; suivez ensuite votre progression via les Rapports
8. Parcourir les rapports — la période, les faits saillants et les cartes de rapports 8. Parcourir les rapports — la période, les faits saillants et les cartes de rapports

View file

@ -946,7 +946,7 @@
"Set up an import source — opening a subfolder for the first time detects the file format", "Set up an import source — opening a subfolder for the first time detects the file format",
"Import a first file — after reading the preview and its signed recap", "Import a first file — after reading the preview and its signed recap",
"Browse categories and keywords — what keywords do, before categorizing", "Browse categories and keywords — what keywords do, before categorizing",
"Categorize a transaction by hand — the keyword seed does most of the work, a manual correction refines the rest", "Categorize a transaction by hand — the keywords that come with the app do most of the work, a manual correction refines the rest",
"Enter a first budget — Base edition and up; then track your progress through Reports", "Enter a first budget — Base edition and up; then track your progress through Reports",
"Browse the reports — the period, the highlights and the report cards" "Browse the reports — the period, the highlights and the report cards"
], ],

View file

@ -946,7 +946,7 @@
"Configurer une source d'import — la première ouverture d'un sous-dossier détecte le format du fichier", "Configurer une source d'import — la première ouverture d'un sous-dossier détecte le format du fichier",
"Importer un premier fichier — après avoir lu l'aperçu et son récapitulatif signé", "Importer un premier fichier — après avoir lu l'aperçu et son récapitulatif signé",
"Parcourir les catégories et les mots-clés — ce que font les mots-clés, avant de classer", "Parcourir les catégories et les mots-clés — ce que font les mots-clés, avant de classer",
"Classer une transaction à la main — le seed de mots-clés fait le gros du travail, une correction manuelle affine le reste", "Classer une transaction à la main — les mots-clés fournis avec l'application font le gros du travail, une correction manuelle affine le reste",
"Saisir un premier budget — édition Base et plus ; suivez ensuite votre progression via les Rapports", "Saisir un premier budget — édition Base et plus ; suivez ensuite votre progression via les Rapports",
"Parcourir les rapports — la période, les faits saillants et les cartes de rapports" "Parcourir les rapports — la période, les faits saillants et les cartes de rapports"
], ],