Simpl-Resultat/src/components/settings/CategoriesCard.tsx
le king fu 0132e6e164
All checks were successful
PR Check / rust (push) Successful in 21m45s
PR Check / frontend (push) Successful in 2m17s
PR Check / rust (pull_request) Successful in 21m1s
PR Check / frontend (pull_request) Successful in 2m13s
feat(categories): add restore backup banner and permanent restore action (#122)
Surfaces the pre-migration SREF backup to the user so they can roll back a
category migration without digging into the filesystem:

- 90-day dismissable banner at the top of Settings > Categories pointing to
  the automatic backup (hidden once reverted, once dismissed, or past 90d).
- Permanent "Restore a backup" entry in Settings > Categories, available as
  long as a migration journal exists (even past the 90-day window).
- Confirmation modal with two-step consent, red Restore button, fallback
  file picker when the recorded path is missing, PIN prompt for encrypted
  SREF files, full-page reload on success.

Internals:
- New `categoryRestoreService` wrapping `read_import_file` +
  `importTransactionsWithCategories` with stable error codes
  (file_missing, read_failed, parse_failed, wrong_envelope_type,
  needs_password, wrong_password, import_failed).
- New `file_exists` Tauri command for the pre-flight presence check.
- On success: `categories_schema_version=v2` + merge `reverted_at` into
  `last_categories_migration`.
- Pure `shouldShowBanner` / `isWithinBannerWindow` helpers with tests.
- FR/EN i18n keys under `settings.categoriesCard.restore*`.
- CHANGELOG entries in both locales.

Closes #122
2026-04-20 21:47:43 -04:00

158 lines
6.1 KiB
TypeScript

import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FolderTree, ChevronRight, MoveRight, RotateCcw } from "lucide-react";
import { getPreference } from "../../services/userPreferenceService";
import {
readLastMigrationJournal,
type RestorableMigrationJournal,
} from "../../services/categoryRestoreService";
import CategoriesMigrationBackupBanner from "./CategoriesMigrationBackupBanner";
import CategoriesMigrationRestoreModal from "./CategoriesMigrationRestoreModal";
/**
* Card that surfaces category-related entries in the Settings page.
*
* Entries, decided by the profile's categories_schema_version and whether a
* migration was previously recorded:
* - "Standard categories guide" (always visible) — read-only tree (#117).
* - "Migrate to the standard structure" (v2 only) — 3-step migration (#121).
* - "Restore a backup" (when a migration journal exists, regardless of age)
* — opens the same modal as the 90-day banner (#122).
*
* Profiles already on v1 never see the migrate entry (they're done) but they
* keep the Restore entry as long as a journal is present, so they can always
* roll back even after the banner has expired.
*/
export default function CategoriesCard() {
const { t } = useTranslation();
const [showMigrate, setShowMigrate] = useState(false);
const [journal, setJournal] = useState<RestorableMigrationJournal | null>(
null,
);
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [version, loadedJournal] = await Promise.all([
getPreference("categories_schema_version"),
readLastMigrationJournal(),
]);
if (cancelled) return;
setShowMigrate(version === "v2");
setJournal(loadedJournal);
} catch {
if (!cancelled) {
setShowMigrate(false);
setJournal(null);
}
}
})();
return () => {
cancelled = true;
};
}, []);
// The permanent Restore entry is shown whenever a journal exists, even past
// the 90-day banner window AND even after the user already reverted (a user
// who wants to re-import the backup a second time is free to do so — the
// SREF file is still valid as long as it is on disk).
const showRestoreEntry = journal !== null;
return (
<div className="space-y-3">
{/* Post-migration 90-day banner — renders only within window and when
un-dismissed and not-yet-reverted. */}
<CategoriesMigrationBackupBanner />
<Link
to="/settings/categories/standard"
className="block bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
<FolderTree size={22} />
</div>
<div>
<h2 className="text-lg font-semibold">
{t("settings.categoriesCard.standardGuideTitle")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("settings.categoriesCard.standardGuideDescription")}
</p>
</div>
</div>
<ChevronRight
size={18}
className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors"
/>
</div>
</Link>
{showMigrate && (
<Link
to="/settings/categories/migrate"
className="block bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
<MoveRight size={22} />
</div>
<div>
<h2 className="text-lg font-semibold">
{t("settings.categoriesCard.migrateTitle")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("settings.categoriesCard.migrateDescription")}
</p>
</div>
</div>
<ChevronRight
size={18}
className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors"
/>
</div>
</Link>
)}
{showRestoreEntry && journal && (
<button
type="button"
onClick={() => setIsRestoreModalOpen(true)}
className="block w-full text-left bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
<RotateCcw size={22} />
</div>
<div>
<h2 className="text-lg font-semibold">
{t("settings.categoriesCard.restoreEntry.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("settings.categoriesCard.restoreEntry.description")}
</p>
</div>
</div>
<ChevronRight
size={18}
className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors"
/>
</div>
</button>
)}
{isRestoreModalOpen && journal && (
<CategoriesMigrationRestoreModal
journal={journal}
onClose={() => setIsRestoreModalOpen(false)}
/>
)}
</div>
);
}