From d5b4bdb67c4b5f17b03b63ebf168659c850221fa Mon Sep 17 00:00:00 2001 From: shake-070 Date: Sun, 27 Sep 2026 14:37:46 +0000 Subject: [PATCH] feat: invoice versioning UI, i18n, advanced filtering, bookmarks (#802 #803 #804 #805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #802: Enhance VersionHistory component with version number badges (v1, v2…), 'latest' indicator, select-for-comparison checkboxes, side-by-side ComparisonView modal, Restore button with confirmation modal, and post-restore toast notification. - #803: Add multilingual i18n keys for all new features (versionHistory, advancedFilters, bookmarks) to all four locale files: en, es, pt, fr. - #804: Implement useAdvancedInvoiceFilters hook with localStorage persistence and applyAdvancedFilters helper. Add AdvancedInvoiceFilters UI component with collapsible panel, amount range (min/max USDC), tag multi-select with autocomplete, creator address filter, active-filter badge, and mobile- responsive layout. - #805: Implement useInvoiceBookmarks hook with localStorage persistence for bookmarks (with notes) and named collections (CRUD). Add InvoiceCollectionsPanel component with bookmarks tab, collections tab, create/rename/delete collections, add/remove invoices from collections, and BookmarkButton / InvoiceBookmarkActions sub-components. --- src/components/AdvancedInvoiceFilters.tsx | 329 +++++++++++++ src/components/InvoiceCollectionsPanel.tsx | 539 +++++++++++++++++++++ src/components/VersionHistory.tsx | 518 ++++++++++++++++---- src/hooks/useAdvancedInvoiceFilters.ts | 161 ++++++ src/hooks/useInvoiceBookmarks.ts | 278 +++++++++++ src/messages/en.json | 81 ++++ src/messages/es.json | 81 ++++ src/messages/fr.json | 81 ++++ src/messages/pt.json | 81 ++++ 9 files changed, 2068 insertions(+), 81 deletions(-) create mode 100644 src/components/AdvancedInvoiceFilters.tsx create mode 100644 src/components/InvoiceCollectionsPanel.tsx create mode 100644 src/hooks/useAdvancedInvoiceFilters.ts create mode 100644 src/hooks/useInvoiceBookmarks.ts diff --git a/src/components/AdvancedInvoiceFilters.tsx b/src/components/AdvancedInvoiceFilters.tsx new file mode 100644 index 00000000..3137d0ef --- /dev/null +++ b/src/components/AdvancedInvoiceFilters.tsx @@ -0,0 +1,329 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { normalizeTag, tagColorClass } from "@/lib/invoiceTags"; +import { + type AdvancedFilterRule, + DEFAULT_ADVANCED_FILTERS, + countActiveAdvancedFilters, + hasActiveAdvancedFilters, +} from "@/hooks/useAdvancedInvoiceFilters"; + +interface AdvancedInvoiceFiltersProps { + /** Current filter rule values (controlled from parent). */ + rules: AdvancedFilterRule; + /** Available tags from the invoice tag store for autocomplete. */ + availableTags?: string[]; + /** Called when the user applies new filter rules. */ + onApply: (rules: AdvancedFilterRule) => void; + /** Called when the user resets all filters. */ + onReset: () => void; + className?: string; +} + +/** + * Advanced invoice filtering panel with: + * - Amount range (min/max USDC) + * - Tag multi-select with autocomplete + * - Creator address (partial match) + * + * The component is self-contained — it keeps a local draft while the user + * edits, and only calls `onApply` when they press "Apply Filters". + */ +export default function AdvancedInvoiceFilters({ + rules, + availableTags = [], + onApply, + onReset, + className = "", +}: AdvancedInvoiceFiltersProps) { + const [isOpen, setIsOpen] = useState(false); + + // Local draft state — copied from `rules` whenever the panel opens + const [draft, setDraft] = useState({ ...DEFAULT_ADVANCED_FILTERS }); + + // Tag autocomplete state + const [tagInput, setTagInput] = useState(""); + const [tagSuggestions, setTagSuggestions] = useState([]); + const tagInputRef = useRef(null); + const panelRef = useRef(null); + + const activeCount = countActiveAdvancedFilters(rules); + + // Sync draft when panel opens + useEffect(() => { + if (isOpen) { + setDraft({ ...rules }); + setTagInput(""); + } + }, [isOpen, rules]); + + // Compute tag suggestions + useEffect(() => { + const q = normalizeTag(tagInput); + const appliedSet = new Set(draft.tags.map((t) => t.toLowerCase())); + const filtered = availableTags + .filter((t) => !appliedSet.has(t.toLowerCase()) && (q === "" || t.includes(q))) + .slice(0, 8); + setTagSuggestions(filtered); + }, [tagInput, draft.tags, availableTags]); + + // Close on Escape or outside click + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setIsOpen(false); + }; + const handleClickOutside = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen]); + + const updateDraft = (partial: Partial) => { + setDraft((prev) => ({ ...prev, ...partial })); + }; + + const addTag = (tag: string) => { + const normalized = normalizeTag(tag); + if (!normalized || draft.tags.includes(normalized)) return; + updateDraft({ tags: [...draft.tags, normalized] }); + setTagInput(""); + tagInputRef.current?.focus(); + }; + + const removeTag = (tag: string) => { + updateDraft({ tags: draft.tags.filter((t) => t !== tag) }); + }; + + const handleTagInputKeyDown = (e: React.KeyboardEvent) => { + if ((e.key === "Enter" || e.key === ",") && tagInput.trim()) { + e.preventDefault(); + addTag(tagInput); + } else if (e.key === "Backspace" && !tagInput && draft.tags.length > 0) { + removeTag(draft.tags[draft.tags.length - 1]); + } + }; + + const handleApply = () => { + onApply(draft); + setIsOpen(false); + }; + + const handleReset = () => { + onReset(); + setDraft({ ...DEFAULT_ADVANCED_FILTERS }); + setTagInput(""); + setIsOpen(false); + }; + + const draftHasActive = hasActiveAdvancedFilters(draft); + + return ( +
+ {/* Trigger button */} + + + {/* Panel */} + {isOpen && ( +
+
+

Advanced Filters

+ +
+ + {/* Amount Range */} +
+ +
+ updateDraft({ amountMin: e.target.value })} + placeholder="Min" + aria-label="Minimum amount in USDC" + className="w-full px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-sm text-gray-100 placeholder:text-gray-500 focus:outline-none focus:border-indigo-500 transition-colors" + /> + – + updateDraft({ amountMax: e.target.value })} + placeholder="Max" + aria-label="Maximum amount in USDC" + className="w-full px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-sm text-gray-100 placeholder:text-gray-500 focus:outline-none focus:border-indigo-500 transition-colors" + /> +
+
+ + {/* Tag Filter */} +
+ + + {/* Applied tags */} + {draft.tags.length > 0 && ( +
+ {draft.tags.map((tag) => ( + + {tag} + + + ))} +
+ )} + + {/* Tag input + suggestions */} +
+
+ setTagInput(e.target.value)} + onKeyDown={handleTagInputKeyDown} + placeholder="Filter by tag…" + aria-label="Add tag filter" + className="flex-1 px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-sm text-gray-100 placeholder:text-gray-500 focus:outline-none focus:border-indigo-500 transition-colors" + /> + +
+ + {/* Suggestion dropdown */} + {tagSuggestions.length > 0 && tagInput !== "" && ( +
+ {tagSuggestions.map((tag) => ( + + ))} +
+ )} + + {tagSuggestions.length === 0 && tagInput !== "" && availableTags.length > 0 && ( +

No matching tags

+ )} +
+
+ + {/* Creator Address */} +
+ + updateDraft({ creatorAddress: e.target.value })} + placeholder="G… Stellar address" + aria-label="Filter by creator Stellar address" + className="w-full px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-sm text-gray-100 placeholder:text-gray-500 font-mono focus:outline-none focus:border-indigo-500 transition-colors" + /> +
+ + {/* Action buttons */} +
+ + +
+ + {/* Active count indicator */} + {draftHasActive && ( +

+ {countActiveAdvancedFilters(draft)} active filter + {countActiveAdvancedFilters(draft) !== 1 ? "s" : ""} in draft +

+ )} +
+ )} +
+ ); +} diff --git a/src/components/InvoiceCollectionsPanel.tsx b/src/components/InvoiceCollectionsPanel.tsx new file mode 100644 index 00000000..c64cf35f --- /dev/null +++ b/src/components/InvoiceCollectionsPanel.tsx @@ -0,0 +1,539 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useInvoiceBookmarks, type BookmarkCollection } from "@/hooks/useInvoiceBookmarks"; + +// ── BookmarkButton ──────────────────────────────────────────────────────────── + +interface BookmarkButtonProps { + invoiceId: string; + /** Size variant. */ + size?: "sm" | "md"; + /** Whether to show label text beside the icon. */ + showLabel?: boolean; + className?: string; +} + +/** + * Icon button that toggles the bookmark state for an invoice. + * Uses the shared `useInvoiceBookmarks` hook (data lives in localStorage). + */ +export function BookmarkButton({ + invoiceId, + size = "md", + showLabel = false, + className = "", +}: BookmarkButtonProps) { + const { isBookmarked, toggleBookmark } = useInvoiceBookmarks(); + const bookmarked = isBookmarked(invoiceId); + + const iconSize = size === "sm" ? "w-4 h-4" : "w-5 h-5"; + const btnSize = size === "sm" ? "p-1.5" : "p-2"; + + return ( + + ); +} + +// ── AddToCollectionMenu ─────────────────────────────────────────────────────── + +interface AddToCollectionMenuProps { + invoiceId: string; + collections: BookmarkCollection[]; + onAdd: (collectionId: string) => void; + onRemove: (collectionId: string) => void; + isInCollection: (collectionId: string) => boolean; + onClose: () => void; +} + +function AddToCollectionMenu({ + invoiceId, + collections, + onAdd, + onRemove, + isInCollection, + onClose, +}: AddToCollectionMenuProps) { + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose(); + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [onClose]); + + return ( +
+ {collections.length === 0 ? ( +

No collections yet. Create one below.

+ ) : ( + collections.map((col) => { + const inCol = isInCollection(col.id); + return ( + + ); + }) + )} +
+ ); +} + +// ── InvoiceBookmarkActions ──────────────────────────────────────────────────── + +interface InvoiceBookmarkActionsProps { + invoiceId: string; + className?: string; +} + +/** + * Combined bookmark + "add to collection" control for an invoice detail page or card. + */ +export function InvoiceBookmarkActions({ invoiceId, className = "" }: InvoiceBookmarkActionsProps) { + const { isBookmarked, toggleBookmark, collections, addToCollection, removeFromCollection, isInCollection } = + useInvoiceBookmarks(); + const bookmarked = isBookmarked(invoiceId); + const [showCollectionMenu, setShowCollectionMenu] = useState(false); + + return ( +
+ + + {/* Add to collection */} +
+ + {showCollectionMenu && ( + addToCollection(cid, invoiceId)} + onRemove={(cid) => removeFromCollection(cid, invoiceId)} + isInCollection={(cid) => isInCollection(cid, invoiceId)} + onClose={() => setShowCollectionMenu(false)} + /> + )} +
+
+ ); +} + +// ── InvoiceCollectionsPanel ─────────────────────────────────────────────────── + +interface InvoiceCollectionsPanelProps { + /** Callback with an invoice ID to navigate to that invoice. */ + onInvoiceClick?: (invoiceId: string) => void; + className?: string; +} + +/** + * Full panel for managing bookmarks and collections. + * Lists all bookmarked invoices + organises them into named collections. + */ +export default function InvoiceCollectionsPanel({ + onInvoiceClick, + className = "", +}: InvoiceCollectionsPanelProps) { + const { + bookmarks, + collections, + toggleBookmark, + createCollection, + renameCollection, + deleteCollection, + addToCollection, + removeFromCollection, + isInCollection, + } = useInvoiceBookmarks(); + + const [newCollectionName, setNewCollectionName] = useState(""); + const [createError, setCreateError] = useState(null); + const [renamingId, setRenamingId] = useState(null); + const [renameInput, setRenameInput] = useState(""); + const [renameError, setRenameError] = useState(null); + const [activeTab, setActiveTab] = useState<"bookmarks" | "collections">("bookmarks"); + const [activeCollectionId, setActiveCollectionId] = useState(null); + + const handleCreate = () => { + const err = createCollection(newCollectionName); + if (err) { + setCreateError(err); + return; + } + setNewCollectionName(""); + setCreateError(null); + }; + + const handleRename = (id: string) => { + const err = renameCollection(id, renameInput); + if (err) { + setRenameError(err); + return; + } + setRenamingId(null); + setRenameInput(""); + setRenameError(null); + }; + + const activeCollection = collections.find((c) => c.id === activeCollectionId); + const collectionInvoices = activeCollection + ? bookmarks.filter((b) => activeCollection.invoiceIds.includes(b.invoiceId)) + : []; + + return ( +
+ {/* Header */} +
+

