Compare commits

..

10 Commits

Author SHA1 Message Date
a1e6edc74d Add a source list to the sidebar and lead with the Records tab
The sidebar now lists every source under the Sources item when it is
expanded, so entering a source takes one click instead of two. Bank
feeds sort first, then CSV sources, alphabetical within each group, and
a small icon marks which is which — the same config.simplefin test the
Import page uses.

Selecting a source lands on Records rather than Setup: the index route
under /sources/:name redirects there and Setup moved to an explicit
/setup path at the end of the tab strip. Setup is the rare job; Records
is the frequent one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154qBiPua1PXeet69XmmvQb
2026-08-09 16:03:57 -04:00
3297acf9db Update the docs for the reworked UI
spec.md described a status bar, a flat Sources page, and neither Bridge
nor the import hub. It now covers the routing model, the page split, the
token system, and the lazy-loaded Pivot chunk.

CLAUDE.md's light/dark section told the next reader to add
`.dark .bg-white`-style overrides, which is exactly what the token work
removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2HFeU5neCKagTnmA6o9Tu
2026-08-07 23:04:15 -04:00
1edfbfac43 Merge UI navigation, colour tokens, and mobile layout
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2HFeU5neCKagTnmA6o9Tu
2026-08-07 22:51:26 -04:00
8753719db8 Make the stack pivot link visible and move it clear of delete
The link was copied from the delete button's hover-reveal styling, so
the only way to a stack pivot was invisible until you hovered — and then
it appeared right beside the button that deletes the stack. Pivot is now
always shown; delete stays hover-only, separated by a divider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2HFeU5neCKagTnmA6o9Tu
2026-08-07 22:51:04 -04:00
356e61d043 Add a mobile layout and stop shipping Perspective to every page
Below md: the sidebar is replaced by a fixed bottom bar carrying the same
destinations plus the theme toggle. NAV moved to navItems.jsx so the two
can't drift apart.

Perspective is now lazy-loaded. It was ~90% of the bundle and only Pivot
uses it, so the initial download drops from 4.9 MB gzipped to 173 kB and
the rest arrives only when a pivot is opened. Desktop benefits as much as
phones do.

Bridge's balance table becomes stacked cards under sm: with the same
subtotals, source tabs scroll rather than wrap, and page gutters tighten
on small screens.

Rules, Mappings, and Records are deliberately untouched — they are wide
data tables and belong on a desktop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2HFeU5neCKagTnmA6o9Tu
2026-08-02 14:09:06 -04:00
4eda1c48b7 Replace dark-mode overrides with semantic colour tokens
Dark mode was 58 `.dark .bg-white`-style rules patching over components
that hardcoded light shades, so every new component silently owed the
stylesheet another override — a debt this session kept adding to.

Components now name the role of a colour rather than the shade:
bg-surface, text-ink, text-muted, border-line, text-danger. Those map
through @theme to CSS variables, and light and dark are two sets of
values for the same variables. The override block is gone entirely.

Solid button fills stay literal; they read correctly on both themes and
never had overrides.

Verified in the browser in both themes, Perspective included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2HFeU5neCKagTnmA6o9Tu
2026-08-02 12:41:31 -04:00
aa9315fdd2 Put the source in the URL and drop the global status bar
Selecting a source from a bar at the top meant every page was implicitly
scoped to a hidden bit of state. The source is now a route parameter:
/sources lists them, /sources/:name owns the source, and Import, Rules,
Mappings, Records, and Pivot are tabs beneath it. Sources.jsx split into
SourceList and SourceDetail, with Section and SampleTable extracted so
the create dialog and the detail page share them.

The detail page is grouped into titled panels — Connection, Fields and
view, Sample rows, Maintenance, Delete — rather than one flat form.

Two new top-level pages, following how Monarch separates these:

- Import, because importing is frequent and configuring a source is not.
  Every source in one list, with a sync button for bank feeds.
- Bridge, because one SimpleFIN credential covers every account, so
  connection state belongs in one place rather than per source. Loads
  only when asked, since it queries SimpleFIN, and totals the balances.

The dark mode toggle lived in the status bar and moved to the sidebar.
The out-of-sync and reprocess banners are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2HFeU5neCKagTnmA6o9Tu
2026-08-02 12:25:13 -04:00
43d968b248 Merge SimpleFIN Bridge bank feed integration
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2HFeU5neCKagTnmA6o9Tu
2026-08-02 10:59:19 -04:00
9f164bcd34 Discover feed fields from real data; fix the sync window
Field discovery now samples an account's actual transactions instead of
assuming a shape. flatten() passes through every scalar the bridge sends
rather than whitelisting eleven keys, so institution-specific fields turn
up on their own, and inferFields() — extracted from the CSV suggest route
so both paths share it — unions keys across the sample because API feeds
omit optional fields entirely.

Three bugs the live bridge exposed:

- posted=0 on pending transactions became 1970-01-01; falsy epochs are
  now "no date", with date falling back to transacted_at and posted_date
  kept separate.
- days=0 omitted start-date, which returns only the few most recent
  transactions rather than everything — 4 instead of 89. A start-date is
  always sent now, clamped to 89 days (the bridge hard-caps at 90).
- Sampling asked for more than 45 days, and the bridge's advisory notice
  about that surfaced in the UI as an error. Samples use 44 days; the
  threshold is exclusive.

The Sources page can now link an account: a picker in both the create
dialog and the detail panel, populated on demand, which fills the field
table from the sample and defaults the constraint field to the
transaction id with an explanation of why.

manage.py option 10 claims a setup token and writes the access URL to
.env, replacing the throwaway script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2HFeU5neCKagTnmA6o9Tu
2026-08-02 02:50:03 -04:00
3613037ab5 Add SimpleFIN Bridge sync as an alternative to CSV import
Sources with a `simplefin` block in their config can pull transactions
straight from the bridge instead of taking a CSV upload. Only the fetch
differs — dedupe, logging, and transformation reuse the import path.

The access URL is the whole credential, so it lives in .env rather than
the database that manage.py offers to reset. Claiming a setup token is
exposed as an endpoint because the token is single-use and easy to burn.

The bridge answers 200 with a populated `errors` array when a bank is
failing, which would otherwise read as a successful empty pull — those
errors ride along in the sync response and show on the Import page.

Pending transactions are skipped by default: they get a new id once they
post, which would import the same charge twice under two keys. Sources
should use ['id'] as constraint_fields — the transaction id makes
overlapping pulls free while keeping genuinely repeated charges distinct.

Verified against a stubbed bridge response, not a live account.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2HFeU5neCKagTnmA6o9Tu
2026-08-01 12:47:04 -04:00
33 changed files with 2423 additions and 1524 deletions

View File

@ -9,13 +9,9 @@ DB_PASSWORD=your_password_here
API_PORT=3000
NODE_ENV=development
# Teller (optional — only needed for API-based bank feeds)
# Client certificate + key downloaded from the Teller dashboard; every API call
# presents them (mutual TLS). Keep both outside the repo.
TELLER_CERT_PATH=/etc/dataflow/teller/certificate.pem
TELLER_KEY_PATH=/etc/dataflow/teller/private_key.pem
# One access token per enrollment, produced by Teller Connect. A source picks
# its token with config.teller.token_env; TELLER_TOKEN is the default.
TELLER_TOKEN=token_here
# TELLER_TOKEN_HUNTINGTON=token_here
# SimpleFIN (optional — only needed for API-based bank feeds)
# The access URL is the credential: claim a setup token once via
# POST /api/sources/simplefin-claim and paste the result here.
# A source picks its bridge with config.simplefin.access_url_env;
# SIMPLEFIN_ACCESS_URL is the default.
SIMPLEFIN_ACCESS_URL=https://user:pass@bridge.simplefin.org/simplefin

View File

@ -68,8 +68,8 @@ Theme state lives in `ui/src/theme.jsx` — a React context (`ThemeContext`) wit
`ThemeProvider` that wraps the app in `main.jsx`.
- **Storage key:** `df_dark` in `localStorage`; falls back to `window.matchMedia('(prefers-color-scheme: dark)')` on first visit
- **Toggle:** button in the sidebar header in `App.jsx`; effect writes `localStorage` and toggles the `.dark` class on `<html>`
- **CSS:** `ui/src/index.css` defines CSS custom properties under `:root` (light) and `.dark`. All Tailwind color overrides are written as `.dark .bg-white { ... }` etc.
- **Toggle:** button at the foot of the sidebar (`Sidebar.jsx`), and in `BottomNav.jsx` on mobile; the effect writes `localStorage` and toggles the `.dark` class on `<html>`
- **CSS:** `ui/src/index.css` declares semantic tokens under `@theme` (`bg-surface`, `text-ink`, `text-muted`, `border-line`, `text-danger`, …) that resolve to CSS custom properties redefined by `.dark`. **Write components against the tokens, never against literal shades like `bg-white` or `text-gray-400`** — the old per-utility `.dark .bg-white { … }` overrides are gone and must not come back
- **Palette:** dark mode uses Perspective's "Pro Dark" colours (`--bg-primary: #242526`, panels `#2a2c2f`, gridlines `#3b3f46`, text `#c5c9d0`)
- **Perspective viewer:** `Pivot.jsx` calls `viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')` on initial load and in a `useEffect([dark])` so the viewer stays in sync when the toggle fires
- **Consuming the theme:** `import useTheme from '../theme.jsx'` then `const { dark, setDark } = useTheme()`

42
api/lib/fields.js Normal file
View File

@ -0,0 +1,42 @@
/**
* Field inference
* Derives a field list and column types from sample records, so a source can be
* configured from real data rather than an assumed shape. Used by the CSV
* suggest endpoint and by bank-feed sampling.
*/
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/;
/**
* @param records array of flat objects
* @param limit how many rows to look at
* @returns { fields: [{name, type}], sampleRows }
*
* Keys are unioned across the sample, not read off the first row API feeds
* omit optional keys entirely on records that lack them.
*/
function inferFields(records, limit = 50) {
const sampleRows = records.slice(0, limit);
const keys = [];
for (const row of sampleRows) {
for (const key of Object.keys(row)) {
if (!keys.includes(key)) keys.push(key);
}
}
const fields = keys.map(key => {
const vals = sampleRows.map(r => r[key]).filter(v => v !== '' && v != null);
let type = 'text';
if (vals.length > 0 && vals.every(v => !isNaN(parseFloat(v)) && isFinite(v) && String(v).charAt(0) !== '0')) {
type = 'numeric';
} else if (vals.length > 0 && vals.every(v => ISO_DATE_RE.test(String(v)))) {
type = 'date';
}
return { name: key, type };
});
return { fields, sampleRows };
}
module.exports = { inferFields };

219
api/lib/simplefin.js Normal file
View File

