feat: wire brain service to platform gateway

- Gateway proxies /api/brain/* to brain-api:8200/api/* via pangolin network
- User identity injected via X-Gateway-User-Id header
- Brain app registered in gateway database (sort_order 9)
- Added to GATEWAY_KEY_SERVICES for dashboard integration
- Tested: health, config, list, create all working through gateway

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-01 16:32:53 -05:00
parent c9e776df59
commit 2072c359aa
34 changed files with 16745 additions and 1379 deletions

View File

@@ -1,763 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import AtelierBudgetPage from '$lib/pages/budget/AtelierBudgetPage.svelte';
import LegacyBudgetPage from '$lib/pages/budget/LegacyBudgetPage.svelte';
// ── State (same names as template binds) ──
let activeView = $state<'transactions' | 'budget'>('transactions');
let activeTab = $state('all');
let accountsOpen = $state(false);
let selected = $state<Set<string>>(new Set());
let lastCategory = $state('');
let bulkCategoryOpen = $state(false);
let focusedRowId = $state('');
let loading = $state(true);
let saving = $state(false);
// ── Data (populated from API) ──
let budgetGroups = $state<{ name: string; categories: { name: string; budgeted: number; spent: number; available: number }[] }[]>([]);
let categories = $state<string[]>([]);
let categoryMap = $state<Record<string, string>>({}); // name → id
let accounts = $state<{ name: string; balance: number; positive: boolean; id?: string }[]>([]);
let offBudgetAccounts = $state<{ name: string; balance: number; positive: boolean; id?: string }[]>([]);
let suggestedTransfers = $state<any[]>([]);
let transactions = $state<{ id: string; date: string; payee: string; note: string; account: string; category: string; categoryType: string; amount: number; categoryId?: string; accountId?: string }[]>([]);
// ── Pagination ──
let hasMore = $state(true);
let loadingMore = $state(false);
let activeAccountId = $state<string | null>(null);
const PAGE_SIZE = 100;
// ── Header stats ──
let headerSpending = $state('...');
let headerIncome = $state('...');
let currentMonthLabel = $state('');
// Sort categories with last-used first
let sortedCategories = $derived(() => {
if (!lastCategory) return categories;
const rest = categories.filter(c => c !== lastCategory);
return [lastCategory, ...rest];
});
let canTransfer = $derived(selected.size === 2);
const filteredTransactions = $derived(() => {
if (activeTab === 'uncategorized') return transactions.filter(t => t.categoryType === 'uncat');
if (activeTab === 'categorized') return transactions.filter(t => t.categoryType !== 'uncat');
return transactions;
});
let totalUncatCount = $state(0);
const uncatCount = $derived(activeAccountId
? transactions.filter(t => t.categoryType === 'uncat').length
: totalUncatCount
);
// ── API helper ──
async function api(path: string, opts: RequestInit = {}) {
const res = await fetch(`/api/budget${path}`, { credentials: 'include', ...opts });
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
}
// ── Load data ──
async function loadAccounts() {
try {
const data = await api('/accounts');
const onBudget: typeof accounts = [];
const offBudget: typeof offBudgetAccounts = [];
for (const a of data) {
if (a.closed) continue;
const entry = {
name: a.name,
balance: Math.round(a.balanceDollars),
positive: a.balanceDollars >= 0,
id: a.id
};
if (a.offbudget) offBudget.push(entry);
else onBudget.push(entry);
}
accounts = onBudget;
offBudgetAccounts = offBudget;
} catch { /* silent */ }
}
async function loadCategories() {
try {
const data = await api('/categories');
const allCats: string[] = [];
const map: Record<string, string> = {};
for (const group of data) {
for (const cat of group.categories) {
if (cat.name !== 'Starting Balances') {
allCats.push(cat.name);
map[cat.name] = cat.id;
}
}
}
categories = allCats;
categoryMap = map;
if (!lastCategory && allCats.length > 0) lastCategory = allCats[0];
} catch { /* silent */ }
}
function mapTransaction(t: any) {
return {
id: t.id,
date: formatDateShort(t.date),
payee: t.payeeName || t.payee || '',
note: t.notes || '',
account: t.accountName || '',
accountId: t.accountId || '',
category: t.categoryName || '',
categoryType: t.transfer_id ? 'transfer' : (t.categoryName ? 'normal' : 'uncat'),
amount: t.amountDollars || 0,
categoryId: t.categoryId || ''
};
}
async function loadTransactions(append = false) {
if (loadingMore) return;
if (!append) { loading = true; transactions = []; hasMore = true; }
else loadingMore = true;
try {
let data: any[];
if (activeAccountId) {
// Per-account: supports offset/limit pagination
const offset = append ? transactions.length : 0;
const resp = await api(`/transactions?accountId=${activeAccountId}&limit=${PAGE_SIZE}&offset=${offset}`);
data = resp.transactions || resp || [];
} else {
// All accounts: /recent doesn't support offset
// Load more when viewing uncategorized to capture all of them
const limit = activeTab === 'uncategorized'
? Math.max(500, (append ? transactions.length + PAGE_SIZE : PAGE_SIZE))
: (append ? transactions.length + PAGE_SIZE : PAGE_SIZE);
data = await api(`/transactions/recent?limit=${limit}`);
if (append) {
const existingIds = new Set(transactions.map(t => t.id));
data = data.filter((t: any) => !existingIds.has(t.id));
}
}
const mapped = (Array.isArray(data) ? data : []).map(mapTransaction);
if (append) {
transactions = [...transactions, ...mapped];
} else {
transactions = mapped;
}
hasMore = mapped.length >= PAGE_SIZE;
} catch { /* silent */ }
finally { loading = false; loadingMore = false; }
}
function loadMore() {
if (hasMore && !loadingMore) loadTransactions(true);
}
function selectAccount(accountId: string | null) {
activeAccountId = accountId;
loadTransactions();
}
async function loadBudget() {
try {
const now = new Date();
const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const data = await api(`/budget/${month}`);
budgetGroups = (data.categoryGroups || [])
.filter((g: any) => g.categories?.length > 0)
.map((g: any) => ({
name: g.name,
categories: g.categories
.filter((c: any) => c.name !== 'Starting Balances')
.map((c: any) => ({
name: c.name,
budgeted: Math.round(c.budgeted / 100),
spent: Math.round(Math.abs(c.spent) / 100),
available: Math.round(c.balance / 100)
}))
}))
.filter((g: any) => g.categories.length > 0);
} catch { /* silent */ }
}
async function loadSuggested() {
try {
const data = await api('/suggested-transfers');
suggestedTransfers = data.map((s: any) => ({
id: s.from.id + '-' + s.to.id,
from: { account: s.from.account, payee: s.from.payee },
to: { account: s.to.account, payee: s.to.payee },
amount: s.amountDollars,
confidence: s.confidence,
fromId: s.from.id,
toId: s.to.id
}));
} catch { /* silent */ }
}
// ── Actions ──
async function categorize(id: string, category: string) {
if (!category) return;
lastCategory = category;
let catId = categoryMap[category];
// If category ID not found, refresh category map and retry
if (!catId) {
await loadCategories();
catId = categoryMap[category];
if (!catId) return; // Still not found, bail
}
// Save previous state for revert
const prev = transactions.find(t => t.id === id);
const prevCat = prev?.category || '';
const prevType = prev?.categoryType || 'uncat';
// Optimistic update
const wasUncat = prevType === 'uncat';
transactions = transactions.map(t =>
t.id === id ? { ...t, category, categoryType: 'normal', categoryId: catId } : t
);
if (wasUncat) totalUncatCount = Math.max(0, totalUncatCount - 1);
// Persist to backend
try {
await api(`/transactions/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: catId })
});
} catch {
// Revert on failure
transactions = transactions.map(t =>
t.id === id ? { ...t, category: prevCat, categoryType: prevType, categoryId: '' } : t
);
if (wasUncat) totalUncatCount++;
}
}
async function bulkCategorize(category: string) {
if (!category) return;
lastCategory = category;
const catId = categoryMap[category];
const ids = Array.from(selected).filter(id => {
const t = transactions.find(tx => tx.id === id);
return t && t.categoryType === 'uncat';
});
// Optimistic update
transactions = transactions.map(t =>
ids.includes(t.id) ? { ...t, category, categoryType: 'normal', categoryId: catId } : t
);
totalUncatCount = Math.max(0, totalUncatCount - ids.length);
selected = new Set();
bulkCategoryOpen = false;
// Persist each to backend
for (const id of ids) {
try {
await api(`/transactions/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: catId })
});
} catch { /* silent - optimistic already applied */ }
}
}
async function makeTransfer(fromId: string, toId: string) {
saving = true;
try {
await api('/make-transfer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transactionId1: fromId, transactionId2: toId })
});
// Reload transactions to reflect transfer state
await loadTransactions();
} catch { /* silent */ }
finally { saving = false; }
}
async function linkSelectedAsTransfer() {
if (selected.size !== 2) return;
const ids = Array.from(selected);
await makeTransfer(ids[0], ids[1]);
selected = new Set();
}
async function linkSuggestedTransfer(suggestion: any) {
await makeTransfer(suggestion.fromId, suggestion.toId);
suggestedTransfers = suggestedTransfers.filter(s => s.id !== suggestion.id);
}
function dismissSuggestion(id: string) {
suggestedTransfers = suggestedTransfers.filter(s => s.id !== id);
}
function toggleSelect(id: string) {
const next = new Set(selected);
if (next.has(id)) next.delete(id); else next.add(id);
selected = next;
}
function handleRowKeydown(e: KeyboardEvent, txnId: string) {
if (e.key === 'c' || e.key === 'C') {
e.preventDefault();
const row = (e.target as HTMLElement).closest('.txn-row');
const select = row?.querySelector('.cat-select') as HTMLSelectElement | null;
if (select) { select.focus(); select.click(); }
}
}
// ── Formatters ──
function formatDateShort(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function formatBudgetAmount(amount: number): string {
return '$' + Math.abs(amount).toLocaleString('en-US');
}
function formatBalance(balance: number): string {
const abs = Math.abs(balance);
return (balance < 0 ? '-' : '') + '$' + abs.toLocaleString('en-US');
}
function formatAmount(amount: number): string {
const abs = Math.abs(amount);
const formatted = '$' + abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return amount >= 0 ? '+' + formatted : '-' + formatted;
}
async function loadSummary() {
try {
const [summary, uncat] = await Promise.all([
api('/summary'),
api('/uncategorized-count')
]);
headerSpending = '$' + Math.abs(summary.spendingDollars || 0).toLocaleString('en-US');
headerIncome = '$' + Math.abs(summary.incomeDollars || 0).toLocaleString('en-US');
totalUncatCount = uncat.count || 0;
const m = summary.month || '';
if (m) {
const d = new Date(m + '-01');
currentMonthLabel = d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
}
} catch { /* silent */ }
}
// ── Init ──
onMount(async () => {
await Promise.all([loadAccounts(), loadCategories(), loadTransactions(), loadBudget(), loadSuggested(), loadSummary()]);
loading = false;
});
let { data } = $props();
</script>
<div class="budget-page">
<div class="budget-layout">
<!-- Desktop sidebar -->
<aside class="budget-sidebar desktop-only">
<div class="sidebar-header">Budget</div>
<div class="sidebar-nav">
<button class="sidebar-nav-item" class:active={activeView === 'transactions' && !activeAccountId} onclick={() => { activeView = 'transactions'; selectAccount(null); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
All Transactions
{#if uncatCount > 0}<span class="sidebar-badge">{uncatCount}</span>{/if}
</button>
<button class="sidebar-nav-item" class:active={activeView === 'budget'} onclick={() => activeView = 'budget'}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M2 10h20"/></svg>
Budget
</button>
</div>
<div class="sidebar-accounts">
<div class="acct-group-header"><span>Budget</span><span class="acct-group-total positive">{formatBalance(accounts.reduce((s, a) => s + a.balance, 0))}</span></div>
{#each accounts as acct}
<button class="acct-row" class:active={activeAccountId === acct.id} onclick={() => { activeView = 'transactions'; selectAccount(acct.id || null); }}><span class="acct-name">{acct.name}</span><span class="acct-bal" class:positive={acct.positive} class:negative={!acct.positive}>{formatBalance(acct.balance)}</span></button>
{/each}
<div class="acct-group-header" style="margin-top:8px"><span>Off Budget</span><span class="acct-group-total">{formatBalance(offBudgetAccounts.reduce((s, a) => s + a.balance, 0))}</span></div>
{#each offBudgetAccounts as acct}
<button class="acct-row" class:active={activeAccountId === acct.id} onclick={() => { activeView = 'transactions'; selectAccount(acct.id || null); }}><span class="acct-name">{acct.name}</span><span class="acct-bal positive">{formatBalance(acct.balance)}</span></button>
{/each}
</div>
</aside>
<!-- Main workspace -->
<div class="budget-main">
<div class="budget-header">
<div>
{#if activeAccountId}
<button class="back-to-all" onclick={() => selectAccount(null)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
All Transactions
</button>
<div class="budget-title">{accounts.find(a => a.id === activeAccountId)?.name || offBudgetAccounts.find(a => a.id === activeAccountId)?.name || 'Account'}</div>
{:else}
<div class="budget-label">Budget</div>
<div class="budget-title">{currentMonthLabel} · <strong>{headerSpending}</strong> spent</div>
<div class="budget-meta">{headerIncome} income · {uncatCount} uncategorized</div>
{/if}
</div>
<button class="accounts-trigger mobile-only" onclick={() => accountsOpen = !accountsOpen}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M2 10h20"/></svg>
Accounts
<svg viewBox="0 0 10 6" fill="none" class="chevron" class:open={accountsOpen}><path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
<!-- Mobile view toggle -->
<div class="view-toggle mobile-only">
<button class="view-btn" class:active={activeView === 'transactions'} onclick={() => activeView = 'transactions'}>Transactions</button>
<button class="view-btn" class:active={activeView === 'budget'} onclick={() => activeView = 'budget'}>Budget</button>
</div>
{#if accountsOpen}
<div class="mobile-accounts">
<div class="acct-group-header"><span>Budget</span><span class="acct-group-total positive">{formatBalance(accounts.reduce((s, a) => s + a.balance, 0))}</span></div>
{#each accounts as acct}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="mobile-acct-row" onclick={() => { selectAccount(acct.id || null); accountsOpen = false; }}><span class="acct-name">{acct.name}</span><span class="acct-bal" class:positive={acct.positive} class:negative={!acct.positive}>{formatBalance(acct.balance)}</span></div>
{/each}
<div class="acct-group-header" style="margin-top:8px"><span>Off Budget</span><span class="acct-group-total">{formatBalance(offBudgetAccounts.reduce((s, a) => s + a.balance, 0))}</span></div>
{#each offBudgetAccounts as acct}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="mobile-acct-row" onclick={() => { selectAccount(acct.id || null); accountsOpen = false; }}><span class="acct-name">{acct.name}</span><span class="acct-bal positive">{formatBalance(acct.balance)}</span></div>
{/each}
</div>
{/if}
{#if activeView === 'transactions'}
<!-- Suggested Transfers -->
{#if suggestedTransfers.length > 0}
<div class="suggestions">
<div class="suggestions-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px;color:var(--accent)"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
<span class="suggestions-title">Suggested Transfers</span>
<span class="suggestions-count">{suggestedTransfers.length}</span>
</div>
{#each suggestedTransfers as s}
<div class="suggestion-row">
<div class="suggestion-pair">
<div class="suggestion-acct">{s.from.account}</div>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;color:var(--text-4);flex-shrink:0"><path d="M5 12h14"/><polyline points="12 5 19 12 12 19"/></svg>
<div class="suggestion-acct">{s.to.account}</div>
</div>
<div class="suggestion-amount">${s.amount.toLocaleString('en-US', { minimumFractionDigits: 2 })}</div>
<div class="suggestion-actions">
<button class="sug-btn link" onclick={() => linkSuggestedTransfer(s)}>Link</button>
<button class="sug-btn skip" onclick={() => dismissSuggestion(s.id)}>Skip</button>
</div>
</div>
{/each}
</div>
{/if}
<!-- Selection bar -->
{#if selected.size > 0}
<div class="selection-bar">
<span class="selection-count">{selected.size} selected</span>
{#if canTransfer}
<button class="transfer-btn" onclick={linkSelectedAsTransfer}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
Make Transfer
</button>
{/if}
<div class="bulk-cat">
{#if bulkCategoryOpen}
<select class="bulk-cat-select" onchange={(e) => bulkCategorize((e.target as HTMLSelectElement).value)}>
<option value="">Apply category...</option>
{#each sortedCategories() as cat}
<option value={cat}>{cat}</option>
{/each}
</select>
{:else}
<button class="bulk-cat-btn" onclick={() => bulkCategoryOpen = true}>
Set Category
</button>
{/if}
</div>
<button class="clear-btn" onclick={() => { selected = new Set(); bulkCategoryOpen = false; }}>Clear</button>
</div>
{/if}
<!-- Tabs -->
<div class="budget-tabs">
<button class="tab" class:active={activeTab === 'all'} onclick={() => { activeTab = 'all'; loadTransactions(); }}>All</button>
<button class="tab" class:active={activeTab === 'uncategorized'} onclick={() => { activeTab = 'uncategorized'; loadTransactions(); }}>Uncategorized <span class="tab-badge">{uncatCount}</span></button>
<button class="tab" class:active={activeTab === 'categorized'} onclick={() => { activeTab = 'categorized'; loadTransactions(); }}>Categorized</button>
</div>
<!-- Transactions -->
<div class="txn-card">
{#each filteredTransactions() as txn (txn.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="txn-row"
class:txn-uncat={txn.categoryType === 'uncat'}
class:txn-selected={selected.has(txn.id)}
tabindex="0"
onkeydown={(e) => handleRowKeydown(e, txn.id)}
>
<input type="checkbox" class="txn-check" checked={selected.has(txn.id)} onchange={() => toggleSelect(txn.id)} />
<div class="txn-date">{txn.date}</div>
<div class="txn-payee">
{#if txn.categoryType === 'transfer'}
<div class="txn-name transfer-name">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px;flex-shrink:0"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
{txn.payee}
</div>
{:else}
<div class="txn-name">{txn.payee}</div>
{#if txn.note}<div class="txn-note">{txn.note}</div>{/if}
{/if}
</div>
<div class="txn-account">{txn.account}</div>
<div class="txn-category">
{#if txn.categoryType === 'transfer'}
<span class="cat-pill transfer">Transfer</span>
{:else if txn.categoryType === 'uncat'}
<select class="cat-select" onchange={(e) => categorize(txn.id, (e.target as HTMLSelectElement).value)}>
<option value="">Select category</option>
{#each sortedCategories() as cat}
<option value={cat}>{cat}</option>
{/each}
</select>
{:else}
<span class="cat-pill">{txn.category}</span>
{/if}
</div>
<div class="txn-amount" class:pos={txn.amount >= 0} class:neg={txn.amount < 0}>{formatAmount(txn.amount)}</div>
</div>
{/each}
{#if hasMore}
<button class="load-more-btn" onclick={loadMore} disabled={loadingMore}>
{loadingMore ? 'Loading...' : 'Load more transactions'}
</button>
{/if}
</div>
{:else}
<!-- Budget Overview -->
<div class="budget-overview">
{#each budgetGroups as group}
<div class="budget-group">
<div class="budget-group-header">{group.name}</div>
<div class="budget-table">
<div class="budget-table-header">
<span class="bt-name">Category</span>
<span class="bt-val">Budgeted</span>
<span class="bt-val">Spent</span>
<span class="bt-val">Available</span>
</div>
{#each group.categories as cat}
<div class="budget-table-row" class:overspent={cat.available < 0}>
<span class="bt-name">{cat.name}</span>
<span class="bt-val">{formatBudgetAmount(cat.budgeted)}</span>
<span class="bt-val spent">{formatBudgetAmount(cat.spent)}</span>
<span class="bt-val" class:positive={cat.available > 0} class:negative={cat.available < 0}>{cat.available < 0 ? '-' : ''}{formatBudgetAmount(Math.abs(cat.available))}</span>
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
<style>
.budget-page { padding: 0; margin: -16px; }
.budget-layout { display: flex; min-height: calc(100vh - 56px); }
.desktop-only { display: none; }
@media (min-width: 768px) { .desktop-only { display: flex; } }
.mobile-only { display: flex; }
@media (min-width: 768px) { .mobile-only { display: none; } }
/* ── Sidebar ── */
.budget-sidebar { width: 250px; flex-shrink: 0; border-right: 1px solid var(--border); background: var(--surface); flex-direction: column; overflow-y: auto; }
.sidebar-header { font-size: var(--text-md); font-weight: 600; padding: var(--sp-5) var(--sp-4) var(--sp-3); color: var(--text-1); }
.sidebar-nav { padding: 0 var(--sp-2) var(--sp-2); }
.sidebar-nav-item { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 9px 12px; background: none; border: none; border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: 500; color: var(--text-3); cursor: pointer; transition: all 150ms; text-align: left; font-family: var(--font); }
.sidebar-nav-item:hover { color: var(--text-1); background: var(--card-hover); }
.sidebar-nav-item.active { color: var(--text-1); background: var(--accent-dim); }
.sidebar-nav-item :global(svg) { width: 16px; height: 16px; flex-shrink: 0; }
.sidebar-badge { margin-left: auto; font-size: var(--text-xs); font-family: var(--mono); background: var(--accent-dim); color: var(--accent); padding: 1px 6px; border-radius: var(--radius-xs); }
.sidebar-accounts { padding: var(--sp-2) 0; border-top: 1px solid var(--border); flex: 1; }
.acct-group-header { display: flex; justify-content: space-between; padding: var(--sp-2) var(--sp-4) var(--sp-1); font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-4); }
.acct-group-total { font-family: var(--mono); font-size: var(--text-xs); color: var(--text-3); }
.acct-group-total.positive { color: var(--success); }
.acct-row { display: flex; align-items: center; width: 100%; padding: 6px 16px; background: none; border: none; font-size: var(--text-sm); color: var(--text-2); cursor: pointer; transition: background 150ms; text-align: left; font-family: var(--font); }
.acct-row:hover { background: var(--card-hover); }
.acct-row.active { background: var(--accent-dim); color: var(--accent); }
.load-more-btn {
display: block; width: 100%; padding: var(--sp-3); margin-top: var(--sp-1);
background: none; border: 1px dashed var(--border); border-radius: var(--radius-sm);
font-size: var(--text-sm); font-weight: 500; color: var(--text-3); cursor: pointer;
font-family: var(--font); transition: all var(--transition);
}
.load-more-btn:hover { background: var(--card-hover); color: var(--text-1); }
.load-more-btn:disabled { opacity: 0.5; cursor: default; }
.acct-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.acct-bal { margin-left: auto; font-family: var(--mono); font-size: var(--text-sm); flex-shrink: 0; }
.acct-bal.positive { color: var(--success); }
.acct-bal.negative { color: var(--error); }
/* ── Main ── */
.budget-main { flex: 1; min-width: 0; padding: var(--sp-6); overflow-y: auto; }
.budget-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: var(--sp-4); }
.budget-label { font-size: var(--text-sm); font-weight: 600; color: var(--text-4); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: var(--sp-1); }
.back-to-all {
display: inline-flex; align-items: center; gap: var(--sp-1);
font-size: var(--text-sm); color: var(--text-3); background: none; border: none;
cursor: pointer; font-family: var(--font); padding: 0; margin-bottom: var(--sp-1);
transition: color var(--transition);
}
.back-to-all:hover { color: var(--accent); }
.back-to-all svg { width: 14px; height: 14px; }
.budget-title { font-size: var(--text-xl); font-weight: 300; color: var(--text-1); line-height: 1.2; }
.budget-meta { font-size: var(--text-sm); color: var(--text-3); margin-top: var(--sp-1); }
/* Mobile trigger */
.accounts-trigger { align-items: center; gap: var(--sp-1.5); padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-md); background: var(--card); border: 1px solid var(--border); font-size: var(--text-sm); font-weight: 500; color: var(--text-2); cursor: pointer; font-family: var(--font); transition: all var(--transition); flex-shrink: 0; }
.accounts-trigger:hover { background: var(--card-hover); }
.accounts-trigger :global(svg) { width: 16px; height: 16px; }
.chevron { width: 10px; height: 10px; transition: transform 200ms; }
.chevron.open { transform: rotate(180deg); }
.mobile-accounts { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--sp-3) var(--sp-4); margin-bottom: var(--sp-4); }
.mobile-acct-row { display: flex; justify-content: space-between; padding: var(--sp-2) 0; font-size: var(--text-sm); color: var(--text-2); cursor: pointer; transition: color var(--transition); }
.mobile-acct-row:hover { color: var(--text-1); }
/* ── Suggested Transfers ── */
.suggestions { margin-bottom: var(--sp-4); padding: 14px; border-radius: var(--radius); background: color-mix(in srgb, var(--accent-dim) 60%, transparent); border: 1px solid var(--accent-border); }
.suggestions-header { display: flex; align-items: center; gap: var(--sp-2); margin-bottom: 10px; }
.suggestions-title { font-size: var(--text-sm); font-weight: 600; color: var(--text-2); }
.suggestions-count { font-size: var(--text-xs); font-family: var(--mono); background: var(--accent); color: white; padding: 1px 5px; border-radius: var(--radius-xs); }
.suggestion-row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px 12px; border-radius: var(--radius-md); background: var(--card); margin-bottom: var(--sp-1); transition: background var(--transition); }
.suggestion-row:last-child { margin-bottom: 0; }
.suggestion-row:hover { background: var(--card-hover); }
.suggestion-pair { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; }
.suggestion-acct { font-size: var(--text-sm); font-weight: 500; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.suggestion-amount { font-family: var(--mono); font-size: var(--text-sm); font-weight: 500; color: var(--text-3); flex-shrink: 0; }
.suggestion-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
.sug-btn { padding: 5px 11px; border-radius: var(--radius-sm); font-size: var(--text-sm); font-weight: 600; border: none; cursor: pointer; font-family: var(--font); transition: all var(--transition); }
.sug-btn.link { background: var(--accent); color: white; }
.sug-btn.link:hover { opacity: 0.9; }
.sug-btn.skip { background: none; color: var(--text-4); }
.sug-btn.skip:hover { color: var(--text-2); }
/* ── Selection bar ── */
.selection-bar { display: flex; align-items: center; gap: var(--sp-3); padding: 10px var(--sp-4); margin-bottom: var(--sp-3); border-radius: var(--radius); background: var(--accent-dim); border: 1px solid var(--accent-focus); }
.selection-count { font-size: var(--text-base); font-weight: 600; color: var(--text-1); }
.selection-hint { font-size: var(--text-sm); color: var(--text-3); }
.transfer-btn { display: flex; align-items: center; gap: var(--sp-1.5); padding: 6px 14px; border-radius: var(--radius-sm); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); }
.bulk-cat { display: flex; align-items: center; }
.bulk-cat-btn { padding: 6px 14px; border-radius: var(--radius-sm); background: var(--card); border: 1px solid var(--border); font-size: var(--text-sm); font-weight: 500; color: var(--text-2); cursor: pointer; font-family: var(--font); transition: all var(--transition); }
.bulk-cat-btn:hover { border-color: var(--accent); color: var(--accent); }
.bulk-cat-select { padding: 6px 10px; border-radius: var(--radius-sm); font-size: var(--text-sm); font-weight: 500; border: 1px solid var(--accent); background: var(--card); color: var(--text-1); font-family: var(--font); cursor: pointer; min-width: 140px; }
.bulk-cat-select:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.clear-btn { margin-left: auto; background: none; border: none; font-size: var(--text-sm); color: var(--text-3); cursor: pointer; font-family: var(--font); }
.clear-btn:hover { color: var(--text-1); }
/* ── Tabs ── */
.budget-tabs { display: flex; gap: var(--sp-1); margin-bottom: var(--sp-4); }
.tab { padding: var(--sp-2) 14px; border-radius: var(--radius-md); font-size: var(--text-base); font-weight: 500; color: var(--text-3); background: none; border: none; cursor: pointer; transition: all var(--transition); font-family: var(--font); }
.tab:hover { color: var(--text-1); background: var(--card-hover); }
.tab.active { color: var(--text-1); background: var(--card); box-shadow: var(--shadow-xs); }
.tab-badge { font-size: var(--text-xs); font-family: var(--mono); background: var(--accent-dim); color: var(--accent); padding: 1px 6px; border-radius: var(--radius-xs); margin-left: var(--sp-1); }
/* ── Transaction card ── */
.txn-card { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-md); overflow: hidden; }
.txn-row { display: flex; align-items: center; gap: 14px; padding: 15px 16px; transition: background var(--transition); }
.txn-row:hover { background: var(--card-hover); }
.txn-row + .txn-row { border-top: 1px solid var(--border); }
.txn-row:nth-child(even) { background: color-mix(in srgb, var(--surface) 68%, var(--card)); }
.txn-row:nth-child(even):hover { background: var(--card-hover); }
.txn-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; z-index: 1; }
.txn-uncat { border-left: 4px solid var(--warning); }
.txn-selected { background: color-mix(in srgb, var(--accent) 6%, var(--card)) !important; border-left: 4px solid var(--accent); }
.txn-check { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; flex-shrink: 0; }
.txn-date { font-size: var(--text-sm); color: var(--text-4); width: 48px; flex-shrink: 0; }
.txn-payee { flex: 1.5; min-width: 0; }
.txn-name { font-size: var(--text-base); font-weight: 600; color: var(--text-1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.transfer-name { display: flex; align-items: center; gap: var(--sp-1.5); color: var(--accent); font-weight: 500; }
.txn-note { font-size: var(--text-xs); color: var(--text-4); margin-top: var(--sp-0.5); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.txn-account { flex: 0.7; font-size: var(--text-sm); color: var(--text-4); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.txn-category { flex: 0.9; padding-right: var(--sp-2); }
.cat-pill { display: inline-block; padding: var(--sp-1) 10px; border-radius: var(--radius-sm); font-size: var(--text-sm); font-weight: 500; background: var(--accent-dim); color: var(--accent); }
.cat-pill.transfer { background: var(--accent-dim); color: var(--accent); font-weight: 500; }
.cat-select {
padding: 8px 10px;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
border: 1px solid color-mix(in srgb, var(--warning) 50%, var(--border));
background: color-mix(in srgb, var(--warning) 2%, var(--card));
color: var(--text-1);
font-family: var(--font);
cursor: pointer;
width: 100%;
max-width: 160px;
min-height: 38px;
transition: all var(--transition);
}
.cat-select:hover { border-color: var(--accent); background: var(--card); }
.cat-select:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); background: var(--card); }
.txn-amount { font-family: var(--mono); font-size: var(--text-base); font-weight: 600; text-align: right; min-width: 90px; padding-left: var(--sp-2); }
.txn-amount.pos { color: var(--success); }
.txn-amount.neg { color: var(--error); }
/* ── Sidebar refinement ── */
.acct-row { padding: 5px 16px; font-size: var(--text-xs); }
.acct-bal { font-size: var(--text-xs); }
/* ── View toggle (mobile) ── */
.view-toggle { gap: var(--sp-0.5); padding: 3px; background: var(--surface); border-radius: 10px; border: 1px solid var(--border); margin-bottom: var(--sp-4); }
.view-btn { flex: 1; padding: 7px 0; border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: 500; border: none; background: none; color: var(--text-3); cursor: pointer; font-family: var(--font); transition: all var(--transition); }
.view-btn.active { background: var(--card); color: var(--text-1); box-shadow: var(--shadow-xs); }
/* ── Budget Overview ── */
.budget-overview { display: flex; flex-direction: column; gap: var(--sp-6); }
.budget-group-header { font-size: var(--text-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-4); margin-bottom: var(--sp-2); }
.budget-table { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden; }
.budget-table-header { display: flex; padding: 10px 16px; font-size: var(--text-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-4); border-bottom: 1px solid var(--border); }
.budget-table-row { display: flex; padding: 14px 16px; cursor: default; }
.budget-table-row + .budget-table-row { border-top: 1px solid var(--border); }
.budget-table-row:nth-child(even) { background: color-mix(in srgb, var(--surface) 55%, var(--card)); }
.budget-table-row.overspent { border-left: 3px solid var(--error); }
.bt-name { flex: 1.5; font-size: var(--text-base); font-weight: 400; color: var(--text-2); }
.budget-table-header .bt-name { font-size: var(--text-xs); font-weight: 600; color: var(--text-4); }
.bt-val { flex: 1; text-align: right; font-family: var(--mono); font-size: var(--text-base); font-weight: 600; color: var(--text-1); }
.budget-table-header .bt-val { font-family: var(--font); font-size: var(--text-xs); font-weight: 600; color: var(--text-4); }
.bt-val.spent { color: var(--error); }
.bt-val.positive { color: var(--success); font-weight: 600; }
.bt-val.negative { color: var(--error); font-weight: 600; }
/* ── Mobile ── */
@media (max-width: 767px) {
.budget-page { margin: -16px; }
.budget-main { padding: var(--sp-4) var(--sp-4) var(--sp-20); }
.txn-account { display: none; }
.txn-row { gap: 10px; padding: 16px 14px; }
.txn-payee { flex: 1.2; }
.txn-category { flex: 1; }
.txn-date { width: 42px; font-size: var(--text-sm); }
.txn-name { font-size: var(--text-base); }
.txn-amount { min-width: 75px; font-size: var(--text-sm); }
.cat-select { max-width: none; min-height: 40px; font-size: var(--text-base); }
.suggestion-pair { flex-direction: column; align-items: flex-start; gap: var(--sp-0.5); }
.suggestion-row { flex-wrap: wrap; }
.selection-bar { flex-wrap: wrap; gap: var(--sp-2); }
}
@media (min-width: 768px) {
.budget-page { margin: -20px; }
.mobile-accounts { display: none; }
}
</style>
{#if data?.useAtelierShell}
<AtelierBudgetPage />
{:else}
<LegacyBudgetPage />
{/if}