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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user