@ -0,0 +1,219 @@
/**
* SimpleFIN Bridge client
*
* Read-only access to linked bank accounts. Auth is a single "access URL" with
* credentials embedded (https://user:pass@bridge.simplefin.org/simplefin),
* obtained once by claiming a setup token. No certificates, no refresh flow
* the URL is the credential, so it lives in .env and never in the database.
*
* Protocol: https://www.simplefin.org/protocol.html
*/
const REQUEST_TIMEOUT = 30000;
// The bridge refreshes from banks roughly daily and transactions can land a few
// days late, so pulls overlap by design and lean on constraint_key for dedupe.
const DEFAULT_DAYS = 10;
// The bridge hard-caps ranges at 90 days and reports the capping in `errors`;
// 89 stays just inside it so a routine backfill doesn't look like data loss. It
// also advises staying under 45 days, which it says on anything longer — that
// notice is passed through, since it is a real hint about future behaviour.
// Omitting start-date entirely is not "everything": it returns only the most
// recent handful of transactions, so a start-date is always sent.
const MAX_DAYS = 89;
class SimpleFinError extends Error {
constructor(message, status) {
super(message);
this.name = 'SimpleFinError';
this.status = status;
}
}
// Resolve an access URL from the environment. Sources name their own variable
// via config.simplefin.access_url_env so several bridges can coexist.
function getAccessUrl(urlEnv) {
const name = urlEnv || 'SIMPLEFIN_ACCESS_URL';
const value = process.env[name];
if (!value) {
throw new SimpleFinError(`SimpleFIN access URL not found — set ${name} in .env`, 500);
}
let parsed;
try {
parsed = new URL(value);
} catch {
throw new SimpleFinError(`${name} is not a valid URL`, 500);
}
if (!parsed.username) {
throw new SimpleFinError(`${name} has no credentials — expected https://user:pass@host/path`, 500);
}
return parsed;
}
async function get(accessUrl, path, params) {
// Credentials travel in the Authorization header, not the request line.
const target = new URL(accessUrl.pathname + path, accessUrl.origin);
for (const [k, v] of Object.entries(params || {})) {
if (v !== undefined && v !== null) target.searchParams.append(k, String(v));
}
const auth = Buffer.from(`${decodeURIComponent(accessUrl.username)}:${decodeURIComponent(accessUrl.password)}`).toString('base64');
let res;
try {
res = await fetch(target, {
headers: { Authorization: `Basic ${auth}`, Accept: 'application/json' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
});
} catch (err) {
throw new SimpleFinError(`SimpleFIN request failed: ${err.message}`, 502);
}
if (res.status === 401 || res.status === 403) {
throw new SimpleFinError(
'SimpleFIN rejected the access URL — it may have been revoked; claim a new setup token', res.status);
}
if (!res.ok) {
throw new SimpleFinError(`SimpleFIN returned ${res.status}: ${(await res.text()).slice(0, 200)}`, res.status);
}
try {
return await res.json();
} catch {
throw new SimpleFinError('SimpleFIN returned a non-JSON response', 502);
}
}
/**
* Exchange a setup token for a permanent access URL. Run once per bridge; the
* returned URL goes in .env. The token is base64 of a one-shot claim URL and is
* consumed by this call.
*/
async function claimSetupToken(setupToken) {
let claimUrl;
try {
claimUrl = Buffer.from(String(setupToken).trim(), 'base64').toString('utf8');
new URL(claimUrl);
} catch {
throw new SimpleFinError('Setup token is not valid base64 of a claim URL', 400);
}
let res;
try {
res = await fetch(claimUrl, { method: 'POST', signal: AbortSignal.timeout(REQUEST_TIMEOUT) });
} catch (err) {
throw new SimpleFinError(`Claim request failed: ${err.message}`, 502);
}
if (!res.ok) {
throw new SimpleFinError(
`Claim returned ${res.status} — setup tokens can only be claimed once`, res.status);
}
return (await res.text()).trim();
}
// Epoch seconds → YYYY-MM-DD, so dates sort and compare as plain text the way
// CSV-imported dates already do. Pending transactions carry posted=0 rather than
// omitting it, so falsy epochs are "no date", not 1970-01-01.
function toDate(epochSeconds) {
if (!epochSeconds) return null;
return new Date(epochSeconds * 1000).toISOString().slice(0, 10);
}
// Flatten a SimpleFIN transaction into the shallow map the rule engine expects.
//
// Every scalar the bridge sends is passed through rather than whitelisted, so
// institution-specific fields (mcc, and whatever a given bank adds) show up in
// the data and in field discovery without a code change here. Only the epoch
// timestamps are reshaped, and account context is folded in so records stay
// self-describing.
function flatten(txn, account) {
const out = {};
for (const [key, value] of Object.entries(txn)) {
if (value === null || value === undefined) continue;
if (typeof value === 'object') continue; // nested objects handled below
out[key] = value;
}
// `extra` is free-form and institution-specific; hoist its scalars under a
// prefix so they can't collide with the documented fields
for (const [key, value] of Object.entries(txn.extra || {})) {
if (value !== null && value !== undefined && typeof value !== 'object') {
out[`extra_${key}`] = value;
}
}
const posted = toDate(txn.posted);
const transacted = toDate(txn.transacted_at);
delete out.posted; // replaced by the YYYY-MM-DD forms below
// A pending transaction has no posted date yet; fall back to when it was
// transacted so every record has something usable to sort and filter on
out.date = posted || transacted;
out.posted_date = posted;
out.transacted_at = transacted;
out.pending = txn.pending ? 'true' : 'false';
out.account_id = account.id;
out.account_name = account.name;
out.organization = account.org?.name || account.org?.domain;
return out;
}
async function listAccounts(urlEnv) {
const data = await get(getAccessUrl(urlEnv), '/accounts', { 'balances-only': 1 });
return {
errors: data.errors || [],
accounts: (data.accounts || []).map(a => ({
id: a.id,
name: a.name,
organization: a.org?.name || a.org?.domain,
currency: a.currency,
balance: a.balance,
available_balance: a['available-balance'],
balance_date: toDate(a['balance-date']),
})),
};
}
/**
* Fetch transactions for one account.
*
* days how far back to ask for; 0 or more than MAX_DAYS means the full 90
* includePending pending transactions get a new id once they post, so they are excluded by default
*/
async function fetchTransactions({ accountId, accessUrlEnv, days, includePending = false }) {
days = Number.isFinite(days) ? days : DEFAULT_DAYS;
if (days <= 0 || days > MAX_DAYS) days = MAX_DAYS;
const params = {
account: accountId,
'start-date': Math.floor(Date.now() / 1000) - days * 86400,
};
if (includePending) params.pending = 1;
const data = await get(getAccessUrl(accessUrlEnv), '/accounts', params);
// The bridge reports per-institution problems here and still returns 200 —
// one bank being down must not look like a successful empty pull.
const errors = data.errors || [];
const account = (data.accounts || []).find(a => a.id === accountId);
if (!account) {
const detail = errors.length ? ` — bridge reported: ${errors.join('; ')}` : '';
throw new SimpleFinError(`Account ${accountId} not returned by SimpleFIN${detail}`, 502);
}
const txns = account.transactions || [];
const kept = includePending ? txns : txns.filter(t => !t.pending);
return {
fetched: txns.length,
errors,
records: kept.map(t => flatten(t, account)),
};
}
module.exports = { listAccounts, fetchTransactions, claimSetupToken, SimpleFinError, DEFAULT_DAYS, MAX_DAYS };

View File

@ -1,167 +0,0 @@
/**
* Teller API client
*
* Read-only access to enrolled bank accounts. Teller uses mutual TLS: every
* request presents the client certificate downloaded from the Teller dashboard,
* and the enrollment's access token is sent as the HTTP Basic *username* with an
* empty password.
*
* Uses https.request rather than fetch Node's global fetch is undici, which
* ignores an https.Agent and needs its own dispatcher type for client certs.
*/
const fs = require('fs');
const https = require('https');
const API_HOST = 'api.teller.io';
// Teller returns transactions newest-first with no date filter — only a count
// and a cursor. We over-fetch and trim by date on our side.
const DEFAULT_COUNT = 200;
const DEFAULT_DAYS = 10;
class TellerError extends Error {
constructor(message, status) {
super(message);
this.name = 'TellerError';
this.status = status;
}
}
let _agent = null;
// The mTLS agent is built once and reused; the cert never changes at runtime.
function getAgent() {
if (_agent) return _agent;
const certPath = process.env.TELLER_CERT_PATH;
const keyPath = process.env.TELLER_KEY_PATH;
if (!certPath || !keyPath) {
throw new TellerError('TELLER_CERT_PATH and TELLER_KEY_PATH must be set in .env', 500);
}
let cert, key;
try {
cert = fs.readFileSync(certPath);
key = fs.readFileSync(keyPath);
} catch (err) {
throw new TellerError(`Cannot read Teller certificate: ${err.message}`, 500);
}
_agent = new https.Agent({ cert, key, keepAlive: true });
return _agent;
}
// Resolve an enrollment's access token from the environment. Sources name their
// own variable via config.teller.token_env so several banks can coexist.
function getToken(tokenEnv) {
const name = tokenEnv || 'TELLER_TOKEN';
const token = process.env[name];
if (!token) {
throw new TellerError(`Teller access token not found — set ${name} in .env`, 500);
}
return token;
}
function get(path, token) {
return new Promise((resolve, reject) => {
const req = https.request({
host: API_HOST,
path,
method: 'GET',
agent: getAgent(),
auth: `${token}:`,
headers: { Accept: 'application/json' },
}, (res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', chunk => { body += chunk; });
res.on('end', () => {
if (res.statusCode === 401) {
return reject(new TellerError(
'Teller rejected the access token (401) — the enrollment likely needs to be reconnected through Teller Connect', 401));
}
if (res.statusCode >= 400) {
let detail = body.slice(0, 200);
try { detail = JSON.parse(body).error?.message || detail; } catch { /* keep raw body */ }
return reject(new TellerError(`Teller returned ${res.statusCode}: ${detail}`, res.statusCode));
}
try {
resolve(JSON.parse(body));
} catch {
reject(new TellerError('Teller returned a non-JSON response', 502));
}
});
});
req.on('error', err => reject(new TellerError(`Teller request failed: ${err.message}`, 502)));
req.end();
});
}
// Flatten a Teller transaction into the shallow string map the rule engine
// expects. Nested `details` is hoisted; `links` is dropped.
function flatten(txn) {
const details = txn.details || {};
const counterparty = details.counterparty || {};
return {
id: txn.id,
date: txn.date,
description: txn.description,
amount: txn.amount,
status: txn.status,
type: txn.type,
running_balance: txn.running_balance,
account_id: txn.account_id,
category: details.category,
processing_status: details.processing_status,
counterparty_name: counterparty.name,
counterparty_type: counterparty.type,
};
}
async function listAccounts(tokenEnv) {
const accounts = await get('/accounts', getToken(tokenEnv));
return accounts.map(a => ({
id: a.id,
name: a.name,
type: a.type,
subtype: a.subtype,
last_four: a.last_four,
status: a.status,
institution: a.institution?.name,
}));
}
/**
* Fetch transactions for one account.
*
* days keep transactions on or after today minus this many days; 0 fetches everything returned
* includePending pending transaction ids change when they post, so they are excluded by default
*/
async function fetchTransactions({ accountId, tokenEnv, count, days, includePending = false }) {
// Callers pass values straight off the query string, so anything unparseable
// falls back to the default rather than becoming NaN.
count = Number.isFinite(count) ? count : DEFAULT_COUNT;
days = Number.isFinite(days) ? days : DEFAULT_DAYS;
const token = getToken(tokenEnv);
const txns = await get(`/accounts/${encodeURIComponent(accountId)}/transactions?count=${count}`, token);
let kept = txns;
if (!includePending) {
kept = kept.filter(t => t.status === 'posted');
}
if (days > 0) {
const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
kept = kept.filter(t => t.date >= cutoff); // ISO dates compare correctly as strings
}
return {
fetched: txns.length,
records: kept.map(flatten),
};
}
module.exports = { listAccounts, fetchTransactions, TellerError, DEFAULT_COUNT, DEFAULT_DAYS };

View File

@ -7,7 +7,8 @@ const express = require('express');
const multer = require('multer');
const { parse } = require('csv-parse/sync');
const { lit, arr } = require('../lib/sql');
const teller = require('../lib/teller');
const simplefin = require('../lib/simplefin');
const { inferFields } = require('../lib/fields');
const upload = multer({ storage: multer.memoryStorage() });
@ -24,14 +25,52 @@ module.exports = (pool) => {
}
});
// List the accounts behind a Teller enrollment — used to find the account_id
// to put in a source's config. Declared before /:name so it isn't shadowed.
router.get('/teller-accounts', async (req, res, next) => {
// SimpleFIN helpers. Declared before /:name so they aren't shadowed by it.
// List the accounts behind a bridge — used to find the account_id for a source
router.get('/simplefin-accounts', async (req, res, next) => {
try {
const accounts = await teller.listAccounts(req.query.token_env);
res.json(accounts);
res.json(await simplefin.listAccounts(req.query.access_url_env));
} catch (err) {
if (err instanceof teller.TellerError) return res.status(err.status || 502).json({ error: err.message });
if (err instanceof simplefin.SimpleFinError) return res.status(err.status || 502).json({ error: err.message });
next(err);
}
});
// Sample an account's real transactions and infer its field list — the API
// equivalent of uploading a CSV to /suggest. Whatever the account actually
// returns is what gets offered, so investment or loan accounts describe
// themselves rather than being forced into a checking-account shape.
router.get('/simplefin-sample', async (req, res, next) => {
try {
const { account_id, access_url_env } = req.query;
if (!account_id) return res.status(400).json({ error: 'account_id is required' });
const { fetched, records, errors } = await simplefin.fetchTransactions({
accountId: account_id,
accessUrlEnv: access_url_env,
// The bridge advises staying under 45 days and warns at exactly
// 45, so 44 is the largest quiet sample window
days: req.query.days !== undefined ? parseInt(req.query.days) : 44,
includePending: true, // widen the sample; pending rows can carry extra keys
});
const { fields, sampleRows } = inferFields(records);
res.json({ fields, sampleRows, fetched, errors });
} catch (err) {
if (err instanceof simplefin.SimpleFinError) return res.status(err.status || 502).json({ error: err.message });
next(err);
}
});
// Exchange a one-shot setup token for the permanent access URL to put in .env
router.post('/simplefin-claim', async (req, res, next) => {
try {
const { setup_token } = req.body || {};
if (!setup_token) return res.status(400).json({ error: 'setup_token is required' });
res.json({ access_url: await simplefin.claimSetupToken(setup_token) });
} catch (err) {
if (err instanceof simplefin.SimpleFinError) return res.status(err.status || 502).json({ error: err.message });
next(err);
}
});
@ -65,21 +104,7 @@ module.exports = (pool) => {
const records = parse(req.file.buffer, { columns: true, skip_empty_lines: true, trim: true });
if (records.length === 0) return res.status(400).json({ error: 'CSV file is empty' });
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/;
const sample = records[0];
const sampleRows = records.slice(0, 50);
const fields = Object.keys(sample).map(key => {
const vals = sampleRows.map(r => r[key]).filter(v => v !== '' && v != null);
let type = 'text';
if (vals.length > 0 && vals.every(v => !isNaN(parseFloat(v)) && isFinite(v) && String(v).charAt(0) !== '0')) {
type = 'numeric';
} else if (vals.length > 0 && vals.every(v => ISO_DATE_RE.test(String(v)))) {
type = 'date';
}
return { name: key, type };
});
const { fields, sampleRows } = inferFields(records);
res.json({ name: '', constraint_fields: [], fields, sampleRows });
} catch (err) {
next(err);
@ -152,7 +177,7 @@ module.exports = (pool) => {
}
});
// Pull transactions from Teller and import them, same as a CSV upload.
// Pull transactions from SimpleFIN and import them, same as a CSV upload.
// Safe to re-run: overlapping transactions are skipped by constraint key.
router.post('/:name/sync', async (req, res, next) => {
try {
@ -160,24 +185,23 @@ module.exports = (pool) => {
const source = sourceResult.rows[0];
if (!source || !source.name) return res.status(404).json({ error: 'Source not found' });
const cfg = (source.config || {}).teller;
const cfg = (source.config || {}).simplefin;
if (!cfg || !cfg.account_id) {
return res.status(400).json({
error: `Source "${req.params.name}" has no teller.account_id in its config`
error: `Source "${req.params.name}" has no simplefin.account_id in its config`
});
}
const opts = { ...req.query, ...req.body };
const { fetched, records } = await teller.fetchTransactions({
const { fetched, errors, records } = await simplefin.fetchTransactions({
accountId: cfg.account_id,
tokenEnv: cfg.token_env,
count: opts.count !== undefined ? parseInt(opts.count) : cfg.count,
accessUrlEnv: cfg.access_url_env,
days: opts.days !== undefined ? parseInt(opts.days) : cfg.days,
includePending: opts.include_pending === true || opts.include_pending === 'true',
});
if (records.length === 0) {
return res.json({ success: true, fetched, imported: 0, duplicates: 0 });
return res.json({ success: true, fetched, errors, imported: 0, duplicates: 0 });
}
const importResult = await pool.query(
@ -185,15 +209,15 @@ module.exports = (pool) => {
);
const importData = importResult.rows[0].result;
if (!importData.success) return res.json({ ...importData, fetched });
if (!importData.success) return res.json({ ...importData, fetched, errors });
const transformResult = await pool.query(
`SELECT apply_transformations(${lit(req.params.name)}) as result`
);
res.json({ ...importData, fetched, transform: transformResult.rows[0].result });
res.json({ ...importData, fetched, errors, transform: transformResult.rows[0].result });
} catch (err) {
if (err instanceof teller.TellerError) return res.status(err.status || 502).json({ error: err.message });
if (err instanceof simplefin.SimpleFinError) return res.status(err.status || 502).json({ error: err.message });
next(err);
}
});

View File

@ -48,7 +48,7 @@ api/
auth.js — Basic Auth enforcement on all /api routes
lib/
sql.js — lit() and arr() helpers for SQL literal building
teller.js — Teller API client (mutual TLS) for bank transaction pulls
simplefin.js — SimpleFIN Bridge client for bank transaction pulls
routes/
sources.js — HTTP handlers for source management
rules.js — HTTP handlers for rule management
@ -59,11 +59,15 @@ api/
ui/
src/
api.js — fetch wrapper, credential management
App.jsx — root: login gate, sidebar, source selector, routing
App.jsx — root: login gate, routing, stale/reprocess banners
index.css — semantic colour tokens for light and dark
pages/
Login.jsx — username/password form
Sources.jsx — source CRUD, field config, view generation
Import.jsx — CSV upload, Teller sync, and import log
SourceList.jsx — source list and the create dialog
SourceDetail.jsx — one source: connection, fields, view, maintenance
Bridge.jsx — SimpleFIN accounts, balances, and subtotals
ImportHub.jsx — all sources with sync / upload actions
Import.jsx — CSV upload, SimpleFIN sync, and import log
Rules.jsx — rule CRUD with live pattern preview
Mappings.jsx — mapping table with TSV import/export
Records.jsx — paginated, sortable view of transformed records
@ -71,7 +75,7 @@ ui/
Stacks.jsx — multi-source union views with running balance
Remap.jsx — bulk remap of an output field value across mappings
Log.jsx — global import log across all sources
components/ — Sidebar, StatusBar
components/ — Sidebar, BottomNav, navItems, SourceTabs, Section, SampleTable
theme.jsx — light/dark context provider
public/ — compiled UI (output of npm run build in ui/)
docs/ — this file, tutorial, UI and Perspective references
@ -113,38 +117,42 @@ CSV file → parse in Node.js → import_records(source, data)
→ apply_transformations() runs automatically on new records
```
### Teller sync (API-based bank feeds)
### SimpleFIN sync (API-based bank feeds)
```
POST /api/sources/:name/sync → api/lib/teller.js
→ GET api.teller.io/accounts/:id/transactions (mutual TLS + access token)
→ drop pending, trim to the last N days, flatten nested `details`
POST /api/sources/:name/sync → api/lib/simplefin.js
→ GET {access_url}/accounts?account=…&start-date=… (Basic auth)
→ drop pending, flatten transactions, fold in account context
→ import_records(source, data) — identical path to a CSV import from here on
```
An alternative to CSV upload for sources that read from a bank API. Only the
fetching differs: dedup, logging, and transformation are the same code.
- **Authentication.** Teller uses mutual TLS. Every request presents the client
certificate and key from the Teller dashboard (`TELLER_CERT_PATH`,
`TELLER_KEY_PATH`), with the enrollment's access token as the HTTP Basic
*username* and an empty password. Access tokens come from the Teller Connect
browser flow, run once per bank, and live in `.env` — one variable per
enrollment. They are deliberately **not** stored in the database, which
`manage.py` offers to reset.
- **Source config.** A source opts in by having `teller` in its `config` JSONB:
`{"teller": {"account_id": "acc_…", "token_env": "TELLER_TOKEN_HUNTINGTON",
"days": 10, "count": 200}}`. Only `account_id` is required; `token_env`
defaults to `TELLER_TOKEN`. `GET /api/sources/teller-accounts` lists the
accounts behind a token so you can find the id.
- **`constraint_fields` should be `['id']`.** Teller assigns each transaction a
- **Authentication.** A SimpleFIN access URL *is* the credential — it carries
its own username and password (`https://user:pass@bridge.simplefin.org/simplefin`).
You claim it once from a setup token (`POST /api/sources/simplefin-claim`,
which consumes the token) and store it in `.env`, one variable per bridge. It
is deliberately **not** stored in the database, which `manage.py` offers to reset.
- **Source config.** A source opts in by having `simplefin` in its `config` JSONB:
`{"simplefin": {"account_id": "ACT-…", "access_url_env": "SIMPLEFIN_ACCESS_URL",
"days": 10}}`. Only `account_id` is required. `GET /api/sources/simplefin-accounts`
lists the accounts behind a bridge so you can find the id.
- **`constraint_fields` should be `['id']`.** SimpleFIN assigns each transaction a
stable id, which makes overlapping pulls free and — unlike date + amount +
description — keeps genuinely repeated charges as separate records.
- **Pending transactions are skipped** (`?include_pending=true` overrides). A
pending transaction's id changes when it posts, so importing it would produce
a duplicate under a different key a day or two later.
- **Rolling window, not a cursor.** Teller's transaction endpoint takes a count,
not a date range, so the route over-fetches (`count`, default 200) and trims
to `days` (default 10, `0` for everything returned). Re-running the same
window is harmless; late-arriving transactions get picked up.
pending transaction gets a different id once it posts, so importing it would
produce a duplicate under a different key a day or two later.
- **Bridge errors are surfaced, not swallowed.** SimpleFIN returns HTTP 200 with
an `errors` array when an institution is failing. Those errors ride along in
the sync response so a broken connection doesn't read as a successful empty
pull; the Import page shows them in orange above the counts.
- **Refresh cadence and the 90-day wall.** The bridge polls banks roughly daily
and transactions can take a few days to appear, so the pull asks for a rolling
window (`days`, default 10) rather than tracking a cursor. The window has hard
limits: SimpleFIN caps any range at 90 days and advises staying under 45, so
`MAX_DAYS` is 89 and `days=0` or anything larger clamps to it. A `start-date`
is **always** sent — omitting it does not mean "everything available", it
returns only the few most recent transactions.
- **Cron.** A daily pull is just the endpoint:
`curl -sS -u user:pass -X POST http://localhost:3000/api/sources/NAME/sync`
@ -209,8 +217,9 @@ All routes are under `/api`. Every route requires HTTP Basic Auth. The `GET /hea
| DELETE | /api/sources/:name | Delete source and all its data |
| POST | /api/sources/suggest | Suggest source config from an uploaded CSV |
| POST | /api/sources/:name/import | Import CSV; transformations are applied to the new records |
| POST | /api/sources/:name/sync | Pull transactions from Teller and import them (`?days=`, `?count=`, `?include_pending=`) |
| GET | /api/sources/teller-accounts | List accounts behind a Teller enrollment (`?token_env=`) |
| POST | /api/sources/:name/sync | Pull transactions from SimpleFIN and import them (`?days=`, `?include_pending=`) |
| GET | /api/sources/simplefin-accounts | List accounts behind a bridge (`?access_url_env=`) |
| POST | /api/sources/simplefin-claim | Exchange a setup token for a permanent access URL |
| GET | /api/sources/import-log | Import history across all sources |
| GET | /api/sources/:name/import-log | Import history for one source |
| DELETE | /api/sources/:name/import-log/:id | Delete an import batch and every record in it |
@ -322,11 +331,43 @@ Built with React + Vite + Tailwind CSS. Compiled output goes to `public/`. The s
3. On 401 response, credentials are cleared and the login screen is shown.
4. `localStorage` persists the selected source name across sessions.
**Navigation.** The selected source is a route parameter, not global state: `/sources`
lists sources, `/sources/:name` owns one, and Import, Rules, Mappings, Records, and Pivot
are tabs beneath it (`components/SourceTabs.jsx`). The sidebar holds only top-level
destinations — Sources, Import, Bridge, Remap, Stacks, Log — defined once in
`components/navItems.jsx` and rendered by `Sidebar.jsx` on desktop and `BottomNav.jsx`
below the `md:` breakpoint. Out-of-sync and reprocess banners render above every page from
`App.jsx`.
**Colour.** Components use semantic tokens (`bg-surface`, `text-ink`, `text-muted`,
`border-line`, `text-danger`) declared in `index.css` under `@theme`, which resolve to CSS
variables redefined by `.dark`. There are no per-utility `.dark` override rules; a new
component gets both themes for free.
**Bundle.** `Pivot` is loaded with `React.lazy`, keeping Perspective (~4.7 MB gzipped) out
of the initial download and in a chunk fetched only when a pivot is opened.
**Pages:**
- **Sources** — View and edit source configuration. Shows all known field names and their origins (raw data, schema, rules, mappings). Checkboxes control which fields are constraint fields and which appear in the output view. Supports CSV upload to auto-detect fields.
- **Sources** (`SourceList.jsx`) — Lists every source with its constraint fields and a
badge for bank feeds; clicking opens it. "New source" opens the create dialog, which can
seed fields from a CSV sample or from a linked SimpleFIN account.
- **Import** — Upload a CSV to import records into the selected source. Transformations run automatically on new records. Shows import log with inserted/duplicate counts, expandable key detail, checkbox selection, and delete with confirmation. Sources with `config.teller.account_id` also get a Sync panel — a window selector (10/30/90 days or everything) and a "Sync now" button that pulls from the bank API through the same import path.
- **Source detail** (`SourceDetail.jsx`) — The Setup tab, grouped into titled panels:
Connection (link/unlink a SimpleFIN account, warns when constraint fields aren't `id`),
Fields and view (all known field names and their origins, with checkboxes for constraint
fields and view columns), Sample rows, Maintenance (reprocess), and Delete source.
- **Bridge** (`Bridge.jsx`) — Every account behind the SimpleFIN credential with balances,
the source each maps to, and subtotals split into banking versus retirement (recognised by
keyword on the account and institution names). Queries SimpleFIN only when Refresh is
pressed. Balances render in accounting style with negatives in parentheses.
- **Import** (`ImportHub.jsx`) — Top-level entry point for the frequent job. Lists every
source with record counts and last import date, a Sync button for bank feeds, and an
upload link for CSV sources.
- **Source Import tab** — Upload a CSV to import records into the selected source. Transformations run automatically on new records. Shows import log with inserted/duplicate counts, expandable key detail, checkbox selection, and delete with confirmation. Sources with `config.simplefin.account_id` also get a Sync panel — a window selector (10/30/45 days or a full backfill) and a "Sync now" button that pulls from the bank API through the same import path.
- **Rules** — Create and manage regex rules. Live preview fires automatically (debounced 500ms) as pattern/field/flags are edited, showing match results against real records. Rules can be enabled/disabled by toggle.
@ -353,7 +394,8 @@ Built with React + Vite + Tailwind CSS. Compiled output goes to `public/`. The s
See `docs/perspective.md` for the full technical reference on controlling Perspective programmatically.
- **Stacks** — Named unions of multiple sources. Each stack defines a field mapping (how source fields map to common output columns), an amount field, a date field, and an optional balance offset. The view-data endpoint unions the underlying source views and computes a running balance sorted by date. The Pivot page supports stacks as well as individual sources, with layouts stored in the same `pivot_layouts` table.
- **Stacks** — Named unions of multiple sources, each chip linking to its pivot at
`/stacks/:name/pivot`. Each stack defines a field mapping (how source fields map to common output columns), an amount field, a date field, and an optional balance offset. The view-data endpoint unions the underlying source views and computes a running balance sorted by date. The Pivot page supports stacks as well as individual sources, with layouts stored in the same `pivot_layouts` table.
- **Log** — Global import log across all sources. Same expandable key detail and delete capability as the Import page, plus a source name column.
@ -392,7 +434,9 @@ Shows current status on every screen:
9. **Set login credentials** — Prompts for username and password, bcrypt-hashes the password via `node -e "require('bcrypt')..."`, and writes `LOGIN_USER` and `LOGIN_PASSWORD_HASH` to `.env`. Requires Node.js and bcrypt npm package to be installed.
10. **Uninstall** — Reverses everything the other options install, in reverse order: stops/disables/removes the systemd unit, removes the nginx site and reloads nginx, drops the database and its user (prompts for admin credentials), then deletes `.env`, `public/`, and `node_modules`. Lists exactly what it found before doing anything and requires typing `delete` to proceed. The repository itself is left in place.
10. **Claim SimpleFIN setup token** — Prompts for a setup token (hidden input), exchanges it for a permanent access URL via `api/lib/simplefin.js`, and writes `SIMPLEFIN_ACCESS_URL` to `.env`. Warns and confirms before replacing an existing URL. Setup tokens are single-use, so a failed claim generally means a new token is needed. Prints only the bridge host, never the embedded credentials.
11. **Uninstall** — Reverses everything the other options install, in reverse order: stops/disables/removes the systemd unit, removes the nginx site and reloads nginx, drops the database and its user (prompts for admin credentials), then deletes `.env`, `public/`, and `node_modules`. Lists exactly what it found before doing anything and requires typing `delete` to proceed. The repository itself is left in place.
**Key behaviors:**
- All commands that will be run are printed before the user is asked to confirm.
@ -416,9 +460,7 @@ NODE_ENV development | production
LOGIN_USER Username for Basic Auth
LOGIN_PASSWORD_HASH bcrypt hash of the password
TELLER_CERT_PATH Teller client certificate (only for Teller sync)
TELLER_KEY_PATH Teller private key
TELLER_TOKEN Default Teller access token; per-source override via config.teller.token_env
SIMPLEFIN_ACCESS_URL Default SimpleFIN bridge URL; per-source override via config.simplefin.access_url_env
```
---

View File

@ -902,6 +902,58 @@ def action_set_login_credentials(cfg):
info('Restart the service for changes to take effect (option 7).')
def action_claim_simplefin(cfg):
header('Claim a SimpleFIN setup token')
print(' A setup token is single-use. Claiming it returns the permanent access')
print(' URL, which is written to .env as SIMPLEFIN_ACCESS_URL.')
print()
if not ENV_FILE.exists():
err(f'{ENV_FILE} does not exist — run the database configuration dialog first')
return
if cfg and cfg.get('SIMPLEFIN_ACCESS_URL'):
warn('SIMPLEFIN_ACCESS_URL is already set — claiming again will replace it')
if not confirm('Replace the existing access URL?', default_yes=False):
info('Cancelled — no changes made')
return
token = prompt('Setup token', secret=True)
if not token:
info('Cancelled — no changes made')
return
print(' Claiming token with SimpleFIN...')
r = subprocess.run(
['node', '-e',
"require('./api/lib/simplefin.js').claimSetupToken(process.argv[1])"
".then(u=>process.stdout.write(u),e=>{process.stderr.write(e.message);process.exit(1)})",
token],
capture_output=True, text=True, cwd=ROOT
)
if r.returncode != 0 or not r.stdout:
err(f'Claim failed — the token may already have been used\n {r.stderr.strip()}')
return
access_url = r.stdout.strip()
# Update .env
env_text = ENV_FILE.read_text()
key = 'SIMPLEFIN_ACCESS_URL'
if f'{key}=' in env_text:
import re
env_text = re.sub(rf'^{key}=.*$', f'{key}={access_url}', env_text, flags=re.MULTILINE)
else:
env_text = env_text.rstrip('\n') + f'\n{key}={access_url}\n'
ENV_FILE.write_text(env_text)
# Show the host only — the URL embeds its own credentials
host = access_url.split('@')[-1] if '@' in access_url else access_url
ok(f'{key} written to {ENV_FILE}')
info(f'Bridge: {host}')
info('Restart the service for changes to take effect (option 7).')
# ── Main menu ─────────────────────────────────────────────────────────────────
MENU = [
@ -914,6 +966,7 @@ MENU = [
('Start / restart dataflow.service', action_restart_service),
('Stop dataflow.service', action_stop_service),
('Set login credentials', action_set_login_credentials),
('Claim SimpleFIN setup token (.env)', action_claim_simplefin),
('Uninstall (service, nginx, database, .env, build)', action_uninstall),
]

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ui</title>
<title>Dataflow</title>
</head>
<body>
<div id="root"></div>

View File

@ -1,26 +1,41 @@
import { useState, useEffect } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { useState, useEffect, createElement, lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom'
import { api, setCredentials, clearCredentials } from './api'
import StatusBar from './components/StatusBar.jsx'
import Sidebar from './components/Sidebar.jsx'
import BottomNav from './components/BottomNav.jsx'
import SourceTabs from './components/SourceTabs.jsx'
import Login from './pages/Login'
import Sources from './pages/Sources'
import SourceList from './pages/SourceList'
import SourceDetail from './pages/SourceDetail'
import Bridge from './pages/Bridge'
import ImportHub from './pages/ImportHub'
import Import from './pages/Import'
import Rules from './pages/Rules'
import Mappings from './pages/Mappings'
import Records from './pages/Records'
import Log from './pages/Log'
import Pivot from './pages/Pivot'
const Pivot = lazy(() => import('./pages/Pivot'))
import Remap from './pages/Remap'
import Stacks from './pages/Stacks'
// Source-scoped pages still take a `source` prop; this reads it off the URL so
// they didn't all need rewriting when selection moved out of the status bar.
function ScopedToSource({ component, ...props }) {
const { name } = useParams()
return createElement(component, { source: name, ...props })
}
// Pivot doubles as the stack viewer; a stack in the URL takes precedence there
function StackPivot() {
const { name } = useParams()
return <Pivot source={name} selectedStack={name} setSelectedStack={() => {}} />
}
export default function App() {
const [authed, setAuthed] = useState(false)
const [loginUser, setLoginUser] = useState('')
const [sources, setSources] = useState([])
const [stacks, setStacks] = useState([])
const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '')
const [selectedStack, setSelectedStack] = useState(null)
const [sidebarExpanded, setSidebarExpanded] = useState(() => localStorage.getItem('df_sidebar') !== 'collapsed')
// Sets of names whose dfv view is out of sync with current definitions
const [staleSources, setStaleSources] = useState(new Set())
@ -37,7 +52,6 @@ export default function App() {
if (!source && s.length > 0) setSource(s[0].name)
setAuthed(true)
setLoginUser(user)
api.getStacks().then(setStacks).catch(() => {})
}
function handleLogout() {
@ -47,17 +61,11 @@ export default function App() {
setAuthed(false)
setLoginUser('')
setSources([])
setStacks([])
setSelectedStack(null)
setStaleSources(new Set())
setStaleStacks(new Set())
setReprocessSources(new Set())
}
function refreshStacks() {
api.getStacks().then(setStacks).catch(() => {})
}
// Load initial stale state from DB once on login
useEffect(() => {
if (!authed) return
@ -127,22 +135,20 @@ export default function App() {
<BrowserRouter>
<div className="flex h-screen">
<div className="hidden md:flex">
<Sidebar
expanded={sidebarExpanded}
setExpanded={setSidebarExpanded}
loginUser={loginUser}
onLogout={handleLogout}
sources={sources}
/>
</div>
{/* Main */}
<div className="flex-1 overflow-hidden flex flex-col min-w-0">
<StatusBar
sources={sources} source={source} setSource={setSource}
stacks={stacks} selectedStack={selectedStack} setSelectedStack={setSelectedStack}
/>
{(staleSources.size > 0 || staleStacks.size > 0) && (
<div className="bg-amber-50 border-b border-amber-200 px-4 py-1.5 text-xs text-amber-800 flex flex-wrap items-center gap-x-3 gap-y-1">
<div className="bg-warn-soft border-b border-warn-line px-4 py-1.5 text-xs text-warn flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="font-medium">View out of sync:</span>
{[...staleSources].map(name => (
<span key={name} className="flex items-center gap-1">
@ -150,20 +156,20 @@ export default function App() {
<button
onClick={() => handleGenerateSource(name)}
disabled={generating[`src:${name}`]}
className="px-1.5 py-0.5 rounded bg-amber-200 hover:bg-amber-300 disabled:opacity-50 font-medium"
className="px-1.5 py-0.5 rounded bg-warn-line hover:bg-warn-line disabled:opacity-50 font-medium"
>
{generating[`src:${name}`] ? '…' : 'Generate'}
</button>
</span>
))}
{staleSources.size > 0 && staleStacks.size > 0 && <span className="text-amber-400">|</span>}
{staleSources.size > 0 && staleStacks.size > 0 && <span className="text-warn">|</span>}
{[...staleStacks].map(name => (
<span key={name} className="flex items-center gap-1">
stack: {name}
<button
onClick={() => handleGenerateStack(name)}
disabled={generating[`stk:${name}`]}
className="px-1.5 py-0.5 rounded bg-amber-200 hover:bg-amber-300 disabled:opacity-50 font-medium"
className="px-1.5 py-0.5 rounded bg-warn-line hover:bg-warn-line disabled:opacity-50 font-medium"
>
{generating[`stk:${name}`] ? '…' : 'Generate'}
</button>
@ -172,7 +178,7 @@ export default function App() {
</div>
)}
{reprocessSources.size > 0 && (
<div className="bg-blue-50 border-b border-blue-200 px-4 py-1.5 text-xs text-blue-800 flex flex-wrap items-center gap-x-3 gap-y-1">
<div className="bg-accent-soft border-b border-accent-line px-4 py-1.5 text-xs text-accent flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="font-medium">Mappings updated:</span>
{[...reprocessSources].map(name => (
<span key={name} className="flex items-center gap-1">
@ -189,22 +195,34 @@ export default function App() {
</div>
)}
<div className="flex-1 overflow-auto">
<div className="flex-1 overflow-auto pb-14 md:pb-0">
<Suspense fallback={<div className="p-6 text-sm text-muted">Loading</div>}>
<Routes>
<Route path="/" element={<Navigate to="/sources" replace />} />
<Route path="/sources" element={<Sources source={source} sources={sources} setSources={setSources} setSource={setSource} />} />
<Route path="/import" element={<Import source={source} />} />
<Route path="/rules" element={<Rules source={source} onStale={markSourceStale} />} />
<Route path="/mappings" element={<Mappings source={source} onNeedsReprocess={markNeedsReprocess} />} />
<Route path="/sources" element={<SourceList sources={sources} setSources={setSources} setSource={setSource} />} />
<Route path="/sources/:name" element={<SourceTabs sources={sources} />}>
<Route index element={<Navigate to="records" replace />} />
<Route path="setup" element={<SourceDetail sources={sources} setSources={setSources} />} />
<Route path="import" element={<ScopedToSource component={Import} />} />
<Route path="rules" element={<ScopedToSource component={Rules} onStale={markSourceStale} />} />
<Route path="mappings" element={<ScopedToSource component={Mappings} onNeedsReprocess={markNeedsReprocess} />} />
<Route path="records" element={<ScopedToSource component={Records} />} />
<Route path="pivot" element={<ScopedToSource component={Pivot} />} />
</Route>
<Route path="/import" element={<ImportHub sources={sources} />} />
<Route path="/bridge" element={<Bridge sources={sources} />} />
<Route path="/stacks" element={<Stacks sources={sources} onStackStale={markStackStale} onStackViewGenerated={clearStackStale} />} />
<Route path="/stacks/:name/pivot" element={<StackPivot />} />
<Route path="/remap" element={<Remap />} />
<Route path="/records" element={<Records source={source} />} />
<Route path="/pivot" element={<Pivot source={source} selectedStack={selectedStack} setSelectedStack={setSelectedStack} />} />
<Route path="/stacks" element={<Stacks sources={sources} onStackStale={markStackStale} onStackViewGenerated={clearStackStale} onStacksChange={refreshStacks} />} />
<Route path="/log" element={<Log />} />
</Routes>
</Suspense>
</div>
</div>
</div>
<BottomNav />
</BrowserRouter>
)
}

View File

@ -66,12 +66,18 @@ export const api = {
fd.append('file', file)
return request('POST', `/sources/${name}/import`, fd, true)
},
syncTeller: (name, opts = {}) => {
syncSimpleFin: (name, opts = {}) => {
const params = new URLSearchParams(opts)
return request('POST', `/sources/${name}/sync${params.toString() ? `?${params}` : ''}`)
},
getTellerAccounts: (tokenEnv) =>
request('GET', `/sources/teller-accounts${tokenEnv ? `?token_env=${encodeURIComponent(tokenEnv)}` : ''}`),
getSimpleFinSample: (accountId, days) => {
const params = new URLSearchParams({ account_id: accountId })
if (days !== undefined) params.set('days', days)
return request('GET', `/sources/simplefin-sample?${params}`)
},
getSimpleFinAccounts: (accessUrlEnv) =>
request('GET', `/sources/simplefin-accounts${accessUrlEnv ? `?access_url_env=${encodeURIComponent(accessUrlEnv)}` : ''}`),
claimSimpleFinToken: (setup_token) => request('POST', '/sources/simplefin-claim', { setup_token }),
transform: (name) => request('POST', `/sources/${name}/transform`),
reprocess: (name) => request('POST', `/sources/${name}/reprocess`),
generateView: (name) => request('POST', `/sources/${name}/view`),

View File

@ -0,0 +1,40 @@
import { NavLink } from 'react-router-dom'
import useTheme from '../theme.jsx'
import { NAV } from './navItems.jsx'
// Phone-sized replacement for the sidebar: same destinations, thumb-reachable.
// Hidden from md: upward, where the sidebar takes over.
export default function BottomNav() {
const { dark, setDark } = useTheme()
return (
<nav className="md:hidden fixed bottom-0 inset-x-0 z-20 bg-surface border-t border-line flex justify-around items-stretch h-14 pb-[env(safe-area-inset-bottom)]">
{NAV.map(({ to, label, icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex-1 flex flex-col items-center justify-center gap-0.5 text-[10px] ${
isActive ? 'text-accent' : 'text-muted'
}`
}
>
<span>{icon}</span>
{label}
</NavLink>
))}
<button
onClick={() => setDark(d => !d)}
className="flex-1 flex flex-col items-center justify-center gap-0.5 text-[10px] text-muted"
title={dark ? 'Light mode' : 'Dark mode'}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{dark
? <><circle cx="12" cy="12" r="4"/><line x1="12" y1="2" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="22"/><line x1="2" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="22" y2="12"/></>
: <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>}
</svg>
Theme
</button>
</nav>
)
}

View File

@ -0,0 +1,28 @@
// Compact preview of raw record values used by the source detail page and by
// the create dialog when a CSV or bank feed is sampled
export default function SampleTable({ rows }) {
if (!rows || rows.length === 0) return null
const cols = Object.keys(rows[0])
return (
<div className="overflow-auto border border-line-soft rounded bg-raised max-h-36">
<table className="text-xs w-full">
<thead>
<tr className="text-left text-muted border-b border-line-soft bg-raised sticky top-0">
{cols.map(c => <th key={c} className="px-2 py-1 font-medium whitespace-nowrap">{c}</th>)}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} className="border-t border-line-soft">
{cols.map(c => (
<td key={c} className="px-2 py-1 whitespace-nowrap text-ink-soft max-w-32 truncate font-mono">
{row[c] == null ? <span className="text-muted"></span> : String(row[c])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@ -0,0 +1,13 @@
// One titled panel per job, so setup, schema, and destructive actions read as
// separate things rather than one flat form
export default function Section({ title, description, children }) {
return (
<section className="bg-surface border border-line rounded p-4">
<h2 className="text-sm font-semibold text-ink-soft">{title}</h2>
{description
? <p className="text-xs text-muted mt-0.5 mb-3">{description}</p>
: <div className="mb-3" />}
{children}
</section>
)
}

View File

@ -1,120 +1,47 @@
import { Fragment, useMemo } from 'react'
import { NavLink } from 'react-router-dom'
import useTheme from '../theme.jsx'
import { NAV } from './navItems.jsx'
const NAV = [
{
to: '/sources',
label: 'Sources',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="10" cy="5.5" rx="7" ry="2.5"/>
<path d="M3 5.5v9c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5v-9"/>
<path d="M3 10.5c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5"/>
</svg>
),
},
{
to: '/import',
label: 'Import',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<line x1="10" y1="3" x2="10" y2="14"/>
<polyline points="6,10 10,14 14,10"/>
<line x1="3" y1="18" x2="17" y2="18"/>
</svg>
),
},
{
to: '/rules',
label: 'Rules',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6,7 2,10 6,13"/>
<polyline points="14,7 18,10 14,13"/>
<line x1="12" y1="4" x2="8" y2="16"/>
</svg>
),
},
{
to: '/mappings',
label: 'Mappings',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<line x1="2" y1="7" x2="12" y2="7"/>
<polyline points="9,4 12,7 9,10"/>
<line x1="8" y1="13" x2="18" y2="13"/>
<polyline points="11,10 14,13 11,16"/>
</svg>
),
},
{
to: '/remap',
label: 'Remap',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<polyline points="2,8 2,4 6,4"/>
<path d="M2 4a8 8 0 0 1 14 2"/>
<polyline points="18,12 18,16 14,16"/>
<path d="M18 16a8 8 0 0 1-14-2"/>
</svg>
),
},
{
to: '/records',
label: 'Records',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="16" height="14" rx="1.5"/>
<line x1="2" y1="8" x2="18" y2="8"/>
<line x1="7" y1="8" x2="7" y2="17"/>
</svg>
),
},
{
to: '/pivot',
label: 'Pivot',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="7" height="7" rx="1"/>
<rect x="11" y="2" width="7" height="7" rx="1"/>
<rect x="2" y="11" width="7" height="7" rx="1"/>
<rect x="11" y="11" width="7" height="7" rx="1"/>
</svg>
),
},
{
to: '/stacks',
label: 'Stacks',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<polygon points="10,2 18,6 10,10 2,6"/>
<polyline points="2,10 10,14 18,10"/>
<polyline points="2,14 10,18 18,14"/>
</svg>
),
},
{
to: '/log',
label: 'Log',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<circle cx="10" cy="10" r="8"/>
<polyline points="10,5 10,10 14,12"/>
</svg>
),
},
]
// Same distinction the Import page makes: a source is either on a bank feed or
// it gets CSVs uploaded to it.
const feedIcon = (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M4 10.5a4.5 4.5 0 0 1 4.5 4.5"/>
<path d="M4 6a9 9 0 0 1 9 9"/>
<circle cx="4.2" cy="14.8" r="1.2" fill="currentColor" stroke="none"/>
</svg>
)
const csvIcon = (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 1.5h5l3 3v10H4z"/>
<polyline points="9,1.5 9,4.5 12,4.5"/>
</svg>
)
export default function Sidebar({ expanded, setExpanded, loginUser, onLogout, sources = [] }) {
const { dark, setDark } = useTheme()
// Bank feeds first, then CSV sources, alphabetical within each group
const navSources = useMemo(() => (
sources
.map(s => ({ ...s, isFeed: !!s.config?.simplefin?.account_id }))
.sort((a, b) =>
(b.isFeed - a.isFeed) || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
)
), [sources])
export default function Sidebar({ expanded, setExpanded, loginUser, onLogout }) {
return (
<div
className="bg-white border-r border-gray-200 flex flex-col shrink-0 overflow-hidden transition-all duration-150"
className="bg-surface border-r border-line flex flex-col shrink-0 overflow-hidden transition-all duration-150"
style={{ width: expanded ? 200 : 48 }}
>
{/* Header */}
<div className="h-12 flex items-center px-3 border-b border-gray-100 gap-2 shrink-0">
<div className="h-12 flex items-center px-3 border-b border-line-soft gap-2 shrink-0">
<button
onClick={() => setExpanded(e => !e)}
className="w-8 h-8 flex items-center justify-center rounded hover:bg-gray-100 text-gray-400 shrink-0"
className="w-8 h-8 flex items-center justify-center rounded hover:bg-raised text-muted shrink-0"
title="Toggle sidebar"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@ -124,7 +51,7 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout })
</svg>
</button>
<span
className="text-xs font-semibold text-gray-600 tracking-wide uppercase whitespace-nowrap transition-opacity duration-100"
className="text-xs font-semibold text-ink-soft tracking-wide uppercase whitespace-nowrap transition-opacity duration-100"
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none' }}
>
Dataflow
@ -132,35 +59,90 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout })
</div>
{/* Nav */}
<nav className="flex flex-col gap-0.5 p-2 flex-1">
<nav className="flex flex-col gap-0.5 p-2 flex-1 overflow-y-auto">
{NAV.map(({ to, label, icon }) => (
<NavLink
key={to}
to={to}
title={!expanded ? label : undefined}
className={({ isActive }) =>
`flex items-center gap-3 px-2 py-2 rounded w-full transition-colors ${
isActive
? 'bg-blue-50 text-blue-700'
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-800'
}`
}
>
<span className="shrink-0">{icon}</span>
<span
className="text-sm whitespace-nowrap transition-opacity duration-100"
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none', width: expanded ? 'auto' : 0, overflow: 'hidden' }}
<Fragment key={to}>
<NavLink
to={to}
end={to === '/sources'}
title={!expanded ? label : undefined}
className={({ isActive }) =>
`flex items-center gap-3 px-2 py-2 rounded w-full transition-colors ${
isActive
? 'bg-accent-soft text-accent'
: 'text-muted hover:bg-raised hover:text-ink'
}`
}
>
{label}
</span>
</NavLink>
<span className="shrink-0">{icon}</span>
<span
className="text-sm whitespace-nowrap transition-opacity duration-100"
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none', width: expanded ? 'auto' : 0, overflow: 'hidden' }}
>
{label}
</span>
</NavLink>
{/* Jump straight to a source. Collapsed there is no room for names,
so the shortcut list only exists when the sidebar is open. */}
{to === '/sources' && expanded && navSources.map(({ isFeed, ...s }) => {
return (
<NavLink
key={s.name}
to={`/sources/${encodeURIComponent(s.name)}`}
title={`${s.name}${isFeed ? 'bank feed' : 'CSV'}`}
className={({ isActive }) =>
`flex items-center gap-2 ml-4 pl-2 pr-2 py-1 rounded border-l border-line-soft transition-colors ${
isActive
? 'bg-accent-soft text-accent'
: 'text-muted hover:bg-raised hover:text-ink'
}`
}
>
<span className="shrink-0 opacity-70">{isFeed ? feedIcon : csvIcon}</span>
<span className="text-xs truncate">{s.name}</span>
</NavLink>
)
})}
</Fragment>
))}
</nav>
{/* Theme */}
<div className="border-t border-line-soft px-3 py-2 shrink-0">
<button
onClick={() => setDark(d => !d)}
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
className="flex items-center gap-2.5 w-full rounded px-1 py-1 text-muted hover:bg-raised hover:text-ink"
>
<span className="shrink-0">
{dark ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4"/>
<line x1="12" y1="2" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="22"/>
<line x1="4.93" y1="4.93" x2="7.05" y2="7.05"/><line x1="16.95" y1="16.95" x2="19.07" y2="19.07"/>
<line x1="2" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="22" y2="12"/>
<line x1="4.93" y1="19.07" x2="7.05" y2="16.95"/><line x1="16.95" y1="7.05" x2="19.07" y2="4.93"/>
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
)}
</span>
<span
className="text-sm whitespace-nowrap transition-opacity duration-100"
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none', width: expanded ? 'auto' : 0, overflow: 'hidden' }}
>
{dark ? 'Light mode' : 'Dark mode'}
</span>
</button>
</div>
{/* User / logout */}
<div className="border-t border-gray-100 px-3 py-2.5 flex items-center gap-2 shrink-0 overflow-hidden">
<div className="border-t border-line-soft px-3 py-2.5 flex items-center gap-2 shrink-0 overflow-hidden">
<div
className="w-6 h-6 rounded-full bg-gray-200 text-gray-500 flex items-center justify-center shrink-0 text-xs font-medium"
className="w-6 h-6 rounded-full bg-raised text-muted flex items-center justify-center shrink-0 text-xs font-medium"
title={!expanded ? loginUser : undefined}
>
{loginUser ? loginUser[0].toUpperCase() : '?'}
@ -169,10 +151,10 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout })
className="flex-1 flex items-center justify-between min-w-0 transition-opacity duration-100"
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none', width: expanded ? 'auto' : 0, overflow: 'hidden' }}
>
<span className="text-xs text-gray-400 truncate">{loginUser}</span>
<span className="text-xs text-muted truncate">{loginUser}</span>
<button
onClick={onLogout}
className="text-xs text-gray-400 hover:text-red-500 ml-2 shrink-0"
className="text-xs text-muted hover:text-danger ml-2 shrink-0"
>
Sign out
</button>

View File

@ -0,0 +1,60 @@
import { NavLink, Outlet, useParams, Link } from 'react-router-dom'
// Everything scoped to one source lives under /sources/:name, so the source is
// in the URL rather than in a global selector.
// Records is the tab you want nine times out of ten, so it leads and is what
// /sources/:name redirects to; Setup is the rare one and sits at the end.
const TABS = [
{ to: 'records', label: 'Records' },
{ to: 'import', label: 'Import' },
{ to: 'rules', label: 'Rules' },
{ to: 'mappings', label: 'Mappings' },
{ to: 'pivot', label: 'Pivot' },
{ to: 'setup', label: 'Setup' },
]
export default function SourceTabs({ sources }) {
const { name } = useParams()
const sourceObj = sources.find(s => s.name === name)
const base = `/sources/${encodeURIComponent(name)}`
return (
<div className="flex flex-col h-full min-h-0">
<div className="px-4 sm:px-6 pt-4 sm:pt-5 shrink-0">
<div className="flex items-center gap-3">
<Link to="/sources" className="text-xs text-muted hover:text-ink-soft">Sources</Link>
<span className="text-muted text-xs">/</span>
<h1 className="text-xl font-semibold text-ink">{name}</h1>
{sourceObj?.config?.simplefin?.account_id && (
<span className="text-xs bg-accent-soft text-accent border border-accent-line rounded px-1.5 py-0.5">
bank feed
</span>
)}
</div>
<nav className="flex gap-1 mt-3 border-b border-line overflow-x-auto whitespace-nowrap">
{TABS.map(({ to, label, end }) => (
<NavLink
key={label}
to={to ? `${base}/${to}` : base}
end={end}
className={({ isActive }) =>
`text-sm px-3 py-1.5 -mb-px border-b-2 ${
isActive
? 'border-accent text-accent font-medium'
: 'border-transparent text-muted hover:text-ink-soft'
}`
}
>
{label}
</NavLink>
))}
</nav>
</div>
<div className="flex-1 overflow-auto min-h-0">
<Outlet />
</div>
</div>
)
}

View File

@ -1,73 +0,0 @@
import { NavLink } from 'react-router-dom'
import useTheme from '../theme.jsx'
export default function StatusBar({ sources = [], source, setSource, stacks = [], selectedStack, setSelectedStack }) {
const { dark, setDark } = useTheme()
return (
<div className="bg-white border-b border-gray-200 px-3 h-9 flex items-center gap-3 shrink-0 text-xs">
<span className="text-gray-400">Source</span>
<select
value={source || ''}
onChange={e => setSource(e.target.value)}
disabled={sources.length === 0}
className="border border-gray-200 rounded px-2 py-0.5 bg-white focus:outline-none focus:border-blue-400"
>
{sources.length === 0
? <option value=""> no sources </option>
: sources.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
</select>
<NavLink
to="/sources?new=1"
className="text-blue-400 hover:text-blue-600 leading-none"
title="New source"
>+</NavLink>
{stacks.length > 0 && (
<>
<span className="text-gray-200">|</span>
<span className="text-gray-400">Stacks</span>
{stacks.map(s => (
<button
key={s.name}
onClick={() => setSelectedStack(n => n === s.name ? null : s.name)}
className={`rounded px-2 py-0.5 border transition-colors ${
selectedStack === s.name
? 'bg-purple-50 border-purple-300 text-purple-700'
: 'bg-white border-gray-200 text-gray-500 hover:border-gray-400'
}`}
>
{s.name}
</button>
))}
</>
)}
<div className="ml-auto">
<button
onClick={() => setDark(d => !d)}
className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 text-gray-500"
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{dark ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4"/>
<line x1="12" y1="2" x2="12" y2="5"/>
<line x1="12" y1="19" x2="12" y2="22"/>
<line x1="4.93" y1="4.93" x2="7.05" y2="7.05"/>
<line x1="16.95" y1="16.95" x2="19.07" y2="19.07"/>
<line x1="2" y1="12" x2="5" y2="12"/>
<line x1="19" y1="12" x2="22" y2="12"/>
<line x1="4.93" y1="19.07" x2="7.05" y2="16.95"/>
<line x1="16.95" y1="7.05" x2="19.07" y2="4.93"/>
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
)}
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,70 @@
// Top-level destinations, shared by the desktop sidebar and the mobile bar
export const NAV = [
{
to: '/sources',
label: 'Sources',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="10" cy="5.5" rx="7" ry="2.5"/>
<path d="M3 5.5v9c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5v-9"/>
<path d="M3 10.5c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5"/>
</svg>
),
},
{
to: '/import',
label: 'Import',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<line x1="10" y1="3" x2="10" y2="14"/>
<polyline points="6,10 10,14 14,10"/>
<line x1="3" y1="18" x2="17" y2="18"/>
</svg>
),
},
{
to: '/bridge',
label: 'Bridge',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 13a8 8 0 0 1 16 0"/>
<line x1="2" y1="13" x2="18" y2="13"/>
<line x1="7" y1="13" x2="7" y2="9"/>
<line x1="13" y1="13" x2="13" y2="9"/>
</svg>
),
},
{
to: '/remap',
label: 'Remap',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<polyline points="2,8 2,4 6,4"/>
<path d="M2 4a8 8 0 0 1 14 2"/>
<polyline points="18,12 18,16 14,16"/>
<path d="M18 16a8 8 0 0 1-14-2"/>
</svg>
),
},
{
to: '/stacks',
label: 'Stacks',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<polygon points="10,2 18,6 10,10 2,6"/>
<polyline points="2,10 10,14 18,10"/>
<polyline points="2,14 10,18 18,14"/>
</svg>
),
},
{
to: '/log',
label: 'Log',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<circle cx="10" cy="10" r="8"/>
<polyline points="10,5 10,10 14,12"/>
</svg>
),
},
]

View File

@ -1,5 +1,42 @@
@import "tailwindcss";
/*
Semantic colour tokens.
Components name the *role* of a colour (bg-surface, text-muted, border-line)
rather than a literal shade, so light and dark are two sets of variable values
instead of two sets of rules. Adding a component no longer means adding a
matching `.dark` override.
The raw palette below is the only place actual colours appear.
*/
@theme {
--color-canvas: var(--bg-primary);
--color-surface: var(--bg-secondary);
--color-raised: var(--bg-tertiary);
--color-ink: var(--text-primary);
--color-ink-soft: var(--text-secondary);
--color-muted: var(--text-muted);
--color-line: var(--border-color);
--color-line-soft: var(--border-light);
--color-accent: var(--accent-text);
--color-accent-soft: var(--accent-bg);
--color-accent-line: var(--accent-line);
--color-ok: var(--ok-text);
--color-ok-soft: var(--ok-bg);
--color-warn: var(--warn-text);
--color-warn-soft: var(--warn-bg);
--color-warn-line: var(--warn-line);
--color-danger: var(--danger-text);
--color-danger-soft: var(--danger-bg);
--color-danger-line: var(--danger-line);
}
:root, .light {
--bg-primary: #f3f4f6;
--bg-secondary: #ffffff;
@ -11,6 +48,16 @@
--border-light: #f3f4f6;
--accent-bg: #eff6ff;
--accent-text: #1d4ed8;
--accent-line: #bfdbfe;
--ok-text: #059669;
--ok-bg: #ecfdf5;
--warn-text: #b45309;
--warn-bg: #fffbeb;
--warn-line: #fde68a;
--danger-text: #ef4444;
--danger-bg: #fef2f2;
--danger-line: #fecaca;
}
/* Dark palette tuned to Perspective's "Pro Dark" theme:
@ -27,6 +74,17 @@
--border-light: #3b3f46;
--accent-bg: rgba(39, 113, 170, 0.32);
--accent-text: #4778c2;
--accent-line: #2770a9;
/* Status accents desaturated to sit on Pro Dark's neutral background */
--ok-text: #6ee7b7;
--ok-bg: #1a3d2c;
--warn-text: #f5c66f;
--warn-bg: #3a2e14;
--warn-line: #5a4a26;
--danger-text: #ff9485;
--danger-bg: #3d1f1f;
--danger-line: #6b3030;
}
body {
@ -36,62 +94,14 @@ body {
color: var(--text-primary);
}
.dark .bg-white { background-color: var(--bg-secondary); }
.dark .bg-gray-50 { background-color: var(--bg-tertiary); }
.dark .bg-gray-100 { background-color: var(--bg-tertiary); }
.dark .bg-gray-200 { background-color: var(--bg-tertiary); }
.dark .bg-gray-300 { background-color: var(--bg-tertiary); }
.dark .text-gray-300 { color: var(--text-muted); }
.dark .text-gray-400 { color: var(--text-muted); }
.dark .text-gray-500 { color: var(--text-muted); }
.dark .text-gray-600 { color: var(--text-secondary); }
.dark .text-gray-700 { color: var(--text-secondary); }
.dark .text-gray-800 { color: var(--text-primary); }
.dark .text-gray-900 { color: var(--text-primary); }
.dark .bg-blue-50 { background-color: var(--accent-bg); }
.dark .bg-blue-100 { background-color: var(--accent-bg); }
.dark .text-blue-400 { color: var(--accent-text); }
.dark .text-blue-600 { color: var(--accent-text); }
.dark .text-blue-700 { color: var(--accent-text); }
.dark .text-blue-800 { color: var(--accent-text); }
.dark .border-blue-200 { border-color: var(--accent-text); }
.dark .border-blue-300 { border-color: var(--accent-text); }
.dark .hover\:bg-blue-50:hover { background-color: var(--accent-bg); }
/* Bare border utilities have no colour of their own */
.border, .border-t, .border-b, .border-l, .border-r { border-color: var(--border-color); }
/* Status accents — desaturated to sit on Pro Dark's neutral background */
.dark .bg-green-50 { background-color: #1a3d2c; }
.dark .text-green-600 { color: #6ee7b7; }
.dark .text-green-700 { color: #6ee7b7; }
.dark .text-green-400 { color: #6ee7b7; }
.dark .bg-amber-50 { background-color: #3a2e14; }
.dark .text-amber-800 { color: #f5c66f; }
.dark .border-amber-200 { border-color: #5a4a26; }
.dark .bg-amber-200 { background-color: #5a4a26; }
.dark .hover\:bg-amber-300:hover { background-color: #6b5830; }
.dark .bg-red-50 { background-color: #3d1f1f; }
.dark .text-red-500 { color: #ff9485; }
.dark .text-red-700 { color: #ff9485; }
.dark .border-gray-100 { border-color: var(--border-light); }
.dark .border-gray-200 { border-color: var(--border-color); }
.dark .border-gray-300 { border-color: var(--border-color); }
.dark .border-blue-100 { border-color: var(--border-color); }
.dark .border-b { border-color: var(--border-color); }
.dark .border-t { border-color: var(--border-color); }
.dark .border-r { border-color: var(--border-color); }
.dark .border-l { border-color: var(--border-color); }
.dark .hover\:bg-gray-50:hover { background-color: var(--bg-tertiary); }
.dark .hover\:bg-gray-100:hover { background-color: var(--bg-tertiary); }
.dark .hover\:bg-gray-200:hover { background-color: var(--bg-tertiary); }
.dark .hover\:text-gray-500:hover { color: var(--text-secondary); }
.dark .hover\:text-gray-600:hover { color: var(--text-secondary); }
.dark .hover\:text-gray-700:hover { color: var(--text-primary); }
.dark .hover\:text-gray-800:hover { color: var(--text-primary); }
.dark .hover\:border-gray-300:hover { border-color: var(--border-color); }
.dark .hover\:border-gray-400:hover { border-color: var(--border-color); }
.dark .focus\:border-gray-300:focus { border-color: var(--border-color); }
.dark .focus\:border-blue-400:focus { border-color: var(--accent-text); }
.dark ::selection { background-color: var(--accent-bg); color: var(--text-primary); }
.dark input { background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color); }
.dark select { background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color); }
.dark textarea { background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color); }
.dark .bg-transparent { background-color: transparent; }
/* Form controls don't inherit the surface token on their own */
input, select, textarea {
background-color: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border-color);
}
::selection { background-color: var(--accent-bg); color: var(--text-primary); }

176
ui/src/pages/Bridge.jsx Normal file
View File

@ -0,0 +1,176 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
import Section from '../components/Section.jsx'
// Accounting style: aligned to 2 decimals, negatives in parentheses
function money(value) {
const n = parseFloat(value)
if (!isFinite(n)) return '—'
const abs = Math.abs(n).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
return n < 0 ? `(${abs})` : abs
}
// SimpleFIN doesn't report an account type, so retirement accounts are
// recognised from the institution and account names
const RETIREMENT_RE = /401\(?k\)?|403\(?b\)?|\bira\b|retirement|pension|profit sharing/i
const isRetirement = (a) => RETIREMENT_RE.test(`${a.name} ${a.organization || ''}`)
// One SimpleFIN bridge covers every linked bank account, so connection state is
// a bridge-level concern rather than something to hunt for source by source.
export default function Bridge({ sources }) {
const [accounts, setAccounts] = useState(null)
const [errors, setErrors] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function load() {
setLoading(true)
setError('')
try {
const res = await api.getSimpleFinAccounts()
setAccounts(res.accounts || [])
setErrors(res.errors || [])
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
// Which source, if any, pulls from each account
const sourceFor = (accountId) =>
sources.find(s => s.config?.simplefin?.account_id === accountId)
const sum = (list) => list.reduce((t, a) => t + (parseFloat(a.balance) || 0), 0)
const banking = (accounts || []).filter(a => !isRetirement(a))
const retirement = (accounts || []).filter(isRetirement)
// Banking first, then retirement, so each subtotal sits under its own rows
const ordered = [...banking, ...retirement]
return (
<div className="p-4 sm:p-6 max-w-5xl space-y-4">
<div className="flex items-center justify-between mb-2">
<h1 className="text-xl font-semibold text-ink">Bridge</h1>
<button
onClick={load}
disabled={loading}
className="text-sm border border-line rounded px-3 py-1.5 text-ink-soft hover:bg-raised hover:border-line disabled:opacity-50"
>
{loading ? 'Refreshing…' : 'Refresh'}
</button>
</div>
{error && (
<Section title="Not connected" description="Claim a setup token with manage.py option 10, then restart the service.">
<p className="text-xs text-danger">{error}</p>
</Section>
)}
{errors.length > 0 && (
<div className="bg-warn-soft border border-warn-line rounded p-3 text-xs text-warn space-y-1">
{errors.map((e, i) => <div key={i}>{e}</div>)}
</div>
)}
{!accounts && !loading && !error && (
<p className="text-sm text-muted">Click Refresh to load balances from SimpleFIN.</p>
)}
{accounts && (
<Section
title="SimpleFIN accounts"
description="Every account behind the bridge credential, and which source pulls from it."
>
<table className="w-full text-xs hidden sm:table">
<thead>
<tr className="text-left text-muted border-b border-line-soft">
<th className="pb-1 font-medium">Account</th>
<th className="pb-1 font-medium">Institution</th>
<th className="pb-1 font-medium text-right">Balance</th>
<th className="pb-1 pl-4 font-medium">As of</th>
<th className="pb-1 font-medium">Source</th>
</tr>
</thead>
<tbody>
{ordered.map(a => {
const src = sourceFor(a.id)
return (
<tr key={a.id} className="border-t border-line-soft">
<td className="py-1.5 text-ink-soft">{a.name}</td>
<td className="py-1.5 text-muted">{a.organization}</td>
<td className="py-1.5 text-right font-mono text-ink-soft">{money(a.balance)}</td>
<td className="py-1.5 pl-4 text-muted">{a.balance_date}</td>
<td className="py-1.5">
{src
? <Link to={`/sources/${encodeURIComponent(src.name)}`} className="text-accent hover:text-accent">{src.name}</Link>
: <span className="text-muted">not linked</span>}
</td>
</tr>
)
})}
</tbody>
<tfoot>
{banking.length > 0 && (
<tr className="border-t border-line">
<td className="pt-2 text-muted" colSpan={2}>Banking and cards</td>
<td className="pt-2 text-right font-mono text-ink-soft">{money(sum(banking))}</td>
<td colSpan={2}></td>
</tr>
)}
{retirement.length > 0 && (
<tr>
<td className="pt-1 text-muted" colSpan={2}>Retirement</td>
<td className="pt-1 text-right font-mono text-ink-soft">{money(sum(retirement))}</td>
<td colSpan={2}></td>
</tr>
)}
<tr className="border-t border-line">
<td className="pt-2 text-ink-soft font-medium" colSpan={2}>Total</td>
<td className="pt-2 text-right font-mono font-medium text-ink">{money(sum(accounts))}</td>
<td colSpan={2}></td>
</tr>
</tfoot>
</table>
{/* Stacked cards for narrow screens */}
<div className="sm:hidden divide-y divide-line-soft">
{ordered.map(a => {
const src = sourceFor(a.id)
return (
<div key={a.id} className="py-2">
<div className="flex justify-between gap-2">
<span className="text-xs text-ink-soft">{a.name}</span>
<span className="text-xs font-mono text-ink">{money(a.balance)}</span>
</div>
<div className="flex justify-between gap-2 text-xs text-muted">
<span>{a.organization}</span>
<span>{a.balance_date}</span>
</div>
<div className="text-xs mt-0.5">
{src
? <Link to={`/sources/${encodeURIComponent(src.name)}`} className="text-accent">{src.name}</Link>
: <span className="text-muted">not linked</span>}
</div>
</div>
)
})}
<div className="pt-2 flex justify-between text-xs">
<span className="text-muted">Banking and cards</span>
<span className="font-mono text-ink-soft">{money(sum(banking))}</span>
</div>
<div className="pt-1 flex justify-between text-xs border-0">
<span className="text-muted">Retirement</span>
<span className="font-mono text-ink-soft">{money(sum(retirement))}</span>
</div>
<div className="pt-2 flex justify-between text-xs font-medium">
<span className="text-ink-soft">Total</span>
<span className="font-mono text-ink">{money(sum(accounts))}</span>
</div>
</div>
{accounts.length === 0 && <p className="text-xs text-muted">No accounts returned.</p>}
</Section>
)}
</div>
)
}

View File

@ -6,7 +6,7 @@ function KeyList({ keys, label, color }) {
return (
<div className="mb-2">
<div className={`text-xs font-medium mb-1 ${color}`}>{label} ({keys.length})</div>
<div className="max-h-32 overflow-y-auto bg-gray-50 rounded p-2 font-mono text-xs text-gray-500 space-y-0.5">
<div className="max-h-32 overflow-y-auto bg-raised rounded p-2 font-mono text-xs text-muted space-y-0.5">
{keys.map((k, i) => (
<div key={i}>
{typeof k === 'object' && k !== null
@ -28,19 +28,19 @@ function LogRow({ entry, selected, onToggle }) {
return (
<>
<tr className={`border-b border-gray-50 ${selected ? 'bg-red-50' : ''}`}>
<tr className={`border-b border-line-soft ${selected ? 'bg-danger-soft' : ''}`}>
<td className="py-1.5 pr-2">
<input type="checkbox" checked={selected} onChange={onToggle} className="cursor-pointer" />
</td>
<td className="py-1.5 text-xs text-gray-400 font-mono">{entry.id}</td>
<td className="py-1.5 text-gray-500">{new Date(entry.imported_at).toLocaleString()}</td>
<td className="py-1.5 text-gray-800">{entry.records_imported}</td>
<td className="py-1.5 text-gray-400">{entry.records_duplicate}</td>
<td className="py-1.5 text-xs text-muted font-mono">{entry.id}</td>
<td className="py-1.5 text-muted">{new Date(entry.imported_at).toLocaleString()}</td>
<td className="py-1.5 text-ink">{entry.records_imported}</td>
<td className="py-1.5 text-muted">{entry.records_duplicate}</td>
<td className="py-1.5">
{hasKeys && (
<button
onClick={() => setExpanded(e => !e)}
className="text-xs text-blue-400 hover:text-blue-600"
className="text-xs text-accent hover:text-accent"
>
{expanded ? '▲ hide' : '▼ keys'}
</button>
@ -48,10 +48,10 @@ function LogRow({ entry, selected, onToggle }) {
</td>
</tr>
{expanded && (
<tr className={selected ? 'bg-red-50' : 'bg-gray-50'}>
<tr className={selected ? 'bg-danger-soft' : 'bg-raised'}>
<td colSpan={6} className="px-4 py-3">
<KeyList keys={insertedKeys} label="Inserted" color="text-green-600" />
<KeyList keys={excludedKeys} label="Excluded" color="text-gray-500" />
<KeyList keys={insertedKeys} label="Inserted" color="text-ok" />
<KeyList keys={excludedKeys} label="Excluded" color="text-muted" />
</td>
</tr>
)}
@ -67,7 +67,7 @@ export default function Import({ source }) {
const [error, setError] = useState('')
const [dragOver, setDragOver] = useState(false)
const [selected, setSelected] = useState(new Set())
const [teller, setTeller] = useState(null)
const [simplefin, setSimplefin] = useState(null)
const [days, setDays] = useState('10')
const fileRef = useRef()
@ -75,7 +75,7 @@ export default function Import({ source }) {
if (!source) return
api.getStats(source).then(setStats).catch(() => {})
api.getImportLog(source).then(setLog).catch(() => {})
api.getSource(source).then(s => setTeller(s.config?.teller || null)).catch(() => setTeller(null))
api.getSource(source).then(s => setSimplefin(s.config?.simplefin || null)).catch(() => setSimplefin(null))
setSelected(new Set())
}, [source])
@ -102,7 +102,7 @@ export default function Import({ source }) {
setError('')
setResult(null)
try {
const res = await api.syncTeller(source, { days })
const res = await api.syncSimpleFin(source, { days })
setResult(res)
api.getStats(source).then(setStats)
api.getImportLog(source).then(setLog)
@ -168,11 +168,11 @@ export default function Import({ source }) {
}
}
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
return (
<div className="p-6 max-w-2xl">
<h1 className="text-xl font-semibold text-gray-800 mb-6">Import {source}</h1>
<div className="p-4 sm:p-6 max-w-2xl">
<h1 className="text-xl font-semibold text-ink mb-6">Import {source}</h1>
{/* Stats */}
{stats && (
@ -182,30 +182,30 @@ export default function Import({ source }) {
{ label: 'Transformed', value: stats.transformed_records },
{ label: 'Pending', value: stats.pending_records },
].map(({ label, value }) => (
<div key={label} className="bg-white border border-gray-200 rounded px-4 py-3 flex-1 text-center">
<div className="text-2xl font-semibold text-gray-800">{value}</div>
<div className="text-xs text-gray-400 mt-0.5">{label}</div>
<div key={label} className="bg-surface border border-line rounded px-4 py-3 flex-1 text-center">
<div className="text-2xl font-semibold text-ink">{value}</div>
<div className="text-xs text-muted mt-0.5">{label}</div>
</div>
))}
</div>
)}
{/* Teller sync — only for sources with a teller account in their config */}
{teller?.account_id && (
<div className="bg-white border border-gray-200 rounded p-4 mb-4 flex items-center gap-3">
<div className="flex-1">
<div className="text-sm font-medium text-gray-700">Teller</div>
<div className="text-xs text-gray-400 font-mono">{teller.account_id}</div>
{/* SimpleFIN sync — only for sources with a bridge account in their config */}
{simplefin?.account_id && (
<div className="bg-surface border border-line rounded p-4 mb-4 flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-ink-soft">SimpleFIN</div>
<div className="text-xs text-muted font-mono truncate">{simplefin.account_id}</div>
</div>
<select
value={days}
onChange={e => setDays(e.target.value)}
className="text-sm border border-gray-200 rounded px-2 py-1.5 bg-white text-gray-700"
className="text-sm border border-line rounded px-2 py-1.5 bg-surface text-ink-soft"
>
<option value="10">Last 10 days</option>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
<option value="0">Everything available</option>
<option value="45">Last 45 days</option>
<option value="89">Backfill (bridge maximum)</option>
</select>
<button onClick={handleSync} disabled={loading}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
@ -217,7 +217,7 @@ export default function Import({ source }) {
{/* Drop zone */}
<div
className={`border-2 border-dashed rounded-lg p-8 text-center mb-4 cursor-pointer transition-colors ${
dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
dragOver ? 'border-accent bg-accent-soft' : 'border-line hover:border-line'
}`}
onDragOver={e => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
@ -232,22 +232,22 @@ export default function Import({ source }) {
onChange={e => handleImport(e.target.files[0])}
/>
{loading
? <p className="text-sm text-gray-500">Importing</p>
: <p className="text-sm text-gray-400">Drop a CSV file here, or click to browse</p>
? <p className="text-sm text-muted">Importing</p>
: <p className="text-sm text-muted">Drop a CSV file here, or click to browse</p>
}
</div>
{error && <p className="text-sm text-red-500 mb-3">{error}</p>}
{error && <p className="text-sm text-danger mb-3">{error}</p>}
{result && (
<div className={`border rounded p-4 mb-4 text-sm ${result.success === false ? 'bg-red-50 border-red-200' : 'bg-white border-gray-200'}`}>
<div className={`border rounded p-4 mb-4 text-sm ${result.success === false ? 'bg-danger-soft border-danger-line' : 'bg-surface border-line'}`}>
{result.success === false ? (
<>
<p className="text-red-600 font-medium mb-2">{result.error}</p>
<p className="text-danger font-medium mb-2">{result.error}</p>
{result.duplicate_rows && (
<div>
<p className="text-xs text-red-500 mb-1">Offending rows:</p>
<div className="max-h-48 overflow-y-auto bg-white rounded border border-red-100 p-2 font-mono text-xs text-red-700 space-y-0.5">
<p className="text-xs text-danger mb-1">Offending rows:</p>
<div className="max-h-48 overflow-y-auto bg-surface rounded border border-danger-line p-2 font-mono text-xs text-danger space-y-0.5">
{result.duplicate_rows.map((row, i) => (
<div key={i}>
{Object.entries(row).map(([f, v]) => `${f}: ${v}`).join(' · ')}
@ -259,24 +259,29 @@ export default function Import({ source }) {
</>
) : result.imported !== undefined ? (
<>
{result.errors?.length > 0 && (
<div className="mb-2 text-xs text-warn">
{result.errors.map((e, i) => <div key={i}>Bridge: {e}</div>)}
</div>
)}
{result.fetched !== undefined && (
<>
<span className="text-gray-500">{result.fetched} fetched</span>
<span className="text-gray-400 mx-2">·</span>
<span className="text-muted">{result.fetched} fetched</span>
<span className="text-muted mx-2">·</span>
</>
)}
<span className="text-green-600 font-medium">{result.imported} imported</span>
<span className="text-gray-400 mx-2">·</span>
<span className="text-gray-500">{result.duplicates} duplicates skipped</span>
<span className="text-ok font-medium">{result.imported} imported</span>
<span className="text-muted mx-2">·</span>
<span className="text-muted">{result.duplicates} duplicates skipped</span>
{result.transform && (
<>
<span className="text-gray-400 mx-2">·</span>
<span className="text-gray-500">{result.transform.transformed} transformed</span>
<span className="text-muted mx-2">·</span>
<span className="text-muted">{result.transform.transformed} transformed</span>
</>
)}
</>
) : (
<span className="text-green-600 font-medium">{result.transformed} records transformed</span>
<span className="text-ok font-medium">{result.transformed} records transformed</span>
)}
</div>
)}
@ -301,7 +306,7 @@ export default function Import({ source }) {
{log.length > 0 && (
<div>
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-semibold text-gray-700">Import history</h2>
<h2 className="text-sm font-semibold text-ink-soft">Import history</h2>
{selected.size > 0 && (
<button
onClick={handleDeleteSelected}
@ -314,7 +319,7 @@ export default function Import({ source }) {
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-400 border-b border-gray-100">
<tr className="text-left text-xs text-muted border-b border-line-soft">
<th className="pb-1 w-6"></th>
<th className="pb-1 font-medium w-12">ID</th>
<th className="pb-1 font-medium">Date</th>

129
ui/src/pages/ImportHub.jsx Normal file
View File

@ -0,0 +1,129 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
// Importing is the frequent job; configuring a source is the rare one. This is
// the top-level entry point for the frequent one every source in one place,
// with a sync button for anything on a bank feed.
export default function ImportHub({ sources }) {
const [stats, setStats] = useState({}) // name -> stats
const [lastImport, setLastImport] = useState({}) // name -> ISO timestamp
const [busy, setBusy] = useState('')
const [results, setResults] = useState({}) // name -> message
const [errors, setErrors] = useState({}) // name -> message
useEffect(() => {
let cancelled = false
Promise.all(sources.map(s =>
api.getStats(s.name).then(st => [s.name, st]).catch(() => [s.name, null])
)).then(pairs => {
if (!cancelled) setStats(Object.fromEntries(pairs))
})
api.getAllImportLog().then(log => {
if (cancelled) return
const latest = {}
for (const entry of log) {
if (!latest[entry.source_name] || entry.imported_at > latest[entry.source_name]) {
latest[entry.source_name] = entry.imported_at
}
}
setLastImport(latest)
}).catch(() => {})
return () => { cancelled = true }
}, [sources])
async function sync(name) {
setBusy(name)
setErrors(e => ({ ...e, [name]: '' }))
setResults(r => ({ ...r, [name]: '' }))
try {
const res = await api.syncSimpleFin(name, { days: 10 })
setResults(r => ({
...r,
[name]: `${res.imported} imported, ${res.duplicates} already had` +
(res.errors?.length ? `${res.errors.join('; ')}` : ''),
}))
api.getStats(name).then(st => setStats(s => ({ ...s, [name]: st }))).catch(() => {})
api.getAllImportLog().then(log => {
const entry = log.find(l => l.source_name === name)
if (entry) setLastImport(l => ({ ...l, [name]: entry.imported_at }))
}).catch(() => {})
} catch (err) {
setErrors(e => ({ ...e, [name]: err.message }))
} finally {
setBusy('')
}
}
const feeds = sources.filter(s => s.config?.simplefin?.account_id)
const manual = sources.filter(s => !s.config?.simplefin?.account_id)
function Row({ s, isFeed }) {
const st = stats[s.name]
const when = lastImport[s.name]
return (
<div className="px-4 py-3 flex items-center gap-3 flex-wrap">
<div className="flex-1 min-w-40">
<Link to={`/sources/${encodeURIComponent(s.name)}/import`}
className="text-sm font-medium text-ink hover:text-accent">
{s.name}
</Link>
<div className="text-xs text-muted">
{st ? `${st.total_records} records` : '—'}
{st && Number(st.pending_records) > 0 && ` · ${st.pending_records} untransformed`}
{when && ` · last import ${new Date(when).toLocaleDateString()}`}
</div>
</div>
{results[s.name] && <span className="text-xs text-ok">{results[s.name]}</span>}
{errors[s.name] && <span className="text-xs text-danger">{errors[s.name]}</span>}
{isFeed ? (
<button
onClick={() => sync(s.name)}
disabled={busy === s.name}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50"
>
{busy === s.name ? 'Syncing…' : 'Sync'}
</button>
) : (
<Link to={`/sources/${encodeURIComponent(s.name)}/import`}
className="text-sm border border-line rounded px-3 py-1.5 text-ink-soft hover:bg-raised hover:border-line">
Upload CSV
</Link>
)}
</div>
)
}
return (
<div className="p-4 sm:p-6 max-w-4xl space-y-6">
<h1 className="text-xl font-semibold text-ink">Import</h1>
{feeds.length > 0 && (
<div>
<h2 className="text-sm font-semibold text-ink-soft mb-2">Bank feeds</h2>
<div className="bg-surface border border-line rounded divide-y divide-line-soft">
{feeds.map(s => <Row key={s.name} s={s} isFeed />)}
</div>
<p className="text-xs text-muted mt-1">Syncs pull the last 10 days; use a source&rsquo;s Import tab to backfill further.</p>
</div>
)}
{manual.length > 0 && (
<div>
<h2 className="text-sm font-semibold text-ink-soft mb-2">CSV sources</h2>
<div className="bg-surface border border-line rounded divide-y divide-line-soft">
{manual.map(s => <Row key={s.name} s={s} isFeed={false} />)}
</div>
</div>
)}
{sources.length === 0 && <p className="text-sm text-muted">No sources yet.</p>}
<Link to="/log" className="inline-block text-xs text-accent hover:text-accent">
Full import history
</Link>
</div>
)
}

View File

@ -6,7 +6,7 @@ function KeyList({ keys, label, color }) {
return (
<div className="mb-2">
<div className={`text-xs font-medium mb-1 ${color}`}>{label} ({keys.length})</div>
<div className="max-h-32 overflow-y-auto bg-gray-50 rounded p-2 font-mono text-xs text-gray-500 space-y-0.5">
<div className="max-h-32 overflow-y-auto bg-raised rounded p-2 font-mono text-xs text-muted space-y-0.5">
{keys.map((k, i) => (
<div key={i}>
{typeof k === 'object' && k !== null
@ -28,17 +28,17 @@ function LogRow({ entry }) {
return (
<>
<tr className="border-b border-gray-50 hover:bg-gray-50">
<td className="py-1.5 text-xs text-gray-400 font-mono pr-3">{entry.id}</td>
<td className="py-1.5 text-gray-700 pr-3">{entry.source_name}</td>
<td className="py-1.5 text-gray-500 pr-3">{new Date(entry.imported_at).toLocaleString()}</td>
<td className="py-1.5 text-gray-800 pr-3">{entry.records_imported}</td>
<td className="py-1.5 text-gray-400 pr-3">{entry.records_duplicate}</td>
<tr className="border-b border-line-soft hover:bg-raised">
<td className="py-1.5 text-xs text-muted font-mono pr-3">{entry.id}</td>
<td className="py-1.5 text-ink-soft pr-3">{entry.source_name}</td>
<td className="py-1.5 text-muted pr-3">{new Date(entry.imported_at).toLocaleString()}</td>
<td className="py-1.5 text-ink pr-3">{entry.records_imported}</td>
<td className="py-1.5 text-muted pr-3">{entry.records_duplicate}</td>
<td className="py-1.5">
{hasKeys && (
<button
onClick={() => setExpanded(e => !e)}
className="text-xs text-blue-400 hover:text-blue-600"
className="text-xs text-accent hover:text-accent"
>
{expanded ? '▲ hide' : '▼ keys'}
</button>
@ -46,10 +46,10 @@ function LogRow({ entry }) {
</td>
</tr>
{expanded && (
<tr className="bg-gray-50">
<tr className="bg-raised">
<td colSpan={6} className="px-4 py-3">
<KeyList keys={insertedKeys} label="Inserted" color="text-green-600" />
<KeyList keys={excludedKeys} label="Excluded" color="text-gray-500" />
<KeyList keys={insertedKeys} label="Inserted" color="text-ok" />
<KeyList keys={excludedKeys} label="Excluded" color="text-muted" />
</td>
</tr>
)}
@ -70,18 +70,18 @@ export default function Log() {
return (
<div className="p-6">
<h1 className="text-xl font-semibold text-gray-800 mb-6">Import Log</h1>
<h1 className="text-xl font-semibold text-ink mb-6">Import Log</h1>
{loading && <p className="text-sm text-gray-400">Loading</p>}
{loading && <p className="text-sm text-muted">Loading</p>}
{!loading && log.length === 0 && (
<p className="text-sm text-gray-400">No imports yet.</p>
<p className="text-sm text-muted">No imports yet.</p>
)}
{log.length > 0 && (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-400 border-b border-gray-100">
<tr className="text-left text-xs text-muted border-b border-line-soft">
<th className="pb-1 font-medium pr-3">ID</th>
<th className="pb-1 font-medium pr-3">Source</th>
<th className="pb-1 font-medium pr-3">Date</th>

View File

@ -20,32 +20,32 @@ export default function Login({ onLogin }) {
}
return (
<div className="flex items-center justify-center h-screen bg-gray-50">
<div className="bg-white border border-gray-200 rounded-lg p-8 w-80 shadow-sm">
<h1 className="text-lg font-semibold text-gray-800 mb-6">Dataflow</h1>
<div className="flex items-center justify-center h-screen bg-raised">
<div className="bg-surface border border-line rounded-lg p-8 w-80 shadow-sm">
<h1 className="text-lg font-semibold text-ink mb-6">Dataflow</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Username</label>
<label className="block text-xs text-muted mb-1">Username</label>
<input
type="text"
autoFocus
value={user}
onChange={e => setUser(e.target.value)}
className="w-full border border-gray-200 rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-400"
className="w-full border border-line rounded px-3 py-2 text-sm focus:outline-none focus:border-accent"
required
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Password</label>
<label className="block text-xs text-muted mb-1">Password</label>
<input
type="password"
value={pass}
onChange={e => setPass(e.target.value)}
className="w-full border border-gray-200 rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-400"
className="w-full border border-line rounded px-3 py-2 text-sm focus:outline-none focus:border-accent"
required
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{error && <p className="text-xs text-danger">{error}</p>}
<button
type="submit"
disabled={loading}

View File

@ -68,13 +68,13 @@ function AutocompleteInput({ value, onChange, onEnter, suggestions = [], classNa
<div
ref={listRef}
style={{ position: 'fixed', top: dropPos.top, left: dropPos.left, minWidth: dropPos.minWidth, zIndex: 9999 }}
className="bg-white border border-gray-200 rounded shadow-lg max-h-48 overflow-y-auto"
className="bg-surface border border-line rounded shadow-lg max-h-48 overflow-y-auto"
>
{filtered.map((s, i) => (
<div
key={s}
className={`px-2 py-1 text-xs cursor-pointer whitespace-nowrap ${
i === highlighted ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'
i === highlighted ? 'bg-accent-soft text-accent' : 'text-ink-soft hover:bg-raised'
}`}
onMouseDown={e => { e.preventDefault(); select(s) }}
>
@ -100,11 +100,11 @@ function SortHeader({ col, label, sortBy, onSort, className = '' }) {
const active = sortBy?.col === col
return (
<th
className={`px-3 py-2 font-medium cursor-pointer select-none hover:text-gray-600 ${className}`}
className={`px-3 py-2 font-medium cursor-pointer select-none hover:text-ink-soft ${className}`}
onClick={() => onSort(col)}
>
{label}
<span className="ml-1 text-gray-300">{active ? (sortBy.dir === 'asc' ? '↑' : '↓') : '↕'}</span>
<span className="ml-1 text-muted">{active ? (sortBy.dir === 'asc' ? '↑' : '↓') : '↕'}</span>
</th>
)
}
@ -354,18 +354,18 @@ export default function Mappings({ source, onNeedsReprocess }) {
}
}
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
const displayRows = sortedRows(filteredRows)
return (
<div>
{/* Sticky control bar */}
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-3 flex-wrap">
<span className="text-sm font-medium text-gray-700">{source}</span>
<div className="sticky top-0 z-10 bg-surface border-b border-line px-6 py-3 flex items-center gap-3 flex-wrap">
<span className="text-sm font-medium text-ink-soft">{source}</span>
<select
className="text-sm border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:border-blue-400"
className="text-sm border border-line rounded px-2 py-1.5 focus:outline-none focus:border-accent"
value={selectedRule}
onChange={e => setSelectedRule(e.target.value)}
>
@ -374,7 +374,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
</select>
{selectedRule && (
<div className="flex bg-gray-100 rounded p-0.5">
<div className="flex bg-raised rounded p-0.5">
{[
{ key: 'all', label: `All (${allValues.length})` },
{ key: 'unmapped', label: `Unmapped (${unmappedCount})` },
@ -382,7 +382,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
].map(({ key, label }) => (
<button key={key} onClick={() => setFilter(key)}
className={`text-xs px-3 py-1 rounded transition-colors ${
filter === key ? 'bg-white text-gray-800 shadow-sm' : 'text-gray-500'
filter === key ? 'bg-surface text-ink shadow-sm' : 'text-muted'
}`}>
{label}
</button>
@ -393,15 +393,15 @@ export default function Mappings({ source, onNeedsReprocess }) {
{selectedRule && (
<div className="relative">
<input
className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-blue-400 ${
rowFilterError ? 'border-red-400 bg-red-50' : rowFilter ? 'border-blue-300' : 'border-gray-200'
className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-accent ${
rowFilterError ? 'border-danger-line bg-danger-soft' : rowFilter ? 'border-accent-line' : 'border-line'
}`}
placeholder="filter regex…"
value={rowFilter}
onChange={e => setRowFilter(e.target.value)}
/>
{rowFilter && !rowFilterError && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-gray-400">
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted">
{filteredRows.length}
</span>
)}
@ -435,12 +435,12 @@ export default function Mappings({ source, onNeedsReprocess }) {
alert(err.message)
}
}}
className="text-sm px-3 py-1.5 border border-gray-200 rounded hover:bg-gray-50 text-gray-600"
className="text-sm px-3 py-1.5 border border-line rounded hover:bg-raised text-ink-soft"
>
Export TSV
</button>
)}
<label className={`text-sm px-3 py-1.5 border border-gray-200 rounded cursor-pointer hover:bg-gray-50 text-gray-600 ${importing ? 'opacity-50 pointer-events-none' : ''}`}>
<label className={`text-sm px-3 py-1.5 border border-line rounded cursor-pointer hover:bg-raised text-ink-soft ${importing ? 'opacity-50 pointer-events-none' : ''}`}>
{importing ? 'Importing…' : 'Import TSV'}
<input type="file" accept=".tsv,.txt" className="hidden" onChange={handleImportCSV} />
</label>
@ -450,24 +450,24 @@ export default function Mappings({ source, onNeedsReprocess }) {
{/* Content */}
<div className="p-6">
{!selectedRule && (
<p className="text-sm text-gray-400">Select a rule to view mappings.</p>
<p className="text-sm text-muted">Select a rule to view mappings.</p>
)}
{selectedRule && loading && (
<p className="text-sm text-gray-400">Loading</p>
<p className="text-sm text-muted">Loading</p>
)}
{selectedRule && !loading && allValues.length === 0 && (
<p className="text-sm text-gray-400">No extracted values for this rule. Run a transform first.</p>
<p className="text-sm text-muted">No extracted values for this rule. Run a transform first.</p>
)}
{selectedRule && !loading && allValues.length > 0 && (
<div className="overflow-x-auto">
{/* Bulk assign bar */}
{selected.size > 0 && (
<div className="flex items-center gap-2 mb-2 p-2 bg-blue-50 border border-blue-200 rounded flex-wrap">
<span className="text-xs text-blue-700 font-medium whitespace-nowrap">{selected.size} selected</span>
<div className="flex items-center gap-2 mb-2 p-2 bg-accent-soft border border-accent-line rounded flex-wrap">
<span className="text-xs text-accent font-medium whitespace-nowrap">{selected.size} selected</span>
{cols.map(col => (
<AutocompleteInput
key={col}
className="border border-blue-300 rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-blue-500 bg-white"
className="border border-accent-line rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-accent bg-surface"
placeholder={col}
value={bulkDraft[col] || ''}
onChange={v => setBulkDraft(d => ({ ...d, [col]: v }))}
@ -483,15 +483,15 @@ export default function Mappings({ source, onNeedsReprocess }) {
</button>
<button
onClick={() => { setSelected(new Set()); setBulkDraft({}) }}
className="text-xs text-blue-400 hover:text-blue-600"
className="text-xs text-accent hover:text-accent"
>
cancel
</button>
</div>
)}
<table className="w-full text-xs bg-white border border-gray-200 rounded">
<table className="w-full text-xs bg-surface border border-line rounded">
<thead>
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50">
<tr className="text-left text-muted border-b border-line-soft bg-raised">
<th className="px-2 py-2 w-6">
<input
type="checkbox"
@ -511,7 +511,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
{extraCols.map((col, idx) => (
<th key={`extra-${idx}`} className="px-3 py-2 font-medium">
<input
className="border border-gray-200 rounded px-1 py-0.5 w-24 focus:outline-none focus:border-blue-400 font-normal"
className="border border-line rounded px-1 py-0.5 w-24 focus:outline-none focus:border-accent font-normal"
value={col}
placeholder="new key"
onChange={e => setExtraCols(ec => { const c = [...ec]; c[idx] = e.target.value; return c })}
@ -521,7 +521,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
<th className="px-2 py-2">
<button
onClick={() => setExtraCols(ec => [...ec, ''])}
className="text-gray-400 hover:text-gray-700 font-medium"
className="text-muted hover:text-ink-soft font-medium"
title="Add column"
>+</button>
</th>
@ -536,7 +536,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
const isSaving = saving[k]
const isSelected = selected.has(k)
const hasDraft = !!(drafts[k] && Object.keys(drafts[k]).length > 0)
const rowBg = isSelected ? 'bg-blue-50' : hasDraft ? 'bg-blue-50' : row.is_mapped ? '' : 'bg-yellow-50'
const rowBg = isSelected ? 'bg-accent-soft' : hasDraft ? 'bg-accent-soft' : row.is_mapped ? '' : 'bg-warn-soft'
function handleRowClick(e) {
if (e.target.closest('input,button,a,select')) return
@ -571,7 +571,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
key={k}
ref={el => rowRefs.current[k] = el}
tabIndex={0}
className={`border-t border-gray-50 hover:bg-gray-50 cursor-pointer outline-none ${rowBg}`}
className={`border-t border-line-soft hover:bg-raised cursor-pointer outline-none ${rowBg}`}
onClick={handleRowClick}
onKeyDown={handleRowKeyDown}
>
@ -586,13 +586,13 @@ export default function Mappings({ source, onNeedsReprocess }) {
}}
/>
</td>
<td className="px-3 py-1.5 font-mono text-gray-800 whitespace-nowrap">{displayValue(row.extracted_value)}</td>
<td className="px-3 py-1.5 text-right text-gray-400">{row.record_count}</td>
<td className="px-3 py-1.5 font-mono text-ink whitespace-nowrap">{displayValue(row.extracted_value)}</td>
<td className="px-3 py-1.5 text-right text-muted">{row.record_count}</td>
{cols.map(col => (
<td key={col} className="px-3 py-1.5">
<AutocompleteInput
className={`border rounded px-2 py-1 w-full min-w-24 focus:outline-none focus:border-blue-400 ${
hasDraft ? 'border-blue-300' : row.is_mapped ? 'border-gray-200' : 'border-yellow-300'
className={`border rounded px-2 py-1 w-full min-w-24 focus:outline-none focus:border-accent ${
hasDraft ? 'border-accent-line' : row.is_mapped ? 'border-line' : 'border-warn-line'
}`}
value={cellVal(col)}
onChange={v => setCellValue(row.extracted_value, col, v)}
@ -605,7 +605,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
<td className="px-3 py-1.5 whitespace-nowrap">
{samples.length > 0 && (
<button
className="text-blue-400 hover:text-blue-600"
className="text-accent hover:text-accent"
onClick={() => setSampleOpen(s => ({ ...s, [k]: !s[k] }))}
>
{sampleOpen[k] ? 'hide' : 'show'}
@ -624,7 +624,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
{row.is_mapped && (
<button
onClick={() => deleteRow(row)}
className="text-red-400 hover:text-red-600 text-base leading-none"
className="text-danger hover:text-danger text-base leading-none"
title="Remove mapping"
>×</button>
)}
@ -634,21 +634,21 @@ export default function Mappings({ source, onNeedsReprocess }) {
{sampleOpen[k] && (() => {
const sampleCols = [...new Set(samples.flatMap(r => Object.keys(r)))]
return (
<tr key={`${k}-sample`} className="border-t border-gray-50 bg-gray-50">
<tr key={`${k}-sample`} className="border-t border-line-soft bg-raised">
<td colSpan={3 + cols.length + 4} className="px-3 py-2">
<table className="w-full text-xs border border-gray-100 rounded bg-white">
<table className="w-full text-xs border border-line-soft rounded bg-surface">
<thead>
<tr className="bg-gray-50 border-b border-gray-100">
<tr className="bg-raised border-b border-line-soft">
{sampleCols.map(c => (
<th key={c} className="px-2 py-1 text-left font-medium text-gray-400 whitespace-nowrap">{c}</th>
<th key={c} className="px-2 py-1 text-left font-medium text-muted whitespace-nowrap">{c}</th>
))}
</tr>
</thead>
<tbody>
{samples.map((rec, i) => (
<tr key={i} className="border-t border-gray-50">
<tr key={i} className="border-t border-line-soft">
{sampleCols.map(c => (
<td key={c} className="px-2 py-1 font-mono text-gray-600 whitespace-nowrap">
<td key={c} className="px-2 py-1 font-mono text-ink-soft whitespace-nowrap">
{rec[c] != null ? String(rec[c]) : ''}
</td>
))}

View File

@ -370,7 +370,7 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
viewer.restore({ table: selectedView, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG })
}
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
const cols = inspectedRows?.length ? Object.keys(inspectedRows[0]) : []
@ -416,24 +416,24 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
<div className="w-full h-full flex flex-col">
{/* Layouts sub-bar */}
<div className="flex items-center gap-2 px-3 h-9 bg-white border-b border-gray-200 shrink-0 text-xs">
<div className="flex items-center gap-2 px-3 h-9 bg-surface border-b border-line shrink-0 text-xs">
{layouts.map(l => (
<div key={l.id}
onClick={() => applyLayout(l)}
className={`flex items-center gap-1 rounded px-2 py-0.5 cursor-pointer border transition-colors
${activeLayoutId === l.id
? 'bg-blue-50 border-blue-300 text-blue-700'
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-400'}`}>
? 'bg-accent-soft border-accent-line text-accent'
: 'bg-surface border-line text-ink-soft hover:border-line'}`}>
{l.layout_name}
<button
onClick={(e) => handleDelete(l, e)}
className="text-gray-300 hover:text-red-400 leading-none ml-0.5 text-sm">×</button>
className="text-muted hover:text-danger leading-none ml-0.5 text-sm">×</button>
</div>
))}
{activeLayoutId !== null && !showSaveAs && (
<button onClick={handleSaveOver}
className="text-blue-500 hover:text-blue-700 border border-blue-200 rounded px-2 py-0.5">
className="text-accent hover:text-accent border border-accent-line rounded px-2 py-0.5">
Save
</button>
)}
@ -446,27 +446,27 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
onChange={e => setSaveAsName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }}
placeholder="Layout name…"
className="border border-gray-300 rounded px-2 py-0.5 w-36 focus:outline-none focus:border-blue-400"
className="border border-line rounded px-2 py-0.5 w-36 focus:outline-none focus:border-accent"
/>
<button onClick={handleSaveAs} className="text-blue-600 hover:text-blue-800 px-1">Save</button>
<button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-gray-400 hover:text-gray-600 px-1">Cancel</button>
<button onClick={handleSaveAs} className="text-accent hover:text-accent px-1">Save</button>
<button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-muted hover:text-ink-soft px-1">Cancel</button>
</div>
) : (
<button
onClick={() => setShowSaveAs(true)}
className="text-gray-400 hover:text-gray-600 border border-dashed border-gray-200 rounded px-2 py-0.5">
className="text-muted hover:text-ink-soft border border-dashed border-line rounded px-2 py-0.5">
+ Save as
</button>
)}
{activeLayoutId !== null && (
<button onClick={handleResetToDefault} className="text-gray-300 hover:text-gray-500 ml-1">reset</button>
<button onClick={handleResetToDefault} className="text-muted hover:text-muted ml-1">reset</button>
)}
{layoutMsg && <span className="text-green-600 ml-1">{layoutMsg}</span>}
{layoutMsg && <span className="text-ok ml-1">{layoutMsg}</span>}
<div className="ml-auto flex items-center gap-1">
<span className="text-gray-400">depth:</span>
<span className="text-muted">depth:</span>
{[0, 1, 2, 3].map(d => (
<button key={d} onClick={async () => {
const v = viewerRef.current; if (!v) return
@ -475,7 +475,7 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
const p = await v.getPlugin()
await p.draw(view)
expandDepthRef.current = d
}} className="border border-gray-200 rounded px-1.5 py-0.5 text-gray-500 hover:border-gray-400">
}} className="border border-line rounded px-1.5 py-0.5 text-muted hover:border-line">
{d}
</button>
))}
@ -486,18 +486,18 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
<div className="relative flex-1 flex min-h-0">
<div className="relative flex-1">
{status === 'loading' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
<p className="text-sm text-gray-400">Loading</p>
<div className="absolute inset-0 flex items-center justify-center z-10 bg-raised">
<p className="text-sm text-muted">Loading</p>
</div>
)}
{status === 'error' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
<p className="text-sm text-red-500">Error: {error}</p>
<div className="absolute inset-0 flex items-center justify-center z-10 bg-raised">
<p className="text-sm text-danger">Error: {error}</p>
</div>
)}
{status === 'noview' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
<p className="text-sm text-gray-400">No view data generate a view and transform records first.</p>
<div className="absolute inset-0 flex items-center justify-center z-10 bg-raised">
<p className="text-sm text-muted">No view data generate a view and transform records first.</p>
</div>
)}
<perspective-viewer
@ -509,7 +509,7 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
{inspectedRows && clickDetail && (
<div
style={{ width: paneWidth }}
className="relative border-l border-gray-200 bg-white flex flex-col overflow-hidden flex-shrink-0"
className="relative border-l border-line bg-surface flex flex-col overflow-hidden flex-shrink-0"
>
{/* Drag-to-resize handle on left edge */}
<div
@ -529,27 +529,27 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
/>
{/* Header: breadcrumb + row count + controls */}
<div className="flex items-center justify-between pl-3 pr-2 py-2 border-b border-gray-100 flex-shrink-0">
<div className="flex items-center justify-between pl-3 pr-2 py-2 border-b border-line-soft flex-shrink-0">
<div className="flex items-center gap-2 min-w-0">
{cellCoords.length > 0 && (
<span className="text-xs text-gray-700 font-mono font-semibold truncate">
<span className="text-xs text-ink-soft font-mono font-semibold truncate">
{cellCoords.join(' ')}
</span>
)}
<span className="text-xs text-gray-400 flex-shrink-0">
<span className="text-xs text-muted flex-shrink-0">
{inspectedRows.length} row{inspectedRows.length !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-0.5">
<button onClick={() => setDecimals(d => Math.max(0, d - 1))}
className="text-xs text-gray-400 hover:text-gray-600 w-4 text-center"></button>
<span className="text-xs text-gray-400 w-4 text-center">{decimals}</span>
className="text-xs text-muted hover:text-ink-soft w-4 text-center"></button>
<span className="text-xs text-muted w-4 text-center">{decimals}</span>
<button onClick={() => setDecimals(d => Math.min(8, d + 1))}
className="text-xs text-gray-400 hover:text-gray-600 w-4 text-center">+</button>
className="text-xs text-muted hover:text-ink-soft w-4 text-center">+</button>
</div>
<button onClick={() => { setInspectedRows(null); setClickDetail(null); lastClickKeyRef.current = null }}
className="text-gray-300 hover:text-gray-500 leading-none text-lg">×</button>
className="text-muted hover:text-muted leading-none text-lg">×</button>
</div>
</div>
@ -558,10 +558,10 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
{(() => {
const userFilters = (clickDetail.eventFilters || []).filter(([f]) => !coordFields.has(f))
return userFilters.length > 0 ? (
<div className="px-3 py-2 border-b border-gray-100">
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">Filters</div>
<div className="px-3 py-2 border-b border-line-soft">
<div className="text-xs text-muted uppercase tracking-wide mb-1">Filters</div>
{userFilters.map((f, i) => (
<div key={i} className="text-xs text-gray-500 py-0.5 font-mono">{f.join(' ')}</div>
<div key={i} className="text-xs text-muted py-0.5 font-mono">{f.join(' ')}</div>
))}
</div>
) : null
@ -572,13 +572,13 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
<div className="overflow-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50 sticky top-0">
<tr className="text-left text-muted border-b border-line-soft bg-raised sticky top-0">
{cols.map(c => {
const active = sortCol === c
return (
<th key={c}
onClick={() => { if (active) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSortCol(c); setSortDir('asc') } }}
className="px-2 py-1 font-medium whitespace-nowrap cursor-pointer select-none hover:text-gray-600">
className="px-2 py-1 font-medium whitespace-nowrap cursor-pointer select-none hover:text-ink-soft">
{c}{active ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''}
</th>
)
@ -587,12 +587,12 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
</thead>
<tbody>
{sortedRows.map((row, i) => (
<tr key={i} className="border-t border-gray-50 hover:bg-gray-50">
<tr key={i} className="border-t border-line-soft hover:bg-raised">
{cols.map(c => {
const f = formatVal(row[c], decimals)
return (
<td key={c} className="px-2 py-1 font-mono whitespace-nowrap text-gray-700 max-w-40 truncate">
{f == null ? <span className="text-gray-300"></span> : f}
<td key={c} className="px-2 py-1 font-mono whitespace-nowrap text-ink-soft max-w-40 truncate">
{f == null ? <span className="text-muted"></span> : f}
</td>
)
})}
@ -601,7 +601,7 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
</tbody>
{Object.keys(totals).length > 0 && (
<tfoot>
<tr className="border-t-2 border-gray-200 bg-gray-50 font-semibold text-gray-700 sticky bottom-0">
<tr className="border-t-2 border-line bg-raised font-semibold text-ink-soft sticky bottom-0">
{cols.map(c => (
<td key={c} className="px-2 py-1 font-mono whitespace-nowrap text-right">
{totals[c] != null ? formatVal(totals[c], decimals) : ''}

View File

@ -49,10 +49,10 @@ function AutocompleteInput({ value, onChange, onEnter, suggestions = [], classNa
{open && filtered.length > 0 && dropPos && (
<div ref={listRef}
style={{ position: 'fixed', top: dropPos.top, left: dropPos.left, minWidth: dropPos.minWidth, zIndex: 9999 }}
className="bg-white border border-gray-200 rounded shadow-lg max-h-40 overflow-y-auto">
className="bg-surface border border-line rounded shadow-lg max-h-40 overflow-y-auto">
{filtered.map((s, i) => (
<div key={s}
className={`px-2 py-1 text-xs cursor-pointer whitespace-nowrap ${i === highlighted ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'}`}
className={`px-2 py-1 text-xs cursor-pointer whitespace-nowrap ${i === highlighted ? 'bg-accent-soft text-accent' : 'text-ink-soft hover:bg-raised'}`}
onMouseDown={e => { e.preventDefault(); select(s) }}>{s}</div>
))}
</div>
@ -283,7 +283,7 @@ export default function Records({ source }) {
}
}
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
const displayCols = gridCols(rows.length > 0 ? Object.keys(rows[0]) : cols)
const visCols = gridCols(cols)
@ -300,42 +300,42 @@ export default function Records({ source }) {
<div className="flex h-full min-h-0 overflow-hidden">
<div className="flex-1 overflow-auto p-6 min-w-0">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold text-gray-800">Records {source}</h1>
<h1 className="text-xl font-semibold text-ink">Records {source}</h1>
{exists && rows.length > 0 && (
<span className="text-xs text-gray-400 font-mono">dfv.{source}</span>
<span className="text-xs text-muted font-mono">dfv.{source}</span>
)}
</div>
{/* Filter bar */}
{exists !== false && visCols.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2 items-center">
<span className="text-xs text-gray-400 font-medium mr-1">DB query:</span>
<span className="text-xs text-muted font-medium mr-1">DB query:</span>
{filters.map((f, i) => (
<div key={i} className="flex items-center gap-1 bg-white border border-gray-200 rounded px-2 py-1">
<div key={i} className="flex items-center gap-1 bg-surface border border-line rounded px-2 py-1">
<select
className="text-xs text-gray-600 border-0 focus:outline-none bg-transparent"
className="text-xs text-ink-soft border-0 focus:outline-none bg-transparent"
value={f.col}
onChange={e => updateFilter(i, 'col', e.target.value)}
>
{visCols.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<span className="text-xs text-gray-300 mx-0.5">~*</span>
<span className="text-xs text-muted mx-0.5">~*</span>
<input
className="text-xs font-mono border-0 focus:outline-none w-36 bg-transparent"
placeholder="regex…"
value={f.pattern}
onChange={e => updateFilter(i, 'pattern', e.target.value)}
/>
<button onClick={() => removeFilter(i)} className="text-gray-300 hover:text-gray-500 ml-1 leading-none">×</button>
<button onClick={() => removeFilter(i)} className="text-muted hover:text-muted ml-1 leading-none">×</button>
</div>
))}
<button onClick={addFilter}
className="text-xs text-gray-400 hover:text-gray-600 border border-dashed border-gray-200 rounded px-2 py-1">
className="text-xs text-muted hover:text-ink-soft border border-dashed border-line rounded px-2 py-1">
+ filter
</button>
{filters.length > 0 && (
<button onClick={() => { setFilters([]); setOffset(0); load(0, sort.col, sort.dir, []) }}
className="text-xs text-gray-400 hover:text-red-500">clear</button>
className="text-xs text-muted hover:text-danger">clear</button>
)}
</div>
)}
@ -343,24 +343,24 @@ export default function Records({ source }) {
{/* Bulk select + override bar */}
{exists && visCols.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2 items-center">
<span className="text-xs text-gray-400 font-medium mr-1">Bulk select:</span>
<span className="text-xs text-muted font-medium mr-1">Bulk select:</span>
<input
className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-blue-400 ${
rowFilter ? 'border-blue-300' : 'border-gray-200'
className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-accent ${
rowFilter ? 'border-accent-line' : 'border-line'
}`}
placeholder="regex on loaded rows…"
value={rowFilter}
onChange={e => setRowFilter(e.target.value)}
/>
{rowFilter && (
<span className="text-xs text-gray-400">{selected.size} of {rows.length} rows selected</span>
<span className="text-xs text-muted">{selected.size} of {rows.length} rows selected</span>
)}
{selected.size > 0 && (
<div className="flex items-center gap-2 ml-4 p-2 bg-blue-50 border border-blue-200 rounded flex-wrap">
<div className="flex items-center gap-2 ml-4 p-2 bg-accent-soft border border-accent-line rounded flex-wrap">
{allOverrideCols.map(col => (
<AutocompleteInput
key={col}
className="border border-blue-300 rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-blue-500 bg-white"
className="border border-accent-line rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-accent bg-surface"
placeholder={col}
value={bulkDraft[col] || ''}
onChange={v => setBulkDraft(d => ({ ...d, [col]: v }))}
@ -395,7 +395,7 @@ export default function Records({ source }) {
</button>
<button
onClick={() => { setSelected(new Set()); setBulkDraft({}); setRowFilter('') }}
className="text-xs text-blue-400 hover:text-blue-600"
className="text-xs text-accent hover:text-accent"
>
cancel
</button>
@ -404,25 +404,25 @@ export default function Records({ source }) {
</div>
)}
{loading && <p className="text-sm text-gray-400">Loading</p>}
{!loading && viewError && <p className="text-sm text-red-500">View error: {viewError} check field types in Sources.</p>}
{loading && <p className="text-sm text-muted">Loading</p>}
{!loading && viewError && <p className="text-sm text-danger">View error: {viewError} check field types in Sources.</p>}
{!loading && exists === false && (
<p className="text-sm text-gray-400">
No view generated yet. Go to <span className="font-medium text-gray-600">Sources</span>, check fields as <span className="font-medium text-gray-600">In view</span>, then click <span className="font-medium text-gray-600">Generate view</span>.
<p className="text-sm text-muted">
No view generated yet. Go to <span className="font-medium text-ink-soft">Sources</span>, check fields as <span className="font-medium text-ink-soft">In view</span>, then click <span className="font-medium text-ink-soft">Generate view</span>.
</p>
)}
{!loading && exists && rows.length === 0 && (
<p className="text-sm text-gray-400">
<p className="text-sm text-muted">
{filters.some(f => f.col && f.pattern) ? 'No records match the current filters.' : 'View exists but no transformed records yet. Import data and run a transform first.'}
</p>
)}
{!loading && exists && rows.length > 0 && (
<>
<div className="bg-white border border-gray-200 rounded overflow-auto mb-4">
<div className="bg-surface border border-line rounded overflow-auto mb-4">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-400 border-b border-gray-100 bg-gray-50">
<tr className="text-left text-xs text-muted border-b border-line-soft bg-raised">
<th className="px-2 py-2 w-8">
<input
type="checkbox"
@ -438,9 +438,9 @@ export default function Records({ source }) {
const active = sort.col === col
return (
<th key={col} onClick={() => toggleSort(col)}
className="px-3 py-2 font-medium whitespace-nowrap cursor-pointer select-none hover:text-gray-600">
className="px-3 py-2 font-medium whitespace-nowrap cursor-pointer select-none hover:text-ink-soft">
{col}
<span className="ml-1 text-gray-300">{active ? (sort.dir === 'asc' ? '▲' : '▼') : '⇅'}</span>
<span className="ml-1 text-muted">{active ? (sort.dir === 'asc' ? '▲' : '▼') : '⇅'}</span>
</th>
)
})}
@ -453,8 +453,8 @@ export default function Records({ source }) {
const isPanelSelected = selectedRow?.id != null && selectedRow.id === row.id
return (
<tr key={i} onClick={() => openPanel(row)}
className={`border-t border-gray-50 cursor-pointer transition-colors
${isPanelSelected ? 'bg-blue-50' : isRowSelected ? 'bg-blue-50' : isOverridden ? 'bg-amber-50 hover:bg-amber-100' : 'hover:bg-gray-50'}`}>
className={`border-t border-line-soft cursor-pointer transition-colors
${isPanelSelected ? 'bg-accent-soft' : isRowSelected ? 'bg-accent-soft' : isOverridden ? 'bg-warn-soft hover:bg-warn-soft' : 'hover:bg-raised'}`}>
<td className="px-2 py-2">
<input
type="checkbox"
@ -469,8 +469,8 @@ export default function Records({ source }) {
{displayCols.map((col, j) => {
const formatted = formatVal(row[col])
return (
<td key={j} className="px-3 py-2 text-xs text-gray-600 whitespace-nowrap max-w-48 truncate">
{formatted === null ? <span className="text-gray-300"></span> : formatted}
<td key={j} className="px-3 py-2 text-xs text-ink-soft whitespace-nowrap max-w-48 truncate">
{formatted === null ? <span className="text-muted"></span> : formatted}
</td>
)
})}
@ -481,12 +481,12 @@ export default function Records({ source }) {
</table>
</div>
<div className="flex items-center gap-3 text-sm text-gray-500">
<div className="flex items-center gap-3 text-sm text-muted">
<button onClick={prev} disabled={offset === 0}
className="px-3 py-1 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-40"> Prev</button>
className="px-3 py-1 border border-line rounded hover:bg-raised disabled:opacity-40"> Prev</button>
<span>{offset + 1}{offset + rows.length}</span>
<button onClick={next} disabled={rows.length < LIMIT}
className="px-3 py-1 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-40">Next </button>
className="px-3 py-1 border border-line rounded hover:bg-raised disabled:opacity-40">Next </button>
</div>
</>
)}
@ -494,58 +494,58 @@ export default function Records({ source }) {
{/* Panel */}
{panelOpen && (
<div className="w-80 border-l border-gray-200 bg-white flex flex-col overflow-hidden flex-shrink-0">
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-100">
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wide">Record</span>
<button onClick={closePanel} className="text-gray-300 hover:text-gray-500 leading-none text-lg">×</button>
<div className="w-80 border-l border-line bg-surface flex flex-col overflow-hidden flex-shrink-0">
<div className="flex items-center justify-between px-3 py-2 border-b border-line-soft">
<span className="text-xs font-semibold text-ink-soft uppercase tracking-wide">Record</span>
<button onClick={closePanel} className="text-muted hover:text-muted leading-none text-lg">×</button>
</div>
{panelLoading && <p className="text-xs text-gray-400 p-3">Loading</p>}
{panelLoading && <p className="text-xs text-muted p-3">Loading</p>}
{selectedRecord && !panelLoading && (
<div className="flex-1 overflow-y-auto flex flex-col min-h-0">
{panelMsg && (
<div className={`text-xs px-3 py-2 border-b border-gray-100 ${panelMsg.ok ? 'text-green-600' : 'text-red-500'}`}>
<div className={`text-xs px-3 py-2 border-b border-line-soft ${panelMsg.ok ? 'text-ok' : 'text-danger'}`}>
{panelMsg.text}
</div>
)}
{/* Raw fields — read only */}
<div className="border-b border-gray-100">
<div className="px-3 py-1.5 bg-gray-50 border-b border-gray-100">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Raw</span>
<div className="border-b border-line-soft">
<div className="px-3 py-1.5 bg-raised border-b border-line-soft">
<span className="text-xs font-medium text-muted uppercase tracking-wide">Raw</span>
</div>
{Object.entries(selectedRecord.data || {}).map(([field, val]) => (
<div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-gray-50 first:border-t-0">
<span className="text-xs font-mono text-gray-400 w-28 shrink-0 truncate">{field}</span>
<span className="text-xs font-mono text-gray-500 truncate">{formatVal(val) ?? <span className="text-gray-300"></span>}</span>
<div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-line-soft first:border-t-0">
<span className="text-xs font-mono text-muted w-28 shrink-0 truncate">{field}</span>
<span className="text-xs font-mono text-muted truncate">{formatVal(val) ?? <span className="text-muted"></span>}</span>
</div>
))}
</div>
{/* Transformed fields — read only delta */}
<div className="border-b border-gray-100">
<div className="px-3 py-1.5 bg-gray-50 border-b border-gray-100">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Transformed</span>
<div className="border-b border-line-soft">
<div className="px-3 py-1.5 bg-raised border-b border-line-soft">
<span className="text-xs font-medium text-muted uppercase tracking-wide">Transformed</span>
</div>
{Object.entries(selectedRecord.transformed || {}).filter(([k]) => !HIDDEN_COLS.has(k)).length === 0
? <div className="px-3 py-2 text-xs text-gray-300">No rule output yet.</div>
? <div className="px-3 py-2 text-xs text-muted">No rule output yet.</div>
: Object.entries(selectedRecord.transformed || {}).filter(([k]) => !HIDDEN_COLS.has(k)).map(([field, val]) => (
<div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-gray-50 first:border-t-0">
<span className="text-xs font-mono text-gray-400 w-28 shrink-0 truncate">{field}</span>
<span className="text-xs font-mono text-blue-600 truncate">{formatVal(val) ?? <span className="text-gray-300"></span>}</span>
<div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-line-soft first:border-t-0">
<span className="text-xs font-mono text-muted w-28 shrink-0 truncate">{field}</span>
<span className="text-xs font-mono text-accent truncate">{formatVal(val) ?? <span className="text-muted"></span>}</span>
</div>
))
}
</div>
{/* Overrides — editable */}
<div className="flex-1 border-b border-gray-100">
<div className="flex items-center justify-between px-3 py-1.5 bg-gray-50 border-b border-gray-100">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Overrides</span>
<div className="flex-1 border-b border-line-soft">
<div className="flex items-center justify-between px-3 py-1.5 bg-raised border-b border-line-soft">
<span className="text-xs font-medium text-muted uppercase tracking-wide">Overrides</span>
<button
onClick={() => setExtraCols(ec => [...ec, ''])}
className="text-gray-400 hover:text-gray-700 font-medium text-sm leading-none"
className="text-muted hover:text-ink-soft font-medium text-sm leading-none"
title="Add field">+</button>
</div>
<table className="w-full text-xs">
@ -559,14 +559,14 @@ export default function Records({ source }) {
const placeholder = formatVal(selectedRecord.transformed?.[col]) ?? ''
const suggestions = [...(globalValues[col] || [])].sort()
return (
<tr key={col} className="border-t border-gray-50">
<tr key={col} className="border-t border-line-soft">
<td className="px-3 py-1.5 w-28 shrink-0">
<span className="font-mono text-gray-500 truncate block">{col}</span>
<span className="font-mono text-muted truncate block">{col}</span>
</td>
<td className="px-1 py-1.5">
<AutocompleteInput
className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${
override ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-gray-200 text-gray-600'
override ? 'border-warn-line bg-warn-soft text-warn' : 'border-line text-ink-soft'
}`}
value={override}
placeholder={placeholder}
@ -579,7 +579,7 @@ export default function Records({ source }) {
{override && (
<button
onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })}
className="text-gray-300 hover:text-red-400 leading-none text-base">×</button>
className="text-muted hover:text-danger leading-none text-base">×</button>
)}
</td>
</tr>
@ -589,10 +589,10 @@ export default function Records({ source }) {
const val = overrideDraft[col] ?? ''
const suggestions = [...(globalValues[col] || [])].sort()
return (
<tr key={`extra-${i}`} className="border-t border-gray-50">
<tr key={`extra-${i}`} className="border-t border-line-soft">
<td className="px-3 py-1.5 w-28 shrink-0">
<input
className="w-full text-xs font-mono border border-gray-200 rounded px-1 py-0.5 focus:outline-none focus:border-blue-400"
className="w-full text-xs font-mono border border-line rounded px-1 py-0.5 focus:outline-none focus:border-accent"
value={col}
placeholder="field name"
onChange={e => {
@ -610,7 +610,7 @@ export default function Records({ source }) {
<td className="px-1 py-1.5">
<AutocompleteInput
className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${
val ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-gray-200 text-gray-600'
val ? 'border-warn-line bg-warn-soft text-warn' : 'border-line text-ink-soft'
}`}
value={val}
onChange={v => setOverrideDraft(d => ({ ...d, [col]: v }))}
@ -622,7 +622,7 @@ export default function Records({ source }) {
{val && (
<button
onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })}
className="text-gray-300 hover:text-red-400 leading-none text-base">×</button>
className="text-muted hover:text-danger leading-none text-base">×</button>
)}
</td>
</tr>
@ -632,7 +632,7 @@ export default function Records({ source }) {
</table>
</div>
<div className="flex gap-2 px-3 py-2 border-t border-gray-100 shrink-0">
<div className="flex gap-2 px-3 py-2 border-t border-line-soft shrink-0">
<button
onClick={handleSaveOverrides}
disabled={panelSaving || !isDirty}
@ -643,7 +643,7 @@ export default function Records({ source }) {
<button
onClick={handleClearOverrides}
disabled={panelSaving}
className="text-xs border border-gray-200 rounded px-3 py-1.5 text-gray-500 hover:border-red-300 hover:text-red-500 disabled:opacity-40">
className="text-xs border border-line rounded px-3 py-1.5 text-muted hover:border-danger-line hover:text-danger disabled:opacity-40">
Clear
</button>
)}

View File

@ -73,8 +73,8 @@ export default function Remap() {
}
return (
<div className="p-6 max-w-4xl">
<h1 className="text-base font-semibold text-gray-800 mb-4">Remap Output Values</h1>
<div className="p-4 sm:p-6 max-w-4xl">
<h1 className="text-base font-semibold text-ink mb-4">Remap Output Values</h1>
{/* Search */}
<form onSubmit={handleSearch} className="flex items-center gap-2 mb-5">
@ -83,7 +83,7 @@ export default function Remap() {
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search output values…"
className="text-sm border border-gray-300 rounded px-3 py-1.5 w-72 focus:outline-none focus:border-blue-400"
className="text-sm border border-line rounded px-3 py-1.5 w-72 focus:outline-none focus:border-accent"
/>
<button type="submit" disabled={searching}
className="text-sm bg-blue-600 text-white rounded px-3 py-1.5 hover:bg-blue-700 disabled:opacity-50">
@ -95,15 +95,15 @@ export default function Remap() {
{results !== null && (
<div className="mb-6">
{results.length === 0 ? (
<p className="text-sm text-gray-400">No matching output values found.</p>
<p className="text-sm text-muted">No matching output values found.</p>
) : (
<>
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">
<div className="text-xs text-muted uppercase tracking-wide mb-1">
{results.length} result{results.length !== 1 ? 's' : ''} click one to remap
</div>
<table className="w-full text-sm border border-gray-200 rounded overflow-hidden">
<table className="w-full text-sm border border-line rounded overflow-hidden">
<thead>
<tr className="bg-gray-50 text-left text-xs text-gray-400 uppercase tracking-wide">
<tr className="bg-raised text-left text-xs text-muted uppercase tracking-wide">
<th className="px-3 py-2">Field</th>
<th className="px-3 py-2">Value</th>
<th className="px-3 py-2 text-right">Mappings</th>
@ -115,11 +115,11 @@ export default function Remap() {
return (
<tr key={i}
onClick={() => handleSelect(r)}
className={`border-t border-gray-100 cursor-pointer transition-colors
${isActive ? 'bg-blue-50' : 'hover:bg-gray-50'}`}>
<td className="px-3 py-2 font-mono text-gray-500">{r.col}</td>
<td className="px-3 py-2 font-mono text-gray-800">{r.val}</td>
<td className="px-3 py-2 text-right text-gray-400">{r.mapping_count}</td>
className={`border-t border-line-soft cursor-pointer transition-colors
${isActive ? 'bg-accent-soft' : 'hover:bg-raised'}`}>
<td className="px-3 py-2 font-mono text-muted">{r.col}</td>
<td className="px-3 py-2 font-mono text-ink">{r.val}</td>
<td className="px-3 py-2 text-right text-muted">{r.mapping_count}</td>
</tr>
)
})}
@ -132,25 +132,25 @@ export default function Remap() {
{/* Remap panel */}
{selected && (
<div className="border border-gray-200 rounded p-4 mb-6 bg-white">
<div className="text-xs text-gray-400 uppercase tracking-wide mb-3">
Remap <span className="font-mono text-gray-600">{selected.col}</span>
<div className="border border-line rounded p-4 mb-6 bg-surface">
<div className="text-xs text-muted uppercase tracking-wide mb-3">
Remap <span className="font-mono text-ink-soft">{selected.col}</span>
</div>
<div className="flex items-center gap-3 mb-4">
<div className="flex-1">
<div className="text-xs text-gray-400 mb-1">From</div>
<div className="text-sm font-mono bg-gray-50 border border-gray-200 rounded px-3 py-1.5 text-gray-700">
<div className="text-xs text-muted mb-1">From</div>
<div className="text-sm font-mono bg-raised border border-line rounded px-3 py-1.5 text-ink-soft">
{selected.val}
</div>
</div>
<div className="text-gray-300 mt-4"></div>
<div className="text-muted mt-4"></div>
<div className="flex-1">
<div className="text-xs text-gray-400 mb-1">To</div>
<div className="text-xs text-muted mb-1">To</div>
<input
value={toVal}
onChange={e => setToVal(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleApply()}
className="w-full text-sm font-mono border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:border-blue-400"
className="w-full text-sm font-mono border border-line rounded px-3 py-1.5 focus:outline-none focus:border-accent"
/>
</div>
<div className="mt-4">
@ -164,22 +164,22 @@ export default function Remap() {
</div>
{msg && (
<div className={`text-sm mb-3 ${msg.ok ? 'text-green-600' : 'text-red-500'}`}>
<div className={`text-sm mb-3 ${msg.ok ? 'text-ok' : 'text-danger'}`}>
{msg.text}
</div>
)}
{/* Affected mappings */}
{loadingMatches ? (
<p className="text-xs text-gray-400">Loading</p>
<p className="text-xs text-muted">Loading</p>
) : matches && matches.length > 0 && (
<div>
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">
<div className="text-xs text-muted uppercase tracking-wide mb-1">
Affected mappings
</div>
<table className="w-full text-xs border border-gray-100 rounded overflow-hidden">
<table className="w-full text-xs border border-line-soft rounded overflow-hidden">
<thead>
<tr className="bg-gray-50 text-left text-gray-400">
<tr className="bg-raised text-left text-muted">
<th className="px-2 py-1">Source</th>
<th className="px-2 py-1">Rule</th>
<th className="px-2 py-1">Input</th>
@ -188,15 +188,15 @@ export default function Remap() {
</thead>
<tbody>
{matches.map(m => (
<tr key={m.id} className="border-t border-gray-50">
<td className="px-2 py-1 font-mono text-gray-500">{m.source_name}</td>
<td className="px-2 py-1 font-mono text-gray-500">{m.rule_name}</td>
<td className="px-2 py-1 font-mono text-gray-700">
<tr key={m.id} className="border-t border-line-soft">
<td className="px-2 py-1 font-mono text-muted">{m.source_name}</td>
<td className="px-2 py-1 font-mono text-muted">{m.rule_name}</td>
<td className="px-2 py-1 font-mono text-ink-soft">
{typeof m.input_value === 'string' ? m.input_value : JSON.stringify(m.input_value)}
</td>
<td className="px-2 py-1 font-mono text-gray-700">
<td className="px-2 py-1 font-mono text-ink-soft">
{Object.entries(m.output).map(([k, v]) => (
<span key={k} className={k === selected.col ? 'text-blue-600 font-semibold' : ''}>
<span key={k} className={k === selected.col ? 'text-accent font-semibold' : ''}>
{k}: {v}{' '}
</span>
))}

View File

@ -7,27 +7,27 @@ function PreviewModal({ rows, onClose }) {
const matched = rows.filter(r => r.extracted_value != null).length
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-lg shadow-xl w-3/4 max-w-3xl max-h-[80vh] flex flex-col"
<div className="bg-surface rounded-lg shadow-xl w-3/4 max-w-3xl max-h-[80vh] flex flex-col"
onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
<span className="text-sm font-medium text-gray-700">
Pattern results <span className="text-gray-500 font-normal">{matched}/{rows.length} matched</span>
<div className="flex items-center justify-between px-5 py-3 border-b border-line-soft">
<span className="text-sm font-medium text-ink-soft">
Pattern results <span className="text-muted font-normal">{matched}/{rows.length} matched</span>
</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none"></button>
<button onClick={onClose} className="text-muted hover:text-ink-soft text-lg leading-none"></button>
</div>
<div className="overflow-auto flex-1 px-5 py-3">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-gray-400 border-b border-gray-100">
<tr className="text-left text-muted border-b border-line-soft">
<th className="pb-2 font-medium w-1/2 pr-4">Raw value</th>
<th className="pb-2 font-medium">Result</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-t border-gray-50">
<td className="py-1 font-mono text-gray-400 pr-4 break-all">{r.raw_value}</td>
<td className={`py-1 font-mono break-all ${r.extracted_value != null ? 'text-gray-800' : 'text-gray-300'}`}>
<tr key={i} className="border-t border-line-soft">
<td className="py-1 font-mono text-muted pr-4 break-all">{r.raw_value}</td>
<td className={`py-1 font-mono break-all ${r.extracted_value != null ? 'text-ink' : 'text-muted'}`}>
{r.extracted_value != null
? (Array.isArray(r.extracted_value) ? r.extracted_value.join(' · ') : String(r.extracted_value))
: '—'}
@ -67,33 +67,33 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS
}, [form.field, form.pattern, form.flags, form.function_type, form.replace_value, source])
return (
<div className="bg-white border border-gray-200 rounded p-4 mb-4">
<h2 className="text-sm font-semibold text-gray-700 mb-3">{editing ? 'Edit rule' : 'New rule'}</h2>
<div className="bg-surface border border-line rounded p-4 mb-4">
<h2 className="text-sm font-semibold text-ink-soft mb-3">{editing ? 'Edit rule' : 'New rule'}</h2>
<form onSubmit={onSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-500 block mb-1">Rule name</label>
<label className="text-xs text-muted block mb-1">Rule name</label>
<input
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder="e.g. First 20"
/>
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">Sequence</label>
<label className="text-xs text-muted block mb-1">Sequence</label>
<input
type="number"
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={form.sequence} onChange={e => setForm(f => ({ ...f, sequence: parseInt(e.target.value) || 0 }))}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-500 block mb-1">Input field</label>
<label className="text-xs text-muted block mb-1">Input field</label>
{fields.length > 0 ? (
<select
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={form.field} onChange={e => setForm(f => ({ ...f, field: e.target.value }))}
>
<option value=""> select field </option>
@ -101,34 +101,34 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS
</select>
) : (
<input
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={form.field} onChange={e => setForm(f => ({ ...f, field: e.target.value }))}
placeholder="e.g. description"
/>
)}
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">Output field</label>
<label className="text-xs text-muted block mb-1">Output field</label>
<input
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={form.output_field} onChange={e => setForm(f => ({ ...f, output_field: e.target.value }))}
placeholder="e.g. merchant"
/>
</div>
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">Pattern (regex)</label>
<label className="text-xs text-muted block mb-1">Pattern (regex)</label>
<input
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400"
className="w-full border border-line rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-accent"
value={form.pattern} onChange={e => setForm(f => ({ ...f, pattern: e.target.value }))}
placeholder="e.g. .{1,20}"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-500 block mb-1">Function</label>
<label className="text-xs text-muted block mb-1">Function</label>
<select
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={form.function_type} onChange={e => setForm(f => ({ ...f, function_type: e.target.value }))}
>
<option value="extract">extract</option>
@ -136,16 +136,16 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS
</select>
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">Flags</label>
<label className="text-xs text-muted block mb-1">Flags</label>
<input
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400"
className="w-full border border-line rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-accent"
value={form.flags} onChange={e => setForm(f => ({ ...f, flags: e.target.value }))}
placeholder="e.g. i"
/>
</div>
</div>
{form.function_type === 'extract' && (
<label className="flex items-center gap-2 text-xs text-gray-600 cursor-pointer select-none">
<label className="flex items-center gap-2 text-xs text-ink-soft cursor-pointer select-none">
<input
type="checkbox"
checked={!!form.retain}
@ -156,9 +156,9 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS
)}
{form.function_type === 'replace' && (
<div>
<label className="text-xs text-gray-500 block mb-1">Replacement string</label>
<label className="text-xs text-muted block mb-1">Replacement string</label>
<input
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400"
className="w-full border border-line rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-accent"
value={form.replace_value} onChange={e => setForm(f => ({ ...f, replace_value: e.target.value }))}
placeholder="e.g. leave blank to delete the match"
/>
@ -166,23 +166,23 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS
)}
{/* Live preview */}
{(preview.length > 0 || previewing) && (
<div className="border border-gray-100 rounded p-2 bg-gray-50">
<div className="border border-line-soft rounded p-2 bg-raised">
<div className="flex items-center justify-between mb-1">
<p className="text-xs text-gray-400">
<p className="text-xs text-muted">
{previewing ? 'Testing…' : `${preview.filter(r => r.extracted_value != null).length}/${preview.length} matched`}
</p>
{!previewing && preview.length > 0 && (
<button type="button" onClick={() => setModalOpen(true)}
className="text-xs text-blue-400 hover:text-blue-600">expand</button>
className="text-xs text-accent hover:text-accent">expand</button>
)}
</div>
{!previewing && (
<table className="w-full text-xs">
<tbody>
{preview.slice(0, 5).map((r, i) => (
<tr key={i} className="border-t border-gray-100 first:border-0">
<td className="py-0.5 font-mono text-gray-400 truncate max-w-0 w-1/2 pr-3">{r.raw_value}</td>
<td className={`py-0.5 font-mono truncate ${r.extracted_value != null ? 'text-gray-800' : 'text-gray-300'}`}>
<tr key={i} className="border-t border-line-soft first:border-0">
<td className="py-0.5 font-mono text-muted truncate max-w-0 w-1/2 pr-3">{r.raw_value}</td>
<td className={`py-0.5 font-mono truncate ${r.extracted_value != null ? 'text-ink' : 'text-muted'}`}>
{r.extracted_value != null
? (Array.isArray(r.extracted_value) ? r.extracted_value.join(' · ') : String(r.extracted_value))
: '—'}
@ -197,14 +197,14 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS
{modalOpen && <PreviewModal rows={preview} onClose={() => setModalOpen(false)} />}
{error && <p className="text-xs text-red-500">{error}</p>}
{error && <p className="text-xs text-danger">{error}</p>}
<div className="flex gap-2">
<button type="submit" disabled={loading}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
{loading ? 'Saving…' : (editing ? 'Save' : 'Create')}
</button>
<button type="button" onClick={onCancel}
className="text-sm text-gray-500 px-3 py-1.5 rounded hover:bg-gray-100">
className="text-sm text-muted px-3 py-1.5 rounded hover:bg-raised">
Cancel
</button>
</div>
@ -310,12 +310,12 @@ export default function Rules({ source, onStale }) {
}
}
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
return (
<div className="p-6 max-w-3xl">
<div className="p-4 sm:p-6 max-w-3xl">
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-semibold text-gray-800">Rules {source}</h1>
<h1 className="text-xl font-semibold text-ink">Rules {source}</h1>
<button onClick={startCreate}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700">
New rule
@ -332,17 +332,17 @@ export default function Rules({ source, onStale }) {
)}
{rules.length === 0 && !creating && (
<p className="text-sm text-gray-400">No rules yet. Add a regex rule to start extracting values.</p>
<p className="text-sm text-muted">No rules yet. Add a regex rule to start extracting values.</p>
)}
<div className="space-y-2">
{rules.map(rule => {
const isExpanded = expanded === rule.id
return (
<div key={rule.id} className="bg-white border border-gray-200 rounded">
<div key={rule.id} className="bg-surface border border-line rounded">
{/* Header — always visible, click to expand/collapse */}
<div
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-gray-50 select-none"
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-raised select-none"
onClick={() => {
if (isExpanded) { setExpanded(null); setEditing(null) }
else { setExpanded(rule.id); startEdit(rule) }
@ -350,33 +350,33 @@ export default function Rules({ source, onStale }) {
>
<button
onClick={e => { e.stopPropagation(); handleToggle(rule) }}
className={`w-8 h-4 rounded-full flex-shrink-0 transition-colors ${rule.enabled ? 'bg-blue-500' : 'bg-gray-200'}`}
className={`w-8 h-4 rounded-full flex-shrink-0 transition-colors ${rule.enabled ? 'bg-blue-500' : 'bg-raised'}`}
title={rule.enabled ? 'Disable' : 'Enable'}
/>
<div className="flex-1 min-w-0">
<span className="font-medium text-gray-800 text-sm">{rule.name}</span>
<span className="text-gray-400 text-xs ml-2">seq {rule.sequence}</span>
<span className="font-medium text-ink text-sm">{rule.name}</span>
<span className="text-muted text-xs ml-2">seq {rule.sequence}</span>
{!isExpanded && (
<div className="text-xs text-gray-400 mt-0.5 truncate">
<div className="text-xs text-muted mt-0.5 truncate">
<span className="font-mono">{rule.field}</span>
<span className="mx-1"></span>
<span className="font-mono bg-gray-50 px-1 rounded">{rule.pattern}</span>
{rule.flags && <span className="text-blue-400 ml-1">/{rule.flags}</span>}
<span className="font-mono bg-raised px-1 rounded">{rule.pattern}</span>
{rule.flags && <span className="text-accent ml-1">/{rule.flags}</span>}
<span className="mx-1"></span>
<span className="font-mono">{rule.output_field}</span>
{rule.function_type === 'replace' && <span className="ml-1 text-orange-400">(replace)</span>}
{rule.function_type === 'replace' && <span className="ml-1 text-warn">(replace)</span>}
</div>
)}
</div>
<span className="text-xs text-gray-300 flex-shrink-0">{isExpanded ? '▲' : '▼'}</span>
<span className="text-xs text-muted flex-shrink-0">{isExpanded ? '▲' : '▼'}</span>
</div>
{/* Expanded content */}
{isExpanded && (
<div className="border-t border-gray-100">
<div className="border-t border-line-soft">
<div className="px-4 pt-3 pb-1 flex justify-end">
<button onClick={e => { e.stopPropagation(); handleDelete(rule.id) }}
className="text-xs text-red-400 hover:text-red-600">Delete</button>
className="text-xs text-danger hover:text-danger">Delete</button>
</div>
<div className="px-4 pb-4">
<FormPanel

View File

@ -0,0 +1,424 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { api } from '../api'
import Section from '../components/Section.jsx'
import SampleTable from '../components/SampleTable.jsx'
const FIELD_TYPES = ['text', 'numeric', 'date']
export default function SourceDetail({ sources, setSources }) {
const { name: source } = useParams()
const navigate = useNavigate()
const [constraintFields, setConstraintFields] = useState('')
const [globalPicklist, setGlobalPicklist] = useState(true)
const [schemaFields, setSchemaFields] = useState([])
const [stats, setStats] = useState(null)
const [sampleRows, setSampleRows] = useState([])
const [saving, setSaving] = useState(false)
const [reprocessing, setReprocessing] = useState(false)
const [generating, setGenerating] = useState(false)
const [result, setResult] = useState('')
const [error, setError] = useState('')
const [viewName, setViewName] = useState('')
const [availableFields, setAvailableFields] = useState([])
const [fieldSort, setFieldSort] = useState({ col: 'key', dir: 'asc' })
const [bridgeAccounts, setBridgeAccounts] = useState(null)
const [bridgeLoading, setBridgeLoading] = useState(false)
const [bridgeError, setBridgeError] = useState('')
const sourceObj = sources.find(s => s.name === source)
useEffect(() => {
if (!sourceObj) return
setConstraintFields(sourceObj.constraint_fields?.join(', ') || '')
setGlobalPicklist(sourceObj.global_picklist !== false)
setSchemaFields((sourceObj.config?.fields || []).map((f, i) => ({ seq: i + 1, ...f })))
setViewName(sourceObj.config?.fields?.length ? `dfv.${sourceObj.name}` : '')
setResult('')
setError('')
setStats(null)
setAvailableFields([])
setSampleRows([])
setBridgeAccounts(null)
setBridgeError('')
api.getStats(sourceObj.name).then(setStats).catch(() => {})
api.getFields(sourceObj.name).then(setAvailableFields).catch(() => {})
api.getRecords(sourceObj.name, 50).then(rows => setSampleRows(rows.map(r => r.data).filter(Boolean))).catch(() => {})
}, [source, sourceObj?.name])
async function handleSave(e) {
e.preventDefault()
setSaving(true)
setError('')
try {
const constraint_fields = constraintFields.split(',').map(s => s.trim()).filter(Boolean)
const fields = [...schemaFields.filter(f => f.name)].sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
const config = { ...(sourceObj.config || {}), fields }
await api.updateSource(sourceObj.name, { constraint_fields, config, global_picklist: globalPicklist })
if (fields.length > 0) {
const res = await api.generateView(sourceObj.name)
if (res.success) setViewName(res.view)
}
const updated = await api.getSources()
setSources(updated)
setResult('Saved.')
} catch (err) {
setError(err.message)
} finally {
setSaving(false)
}
}
async function handleGenerateView() {
setGenerating(true)
setResult('')
setError('')
try {
const constraint_fields = constraintFields.split(',').map(s => s.trim()).filter(Boolean)
const fields = [...schemaFields.filter(f => f.name)].sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
const config = { ...(sourceObj.config || {}), fields }
await api.updateSource(sourceObj.name, { constraint_fields, config, global_picklist: globalPicklist })
const res = await api.generateView(sourceObj.name)
if (res.success) {
setViewName(res.view)
setResult(`View created: ${res.view}`)
} else {
setError(res.error)
}
} catch (err) {
setError(err.message)
} finally {
setGenerating(false)
}
}
async function handleReprocess() {
if (!confirm(`Reprocess all records for "${sourceObj.name}"? This will clear and reapply all transformations.`)) return
setReprocessing(true)
setResult('')
setError('')
try {
const res = await api.reprocess(sourceObj.name)
setResult(`Reprocessed ${res.transformed} records.`)
api.getStats(sourceObj.name).then(setStats).catch(() => {})
} catch (err) {
setError(err.message)
} finally {
setReprocessing(false)
}
}
async function handleDelete() {
if (!confirm(`Delete source "${sourceObj.name}" and all its data?`)) return
try {
await api.deleteSource(sourceObj.name)
setSources(await api.getSources())
navigate('/sources')
} catch (err) {
alert(err.message)
}
}
// The bridge is an external call, so accounts are fetched on demand rather
// than on every visit to this page.
async function loadBridgeAccounts() {
setBridgeLoading(true)
setBridgeError('')
try {
const res = await api.getSimpleFinAccounts()
setBridgeAccounts(res.accounts || [])
if (res.errors?.length) setBridgeError(res.errors.join('; '))
} catch (err) {
setBridgeError(err.message)
} finally {
setBridgeLoading(false)
}
}
// Writes only config constraint_fields and global_picklist are left NULL so
// update_source keeps whatever is already stored.
async function handleLinkAccount(accountId) {
setError('')
setResult('')
try {
const config = { ...(sourceObj.config || {}) }
if (accountId) {
config.simplefin = { ...(config.simplefin || {}), account_id: accountId }
} else {
delete config.simplefin
}
await api.updateSource(sourceObj.name, { config })
setSources(await api.getSources())
setResult(accountId ? 'Account linked.' : 'Account unlinked.')
} catch (err) {
setError(err.message)
}
}
if (!sourceObj) return <div className="p-4 sm:p-6 text-sm text-muted">Source not found.</div>
return (
<div className="p-4 sm:p-6 max-w-5xl space-y-4">
{stats && (
<div className="flex gap-4 text-xs">
<span className="text-muted"><span className="font-medium text-ink">{stats.total_records}</span> total</span>
<span className="text-muted"><span className="font-medium text-ink">{stats.transformed_records}</span> transformed</span>
<span className="text-muted"><span className="font-medium text-ink">{stats.pending_records}</span> pending</span>
</div>
)}
{/* Bank feed — link this source to a SimpleFIN account */}
<Section
title="Connection"
description="Where this source gets its data. Unlinked sources are filled by CSV upload on the Import page."
>
<div className="flex items-center gap-3 flex-wrap">
{bridgeAccounts === null ? (
<>
<span className="text-xs text-muted font-mono">
{sourceObj.config?.simplefin?.account_id || 'not linked'}
</span>
<button
onClick={loadBridgeAccounts}
disabled={bridgeLoading}
className="text-xs border border-line rounded px-2 py-1 text-ink-soft hover:bg-raised hover:border-line disabled:opacity-50"
>
{bridgeLoading ? 'Loading…' : sourceObj.config?.simplefin?.account_id ? 'Change' : 'Link SimpleFIN account'}
</button>
</>
) : (
<select
value={sourceObj.config?.simplefin?.account_id || ''}
onChange={e => handleLinkAccount(e.target.value)}
className="text-xs border border-line rounded px-2 py-1 bg-surface text-ink-soft"
>
<option value="">Not linked</option>
{bridgeAccounts.map(a => (
<option key={a.id} value={a.id}>
{a.name}{a.organization ? `${a.organization}` : ''}{a.balance ? ` (${a.balance})` : ''}
</option>
))}
</select>
)}
</div>
{bridgeError && <p className="text-xs text-warn mt-1">{bridgeError}</p>}
{/* Dedupe depends on the transaction id being the constraint key */}
{sourceObj.config?.simplefin?.account_id
&& sourceObj.constraint_fields?.join(',') !== 'id' && (
<p className="text-xs text-warn mt-1">
Constraint fields are {sourceObj.constraint_fields?.join(', ') || 'none'} a
bank feed should use id so re-syncs dont duplicate rows.
</p>
)}
</Section>
{/* Unified field table */}
{availableFields.length > 0 && (
<Section
title="Fields and view"
description="Every field seen in this source's records. Tick which identify a row for deduplication, and which become columns in the generated view."
>
<table className="w-full text-xs">
<thead>
<tr className="text-left text-muted border-b border-line-soft">
{[
{ col: 'key', label: 'Key' },
{ col: 'origin', label: 'Origin' },
{ col: 'type', label: 'Type' },
{ col: 'constraint', label: 'Constraint', center: true },
{ col: 'inview', label: 'In view', center: true },
{ col: 'seq', label: 'Seq', center: true },
].map(({ col, label, center }) => (
<th
key={col}
onClick={() => setFieldSort(s => ({ col, dir: s.col === col && s.dir === 'asc' ? 'desc' : 'asc' }))}
className={`pb-1 font-medium cursor-pointer select-none hover:text-ink-soft ${center ? 'text-center' : ''}`}
>
{label}
<span className="ml-1 text-muted">
{fieldSort.col === col ? (fieldSort.dir === 'asc' ? '▲' : '▼') : '⇅'}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{[...availableFields].sort((a, b) => {
const constraintList = constraintFields.split(',').map(s => s.trim())
const aSchema = schemaFields.find(sf => sf.name === a.key)
const bSchema = schemaFields.find(sf => sf.name === b.key)
let av, bv
if (fieldSort.col === 'key') { av = a.key; bv = b.key }
else if (fieldSort.col === 'origin') { av = a.origins.join(','); bv = b.origins.join(',') }
else if (fieldSort.col === 'type') { av = aSchema?.type || ''; bv = bSchema?.type || '' }
else if (fieldSort.col === 'constraint') { av = constraintList.includes(a.key) ? 0 : 1; bv = constraintList.includes(b.key) ? 0 : 1 }
else if (fieldSort.col === 'inview') { av = aSchema ? 0 : 1; bv = bSchema ? 0 : 1 }
else if (fieldSort.col === 'seq') { av = aSchema?.seq ?? 999; bv = bSchema?.seq ?? 999 }
if (av < bv) return fieldSort.dir === 'asc' ? -1 : 1
if (av > bv) return fieldSort.dir === 'asc' ? 1 : -1
return 0
}).map(f => {
const isRaw = f.origins.includes('raw')
const constraintChecked = constraintFields.split(',').map(s => s.trim()).includes(f.key)
const schemaEntry = schemaFields.find(sf => sf.name === f.key)
const inView = !!schemaEntry
return (
<tr key={f.key} className="border-t border-line-soft">
<td className="py-1 font-mono text-ink-soft">{f.key}</td>
<td className="py-1 text-muted">{f.origins.join(', ')}</td>
<td className="py-1">
{inView && (
<div className="flex gap-1 items-center">
<select
className="border border-line rounded px-1 py-0.5 text-xs focus:outline-none focus:border-accent"
value={schemaEntry.type}
onChange={e => setSchemaFields(sf =>
sf.map(s => s.name === f.key ? { ...s, type: e.target.value } : s)
)}
>
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<input
className="border border-line rounded px-1 py-0.5 text-xs font-mono w-32 focus:outline-none focus:border-accent"
value={schemaEntry.expression || ''}
placeholder="{field} * {sign}"
onChange={e => setSchemaFields(sf =>
sf.map(s => s.name === f.key ? { ...s, expression: e.target.value || undefined } : s)
)}
/>
</div>
)}
</td>
<td className="py-1 text-center">
{isRaw && (
<input
type="checkbox"
checked={constraintChecked}
onChange={e => {
const current = constraintFields.split(',').map(s => s.trim()).filter(Boolean)
const next = e.target.checked
? [...current, f.key]
: current.filter(k => k !== f.key)
setConstraintFields(next.join(', '))
}}
/>
)}
</td>
<td className="py-1 text-center">
<input
type="checkbox"
checked={inView}
onChange={e => {
if (e.target.checked) {
const nextSeq = schemaFields.length > 0
? Math.max(...schemaFields.map(s => s.seq ?? 0)) + 1
: 1
setSchemaFields(sf => [...sf, { name: f.key, type: 'text', seq: nextSeq }])
} else {
setSchemaFields(sf => sf.filter(s => s.name !== f.key))
}
}}
/>
</td>
<td className="py-1 text-center">
{inView && (
<input
type="number"
className="w-12 border border-line rounded px-1 py-0.5 text-xs text-center focus:outline-none focus:border-accent"
value={schemaEntry.seq ?? ''}
onChange={e => setSchemaFields(sf =>
sf.map(s => s.name === f.key ? { ...s, seq: parseInt(e.target.value) || 0 } : s)
)}
/>
)}
</td>
</tr>
)
})}
</tbody>
</table>
<div className="flex items-center gap-3 pt-3 mt-2 border-t border-line-soft flex-wrap">
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
<input type="checkbox" checked={globalPicklist} onChange={e => setGlobalPicklist(e.target.checked)} />
Global picklist
</label>
<form onSubmit={handleSave}>
<button type="submit" disabled={saving}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
{saving ? 'Saving…' : 'Save'}
</button>
</form>
{schemaFields.length > 0 && (
<>
<button
onClick={handleGenerateView}
disabled={generating}
className="text-xs bg-green-600 text-white px-2 py-1.5 rounded hover:bg-green-700 disabled:opacity-50"
>
{generating ? 'Generating…' : 'Generate view'}
</button>
{viewName && (
<code className="text-xs bg-raised px-2 py-1 rounded text-ink-soft">{viewName}</code>
)}
</>
)}
</div>
</Section>
)}
{/* Save button when no fields loaded yet */}
{availableFields.length === 0 && (
<Section
title="Fields and view"
description="No fields yet — they are discovered from imported records. Import or sync data first."
>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
<input type="checkbox" checked={globalPicklist} onChange={e => setGlobalPicklist(e.target.checked)} />
Global picklist
</label>
<form onSubmit={handleSave}>
<button type="submit" disabled={saving}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
{saving ? 'Saving…' : 'Save'}
</button>
</form>
</div>
</Section>
)}
{sampleRows.length > 0 && (
<Section title="Sample rows" description="The most recent imported records, as stored.">
<SampleTable rows={sampleRows} />
</Section>
)}
<Section title="Maintenance">
<div className="flex items-center gap-3">
<button
onClick={handleReprocess}
disabled={reprocessing}
className="text-sm bg-orange-500 text-white px-3 py-1.5 rounded hover:bg-orange-600 disabled:opacity-50"
>
{reprocessing ? 'Reprocessing…' : 'Reprocess all records'}
</button>
<span className="text-xs text-muted">Clears and reruns all transformation rules</span>
</div>
</Section>
{result && <p className="text-xs text-ok">{result}</p>}
{error && <p className="text-xs text-danger">{error}</p>}
<Section title="Delete source" description="Removes the source and every record, rule, and mapping belonging to it.">
<button onClick={handleDelete}
className="text-sm border border-danger-line text-danger px-3 py-1.5 rounded hover:bg-danger-soft hover:border-danger-line">
Delete source
</button>
</Section>
</div>
)
}

388
ui/src/pages/SourceList.jsx Normal file
View File

@ -0,0 +1,388 @@
import { useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import SampleTable from '../components/SampleTable.jsx'
const FIELD_TYPES = ['text', 'numeric', 'date']
// Ticked into the view by default when a bank feed sample contains them
const FEED_DEFAULT_VIEW = ['date', 'description', 'payee', 'amount']
export default function SourceList({ sources, setSources, setSource }) {
const navigate = useNavigate()
const [creating, setCreating] = useState(false)
const [form, setForm] = useState({ name: '', constraint_fields: '', fields: [], schema: [], importSample: true })
const [createError, setCreateError] = useState('')
const [createLoading, setCreateLoading] = useState(false)
const [csvFileName, setCsvFileName] = useState('')
const [bridgeAccounts, setBridgeAccounts] = useState(null)
const [bridgeLoading, setBridgeLoading] = useState(false)
const [bridgeError, setBridgeError] = useState('')
const [sampleInfo, setSampleInfo] = useState(null)
const fileRef = useRef()
async function loadBridgeAccounts() {
setBridgeLoading(true)
setBridgeError('')
try {
const res = await api.getSimpleFinAccounts()
setBridgeAccounts(res.accounts || [])
if (res.errors?.length) setBridgeError(res.errors.join('; '))
} catch (err) {
setBridgeError(err.message)
} finally {
setBridgeLoading(false)
}
}
// Writes only config constraint_fields and global_picklist are left NULL so
// update_source keeps whatever is already stored.
async function handleSelectFeedAccount(accountId) {
if (!accountId) {
// Clearing the feed only resets fields we populated, not a loaded CSV
setForm(f => csvFileName ? { ...f, simplefin_account_id: '' } : {
...f, simplefin_account_id: '', fields: [], schema: [], constraint_fields: '', sampleRows: [],
})
setSampleInfo(null)
return
}
setForm(f => ({ ...f, simplefin_account_id: accountId }))
setBridgeLoading(true)
setBridgeError('')
try {
const res = await api.getSimpleFinSample(accountId)
const names = res.fields.map(f => f.name)
setSampleInfo({ fetched: res.fetched, fields: res.fields.length })
if (res.errors?.length) setBridgeError(res.errors.join('; '))
setForm(f => ({
...f,
fields: res.fields,
sampleRows: res.sampleRows || [],
schema: FEED_DEFAULT_VIEW.filter(n => names.includes(n)).map((name, i) => ({
name, type: res.fields.find(sf => sf.name === name).type, seq: i + 1,
})),
// Only default the constraint if the sample actually has an id
constraint_fields: f.constraint_fields || (names.includes('id') ? 'id' : ''),
}))
} catch (err) {
setBridgeError(err.message)
} finally {
setBridgeLoading(false)
}
}
async function handleSuggest(e) {
const file = e.target.files[0]
if (!file) return
setCsvFileName(file.name)
try {
const suggestion = await api.suggestSource(file)
setForm(f => ({
...f,
fields: suggestion.fields,
constraint_fields: '',
schema: suggestion.fields.map(f => ({ name: f.name, type: f.type, seq: suggestion.fields.indexOf(f) + 1 })),
sampleRows: suggestion.sampleRows || []
}))
} catch (err) {
setCreateError(err.message)
}
}
async function handleCreate(e) {
e.preventDefault()
setCreateError('')
const constraintArr = form.constraint_fields.split(',').map(s => s.trim()).filter(Boolean)
if (!form.name || constraintArr.length === 0) {
setCreateError('Name and at least one constraint field required')
return
}
setCreateLoading(true)
try {
const config = form.schema.length > 0 ? { fields: form.schema } : {}
if (form.simplefin_account_id) {
config.simplefin = { account_id: form.simplefin_account_id }
}
await api.createSource({ name: form.name, constraint_fields: constraintArr, config, global_picklist: form.global_picklist !== false })
if (form.schema.length > 0) {
await api.generateView(form.name)
}
if (form.importSample && fileRef.current?.files[0]) {
await api.importCSV(form.name, fileRef.current.files[0])
}
const updated = await api.getSources()
setSources(updated)
setSource(form.name)
setForm({ name: '', constraint_fields: '', fields: [], schema: [], importSample: true, simplefin_account_id: '' })
setCreating(false)
} catch (err) {
setCreateError(err.message)
} finally {
setCreateLoading(false)
}
}
return (
<div className="p-4 sm:p-6 max-w-5xl">
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-semibold text-ink">Sources</h1>
{!creating && (
<button
onClick={() => { setCreating(true); setCreateError('') }}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700"
>
New source
</button>
)}
</div>
{!creating && sources.length === 0 && (
<p className="text-sm text-muted">No sources yet. Create one to get started.</p>
)}
{!creating && sources.length > 0 && (
<div className="bg-surface border border-line rounded divide-y divide-line-soft">
{sources.map(s => (
<button
key={s.name}
onClick={() => { setSource(s.name); navigate(`/sources/${encodeURIComponent(s.name)}`) }}
className="w-full text-left px-4 py-3 hover:bg-raised flex items-center gap-3"
>
<span className="text-sm font-medium text-ink flex-1">{s.name}</span>
{s.config?.simplefin?.account_id && (
<span className="text-xs bg-accent-soft text-accent border border-accent-line rounded px-1.5 py-0.5">
bank feed
</span>
)}
<span className="text-xs text-muted">
{(s.constraint_fields || []).join(', ') || 'no constraint'}
</span>
<span className="text-muted"></span>
</button>
))}
</div>
)}
{creating && (
<div className="bg-surface border border-line rounded p-4">
<h2 className="text-sm font-semibold text-ink-soft mb-3">New source</h2>
<div className="mb-4">
<input type="file" accept=".csv" ref={fileRef} onChange={handleSuggest} className="hidden" />
<button
type="button"
onClick={() => fileRef.current?.click()}
className="text-sm border border-line rounded px-3 py-1.5 text-ink-soft hover:bg-raised hover:border-line"
>
{csvFileName || 'Choose CSV…'}
</button>
</div>
<form onSubmit={handleCreate} className="space-y-3">
<div>
<label className="text-xs text-muted block mb-1">Source name</label>
<input
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder="e.g. chase, dcard"
/>
</div>
{/* Bank feed optional; picking an account defaults the constraint
field to the transaction id, which is what dedupe needs */}
<div>
<label className="text-xs text-muted block mb-1">Bank feed (optional)</label>
<div className="flex items-center gap-3 flex-wrap">
{bridgeAccounts === null ? (
<button
type="button"
onClick={loadBridgeAccounts}
disabled={bridgeLoading}
className="text-sm border border-line rounded px-3 py-1.5 text-ink-soft hover:bg-raised hover:border-line disabled:opacity-50"
>
{bridgeLoading ? 'Loading…' : 'Link SimpleFIN account…'}
</button>
) : (
<select
value={form.simplefin_account_id || ''}
onChange={e => handleSelectFeedAccount(e.target.value)}
className="text-sm border border-line rounded px-3 py-1.5 bg-surface text-ink-soft"
>
<option value="">No bank feed CSV import</option>
{bridgeAccounts.map(a => (
<option key={a.id} value={a.id}>
{a.name}{a.organization ? `${a.organization}` : ''}{a.balance ? ` (${a.balance})` : ''}
</option>
))}
</select>
)}
</div>
{bridgeError && <p className="text-xs text-warn mt-1">{bridgeError}</p>}
{form.simplefin_account_id && sampleInfo && (
<div className="mt-2 bg-accent-soft border border-accent-line rounded p-3 text-xs text-ink-soft space-y-1">
<p>
Read {sampleInfo.fetched} transaction{sampleInfo.fetched === 1 ? '' : 's'} from this
account and found {sampleInfo.fields} field{sampleInfo.fields === 1 ? '' : 's'}.
The table below lists what this account actually returns fields it never sends
won&rsquo;t appear.
</p>
{form.constraint_fields === 'id' && (
<p>
<span className="font-mono text-ink-soft">id</span> is checked as the constraint
field because it is SimpleFIN&rsquo;s own transaction identifier. Syncs pull an
overlapping window of days, so the same transaction arrives more than once
matching on <span className="font-mono text-ink-soft">id</span> skips the repeats
while still keeping genuinely separate charges that share a date, amount, and
description.
</p>
)}
{sampleInfo.fetched === 0 && (
<p className="text-warn">
No transactions came back, so there was nothing to infer fields from. Sync first,
then set the fields up here.
</p>
)}
</div>
)}
</div>
{form.fields.length > 0 && (
<div className="pt-2 border-t border-line-soft space-y-2">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-muted border-b border-line-soft">
<th className="pb-1 font-medium">Key</th>
<th className="pb-1 font-medium">Type</th>
<th className="pb-1 font-medium text-center">Constraint</th>
<th className="pb-1 font-medium text-center">In view</th>
<th className="pb-1 font-medium text-center">Seq</th>
</tr>
</thead>
<tbody>
{form.fields.map(f => {
const schemaEntry = form.schema.find(s => s.name === f.name)
const inView = !!schemaEntry
const currentType = schemaEntry?.type || f.type
return (
<tr key={f.name} className="border-t border-line-soft">
<td className="py-1 font-mono text-ink-soft">{f.name}</td>
<td className="py-1">
{inView && (
<select
className="border border-line rounded px-1 py-0.5 text-xs focus:outline-none focus:border-accent"
value={currentType}
onChange={e => setForm(ff => ({
...ff,
schema: ff.schema.map(s => s.name === f.name ? { ...s, type: e.target.value } : s)
}))}
>
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
)}
</td>
<td className="py-1 text-center">
<input
type="checkbox"
checked={form.constraint_fields.split(',').map(s => s.trim()).includes(f.name)}
onChange={e => {
const current = form.constraint_fields.split(',').map(s => s.trim()).filter(Boolean)
const next = e.target.checked
? [...current, f.name]
: current.filter(n => n !== f.name)
setForm(ff => ({ ...ff, constraint_fields: next.join(', ') }))
}}
/>
</td>
<td className="py-1 text-center">
<input
type="checkbox"
checked={inView}
onChange={e => {
if (e.target.checked) {
const nextSeq = form.schema.length > 0
? Math.max(...form.schema.map(s => s.seq ?? 0)) + 1
: 1
setForm(ff => ({ ...ff, schema: [...ff.schema, { name: f.name, type: f.type, seq: nextSeq }] }))
} else {
setForm(ff => ({ ...ff, schema: ff.schema.filter(s => s.name !== f.name) }))
}
}}
/>
</td>
<td className="py-1 text-center">
{inView && (
<input
type="number"
className="w-12 border border-line rounded px-1 py-0.5 text-xs text-center focus:outline-none focus:border-accent"
value={schemaEntry.seq ?? ''}
onChange={e => setForm(ff => ({
...ff,
schema: ff.schema.map(s => s.name === f.name ? { ...s, seq: parseInt(e.target.value) || 0 } : s)
}))}
/>
)}
</td>
</tr>
)
})}
</tbody>
</table>
<SampleTable rows={form.sampleRows || []} />
</div>
)}
{form.fields.length === 0 && (
<div>
<label className="text-xs text-muted block mb-1">Constraint fields (comma-separated)</label>
<input
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={form.constraint_fields}
onChange={e => setForm(f => ({ ...f, constraint_fields: e.target.value }))}
placeholder="e.g. date, amount, description"
/>
</div>
)}
<div className="flex gap-4">
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
<input
type="checkbox"
checked={form.global_picklist !== false}
onChange={e => setForm(f => ({ ...f, global_picklist: e.target.checked }))}
/>
Global picklist
</label>
{form.fields.length > 0 && (
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
<input
type="checkbox"
checked={form.importSample !== false}
onChange={e => setForm(f => ({ ...f, importSample: e.target.checked }))}
/>
Import sample data
</label>
)}
</div>
{createError && <p className="text-xs text-danger">{createError}</p>}
<div className="flex gap-2">
<button type="submit" disabled={createLoading}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
{createLoading ? 'Creating…' : 'Create'}
</button>
<button type="button"
onClick={() => { setCreating(false); setCreateError(''); setForm({ name: '', constraint_fields: '', fields: [], schema: [] }) }}
className="text-sm text-muted px-3 py-1.5 rounded hover:bg-raised">
Cancel
</button>
</div>
</form>
</div>
)}
</div>
)
}

View File

@ -1,590 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api } from '../api'
const FIELD_TYPES = ['text', 'numeric', 'date']
function SampleTable({ rows }) {
if (!rows || rows.length === 0) return null
const cols = Object.keys(rows[0])
return (
<div className="overflow-auto border border-gray-100 rounded bg-gray-50 max-h-36">
<table className="text-xs w-full">
<thead>
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50 sticky top-0">
{cols.map(c => <th key={c} className="px-2 py-1 font-medium whitespace-nowrap">{c}</th>)}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} className="border-t border-gray-100">
{cols.map(c => (
<td key={c} className="px-2 py-1 whitespace-nowrap text-gray-600 max-w-32 truncate font-mono">
{row[c] == null ? <span className="text-gray-300"></span> : String(row[c])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
export default function Sources({ source, sources, setSources, setSource }) {
const [constraintFields, setConstraintFields] = useState('')
const [globalPicklist, setGlobalPicklist] = useState(true)
const [schemaFields, setSchemaFields] = useState([])
const [stats, setStats] = useState(null)
const [sampleRows, setSampleRows] = useState([])
const [saving, setSaving] = useState(false)
const [reprocessing, setReprocessing] = useState(false)
const [generating, setGenerating] = useState(false)
const [result, setResult] = useState('')
const [error, setError] = useState('')
const [viewName, setViewName] = useState('')
const [availableFields, setAvailableFields] = useState([])
const [fieldSort, setFieldSort] = useState({ col: 'key', dir: 'asc' })
const [creating, setCreating] = useState(false)
const [form, setForm] = useState({ name: '', constraint_fields: '', fields: [], schema: [], importSample: true })
const [createError, setCreateError] = useState('')
const [createLoading, setCreateLoading] = useState(false)
const [csvFileName, setCsvFileName] = useState('')
const fileRef = useRef()
const [searchParams, setSearchParams] = useSearchParams()
const sourceObj = sources.find(s => s.name === source)
useEffect(() => {
if (searchParams.get('new') === '1') {
setCreating(true)
setSearchParams({})
}
}, [searchParams])
useEffect(() => {
if (!sourceObj) return
setConstraintFields(sourceObj.constraint_fields?.join(', ') || '')
setGlobalPicklist(sourceObj.global_picklist !== false)
setSchemaFields((sourceObj.config?.fields || []).map((f, i) => ({ seq: i + 1, ...f })))
setViewName(sourceObj.config?.fields?.length ? `dfv.${sourceObj.name}` : '')
setResult('')
setError('')
setStats(null)
setAvailableFields([])
setSampleRows([])
api.getStats(sourceObj.name).then(setStats).catch(() => {})
api.getFields(sourceObj.name).then(setAvailableFields).catch(() => {})
api.getRecords(sourceObj.name, 50).then(rows => setSampleRows(rows.map(r => r.data).filter(Boolean))).catch(() => {})
}, [source, sourceObj?.name])
async function handleSave(e) {
e.preventDefault()
setSaving(true)
setError('')
try {
const constraint_fields = constraintFields.split(',').map(s => s.trim()).filter(Boolean)
const fields = [...schemaFields.filter(f => f.name)].sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
const config = { ...(sourceObj.config || {}), fields }
await api.updateSource(sourceObj.name, { constraint_fields, config, global_picklist: globalPicklist })
if (fields.length > 0) {
const res = await api.generateView(sourceObj.name)
if (res.success) setViewName(res.view)
}
const updated = await api.getSources()
setSources(updated)
setResult('Saved.')
} catch (err) {
setError(err.message)
} finally {
setSaving(false)
}
}
async function handleGenerateView() {
setGenerating(true)
setResult('')
setError('')
try {
const constraint_fields = constraintFields.split(',').map(s => s.trim()).filter(Boolean)
const fields = [...schemaFields.filter(f => f.name)].sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
const config = { ...(sourceObj.config || {}), fields }
await api.updateSource(sourceObj.name, { constraint_fields, config, global_picklist: globalPicklist })
const res = await api.generateView(sourceObj.name)
if (res.success) {
setViewName(res.view)
setResult(`View created: ${res.view}`)
} else {
setError(res.error)
}
} catch (err) {
setError(err.message)
} finally {
setGenerating(false)
}
}
async function handleReprocess() {
if (!confirm(`Reprocess all records for "${sourceObj.name}"? This will clear and reapply all transformations.`)) return
setReprocessing(true)
setResult('')
setError('')
try {
const res = await api.reprocess(sourceObj.name)
setResult(`Reprocessed ${res.transformed} records.`)
api.getStats(sourceObj.name).then(setStats).catch(() => {})
} catch (err) {
setError(err.message)
} finally {
setReprocessing(false)
}
}
async function handleDelete() {
if (!confirm(`Delete source "${sourceObj.name}" and all its data?`)) return
try {
await api.deleteSource(sourceObj.name)
const updated = await api.getSources()
setSources(updated)
if (updated.length > 0) setSource(updated[0].name)
else setSource('')
} catch (err) {
alert(err.message)
}
}
async function handleSuggest(e) {
const file = e.target.files[0]
if (!file) return
setCsvFileName(file.name)
try {
const suggestion = await api.suggestSource(file)
setForm(f => ({
...f,
fields: suggestion.fields,
constraint_fields: '',
schema: suggestion.fields.map(f => ({ name: f.name, type: f.type, seq: suggestion.fields.indexOf(f) + 1 })),
sampleRows: suggestion.sampleRows || []
}))
} catch (err) {
setCreateError(err.message)
}
}
async function handleCreate(e) {
e.preventDefault()
setCreateError('')
const constraintArr = form.constraint_fields.split(',').map(s => s.trim()).filter(Boolean)
if (!form.name || constraintArr.length === 0) {
setCreateError('Name and at least one constraint field required')
return
}
setCreateLoading(true)
try {
const config = form.schema.length > 0 ? { fields: form.schema } : {}
await api.createSource({ name: form.name, constraint_fields: constraintArr, config, global_picklist: form.global_picklist !== false })
if (form.schema.length > 0) {
await api.generateView(form.name)
}
if (form.importSample && fileRef.current?.files[0]) {
await api.importCSV(form.name, fileRef.current.files[0])
}
const updated = await api.getSources()
setSources(updated)
setSource(form.name)
setForm({ name: '', constraint_fields: '', fields: [], schema: [], importSample: true })
setCreating(false)
} catch (err) {
setCreateError(err.message)
} finally {
setCreateLoading(false)
}
}
return (
<div className="p-6 max-w-5xl">
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-semibold text-gray-800">
{sourceObj ? sourceObj.name : 'Sources'}
</h1>
<button
onClick={() => { setCreating(true); setCreateError('') }}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700"
>
New source
</button>
</div>
{/* No source selected */}
{!sourceObj && !creating && (
<p className="text-sm text-gray-400">No sources yet. Create one to get started.</p>
)}
{/* Source detail */}
{sourceObj && !creating && (
<div className="space-y-4">
{/* Stats */}
{stats && (
<div className="flex gap-4 text-xs">
<span className="text-gray-500"><span className="font-medium text-gray-800">{stats.total_records}</span> total</span>
<span className="text-gray-500"><span className="font-medium text-gray-800">{stats.transformed_records}</span> transformed</span>
<span className="text-gray-500"><span className="font-medium text-gray-800">{stats.pending_records}</span> pending</span>
</div>
)}
{/* Unified field table */}
{availableFields.length > 0 && (
<div className="pt-2 border-t border-gray-100 space-y-2">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-gray-400 border-b border-gray-100">
{[
{ col: 'key', label: 'Key' },
{ col: 'origin', label: 'Origin' },
{ col: 'type', label: 'Type' },
{ col: 'constraint', label: 'Constraint', center: true },
{ col: 'inview', label: 'In view', center: true },
{ col: 'seq', label: 'Seq', center: true },
].map(({ col, label, center }) => (
<th
key={col}
onClick={() => setFieldSort(s => ({ col, dir: s.col === col && s.dir === 'asc' ? 'desc' : 'asc' }))}
className={`pb-1 font-medium cursor-pointer select-none hover:text-gray-600 ${center ? 'text-center' : ''}`}
>
{label}
<span className="ml-1 text-gray-300">
{fieldSort.col === col ? (fieldSort.dir === 'asc' ? '▲' : '▼') : '⇅'}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{[...availableFields].sort((a, b) => {
const constraintList = constraintFields.split(',').map(s => s.trim())
const aSchema = schemaFields.find(sf => sf.name === a.key)
const bSchema = schemaFields.find(sf => sf.name === b.key)
let av, bv
if (fieldSort.col === 'key') { av = a.key; bv = b.key }
else if (fieldSort.col === 'origin') { av = a.origins.join(','); bv = b.origins.join(',') }
else if (fieldSort.col === 'type') { av = aSchema?.type || ''; bv = bSchema?.type || '' }
else if (fieldSort.col === 'constraint') { av = constraintList.includes(a.key) ? 0 : 1; bv = constraintList.includes(b.key) ? 0 : 1 }
else if (fieldSort.col === 'inview') { av = aSchema ? 0 : 1; bv = bSchema ? 0 : 1 }
else if (fieldSort.col === 'seq') { av = aSchema?.seq ?? 999; bv = bSchema?.seq ?? 999 }
if (av < bv) return fieldSort.dir === 'asc' ? -1 : 1
if (av > bv) return fieldSort.dir === 'asc' ? 1 : -1
return 0
}).map(f => {
const isRaw = f.origins.includes('raw')
const constraintChecked = constraintFields.split(',').map(s => s.trim()).includes(f.key)
const schemaEntry = schemaFields.find(sf => sf.name === f.key)
const inView = !!schemaEntry
return (
<tr key={f.key} className="border-t border-gray-50">
<td className="py-1 font-mono text-gray-700">{f.key}</td>
<td className="py-1 text-gray-400">{f.origins.join(', ')}</td>
<td className="py-1">
{inView && (
<div className="flex gap-1 items-center">
<select
className="border border-gray-200 rounded px-1 py-0.5 text-xs focus:outline-none focus:border-blue-400"
value={schemaEntry.type}
onChange={e => setSchemaFields(sf =>
sf.map(s => s.name === f.key ? { ...s, type: e.target.value } : s)
)}
>
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<input
className="border border-gray-200 rounded px-1 py-0.5 text-xs font-mono w-32 focus:outline-none focus:border-blue-400"
value={schemaEntry.expression || ''}
placeholder="{field} * {sign}"
onChange={e => setSchemaFields(sf =>
sf.map(s => s.name === f.key ? { ...s, expression: e.target.value || undefined } : s)
)}
/>
</div>
)}
</td>
<td className="py-1 text-center">
{isRaw && (
<input
type="checkbox"
checked={constraintChecked}
onChange={e => {
const current = constraintFields.split(',').map(s => s.trim()).filter(Boolean)
const next = e.target.checked
? [...current, f.key]
: current.filter(k => k !== f.key)
setConstraintFields(next.join(', '))
}}
/>
)}
</td>
<td className="py-1 text-center">
<input
type="checkbox"
checked={inView}
onChange={e => {
if (e.target.checked) {
const nextSeq = schemaFields.length > 0
? Math.max(...schemaFields.map(s => s.seq ?? 0)) + 1
: 1
setSchemaFields(sf => [...sf, { name: f.key, type: 'text', seq: nextSeq }])
} else {
setSchemaFields(sf => sf.filter(s => s.name !== f.key))
}
}}
/>
</td>
<td className="py-1 text-center">
{inView && (
<input
type="number"
className="w-12 border border-gray-200 rounded px-1 py-0.5 text-xs text-center focus:outline-none focus:border-blue-400"
value={schemaEntry.seq ?? ''}
onChange={e => setSchemaFields(sf =>
sf.map(s => s.name === f.key ? { ...s, seq: parseInt(e.target.value) || 0 } : s)
)}
/>
)}
</td>
</tr>
)
})}
</tbody>
</table>
<div className="flex items-center gap-3 pt-1 flex-wrap">
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer">
<input type="checkbox" checked={globalPicklist} onChange={e => setGlobalPicklist(e.target.checked)} />
Global picklist
</label>
<form onSubmit={handleSave}>
<button type="submit" disabled={saving}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
{saving ? 'Saving…' : 'Save'}
</button>
</form>
{schemaFields.length > 0 && (
<>
<button
onClick={handleGenerateView}
disabled={generating}
className="text-xs bg-green-600 text-white px-2 py-1.5 rounded hover:bg-green-700 disabled:opacity-50"
>
{generating ? 'Generating…' : 'Generate view'}
</button>
{viewName && (
<code className="text-xs bg-gray-100 px-2 py-1 rounded text-gray-600">{viewName}</code>
)}
</>
)}
</div>
<SampleTable rows={sampleRows} />
</div>
)}
{/* Save button when no fields loaded yet */}
{availableFields.length === 0 && (
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer">
<input type="checkbox" checked={globalPicklist} onChange={e => setGlobalPicklist(e.target.checked)} />
Global picklist
</label>
<form onSubmit={handleSave}>
<button type="submit" disabled={saving}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
{saving ? 'Saving…' : 'Save'}
</button>
</form>
</div>
)}
{/* Reprocess */}
<div className="flex items-center gap-3 pt-2 border-t border-gray-100">
<button
onClick={handleReprocess}
disabled={reprocessing}
className="text-sm bg-orange-500 text-white px-3 py-1.5 rounded hover:bg-orange-600 disabled:opacity-50"
>
{reprocessing ? 'Reprocessing…' : 'Reprocess all records'}
</button>
<span className="text-xs text-gray-400">Clears and reruns all transformation rules</span>
</div>
{result && <p className="text-xs text-green-600">{result}</p>}
{error && <p className="text-xs text-red-500">{error}</p>}
<div className="pt-2 border-t border-gray-100">
<button onClick={handleDelete} className="text-xs text-red-400 hover:text-red-600">
Delete source
</button>
</div>
</div>
)}
{/* Create form */}
{creating && (
<div className="bg-white border border-gray-200 rounded p-4">
<h2 className="text-sm font-semibold text-gray-700 mb-3">New source</h2>
<div className="mb-4">
<input type="file" accept=".csv" ref={fileRef} onChange={handleSuggest} className="hidden" />
<button
type="button"
onClick={() => fileRef.current?.click()}
className="text-sm border border-gray-300 rounded px-3 py-1.5 text-gray-600 hover:bg-gray-50 hover:border-gray-400"
>
{csvFileName || 'Choose CSV…'}
</button>
</div>
<form onSubmit={handleCreate} className="space-y-3">
<div>
<label className="text-xs text-gray-500 block mb-1">Source name</label>
<input
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder="e.g. chase, dcard"
/>
</div>
{form.fields.length > 0 && (
<div className="pt-2 border-t border-gray-100 space-y-2">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-gray-400 border-b border-gray-100">
<th className="pb-1 font-medium">Key</th>
<th className="pb-1 font-medium">Type</th>
<th className="pb-1 font-medium text-center">Constraint</th>
<th className="pb-1 font-medium text-center">In view</th>
<th className="pb-1 font-medium text-center">Seq</th>
</tr>
</thead>
<tbody>
{form.fields.map(f => {
const schemaEntry = form.schema.find(s => s.name === f.name)
const inView = !!schemaEntry
const currentType = schemaEntry?.type || f.type
return (
<tr key={f.name} className="border-t border-gray-50">
<td className="py-1 font-mono text-gray-700">{f.name}</td>
<td className="py-1">
{inView && (
<select
className="border border-gray-200 rounded px-1 py-0.5 text-xs focus:outline-none focus:border-blue-400"
value={currentType}
onChange={e => setForm(ff => ({
...ff,
schema: ff.schema.map(s => s.name === f.name ? { ...s, type: e.target.value } : s)
}))}
>
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
)}
</td>
<td className="py-1 text-center">
<input
type="checkbox"
checked={form.constraint_fields.split(',').map(s => s.trim()).includes(f.name)}
onChange={e => {
const current = form.constraint_fields.split(',').map(s => s.trim()).filter(Boolean)
const next = e.target.checked
? [...current, f.name]
: current.filter(n => n !== f.name)
setForm(ff => ({ ...ff, constraint_fields: next.join(', ') }))
}}
/>
</td>
<td className="py-1 text-center">
<input
type="checkbox"
checked={inView}
onChange={e => {
if (e.target.checked) {
const nextSeq = form.schema.length > 0
? Math.max(...form.schema.map(s => s.seq ?? 0)) + 1
: 1
setForm(ff => ({ ...ff, schema: [...ff.schema, { name: f.name, type: f.type, seq: nextSeq }] }))
} else {
setForm(ff => ({ ...ff, schema: ff.schema.filter(s => s.name !== f.name) }))
}
}}
/>
</td>
<td className="py-1 text-center">
{inView && (
<input
type="number"
className="w-12 border border-gray-200 rounded px-1 py-0.5 text-xs text-center focus:outline-none focus:border-blue-400"
value={schemaEntry.seq ?? ''}
onChange={e => setForm(ff => ({
...ff,
schema: ff.schema.map(s => s.name === f.name ? { ...s, seq: parseInt(e.target.value) || 0 } : s)
}))}
/>
)}
</td>
</tr>
)
})}
</tbody>
</table>
<SampleTable rows={form.sampleRows || []} />
</div>
)}
{form.fields.length === 0 && (
<div>
<label className="text-xs text-gray-500 block mb-1">Constraint fields (comma-separated)</label>
<input
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
value={form.constraint_fields}
onChange={e => setForm(f => ({ ...f, constraint_fields: e.target.value }))}
placeholder="e.g. date, amount, description"
/>
</div>
)}
<div className="flex gap-4">
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer">
<input
type="checkbox"
checked={form.global_picklist !== false}
onChange={e => setForm(f => ({ ...f, global_picklist: e.target.checked }))}
/>
Global picklist
</label>
{form.fields.length > 0 && (
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer">
<input
type="checkbox"
checked={form.importSample !== false}
onChange={e => setForm(f => ({ ...f, importSample: e.target.checked }))}
/>
Import sample data
</label>
)}
</div>
{createError && <p className="text-xs text-red-500">{createError}</p>}
<div className="flex gap-2">
<button type="submit" disabled={createLoading}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
{createLoading ? 'Creating…' : 'Create'}
</button>
<button type="button"
onClick={() => { setCreating(false); setCreateError(''); setForm({ name: '', constraint_fields: '', fields: [], schema: [] }) }}
className="text-sm text-gray-500 px-3 py-1.5 rounded hover:bg-gray-100">
Cancel
</button>
</div>
</form>
</div>
)}
</div>
)
}

View File

@ -1,3 +1,4 @@
import { Link } from 'react-router-dom'
import { useState, useEffect, useRef } from 'react'
import { api } from '../api'
import { format as formatSql } from 'sql-formatter'
@ -53,54 +54,54 @@ function CalibrateModal({ stack, sourceName, currentOffset, onClose, onApply })
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-white rounded-lg shadow-xl w-[420px] p-5" onClick={e => e.stopPropagation()}>
<div className="bg-surface rounded-lg shadow-xl w-[420px] p-5" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-semibold text-gray-700">Calibrate {sourceName}</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"></button>
<span className="text-sm font-semibold text-ink-soft">Calibrate {sourceName}</span>
<button onClick={onClose} className="text-muted hover:text-ink-soft"></button>
</div>
{/* Date */}
<div className="mb-4">
<label className="text-xs text-gray-500 block mb-1">As-of date</label>
<input type="date" className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
<label className="text-xs text-muted block mb-1">As-of date</label>
<input type="date" className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={asOf} onChange={e => setAsOf(e.target.value)} />
</div>
{/* Reconciliation table */}
<div className="bg-gray-50 rounded border border-gray-200 mb-4 text-sm">
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
<span className="text-gray-500 text-xs">Data sum at date</span>
<span className="font-mono text-gray-700">
{loading ? <span className="text-gray-300"></span> : computed !== null ? fmt(computed) : <span className="text-gray-300"></span>}
<div className="bg-raised rounded border border-line mb-4 text-sm">
<div className="flex items-center justify-between px-3 py-2 border-b border-line">
<span className="text-muted text-xs">Data sum at date</span>
<span className="font-mono text-ink-soft">
{loading ? <span className="text-muted"></span> : computed !== null ? fmt(computed) : <span className="text-muted"></span>}
</span>
</div>
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
<span className="text-gray-500 text-xs">Known balance</span>
<div className="flex items-center justify-between px-3 py-2 border-b border-line">
<span className="text-muted text-xs">Known balance</span>
<input
type="number" step="0.01"
className="font-mono text-right bg-transparent border-0 focus:outline-none w-36 text-sm text-gray-700 placeholder-gray-300"
className="font-mono text-right bg-transparent border-0 focus:outline-none w-36 text-sm text-ink-soft placeholder-gray-300"
placeholder="enter balance"
value={known} onChange={e => setKnown(e.target.value)}
/>
</div>
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
<span className="text-gray-500 text-xs">Current offset</span>
<span className="font-mono text-gray-400">{fmt(currentOffset ?? 0)}</span>
<div className="flex items-center justify-between px-3 py-2 border-b border-line">
<span className="text-muted text-xs">Current offset</span>
<span className="font-mono text-muted">{fmt(currentOffset ?? 0)}</span>
</div>
<div className="flex items-center justify-between px-3 py-2 font-medium">
<span className="text-gray-700 text-xs">Plug (offset needed)</span>
<span className={`font-mono ${plug !== null ? 'text-blue-700' : 'text-gray-300'}`}>
<span className="text-ink-soft text-xs">Plug (offset needed)</span>
<span className={`font-mono ${plug !== null ? 'text-accent' : 'text-muted'}`}>
{plug !== null ? fmt(plug) : '—'}
</span>
</div>
</div>
{error && <p className="text-xs text-red-500 mb-3">{error}</p>}
{error && <p className="text-xs text-danger mb-3">{error}</p>}
{/* Apply */}
<div className="flex gap-2 items-center">
<input type="number" step="0.01"
className="flex-1 border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400"
className="flex-1 border border-line rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-accent"
placeholder="offset to apply"
value={applyOffset} onChange={e => setApplyOffset(e.target.value)} />
<button onClick={() => onApply(parseFloat(applyOffset))} disabled={applyOffset === '' || isNaN(parseFloat(applyOffset))}
@ -433,12 +434,12 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
<div className="space-y-5">
{/* Label */}
<div className="bg-white border border-gray-200 rounded p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Configuration</h3>
<div className="bg-surface border border-line rounded p-4">
<h3 className="text-sm font-semibold text-ink-soft mb-3">Configuration</h3>
<div className="flex gap-3 items-end">
<div className="flex-1">
<label className="text-xs text-gray-500 block mb-1">Label <span className="text-gray-400">(optional)</span></label>
<input className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
<label className="text-xs text-muted block mb-1">Label <span className="text-muted">(optional)</span></label>
<input className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={label} onChange={e => setLabel(e.target.value)}
onKeyDown={e => e.key === 'Enter' && saveLabel()} />
</div>
@ -447,13 +448,13 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{error && <p className="text-xs text-red-500 mt-2">{error}</p>}
{error && <p className="text-xs text-danger mt-2">{error}</p>}
</div>
{/* Sources */}
<div className="bg-white border border-gray-200 rounded p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-1">Sources</h3>
<p className="text-xs text-gray-400 mb-3">Each source contributes rows to the combined view. Set the sign to flip the direction of amounts (e.g. credit card charges are positive in the source but should subtract from your balance). The offset adjusts the running balance use Calibrate to compute it from a known good balance.</p>
<div className="bg-surface border border-line rounded p-4">
<h3 className="text-sm font-semibold text-ink-soft mb-1">Sources</h3>
<p className="text-xs text-muted mb-3">Each source contributes rows to the combined view. Set the sign to flip the direction of amounts (e.g. credit card charges are positive in the source but should subtract from your balance). The offset adjusts the running balance use Calibrate to compute it from a known good balance.</p>
<div className="space-y-2 mb-3">
{members.map((m, idx) => {
const cfg = srcCfg[m.source_name] || {}
@ -466,50 +467,50 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
onDragOver={e => handleSrcDragOver(e, idx)}
onDrop={e => handleSrcDrop(e, idx)}
onDragEnd={() => { setSrcDragIdx(null); setSrcDragOverIdx(null) }}
className={`border border-gray-100 rounded px-3 py-2 text-xs space-y-2 ${srcDragOverIdx === idx && srcDragIdx !== idx ? 'bg-blue-50' : ''}`}>
className={`border border-line-soft rounded px-3 py-2 text-xs space-y-2 ${srcDragOverIdx === idx && srcDragIdx !== idx ? 'bg-accent-soft' : ''}`}>
<div className="flex items-center gap-2">
<span className="text-gray-300 cursor-grab select-none"></span>
<span className="font-medium text-gray-700 flex-1">{m.source_name}</span>
<button onClick={() => removeSource(m.source_name)} className="text-red-300 hover:text-red-500">Remove</button>
<span className="text-muted cursor-grab select-none"></span>
<span className="font-medium text-ink-soft flex-1">{m.source_name}</span>
<button onClick={() => removeSource(m.source_name)} className="text-danger hover:text-danger">Remove</button>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5">
<div>
<label className="text-gray-400 block mb-0.5">Amount field</label>
<label className="text-muted block mb-0.5">Amount field</label>
<select value={cfg.amount_field || ''}
onChange={e => handleSrcAmountField(m.source_name, e.target.value)}
className="w-full border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400">
className="w-full border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent">
<option value=""> select </option>
{sf.map(f => <option key={f} value={f}>{f}</option>)}
</select>
</div>
<div>
<label className="text-gray-400 block mb-0.5">Sign</label>
<label className="text-muted block mb-0.5">Sign</label>
<select value={cfg.sign ?? 1}
onChange={e => { setSrcSign(m.source_name, parseInt(e.target.value)); setMappingsDirty(true) }}
className="w-full border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400">
className="w-full border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent">
<option value={1}>+1 (as-is)</option>
<option value={-1}>1 (flip)</option>
</select>
</div>
<div>
<label className="text-gray-400 block mb-0.5">Date field</label>
<label className="text-muted block mb-0.5">Date field</label>
<select value={cfg.date_field || ''}
onChange={e => handleSrcDateField(m.source_name, e.target.value)}
className="w-full border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400">
className="w-full border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent">
<option value=""> select </option>
{sf.map(f => <option key={f} value={f}>{f}</option>)}
</select>
</div>
<div>
<label className="text-gray-400 block mb-0.5">Balance offset</label>
<label className="text-muted block mb-0.5">Balance offset</label>
<div className="flex items-center gap-1">
<input type="number" step="0.01" value={cfg.offset ?? 0}
onChange={e => { setSrcOffset(m.source_name, parseFloat(e.target.value) || 0); setMappingsDirty(true) }}
className="flex-1 border border-gray-200 rounded px-1.5 py-0.5 font-mono focus:outline-none focus:border-blue-400" />
className="flex-1 border border-line rounded px-1.5 py-0.5 font-mono focus:outline-none focus:border-accent" />
<button onClick={() => handleCalibrate(m.source_name)}
disabled={!canCalibrate}
title={!canCalibrate ? 'Set amount and date fields first' : 'Calibrate balance'}
className="text-blue-400 hover:text-blue-600 underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline">
className="text-accent hover:text-accent underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline">
Calibrate
</button>
</div>
@ -518,43 +519,43 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
</div>
)
})}
{members.length === 0 && <p className="text-xs text-gray-400">No sources added yet.</p>}
{members.length === 0 && <p className="text-xs text-muted">No sources added yet.</p>}
</div>
{availableSources.length > 0 && (
<div className="flex gap-2">
<select className="flex-1 border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
<select className="flex-1 border border-line rounded px-2 py-1 text-sm focus:outline-none focus:border-accent"
value={addingSrc} onChange={e => setAddingSrc(e.target.value)}>
<option value=""> add source </option>
{availableSources.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
</select>
<button onClick={addSource} disabled={!addingSrc}
className="text-sm bg-gray-100 px-3 py-1 rounded hover:bg-gray-200 text-gray-700 disabled:opacity-40">Add</button>
className="text-sm bg-raised px-3 py-1 rounded hover:bg-raised text-ink-soft disabled:opacity-40">Add</button>
</div>
)}
</div>
{/* Output columns mapping grid */}
<div className="bg-white border border-gray-200 rounded p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-1">Output columns</h3>
<p className="text-xs text-gray-400 mb-3">
<div className="bg-surface border border-line rounded p-4">
<h3 className="text-sm font-semibold text-ink-soft mb-1">Output columns</h3>
<p className="text-xs text-muted mb-3">
Each row is a column in the combined view. Each source column shows which field from that source maps to it.
The first <span className="text-blue-500">numeric</span> field drives the running balance; the first <span className="text-green-600">date</span> field drives the ordering.
The first <span className="text-accent">numeric</span> field drives the running balance; the first <span className="text-ok">date</span> field drives the ordering.
Both <span className="font-mono">source_balance</span> (per-source) and <span className="font-mono">net_balance</span> (combined) are always included in the generated view.
Drag rows to reorder.
</p>
{members.length === 0 ? (
<p className="text-xs text-gray-400 mb-3">Add sources above first.</p>
<p className="text-xs text-muted mb-3">Add sources above first.</p>
) : (
<div className="overflow-x-auto mb-3">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="border-b border-gray-200">
<tr className="border-b border-line">
<th className="w-5 pb-2"></th>
<th className="text-left text-gray-400 font-normal pb-2 pr-4">Column</th>
<th className="text-left text-gray-400 font-normal pb-2 pr-4">Type</th>
<th className="text-left text-muted font-normal pb-2 pr-4">Column</th>
<th className="text-left text-muted font-normal pb-2 pr-4">Type</th>
{members.map(m => (
<th key={m.source_name} className="text-left text-gray-400 font-normal pb-2 pr-3 min-w-36">{m.source_name}</th>
<th key={m.source_name} className="text-left text-muted font-normal pb-2 pr-3 min-w-36">{m.source_name}</th>
))}
<th className="w-5 pb-2"></th>
</tr>
@ -570,21 +571,21 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
onDragOver={e => handleDragOver(e, idx)}
onDrop={e => handleDrop(e, idx)}
onDragEnd={() => { setDragIdx(null); setDragOverIdx(null) }}
className={`border-b border-gray-50 ${dragOverIdx === idx && dragIdx !== idx ? 'bg-blue-50' : ''}`}>
<td className="py-1.5 pr-1 text-gray-300 cursor-grab select-none"></td>
<td className="py-1.5 pr-4 font-mono text-gray-700 whitespace-nowrap">
className={`border-b border-line-soft ${dragOverIdx === idx && dragIdx !== idx ? 'bg-accent-soft' : ''}`}>
<td className="py-1.5 pr-1 text-muted cursor-grab select-none"></td>
<td className="py-1.5 pr-4 font-mono text-ink-soft whitespace-nowrap">
{f.name}
{isAmount && <span className="ml-1.5 text-blue-500 font-sans font-normal">amount</span>}
{isDate && <span className="ml-1.5 text-green-600 font-sans font-normal">date</span>}
{isAmount && <span className="ml-1.5 text-accent font-sans font-normal">amount</span>}
{isDate && <span className="ml-1.5 text-ok font-sans font-normal">date</span>}
</td>
<td className="py-1.5 pr-4 text-gray-400">{f.type}</td>
<td className="py-1.5 pr-4 text-muted">{f.type}</td>
{members.map(m => (
<td key={m.source_name} className="py-1.5 pr-3">
<div className="flex items-center gap-1">
<select
value={getMappingValue(m.source_name, f.name)}
onChange={e => setMappingValue(m.source_name, f.name, e.target.value)}
className="border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400 min-w-0 flex-1">
className="border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent min-w-0 flex-1">
<option value=""> same name </option>
{(srcFields[m.source_name] || []).map(sf => (
<option key={sf} value={sf}>{sf}</option>
@ -594,13 +595,13 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
</td>
))}
<td className="py-1.5">
<button onClick={() => removeField(f.name)} className="text-red-300 hover:text-red-500"></button>
<button onClick={() => removeField(f.name)} className="text-danger hover:text-danger"></button>
</td>
</tr>
)
})}
{fields.length === 0 && (
<tr><td colSpan={3 + members.length} className="py-3 text-gray-400 text-center">No columns defined yet add one below.</td></tr>
<tr><td colSpan={3 + members.length} className="py-3 text-muted text-center">No columns defined yet add one below.</td></tr>
)}
</tbody>
</table>
@ -609,15 +610,15 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
{/* Add field */}
<div className="flex gap-2 mb-3">
<input className="flex-1 border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
<input className="flex-1 border border-line rounded px-2 py-1 text-sm focus:outline-none focus:border-accent"
placeholder="column name" value={newField.name}
onChange={e => setNewField(f => ({ ...f, name: e.target.value }))}
onKeyDown={e => e.key === 'Enter' && addField()} />
<select className="border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
<select className="border border-line rounded px-2 py-1 text-sm focus:outline-none focus:border-accent"
value={newField.type} onChange={e => setNewField(f => ({ ...f, type: e.target.value }))}>
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<button onClick={addField} className="text-sm bg-gray-100 px-3 py-1 rounded hover:bg-gray-200 text-gray-700">Add</button>
<button onClick={addField} className="text-sm bg-raised px-3 py-1 rounded hover:bg-raised text-ink-soft">Add</button>
</div>
{mappingsDirty && (
@ -629,12 +630,12 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
</div>
{/* Generate view + balance */}
<div className="bg-white border border-gray-200 rounded p-4">
<div className="bg-surface border border-line rounded p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700">View</h3>
<h3 className="text-sm font-semibold text-ink-soft">View</h3>
<div className="flex gap-2">
<button onClick={fetchBalance}
className="text-sm bg-gray-100 text-gray-700 px-3 py-1.5 rounded hover:bg-gray-200">
className="text-sm bg-raised text-ink-soft px-3 py-1.5 rounded hover:bg-raised">
Refresh balance
</button>
<button onClick={generateView}
@ -645,18 +646,18 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
</div>
{netBalance !== null && (
<div className="mb-3 flex items-center gap-3">
<span className="text-xs text-gray-500">Current net balance</span>
<span className="text-lg font-mono font-semibold text-gray-800">
<span className="text-xs text-muted">Current net balance</span>
<span className="text-lg font-mono font-semibold text-ink">
{Number(netBalance).toLocaleString(undefined, { minimumFractionDigits: 2 })}
</span>
</div>
)}
{balanceError && <p className="text-xs text-gray-400 mb-3">{balanceError}</p>}
{balanceError && <p className="text-xs text-muted mb-3">{balanceError}</p>}
{viewResult && !viewResult.success && (
<p className="text-xs text-red-500">{viewResult.error}</p>
<p className="text-xs text-danger">{viewResult.error}</p>
)}
{viewResult && viewResult.success && (
<p className="text-xs text-green-600">View created: <span className="font-mono">{viewResult.view}</span></p>
<p className="text-xs text-ok">View created: <span className="font-mono">{viewResult.view}</span></p>
)}
</div>
@ -747,28 +748,31 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated, on
<div className="p-6">
{/* Stack list — horizontal row of cards */}
<div className="flex items-center gap-2 mb-5 flex-wrap">
<h1 className="text-sm font-semibold text-gray-800 mr-1">Stacks</h1>
<h1 className="text-sm font-semibold text-ink mr-1">Stacks</h1>
{stacks.map(s => (
<div key={s.name}
onClick={() => loadDetail(s.name)}
className={`flex items-center gap-2 px-3 py-1.5 rounded border cursor-pointer text-xs group transition-colors ${selected === s.name ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50'}`}>
className={`flex items-center gap-2 px-3 py-1.5 rounded border cursor-pointer text-xs group transition-colors ${selected === s.name ? 'border-accent-line bg-accent-soft text-accent' : 'border-line bg-surface text-ink-soft hover:border-line hover:bg-raised'}`}>
<span className="font-medium">{s.label || s.name}</span>
<span className="text-gray-400">{s.source_count}s</span>
<span className="text-muted">{s.source_count}s</span>
<Link to={`/stacks/${encodeURIComponent(s.name)}/pivot`}
onClick={e => e.stopPropagation()}
className="text-accent underline decoration-transparent hover:decoration-inherit">pivot</Link>
<button onClick={e => { e.stopPropagation(); deleteStack(s.name) }}
className="opacity-0 group-hover:opacity-100 text-red-300 hover:text-red-500 leading-none"></button>
className="opacity-0 group-hover:opacity-100 text-danger hover:text-danger leading-none ml-2 pl-2 border-l border-line"></button>
</div>
))}
{creating ? (
<div className="flex items-center gap-1">
<input autoFocus className="border border-blue-400 rounded px-2 py-1 text-xs focus:outline-none w-32"
<input autoFocus className="border border-accent rounded px-2 py-1 text-xs focus:outline-none w-32"
placeholder="stack name" value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') createStack(); if (e.key === 'Escape') setCreating(false) }} />
<button onClick={createStack} className="text-xs bg-blue-600 text-white px-2 py-1 rounded">Create</button>
<button onClick={() => setCreating(false)} className="text-xs text-gray-400 px-1"></button>
{error && <p className="text-xs text-red-500">{error}</p>}
<button onClick={() => setCreating(false)} className="text-xs text-muted px-1"></button>
{error && <p className="text-xs text-danger">{error}</p>}
</div>
) : (
<button onClick={() => setCreating(true)} className="text-xs text-blue-500 hover:text-blue-700 px-2 py-1.5">+ New</button>
<button onClick={() => setCreating(true)} className="text-xs text-accent hover:text-accent px-2 py-1.5">+ New</button>
)}
</div>
@ -776,9 +780,9 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated, on
<div className="flex gap-6 items-start">
{/* Left: config panel */}
<div className="flex-1 min-w-0">
<h2 className="text-base font-semibold text-gray-800 mb-4">
<h2 className="text-base font-semibold text-ink mb-4">
{stackDetail.label || stackDetail.name}
{stackDetail.label && <span className="text-sm text-gray-400 font-normal ml-2">{stackDetail.name}</span>}
{stackDetail.label && <span className="text-sm text-muted font-normal ml-2">{stackDetail.name}</span>}
</h2>
<StackPanel
key={stackDetail.name}
@ -793,9 +797,9 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated, on
{/* Right: SQL panel */}
<div className="flex-1 min-w-0">
<div className="bg-white border border-gray-200 rounded p-4 sticky top-4">
<div className="bg-surface border border-line rounded p-4 sticky top-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700">Generated SQL</h3>
<h3 className="text-sm font-semibold text-ink-soft">Generated SQL</h3>
<button
onClick={runSql}
disabled={!sqlDraft.trim() || sqlRunning}
@ -805,17 +809,17 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated, on
</div>
{sqlDraft ? (
<textarea
className="w-full font-mono text-xs text-gray-700 bg-gray-50 border border-gray-200 rounded p-2 focus:outline-none focus:border-blue-400 resize-none leading-relaxed"
className="w-full font-mono text-xs text-ink-soft bg-raised border border-line rounded p-2 focus:outline-none focus:border-accent resize-none leading-relaxed"
style={{ minHeight: '60vh' }}
value={sqlDraft}
onChange={e => { setSqlDraft(e.target.value); setSqlResult(null) }}
spellCheck={false}
/>
) : (
<p className="text-xs text-gray-400">Generate a view to see the SQL here.</p>
<p className="text-xs text-muted">Generate a view to see the SQL here.</p>
)}
{sqlResult && (
<p className={`text-xs mt-2 ${sqlResult.success ? 'text-green-600' : 'text-red-500'}`}>
<p className={`text-xs mt-2 ${sqlResult.success ? 'text-ok' : 'text-danger'}`}>
{sqlResult.success ? 'View updated successfully.' : sqlResult.error}
</p>
)}
@ -823,7 +827,7 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated, on
</div>
</div>
) : (
<p className="text-sm text-gray-400">Select a stack or create one.</p>
<p className="text-sm text-muted">Select a stack or create one.</p>
)}
</div>
)