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
158 lines
6.1 KiB
TypeScript
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>
|
|
);
|
|
}
|