feat(data): actionable badges, health dots, at-a-glance stats, palette links (Batch 2)
- Transactions nav shows a live "N to review" badge (unmatched count from the
bank-ledger summary, limit:1 so it's cheap; refreshes on sync/import).
- Bank sync nav shows a green/amber/grey health dot (connected / needs-attention
/ off), mirroring the hero tone.
- Connection hero connected line now shows the transaction count at a glance
("SimpleFIN · 1,159 transactions · synced 2h ago · syncs automatically").
- Command palette gains Data section deep-links (Bank sync / Transactions /
Import / Export) via ?section=.
- Count/stat fetch is non-blocking (.catch → 0), never blocks the page.
Build clean; client suite 46 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6a1b2f62b2
commit
c7b110cd68
|
|
@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
|||
import {
|
||||
BarChart2, Calendar, CreditCard, Loader2, Navigation, Plus,
|
||||
Receipt, Search, Settings, Snowflake, Tag, Upload, User, X,
|
||||
Landmark, ArrowRightLeft, Download,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
|
|
@ -28,6 +29,10 @@ const NAV_COMMANDS = [
|
|||
{ id: 'nav-snowball', label: 'Go to Snowball', icon: Snowflake, path: '/snowball', group: 'Navigate' },
|
||||
{ id: 'nav-categories', label: 'Go to Categories', icon: Tag, path: '/categories', group: 'Navigate' },
|
||||
{ id: 'nav-data', label: 'Go to Data', icon: Upload, path: '/data', group: 'Navigate' },
|
||||
{ id: 'nav-data-bank', label: 'Data: Bank sync', icon: Landmark, path: '/data?section=bank-sync', group: 'Navigate' },
|
||||
{ id: 'nav-data-tx', label: 'Data: Transactions', icon: ArrowRightLeft, path: '/data?section=transactions', group: 'Navigate' },
|
||||
{ id: 'nav-data-import', label: 'Data: Import', icon: Upload, path: '/data?section=import', group: 'Navigate' },
|
||||
{ id: 'nav-data-export', label: 'Data: Export & backups', icon: Download, path: '/data?section=export', group: 'Navigate' },
|
||||
{ id: 'nav-settings', label: 'Go to Settings', icon: Settings, path: '/settings', group: 'Navigate' },
|
||||
{ id: 'nav-profile', label: 'Go to Profile', icon: User, path: '/profile', group: 'Navigate' },
|
||||
{ id: 'action-new-bill', label: 'Add a new bill', icon: Plus, path: '/bills?new=1', group: 'Actions' },
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export default function ConnectionHero({
|
|||
enabled, // status.enabled (server feature flag)
|
||||
hasConnections, // status.has_connections
|
||||
conn, // the simplefin data_source (name, last_sync_at, last_error) or null
|
||||
txnTotal, // total synced transactions (at-a-glance), or null
|
||||
onRetry,
|
||||
onGoTo, // (sectionId) => void
|
||||
onSynced, // () => void — refresh after a successful sync
|
||||
|
|
@ -160,7 +161,14 @@ export default function ConnectionHero({
|
|||
<p className="mt-0.5 truncate text-sm text-muted-foreground">
|
||||
{needsAttention
|
||||
? conn.last_error
|
||||
: <>{conn.name || 'SimpleFIN'}{synced ? <> · synced {synced}</> : null} · syncs automatically</>}
|
||||
: (
|
||||
<>
|
||||
{conn.name || 'SimpleFIN'}
|
||||
{Number(txnTotal) > 0 ? <> · {Number(txnTotal).toLocaleString('en-US')} transactions</> : null}
|
||||
{synced ? <> · synced {synced}</> : null}
|
||||
{' · syncs automatically'}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ export default function DataPage() {
|
|||
const [hasConnections, setHasConnections] = useState(false);
|
||||
const [syncLoading, setSyncLoading] = useState(true);
|
||||
const [syncError, setSyncError] = useState(false);
|
||||
const [unmatchedCount, setUnmatchedCount] = useState(0);
|
||||
const [txnTotal, setTxnTotal] = useState(null);
|
||||
const reduceMotion = useReducedMotion();
|
||||
const paneRef = useRef(null);
|
||||
const firstRender = useRef(true);
|
||||
|
|
@ -130,7 +132,19 @@ export default function DataPage() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Cheap "N to review" + transaction count (summary is aggregate, so limit:1 is enough).
|
||||
const loadTxnStats = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.bankTransactionsLedger({ limit: 1 });
|
||||
setUnmatchedCount(data?.summary?.unmatched || 0);
|
||||
setTxnTotal(data?.summary?.total ?? null);
|
||||
} catch {
|
||||
setUnmatchedCount(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadHistory(); loadSimplefinSummary(); }, [loadHistory, loadSimplefinSummary]);
|
||||
useEffect(() => { loadTxnStats(); }, [loadTxnStats, transactionRefreshKey]);
|
||||
|
||||
const handleTransactionImportComplete = useCallback(() => {
|
||||
loadHistory();
|
||||
|
|
@ -218,6 +232,14 @@ export default function DataPage() {
|
|||
? {}
|
||||
: { initial: { opacity: 0, y: 6 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -6 }, transition: { duration: 0.15 } };
|
||||
|
||||
// Health dot for Bank sync + "N to review" badge for Transactions.
|
||||
const bankDot = (syncError || !syncEnabled || !hasConnections) ? 'gray' : simplefinConn?.last_error ? 'amber' : 'green';
|
||||
const navSections = SECTIONS.map(s =>
|
||||
s.id === 'bank-sync' ? { ...s, dot: bankDot }
|
||||
: s.id === 'transactions' ? { ...s, badge: unmatchedCount || undefined }
|
||||
: s,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl space-y-5">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
|
|
@ -245,13 +267,14 @@ export default function DataPage() {
|
|||
enabled={syncEnabled}
|
||||
hasConnections={hasConnections}
|
||||
conn={simplefinConn}
|
||||
txnTotal={txnTotal}
|
||||
onRetry={loadSimplefinSummary}
|
||||
onGoTo={goTo}
|
||||
onSynced={handleSynced}
|
||||
/>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[220px_1fr]">
|
||||
<DataNav sections={SECTIONS} active={activeSection} onSelect={goTo} />
|
||||
<DataNav sections={navSections} active={activeSection} onSelect={goTo} />
|
||||
<div ref={paneRef} tabIndex={-1} className="min-w-0 outline-none">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div key={activeSection} {...motionProps}>
|
||||
|
|
|
|||
Loading…
Reference in New Issue