+ + + + Bookmarks & Collections +

+
+ + {/* Tabs */} +
+ {(["bookmarks", "collections"] as const).map((tab) => ( + + ))} +
+ + {/* Content */} +
+ {/* ── Bookmarks tab ── */} + {activeTab === "bookmarks" && ( +
+ {bookmarks.length === 0 ? ( +
+ + + + No bookmarks yet. Bookmark invoices to see them here. +
+ ) : ( +
    + {bookmarks.map((bookmark) => ( +
  • +
    + + {bookmark.note && ( +

    {bookmark.note}

    + )} +

    + {new Date(bookmark.addedAt).toLocaleDateString()} +

    +
    +
    + {/* Add to collection dropdown */} + {collections.length > 0 && ( + + )} + {/* Remove bookmark */} + +
    +
  • + ))} +
+ )} +
+ )} + + {/* ── Collections tab ── */} + {activeTab === "collections" && ( +
+ {/* Collection detail view */} + {activeCollectionId && activeCollection ? ( +
+ +

{activeCollection.name}

+ {collectionInvoices.length === 0 ? ( +

This collection is empty.

+ ) : ( +
    + {collectionInvoices.map((b) => ( +
  • + + +
  • + ))} +
+ )} +
+ ) : ( +
+ {/* Create collection form */} +
+ { + setNewCollectionName(e.target.value); + setCreateError(null); + }} + onKeyDown={(e) => e.key === "Enter" && handleCreate()} + placeholder="New collection name…" + maxLength={40} + aria-label="New collection name" + className="flex-1 px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-sm text-gray-100 placeholder:text-gray-500 focus:outline-none focus:border-indigo-500 transition-colors" + /> + +
+ {createError &&

{createError}

} + + {/* Collection list */} + {collections.length === 0 ? ( +
+ + + + No collections yet. +
+ ) : ( +
    + {collections.map((col) => ( +
  • + {renamingId === col.id ? ( +
    + { + setRenameInput(e.target.value); + setRenameError(null); + }} + onKeyDown={(e) => e.key === "Enter" && handleRename(col.id)} + maxLength={40} + aria-label={`Rename collection ${col.name}`} + className="flex-1 px-3 py-1.5 rounded-lg bg-gray-800 border border-gray-700 text-sm text-gray-100 focus:outline-none focus:border-indigo-500" + /> + + +
    + ) : ( + <> + + {/* Rename */} + + {/* Delete */} + + + )} + {renamingId === col.id && renameError && ( +

    {renameError}

    + )} +
  • + ))} +
+ )} +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/VersionHistory.tsx b/src/components/VersionHistory.tsx index 3abe59b0..4f47f428 100644 --- a/src/components/VersionHistory.tsx +++ b/src/components/VersionHistory.tsx @@ -26,6 +26,8 @@ interface FieldDiff { interface Props { invoiceId: string; + /** Optional callback called when the user requests to restore a version. */ + onRestore?: (versionIndex: number, entry: HistoryEntry) => void; } // ─── Diff helpers ──────────────────────────────────────────────────────────── @@ -60,12 +62,18 @@ function buildDiff(entry: any): FieldDiff[] { ]; for (const [key, label] of scalarFields) { - const before = entry[`old${key.charAt(0).toUpperCase() + key.slice(1)}`] ?? + const before = + entry[`old${key.charAt(0).toUpperCase() + key.slice(1)}`] ?? entry[`prev${key.charAt(0).toUpperCase() + key.slice(1)}`]; - const after = entry[`new${key.charAt(0).toUpperCase() + key.slice(1)}`] ?? + const after = + entry[`new${key.charAt(0).toUpperCase() + key.slice(1)}`] ?? entry[`next${key.charAt(0).toUpperCase() + key.slice(1)}`]; if (before != null || after != null) { - diffs.push({ field: label, before: before != null ? String(before) : undefined, after: after != null ? String(after) : undefined }); + diffs.push({ + field: label, + before: before != null ? String(before) : undefined, + after: after != null ? String(after) : undefined, + }); } } @@ -164,15 +172,214 @@ function DiffPanel({ diffs }: { diffs: FieldDiff[] }) { ); } +// ─── ComparisonView ────────────────────────────────────────────────────────── + +interface ComparisonViewProps { + left: HistoryEntry & { versionNumber: number }; + right: HistoryEntry & { versionNumber: number }; + onClose: () => void; +} + +function ComparisonView({ left, right, onClose }: ComparisonViewProps) { + const leftDiffs = left.diff ?? []; + const rightDiffs = right.diff ?? []; + + // Build a unified set of field names from both versions + const allFields = Array.from( + new Set([...leftDiffs.map((d) => d.field), ...rightDiffs.map((d) => d.field)]) + ); + + return ( +
+
+ {/* Header */} +
+
+

Version Comparison

+

+ Comparing v{left.versionNumber} ↔ v{right.versionNumber} +

+
+ +
+ + {/* Column headers */} +
+
+ v{left.versionNumber} — {left.action} +
+
+ v{right.versionNumber} — {right.action} +
+
+ + {/* Comparison body */} +
+ {allFields.length === 0 ? ( +
+ No field-level changes recorded for either version. +
+ ) : ( +
+ {allFields.map((field) => { + const leftDiff = leftDiffs.find((d) => d.field === field); + const rightDiff = rightDiffs.find((d) => d.field === field); + return ( +
+
+
+ {field} +
+ {leftDiff ? ( +
+ {leftDiff.before !== undefined && ( +
+ − + {leftDiff.before} +
+ )} + {leftDiff.after !== undefined && ( +
+ + + {leftDiff.after} +
+ )} +
+ ) : ( +
No change
+ )} +
+
+
+ {field} +
+ {rightDiff ? ( +
+ {rightDiff.before !== undefined && ( +
+ − + {rightDiff.before} +
+ )} + {rightDiff.after !== undefined && ( +
+ + + {rightDiff.after} +
+ )} +
+ ) : ( +
No change
+ )} +
+
+ ); + })} +
+ )} +
+
+
+ ); +} + +// ─── RestoreConfirmModal ────────────────────────────────────────────────────── + +interface RestoreConfirmProps { + versionNumber: number; + action: string; + onConfirm: () => void; + onCancel: () => void; +} + +function RestoreConfirmModal({ versionNumber, action, onConfirm, onCancel }: RestoreConfirmProps) { + return ( +
+
+
+
+ + + +
+
+

Restore Version {versionNumber}?

+

+ You are about to restore the state from{" "} + {action}. This action will create + a new version entry in the history. +

+
+
+
+ + +
+
+
+ ); +} + // ─── VersionHistory ─────────────────────────────────────────────────────────── -export default function VersionHistory({ invoiceId }: Props) { +type VersionedEntry = HistoryEntry & { versionNumber: number }; + +export default function VersionHistory({ invoiceId, onRestore }: Props) { const [history, setHistory] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + /** Which entry index has its diff panel open. */ const [expandedIndex, setExpandedIndex] = useState(null); + /** Indices selected for comparison (max 2). */ + const [compareSelection, setCompareSelection] = useState([]); + + /** Whether comparison modal is open. */ + const [showComparison, setShowComparison] = useState(false); + + /** Index of version pending restore confirmation. */ + const [restoreIndex, setRestoreIndex] = useState(null); + + /** Notification message after restore. */ + const [restoreNotice, setRestoreNotice] = useState(null); + useEffect(() => { const loadHistory = async () => { try { @@ -231,6 +438,38 @@ export default function VersionHistory({ invoiceId }: Props) { loadHistory(); }, [invoiceId]); + // Assign version numbers: latest is highest number + const versioned: VersionedEntry[] = history.map((entry, idx) => ({ + ...entry, + versionNumber: history.length - idx, + })); + + // Compare selection helpers + const toggleCompareSelect = (idx: number) => { + setCompareSelection((prev) => { + if (prev.includes(idx)) return prev.filter((i) => i !== idx); + if (prev.length >= 2) return [prev[1], idx]; + return [...prev, idx]; + }); + }; + + const handleCompare = () => { + if (compareSelection.length === 2) setShowComparison(true); + }; + + const handleRestoreConfirm = () => { + if (restoreIndex === null) return; + const entry = versioned[restoreIndex]; + if (onRestore) { + onRestore(restoreIndex, entry); + } + setRestoreNotice( + `Version ${entry.versionNumber} (${entry.action}) has been queued for restore. A new history entry will be created.` + ); + setRestoreIndex(null); + setTimeout(() => setRestoreNotice(null), 5000); + }; + if (loading) { return (
@@ -255,92 +494,209 @@ export default function VersionHistory({ invoiceId }: Props) { ); } + const [leftIdx, rightIdx] = compareSelection; + return ( -
- {history.map((entry, index) => { - const isExpanded = expandedIndex === index; - const hasDiff = (entry.diff?.length ?? 0) > 0 || - entry.oldValue != null || - entry.newValue != null; - - return ( -
+ {/* Toolbar */} +
+
+ + {versioned.length} version{versioned.length !== 1 ? "s" : ""} + + {compareSelection.length > 0 && ( + + {compareSelection.length}/2 selected + + )} +
+
+ {compareSelection.length > 0 && ( + + )} + -
+ Compare selected + +
+
-

- + + + + {restoreNotice} +

+ )} + + {/* Version list */} +
+ {versioned.map((entry, index) => { + const isExpanded = expandedIndex === index; + const isSelected = compareSelection.includes(index); + const hasDiff = + (entry.diff?.length ?? 0) > 0 || entry.oldValue != null || entry.newValue != null; + const isLatest = entry.versionNumber === versioned.length; + + return ( +
+ {/* Timeline dot */} +
+
-

+ {index < versioned.length - 1 && ( +
+ )} +
- {/* Legacy inline diff (always shown for extend_deadline) */} - {entry.oldValue && entry.newValue && !isExpanded && ( -
-

- - {entry.oldValue} -

-

- + {entry.newValue} -

+ {/* Content */} +
+
+
+ {/* Version badge */} + + v{entry.versionNumber} + {isLatest && • latest} + +

{entry.action}

+
+ + {/* Action buttons */} +
+ {/* Select for compare */} + + + {/* Restore button (not shown for latest) */} + {!isLatest && ( + + )} + + {/* "Show diff" toggle button */} + {hasDiff && ( + + )} +
- )} - {entry.address && ( -

- {truncateAddress(entry.address)} +

+

- )} - - {/* Expanded diff panel (issue #606) */} - {isExpanded && ( - 0 - ? entry.diff - : entry.oldValue || entry.newValue - ? [ - { - field: "Value", - before: entry.oldValue, - after: entry.newValue, - }, - ] - : [] - } - /> - )} + + {/* Legacy inline diff (always shown for extend_deadline) */} + {entry.oldValue && entry.newValue && !isExpanded && ( +
+

+ - {entry.oldValue} +

+

+ + {entry.newValue} +

+
+ )} + + {entry.address && ( +

+ {truncateAddress(entry.address)} +

+ )} + + {/* Expanded diff panel (issue #606) */} + {isExpanded && ( + 0 + ? entry.diff + : entry.oldValue || entry.newValue + ? [ + { + field: "Value", + before: entry.oldValue, + after: entry.newValue, + }, + ] + : [] + } + /> + )} +
-
- ); - })} + ); + })} +
+ + {/* Comparison modal */} + {showComparison && compareSelection.length === 2 && ( + setShowComparison(false)} + /> + )} + + {/* Restore confirm modal */} + {restoreIndex !== null && ( + setRestoreIndex(null)} + /> + )}
); } diff --git a/src/hooks/useAdvancedInvoiceFilters.ts b/src/hooks/useAdvancedInvoiceFilters.ts new file mode 100644 index 00000000..17db3d3f --- /dev/null +++ b/src/hooks/useAdvancedInvoiceFilters.ts @@ -0,0 +1,161 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +export interface AdvancedFilterRule { + /** Minimum amount in USDC (stroops / 10_000_000), empty string = no lower bound. */ + amountMin: string; + /** Maximum amount in USDC, empty string = no upper bound. */ + amountMax: string; + /** Tags that an invoice must carry at least one of. Empty array = no tag filter. */ + tags: string[]; + /** Creator Stellar address (partial or full). Empty string = no creator filter. */ + creatorAddress: string; +} + +export const DEFAULT_ADVANCED_FILTERS: AdvancedFilterRule = { + amountMin: "", + amountMax: "", + tags: [], + creatorAddress: "", +}; + +const STORAGE_KEY = "stellarsplit_advanced_filter_rules"; + +function readRules(): AdvancedFilterRule { + if (typeof window === "undefined") return { ...DEFAULT_ADVANCED_FILTERS }; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return { ...DEFAULT_ADVANCED_FILTERS }; + const parsed = JSON.parse(raw); + return { + amountMin: typeof parsed.amountMin === "string" ? parsed.amountMin : "", + amountMax: typeof parsed.amountMax === "string" ? parsed.amountMax : "", + tags: Array.isArray(parsed.tags) ? parsed.tags.filter((t: unknown) => typeof t === "string") : [], + creatorAddress: typeof parsed.creatorAddress === "string" ? parsed.creatorAddress : "", + }; + } catch { + return { ...DEFAULT_ADVANCED_FILTERS }; + } +} + +function writeRules(rules: AdvancedFilterRule): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(rules)); + } catch { + // localStorage unavailable + } +} + +/** + * Returns true when at least one rule is active (non-default). + */ +export function hasActiveAdvancedFilters(rules: AdvancedFilterRule): boolean { + return ( + rules.amountMin !== "" || + rules.amountMax !== "" || + rules.tags.length > 0 || + rules.creatorAddress !== "" + ); +} + +/** + * Count the number of active rules for the badge indicator. + */ +export function countActiveAdvancedFilters(rules: AdvancedFilterRule): number { + let n = 0; + if (rules.amountMin !== "") n++; + if (rules.amountMax !== "") n++; + if (rules.tags.length > 0) n++; + if (rules.creatorAddress !== "") n++; + return n; +} + +/** + * Apply the advanced filter rules to a list of invoices. + * + * @param invoices Raw invoice objects (any shape — we only access the fields + * that the SDK guarantees: `recipients[].amount`, `creator`). + * @param rules The current AdvancedFilterRule state. + * @param tagMap Optional map of invoiceId → string[] from the tag store. + */ +export function applyAdvancedFilters; +}>( + invoices: T[], + rules: AdvancedFilterRule, + tagMap: Record = {} +): T[] { + const minUsdc = rules.amountMin !== "" ? parseFloat(rules.amountMin) : null; + const maxUsdc = rules.amountMax !== "" ? parseFloat(rules.amountMax) : null; + const lowerCreator = rules.creatorAddress.trim().toLowerCase(); + + return invoices.filter((inv) => { + // ── Amount range ────────────────────────────────────────────────────── + if (minUsdc !== null || maxUsdc !== null) { + const totalStroops = inv.recipients.reduce((s, r) => { + const raw = r.amount; + return s + (typeof raw === "bigint" ? Number(raw) : Number(raw)); + }, 0); + const totalUsdc = totalStroops / 10_000_000; + if (minUsdc !== null && totalUsdc < minUsdc) return false; + if (maxUsdc !== null && totalUsdc > maxUsdc) return false; + } + + // ── Tag filter ──────────────────────────────────────────────────────── + if (rules.tags.length > 0) { + const invoiceTags = tagMap[String(inv.id)] ?? []; + const invoiceTagsLower = invoiceTags.map((t) => t.toLowerCase()); + const hasTag = rules.tags.some((t) => invoiceTagsLower.includes(t.toLowerCase())); + if (!hasTag) return false; + } + + // ── Creator address ─────────────────────────────────────────────────── + if (lowerCreator) { + const creator = (inv.creator ?? "").toLowerCase(); + if (!creator.includes(lowerCreator)) return false; + } + + return true; + }); +} + +export interface UseAdvancedInvoiceFiltersResult { + rules: AdvancedFilterRule; + setRules: (rules: AdvancedFilterRule) => void; + resetRules: () => void; + hasActive: boolean; + activeCount: number; +} + +/** + * Hook that manages advanced invoice filter rules with localStorage persistence. + */ +export function useAdvancedInvoiceFilters(): UseAdvancedInvoiceFiltersResult { + const [rules, setRulesState] = useState(DEFAULT_ADVANCED_FILTERS); + + // Hydrate from localStorage on mount + useEffect(() => { + setRulesState(readRules()); + }, []); + + const setRules = useCallback((next: AdvancedFilterRule) => { + setRulesState(next); + writeRules(next); + }, []); + + const resetRules = useCallback(() => { + setRulesState({ ...DEFAULT_ADVANCED_FILTERS }); + writeRules({ ...DEFAULT_ADVANCED_FILTERS }); + }, []); + + return { + rules, + setRules, + resetRules, + hasActive: hasActiveAdvancedFilters(rules), + activeCount: countActiveAdvancedFilters(rules), + }; +} diff --git a/src/hooks/useInvoiceBookmarks.ts b/src/hooks/useInvoiceBookmarks.ts new file mode 100644 index 00000000..12f2012f --- /dev/null +++ b/src/hooks/useInvoiceBookmarks.ts @@ -0,0 +1,278 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface InvoiceBookmark { + invoiceId: string; + /** Unix timestamp (ms) when the bookmark was created. */ + addedAt: number; + /** Optional note attached to this bookmark. */ + note?: string; +} + +export interface BookmarkCollection { + id: string; + name: string; + /** Invoice IDs that belong to this collection. */ + invoiceIds: string[]; + createdAt: number; +} + +export interface BookmarkStore { + bookmarks: InvoiceBookmark[]; + collections: BookmarkCollection[]; +} + +// ── Storage helpers ─────────────────────────────────────────────────────────── + +const STORAGE_KEY = "stellarsplit_invoice_bookmarks"; +const COLLECTION_NAME_MAX_LENGTH = 40; + +function readStore(): BookmarkStore { + if (typeof window === "undefined") return { bookmarks: [], collections: [] }; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return { bookmarks: [], collections: [] }; + const parsed = JSON.parse(raw); + return { + bookmarks: Array.isArray(parsed.bookmarks) ? parsed.bookmarks : [], + collections: Array.isArray(parsed.collections) ? parsed.collections : [], + }; + } catch { + return { bookmarks: [], collections: [] }; + } +} + +function writeStore(store: BookmarkStore): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); + } catch { + // localStorage unavailable + } +} + +// ── Validation ──────────────────────────────────────────────────────────────── + +export function validateCollectionName( + name: string, + existing: BookmarkCollection[], + excludeId?: string +): string | null { + const trimmed = name.trim(); + if (!trimmed) return "Collection name is required"; + if (trimmed.length > COLLECTION_NAME_MAX_LENGTH) + return `Name must be ${COLLECTION_NAME_MAX_LENGTH} characters or fewer`; + const duplicate = existing.some( + (c) => c.id !== excludeId && c.name.toLowerCase() === trimmed.toLowerCase() + ); + if (duplicate) return "A collection with this name already exists"; + return null; +} + +// ── Hook ────────────────────────────────────────────────────────────────────── + +export interface UseInvoiceBookmarksResult { + bookmarks: InvoiceBookmark[]; + collections: BookmarkCollection[]; + + /** Returns true when the invoice has been bookmarked. */ + isBookmarked: (invoiceId: string) => boolean; + + /** Toggle bookmark for an invoice. Returns the new state (true = added). */ + toggleBookmark: (invoiceId: string, note?: string) => boolean; + + /** Add or update a note on a bookmark. */ + setBookmarkNote: (invoiceId: string, note: string) => void; + + /** Create a new collection. Returns an error string or null on success. */ + createCollection: (name: string) => string | null; + + /** Rename a collection. Returns an error string or null on success. */ + renameCollection: (id: string, name: string) => string | null; + + /** Delete a collection (bookmarks are NOT deleted, only disassociated). */ + deleteCollection: (id: string) => void; + + /** Add an invoice to a collection. */ + addToCollection: (collectionId: string, invoiceId: string) => void; + + /** Remove an invoice from a collection. */ + removeFromCollection: (collectionId: string, invoiceId: string) => void; + + /** Returns true when an invoice is in a specific collection. */ + isInCollection: (collectionId: string, invoiceId: string) => boolean; + + /** Remove all bookmarks and collections. */ + clearAll: () => void; +} + +/** + * Hook that manages invoice bookmarks and collections with localStorage persistence. + */ +export function useInvoiceBookmarks(): UseInvoiceBookmarksResult { + const [store, setStore] = useState({ bookmarks: [], collections: [] }); + + // Hydrate from localStorage on mount + useEffect(() => { + setStore(readStore()); + }, []); + + const mutate = useCallback((updater: (prev: BookmarkStore) => BookmarkStore) => { + setStore((prev) => { + const next = updater(prev); + writeStore(next); + return next; + }); + }, []); + + const isBookmarked = useCallback( + (invoiceId: string) => store.bookmarks.some((b) => b.invoiceId === invoiceId), + [store.bookmarks] + ); + + const toggleBookmark = useCallback( + (invoiceId: string, note?: string): boolean => { + const exists = store.bookmarks.some((b) => b.invoiceId === invoiceId); + if (exists) { + mutate((prev) => ({ + ...prev, + bookmarks: prev.bookmarks.filter((b) => b.invoiceId !== invoiceId), + // Also remove from all collections + collections: prev.collections.map((c) => ({ + ...c, + invoiceIds: c.invoiceIds.filter((id) => id !== invoiceId), + })), + })); + return false; + } else { + mutate((prev) => ({ + ...prev, + bookmarks: [ + ...prev.bookmarks, + { invoiceId, addedAt: Date.now(), ...(note ? { note } : {}) }, + ], + })); + return true; + } + }, + [store.bookmarks, mutate] + ); + + const setBookmarkNote = useCallback( + (invoiceId: string, note: string) => { + mutate((prev) => ({ + ...prev, + bookmarks: prev.bookmarks.map((b) => + b.invoiceId === invoiceId ? { ...b, note: note.trim() || undefined } : b + ), + })); + }, + [mutate] + ); + + const createCollection = useCallback( + (name: string): string | null => { + const error = validateCollectionName(name, store.collections); + if (error) return error; + mutate((prev) => ({ + ...prev, + collections: [ + ...prev.collections, + { + id: + typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : String(Date.now()), + name: name.trim(), + invoiceIds: [], + createdAt: Date.now(), + }, + ], + })); + return null; + }, + [store.collections, mutate] + ); + + const renameCollection = useCallback( + (id: string, name: string): string | null => { + const error = validateCollectionName(name, store.collections, id); + if (error) return error; + mutate((prev) => ({ + ...prev, + collections: prev.collections.map((c) => + c.id === id ? { ...c, name: name.trim() } : c + ), + })); + return null; + }, + [store.collections, mutate] + ); + + const deleteCollection = useCallback( + (id: string) => { + mutate((prev) => ({ + ...prev, + collections: prev.collections.filter((c) => c.id !== id), + })); + }, + [mutate] + ); + + const addToCollection = useCallback( + (collectionId: string, invoiceId: string) => { + mutate((prev) => ({ + ...prev, + collections: prev.collections.map((c) => + c.id === collectionId && !c.invoiceIds.includes(invoiceId) + ? { ...c, invoiceIds: [...c.invoiceIds, invoiceId] } + : c + ), + })); + }, + [mutate] + ); + + const removeFromCollection = useCallback( + (collectionId: string, invoiceId: string) => { + mutate((prev) => ({ + ...prev, + collections: prev.collections.map((c) => + c.id === collectionId + ? { ...c, invoiceIds: c.invoiceIds.filter((id) => id !== invoiceId) } + : c + ), + })); + }, + [mutate] + ); + + const isInCollection = useCallback( + (collectionId: string, invoiceId: string) => { + const collection = store.collections.find((c) => c.id === collectionId); + return collection ? collection.invoiceIds.includes(invoiceId) : false; + }, + [store.collections] + ); + + const clearAll = useCallback(() => { + mutate(() => ({ bookmarks: [], collections: [] })); + }, [mutate]); + + return { + bookmarks: store.bookmarks, + collections: store.collections, + isBookmarked, + toggleBookmark, + setBookmarkNote, + createCollection, + renameCollection, + deleteCollection, + addToCollection, + removeFromCollection, + isInCollection, + clearAll, + }; +} diff --git a/src/messages/en.json b/src/messages/en.json index fecb2862..4509f13e 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -113,5 +113,86 @@ "yourRank": "Your wallet is ranked #{rank}.", "notInTop": "(not in top 20)", "backToDashboard": "Back to dashboard" + }, + "versionHistory": { + "title": "Version History", + "versions": "{count} version", + "versions_plural": "{count} versions", + "latest": "latest", + "selected": "{count}/2 selected", + "clearSelection": "Clear selection", + "compareSelected": "Compare selected", + "selectForCompare": "Select", + "selectedForCompare": "✓ Selected", + "restore": "Restore", + "showDiff": "Show diff", + "hideDiff": "Hide diff", + "noChanges": "No detailed field changes recorded for this entry.", + "comparison": { + "title": "Version Comparison", + "subtitle": "Comparing v{left} ↔ v{right}", + "close": "Close comparison", + "noChanges": "No field-level changes recorded for either version.", + "noChange": "No change" + }, + "restore": { + "title": "Restore Version {version}?", + "description": "You are about to restore the state from {action}. This action will create a new version entry in the history.", + "confirm": "Restore", + "cancel": "Cancel", + "notice": "Version {version} ({action}) has been queued for restore. A new history entry will be created." + }, + "loading": "Loading history…", + "error": "Failed to load history", + "empty": "No changes recorded for this invoice" + }, + "advancedFilters": { + "title": "Advanced Filters", + "apply": "Apply Filters", + "reset": "Reset", + "close": "Close", + "amountRange": { + "label": "Amount Range (USDC)", + "min": "Min", + "max": "Max" + }, + "tags": { + "label": "Tags", + "placeholder": "Filter by tag…", + "add": "Add", + "noMatches": "No matching tags" + }, + "creator": { + "label": "Creator Address", + "placeholder": "G… Stellar address" + }, + "activeFilters": "{count} active filter", + "activeFilters_plural": "{count} active filters", + "clearAll": "Clear all" + }, + "bookmarks": { + "title": "Bookmarks", + "collections": "Collections", + "addBookmark": "Bookmark", + "removeBookmark": "Remove bookmark", + "bookmarked": "Bookmarked", + "newCollection": "New Collection", + "collectionName": "Collection name", + "collectionNamePlaceholder": "e.g. Q4 Invoices", + "createCollection": "Create", + "deleteCollection": "Delete collection", + "confirmDeleteCollection": "Delete \"{name}\" collection? Bookmarks inside will be removed.", + "addToCollection": "Add to collection", + "removeFromCollection": "Remove from collection", + "noBookmarks": "No bookmarks yet. Bookmark invoices to see them here.", + "noCollections": "No collections yet. Create one to organise your bookmarks.", + "invoiceCount": "{count} invoice", + "invoiceCount_plural": "{count} invoices", + "emptyCollection": "This collection is empty.", + "cancel": "Cancel", + "save": "Save", + "collectionNameRequired": "Collection name is required", + "collectionNameTooLong": "Name must be 40 characters or fewer", + "collectionNameDuplicate": "A collection with this name already exists" } } diff --git a/src/messages/es.json b/src/messages/es.json index 18fa30f2..af2b84ae 100644 --- a/src/messages/es.json +++ b/src/messages/es.json @@ -92,5 +92,86 @@ "yourRank": "Tu billetera está clasificada #{rank}.", "notInTop": "(no en los mejores 20)", "backToDashboard": "Volver al panel de control" + }, + "versionHistory": { + "title": "Historial de Versiones", + "versions": "{count} versión", + "versions_plural": "{count} versiones", + "latest": "más reciente", + "selected": "{count}/2 seleccionados", + "clearSelection": "Limpiar selección", + "compareSelected": "Comparar seleccionados", + "selectForCompare": "Seleccionar", + "selectedForCompare": "✓ Seleccionado", + "restore": "Restaurar", + "showDiff": "Ver cambios", + "hideDiff": "Ocultar cambios", + "noChanges": "No se registraron cambios detallados para esta entrada.", + "comparison": { + "title": "Comparación de Versiones", + "subtitle": "Comparando v{left} ↔ v{right}", + "close": "Cerrar comparación", + "noChanges": "No se registraron cambios de campo para ninguna de las versiones.", + "noChange": "Sin cambios" + }, + "restore": { + "title": "¿Restaurar Versión {version}?", + "description": "Estás a punto de restaurar el estado de {action}. Esta acción creará una nueva entrada en el historial.", + "confirm": "Restaurar", + "cancel": "Cancelar", + "notice": "La versión {version} ({action}) ha sido puesta en cola para restauración. Se creará una nueva entrada en el historial." + }, + "loading": "Cargando historial…", + "error": "Error al cargar el historial", + "empty": "No se registraron cambios para esta factura" + }, + "advancedFilters": { + "title": "Filtros Avanzados", + "apply": "Aplicar Filtros", + "reset": "Restablecer", + "close": "Cerrar", + "amountRange": { + "label": "Rango de Monto (USDC)", + "min": "Mínimo", + "max": "Máximo" + }, + "tags": { + "label": "Etiquetas", + "placeholder": "Filtrar por etiqueta…", + "add": "Agregar", + "noMatches": "No hay etiquetas coincidentes" + }, + "creator": { + "label": "Dirección del Creador", + "placeholder": "G… Dirección Stellar" + }, + "activeFilters": "{count} filtro activo", + "activeFilters_plural": "{count} filtros activos", + "clearAll": "Limpiar todos" + }, + "bookmarks": { + "title": "Marcadores", + "collections": "Colecciones", + "addBookmark": "Marcar", + "removeBookmark": "Quitar marcador", + "bookmarked": "Marcado", + "newCollection": "Nueva Colección", + "collectionName": "Nombre de la colección", + "collectionNamePlaceholder": "ej. Facturas Q4", + "createCollection": "Crear", + "deleteCollection": "Eliminar colección", + "confirmDeleteCollection": "¿Eliminar la colección \"{name}\"? Se eliminarán los marcadores internos.", + "addToCollection": "Agregar a colección", + "removeFromCollection": "Quitar de la colección", + "noBookmarks": "No hay marcadores aún. Marca facturas para verlas aquí.", + "noCollections": "No hay colecciones aún. Crea una para organizar tus marcadores.", + "invoiceCount": "{count} factura", + "invoiceCount_plural": "{count} facturas", + "emptyCollection": "Esta colección está vacía.", + "cancel": "Cancelar", + "save": "Guardar", + "collectionNameRequired": "El nombre de la colección es obligatorio", + "collectionNameTooLong": "El nombre debe tener 40 caracteres o menos", + "collectionNameDuplicate": "Ya existe una colección con este nombre" } } diff --git a/src/messages/fr.json b/src/messages/fr.json index 0fb8dac5..669d0f18 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -92,5 +92,86 @@ "yourRank": "Votre portefeuille est classé #{rank}.", "notInTop": "(pas dans le top 20)", "backToDashboard": "Retour au tableau de bord" + }, + "versionHistory": { + "title": "Historique des versions", + "versions": "{count} version", + "versions_plural": "{count} versions", + "latest": "dernière", + "selected": "{count}/2 sélectionnés", + "clearSelection": "Effacer la sélection", + "compareSelected": "Comparer la sélection", + "selectForCompare": "Sélectionner", + "selectedForCompare": "✓ Sélectionné", + "restore": "Restaurer", + "showDiff": "Voir les modifications", + "hideDiff": "Masquer les modifications", + "noChanges": "Aucune modification de champ enregistrée pour cette entrée.", + "comparison": { + "title": "Comparaison de versions", + "subtitle": "Comparaison de v{left} ↔ v{right}", + "close": "Fermer la comparaison", + "noChanges": "Aucune modification de champ enregistrée pour ces versions.", + "noChange": "Pas de changement" + }, + "restore": { + "title": "Restaurer la version {version} ?", + "description": "Vous êtes sur le point de restaurer l'état de {action}. Cette action créera une nouvelle entrée dans l'historique.", + "confirm": "Restaurer", + "cancel": "Annuler", + "notice": "La version {version} ({action}) a été mise en file d'attente pour restauration. Une nouvelle entrée d'historique sera créée." + }, + "loading": "Chargement de l'historique…", + "error": "Échec du chargement de l'historique", + "empty": "Aucune modification enregistrée pour cette facture" + }, + "advancedFilters": { + "title": "Filtres avancés", + "apply": "Appliquer les filtres", + "reset": "Réinitialiser", + "close": "Fermer", + "amountRange": { + "label": "Plage de montant (USDC)", + "min": "Minimum", + "max": "Maximum" + }, + "tags": { + "label": "Étiquettes", + "placeholder": "Filtrer par étiquette…", + "add": "Ajouter", + "noMatches": "Aucune étiquette correspondante" + }, + "creator": { + "label": "Adresse du créateur", + "placeholder": "G… Adresse Stellar" + }, + "activeFilters": "{count} filtre actif", + "activeFilters_plural": "{count} filtres actifs", + "clearAll": "Tout effacer" + }, + "bookmarks": { + "title": "Favoris", + "collections": "Collections", + "addBookmark": "Ajouter aux favoris", + "removeBookmark": "Retirer des favoris", + "bookmarked": "En favoris", + "newCollection": "Nouvelle collection", + "collectionName": "Nom de la collection", + "collectionNamePlaceholder": "ex. Factures Q4", + "createCollection": "Créer", + "deleteCollection": "Supprimer la collection", + "confirmDeleteCollection": "Supprimer la collection \"{name}\" ? Les favoris qu'elle contient seront supprimés.", + "addToCollection": "Ajouter à la collection", + "removeFromCollection": "Retirer de la collection", + "noBookmarks": "Aucun favori pour l'instant. Ajoutez des factures aux favoris pour les voir ici.", + "noCollections": "Aucune collection pour l'instant. Créez-en une pour organiser vos favoris.", + "invoiceCount": "{count} facture", + "invoiceCount_plural": "{count} factures", + "emptyCollection": "Cette collection est vide.", + "cancel": "Annuler", + "save": "Enregistrer", + "collectionNameRequired": "Le nom de la collection est requis", + "collectionNameTooLong": "Le nom doit comporter 40 caractères ou moins", + "collectionNameDuplicate": "Une collection avec ce nom existe déjà" } } diff --git a/src/messages/pt.json b/src/messages/pt.json index 95fd32fc..9b1924b4 100644 --- a/src/messages/pt.json +++ b/src/messages/pt.json @@ -92,5 +92,86 @@ "yourRank": "Sua carteira está na posição #{rank}.", "notInTop": "(não está no top 20)", "backToDashboard": "Voltar ao painel" + }, + "versionHistory": { + "title": "Histórico de Versões", + "versions": "{count} versão", + "versions_plural": "{count} versões", + "latest": "mais recente", + "selected": "{count}/2 selecionados", + "clearSelection": "Limpar seleção", + "compareSelected": "Comparar selecionados", + "selectForCompare": "Selecionar", + "selectedForCompare": "✓ Selecionado", + "restore": "Restaurar", + "showDiff": "Ver alterações", + "hideDiff": "Ocultar alterações", + "noChanges": "Nenhuma alteração de campo registrada para esta entrada.", + "comparison": { + "title": "Comparação de Versões", + "subtitle": "Comparando v{left} ↔ v{right}", + "close": "Fechar comparação", + "noChanges": "Nenhuma alteração registrada para nenhuma das versões.", + "noChange": "Sem alterações" + }, + "restore": { + "title": "Restaurar Versão {version}?", + "description": "Você está prestes a restaurar o estado de {action}. Esta ação criará uma nova entrada no histórico.", + "confirm": "Restaurar", + "cancel": "Cancelar", + "notice": "A versão {version} ({action}) foi enfileirada para restauração. Uma nova entrada de histórico será criada." + }, + "loading": "Carregando histórico…", + "error": "Falha ao carregar histórico", + "empty": "Nenhuma alteração registrada para esta fatura" + }, + "advancedFilters": { + "title": "Filtros Avançados", + "apply": "Aplicar Filtros", + "reset": "Redefinir", + "close": "Fechar", + "amountRange": { + "label": "Faixa de Valor (USDC)", + "min": "Mínimo", + "max": "Máximo" + }, + "tags": { + "label": "Tags", + "placeholder": "Filtrar por tag…", + "add": "Adicionar", + "noMatches": "Nenhuma tag encontrada" + }, + "creator": { + "label": "Endereço do Criador", + "placeholder": "G… Endereço Stellar" + }, + "activeFilters": "{count} filtro ativo", + "activeFilters_plural": "{count} filtros ativos", + "clearAll": "Limpar todos" + }, + "bookmarks": { + "title": "Favoritos", + "collections": "Coleções", + "addBookmark": "Favoritar", + "removeBookmark": "Remover favorito", + "bookmarked": "Favoritado", + "newCollection": "Nova Coleção", + "collectionName": "Nome da coleção", + "collectionNamePlaceholder": "ex. Faturas Q4", + "createCollection": "Criar", + "deleteCollection": "Excluir coleção", + "confirmDeleteCollection": "Excluir a coleção \"{name}\"? Os favoritos dentro serão removidos.", + "addToCollection": "Adicionar à coleção", + "removeFromCollection": "Remover da coleção", + "noBookmarks": "Nenhum favorito ainda. Favorite faturas para vê-las aqui.", + "noCollections": "Nenhuma coleção ainda. Crie uma para organizar seus favoritos.", + "invoiceCount": "{count} fatura", + "invoiceCount_plural": "{count} faturas", + "emptyCollection": "Esta coleção está vazia.", + "cancel": "Cancelar", + "save": "Salvar", + "collectionNameRequired": "O nome da coleção é obrigatório", + "collectionNameTooLong": "O nome deve ter 40 caracteres ou menos", + "collectionNameDuplicate": "Já existe uma coleção com este nome" } }