Compare commits

..

1 Commits

Author SHA1 Message Date
c4e7211e6d Add Teller API sync as an alternative to CSV import
Sources with a `teller` block in their config can pull transactions
straight from the bank instead of taking a CSV upload. Only the fetch
differs — dedupe, logging, and transformation reuse the import path.

api/lib/teller.js speaks Teller's mutual-TLS protocol (client cert plus
the access token as the HTTP Basic username) and flattens transactions
into the shallow map the rule engine expects. Access tokens live in .env,
one per enrollment, not in the database that manage.py offers to reset.

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

Untested against the live API — Teller has no self-serve signup at the
moment, so no account to verify against.

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

View File

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

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`. `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 - **Storage key:** `df_dark` in `localStorage`; falls back to `window.matchMedia('(prefers-color-scheme: dark)')` on first visit
- **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>` - **Toggle:** button in the sidebar header in `App.jsx`; 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 - **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.
- **Palette:** dark mode uses Perspective's "Pro Dark" colours (`--bg-primary: #242526`, panels `#2a2c2f`, gridlines `#3b3f46`, text `#c5c9d0`) - **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 - **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()` - **Consuming the theme:** `import useTheme from '../theme.jsx'` then `const { dark, setDark } = useTheme()`

View File

@ -1,42 +0,0 @@
/**
* 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 };

View File

@ -1,219 +0,0 @@
/**
* 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 };

167
api/lib/teller.js Normal file
View File

@ -0,0 +1,167 @@
/**
* 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,8 +7,7 @@ const express = require('express');
const multer = require('multer'); const multer = require('multer');
const { parse } = require('csv-parse/sync'); const { parse } = require('csv-parse/sync');
const { lit, arr } = require('../lib/sql'); const { lit, arr } = require('../lib/sql');
const simplefin = require('../lib/simplefin'); const teller = require('../lib/teller');
const { inferFields } = require('../lib/fields');
const upload = multer({ storage: multer.memoryStorage() }); const upload = multer({ storage: multer.memoryStorage() });
@ -25,52 +24,14 @@ module.exports = (pool) => {
} }
}); });
// SimpleFIN helpers. Declared before /:name so they aren't shadowed by it. // 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.
// List the accounts behind a bridge — used to find the account_id for a source router.get('/teller-accounts', async (req, res, next) => {
router.get('/simplefin-accounts', async (req, res, next) => {
try { try {
res.json(await simplefin.listAccounts(req.query.access_url_env)); const accounts = await teller.listAccounts(req.query.token_env);
res.json(accounts);
} catch (err) { } catch (err) {
if (err instanceof simplefin.SimpleFinError) return res.status(err.status || 502).json({ error: err.message }); if (err instanceof teller.TellerError) 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); next(err);
} }
}); });
@ -104,7 +65,21 @@ module.exports = (pool) => {
const records = parse(req.file.buffer, { columns: true, skip_empty_lines: true, trim: true }); 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' }); if (records.length === 0) return res.status(400).json({ error: 'CSV file is empty' });
const { fields, sampleRows } = inferFields(records); 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 };
});
res.json({ name: '', constraint_fields: [], fields, sampleRows }); res.json({ name: '', constraint_fields: [], fields, sampleRows });
} catch (err) { } catch (err) {
next(err); next(err);
@ -177,7 +152,7 @@ module.exports = (pool) => {
} }
}); });
// Pull transactions from SimpleFIN and import them, same as a CSV upload. // Pull transactions from Teller and import them, same as a CSV upload.
// Safe to re-run: overlapping transactions are skipped by constraint key. // Safe to re-run: overlapping transactions are skipped by constraint key.
router.post('/:name/sync', async (req, res, next) => { router.post('/:name/sync', async (req, res, next) => {
try { try {
@ -185,23 +160,24 @@ module.exports = (pool) => {
const source = sourceResult.rows[0]; const source = sourceResult.rows[0];
if (!source || !source.name) return res.status(404).json({ error: 'Source not found' }); if (!source || !source.name) return res.status(404).json({ error: 'Source not found' });
const cfg = (source.config || {}).simplefin; const cfg = (source.config || {}).teller;
if (!cfg || !cfg.account_id) { if (!cfg || !cfg.account_id) {
return res.status(400).json({ return res.status(400).json({
error: `Source "${req.params.name}" has no simplefin.account_id in its config` error: `Source "${req.params.name}" has no teller.account_id in its config`
}); });
} }
const opts = { ...req.query, ...req.body }; const opts = { ...req.query, ...req.body };
const { fetched, errors, records } = await simplefin.fetchTransactions({ const { fetched, records } = await teller.fetchTransactions({
accountId: cfg.account_id, accountId: cfg.account_id,
accessUrlEnv: cfg.access_url_env, tokenEnv: cfg.token_env,
count: opts.count !== undefined ? parseInt(opts.count) : cfg.count,
days: opts.days !== undefined ? parseInt(opts.days) : cfg.days, days: opts.days !== undefined ? parseInt(opts.days) : cfg.days,
includePending: opts.include_pending === true || opts.include_pending === 'true', includePending: opts.include_pending === true || opts.include_pending === 'true',
}); });
if (records.length === 0) { if (records.length === 0) {
return res.json({ success: true, fetched, errors, imported: 0, duplicates: 0 }); return res.json({ success: true, fetched, imported: 0, duplicates: 0 });
} }
const importResult = await pool.query( const importResult = await pool.query(
@ -209,15 +185,15 @@ module.exports = (pool) => {
); );
const importData = importResult.rows[0].result; const importData = importResult.rows[0].result;
if (!importData.success) return res.json({ ...importData, fetched, errors }); if (!importData.success) return res.json({ ...importData, fetched });
const transformResult = await pool.query( const transformResult = await pool.query(
`SELECT apply_transformations(${lit(req.params.name)}) as result` `SELECT apply_transformations(${lit(req.params.name)}) as result`
); );
res.json({ ...importData, fetched, errors, transform: transformResult.rows[0].result }); res.json({ ...importData, fetched, transform: transformResult.rows[0].result });
} catch (err) { } catch (err) {
if (err instanceof simplefin.SimpleFinError) return res.status(err.status || 502).json({ error: err.message }); if (err instanceof teller.TellerError) return res.status(err.status || 502).json({ error: err.message });
next(err); next(err);
} }
}); });

View File

@ -48,7 +48,7 @@ api/
auth.js — Basic Auth enforcement on all /api routes auth.js — Basic Auth enforcement on all /api routes
lib/ lib/
sql.js — lit() and arr() helpers for SQL literal building sql.js — lit() and arr() helpers for SQL literal building
simplefin.js — SimpleFIN Bridge client for bank transaction pulls teller.js — Teller API client (mutual TLS) for bank transaction pulls
routes/ routes/
sources.js — HTTP handlers for source management sources.js — HTTP handlers for source management
rules.js — HTTP handlers for rule management rules.js — HTTP handlers for rule management
@ -59,15 +59,11 @@ api/
ui/ ui/
src/ src/
api.js — fetch wrapper, credential management api.js — fetch wrapper, credential management
App.jsx — root: login gate, routing, stale/reprocess banners App.jsx — root: login gate, sidebar, source selector, routing
index.css — semantic colour tokens for light and dark
pages/ pages/
Login.jsx — username/password form Login.jsx — username/password form
SourceList.jsx — source list and the create dialog Sources.jsx — source CRUD, field config, view generation
SourceDetail.jsx — one source: connection, fields, view, maintenance Import.jsx — CSV upload, Teller sync, and import log
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 Rules.jsx — rule CRUD with live pattern preview
Mappings.jsx — mapping table with TSV import/export Mappings.jsx — mapping table with TSV import/export
Records.jsx — paginated, sortable view of transformed records Records.jsx — paginated, sortable view of transformed records
@ -75,7 +71,7 @@ ui/
Stacks.jsx — multi-source union views with running balance Stacks.jsx — multi-source union views with running balance
Remap.jsx — bulk remap of an output field value across mappings Remap.jsx — bulk remap of an output field value across mappings
Log.jsx — global import log across all sources Log.jsx — global import log across all sources
components/ — Sidebar, BottomNav, navItems, SourceTabs, Section, SampleTable components/ — Sidebar, StatusBar
theme.jsx — light/dark context provider theme.jsx — light/dark context provider
public/ — compiled UI (output of npm run build in ui/) public/ — compiled UI (output of npm run build in ui/)
docs/ — this file, tutorial, UI and Perspective references docs/ — this file, tutorial, UI and Perspective references
@ -117,42 +113,38 @@ CSV file → parse in Node.js → import_records(source, data)
→ apply_transformations() runs automatically on new records → apply_transformations() runs automatically on new records
``` ```
### SimpleFIN sync (API-based bank feeds) ### Teller sync (API-based bank feeds)
``` ```
POST /api/sources/:name/sync → api/lib/simplefin.js POST /api/sources/:name/sync → api/lib/teller.js
→ GET {access_url}/accounts?account=…&start-date=… (Basic auth) → GET api.teller.io/accounts/:id/transactions (mutual TLS + access token)
→ drop pending, flatten transactions, fold in account context → drop pending, trim to the last N days, flatten nested `details`
→ import_records(source, data) — identical path to a CSV import from here on → 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 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. fetching differs: dedup, logging, and transformation are the same code.
- **Authentication.** A SimpleFIN access URL *is* the credential — it carries - **Authentication.** Teller uses mutual TLS. Every request presents the client
its own username and password (`https://user:pass@bridge.simplefin.org/simplefin`). certificate and key from the Teller dashboard (`TELLER_CERT_PATH`,
You claim it once from a setup token (`POST /api/sources/simplefin-claim`, `TELLER_KEY_PATH`), with the enrollment's access token as the HTTP Basic
which consumes the token) and store it in `.env`, one variable per bridge. It *username* and an empty password. Access tokens come from the Teller Connect
is deliberately **not** stored in the database, which `manage.py` offers to reset. browser flow, run once per bank, and live in `.env` — one variable per
- **Source config.** A source opts in by having `simplefin` in its `config` JSONB: enrollment. They are deliberately **not** stored in the database, which
`{"simplefin": {"account_id": "ACT-…", "access_url_env": "SIMPLEFIN_ACCESS_URL", `manage.py` offers to reset.
"days": 10}}`. Only `account_id` is required. `GET /api/sources/simplefin-accounts` - **Source config.** A source opts in by having `teller` in its `config` JSONB:
lists the accounts behind a bridge so you can find the id. `{"teller": {"account_id": "acc_…", "token_env": "TELLER_TOKEN_HUNTINGTON",
- **`constraint_fields` should be `['id']`.** SimpleFIN assigns each transaction a "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
stable id, which makes overlapping pulls free and — unlike date + amount + stable id, which makes overlapping pulls free and — unlike date + amount +
description — keeps genuinely repeated charges as separate records. description — keeps genuinely repeated charges as separate records.
- **Pending transactions are skipped** (`?include_pending=true` overrides). A - **Pending transactions are skipped** (`?include_pending=true` overrides). A
pending transaction gets a different id once it posts, so importing it would pending transaction's id changes when it posts, so importing it would produce
produce a duplicate under a different key a day or two later. a duplicate under a different key a day or two later.
- **Bridge errors are surfaced, not swallowed.** SimpleFIN returns HTTP 200 with - **Rolling window, not a cursor.** Teller's transaction endpoint takes a count,
an `errors` array when an institution is failing. Those errors ride along in not a date range, so the route over-fetches (`count`, default 200) and trims
the sync response so a broken connection doesn't read as a successful empty to `days` (default 10, `0` for everything returned). Re-running the same
pull; the Import page shows them in orange above the counts. window is harmless; late-arriving transactions get picked up.
- **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: - **Cron.** A daily pull is just the endpoint:
`curl -sS -u user:pass -X POST http://localhost:3000/api/sources/NAME/sync` `curl -sS -u user:pass -X POST http://localhost:3000/api/sources/NAME/sync`
@ -217,9 +209,8 @@ All routes are under `/api`. Every route requires HTTP Basic Auth. The `GET /hea
| DELETE | /api/sources/:name | Delete source and all its data | | DELETE | /api/sources/:name | Delete source and all its data |
| POST | /api/sources/suggest | Suggest source config from an uploaded CSV | | 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/import | Import CSV; transformations are applied to the new records |
| POST | /api/sources/:name/sync | Pull transactions from SimpleFIN and import them (`?days=`, `?include_pending=`) | | POST | /api/sources/:name/sync | Pull transactions from Teller and import them (`?days=`, `?count=`, `?include_pending=`) |
| GET | /api/sources/simplefin-accounts | List accounts behind a bridge (`?access_url_env=`) | | GET | /api/sources/teller-accounts | List accounts behind a Teller enrollment (`?token_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/import-log | Import history across all sources |
| GET | /api/sources/:name/import-log | Import history for one source | | 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 | | DELETE | /api/sources/:name/import-log/:id | Delete an import batch and every record in it |
@ -331,43 +322,11 @@ 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. 3. On 401 response, credentials are cleared and the login screen is shown.
4. `localStorage` persists the selected source name across sessions. 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:** **Pages:**
- **Sources** (`SourceList.jsx`) — Lists every source with its constraint fields and a - **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.
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.
- **Source detail** (`SourceDetail.jsx`) — The Setup tab, grouped into titled panels: - **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.
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. - **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.
@ -394,8 +353,7 @@ of the initial download and in a chunk fetched only when a pivot is opened.
See `docs/perspective.md` for the full technical reference on controlling Perspective programmatically. See `docs/perspective.md` for the full technical reference on controlling Perspective programmatically.
- **Stacks** — Named unions of multiple sources, each chip linking to its pivot at - **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/: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. - **Log** — Global import log across all sources. Same expandable key detail and delete capability as the Import page, plus a source name column.
@ -434,9 +392,7 @@ 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. 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. **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. 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.
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:** **Key behaviors:**
- All commands that will be run are printed before the user is asked to confirm. - All commands that will be run are printed before the user is asked to confirm.
@ -460,7 +416,9 @@ NODE_ENV development | production
LOGIN_USER Username for Basic Auth LOGIN_USER Username for Basic Auth
LOGIN_PASSWORD_HASH bcrypt hash of the password LOGIN_PASSWORD_HASH bcrypt hash of the password
SIMPLEFIN_ACCESS_URL Default SimpleFIN bridge URL; per-source override via config.simplefin.access_url_env 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
``` ```
--- ---

View File

@ -902,58 +902,6 @@ def action_set_login_credentials(cfg):
info('Restart the service for changes to take effect (option 7).') 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 ───────────────────────────────────────────────────────────────── # ── Main menu ─────────────────────────────────────────────────────────────────
MENU = [ MENU = [
@ -966,7 +914,6 @@ MENU = [
('Start / restart dataflow.service', action_restart_service), ('Start / restart dataflow.service', action_restart_service),
('Stop dataflow.service', action_stop_service), ('Stop dataflow.service', action_stop_service),
('Set login credentials', action_set_login_credentials), ('Set login credentials', action_set_login_credentials),
('Claim SimpleFIN setup token (.env)', action_claim_simplefin),
('Uninstall (service, nginx, database, .env, build)', action_uninstall), ('Uninstall (service, nginx, database, .env, build)', action_uninstall),
] ]

View File

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

View File

@ -1,41 +1,26 @@
import { useState, useEffect, createElement, lazy, Suspense } from 'react' import { useState, useEffect } from 'react'
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { api, setCredentials, clearCredentials } from './api' import { api, setCredentials, clearCredentials } from './api'
import StatusBar from './components/StatusBar.jsx'
import Sidebar from './components/Sidebar.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 Login from './pages/Login'
import SourceList from './pages/SourceList' import Sources from './pages/Sources'
import SourceDetail from './pages/SourceDetail'
import Bridge from './pages/Bridge'
import ImportHub from './pages/ImportHub'
import Import from './pages/Import' import Import from './pages/Import'
import Rules from './pages/Rules' import Rules from './pages/Rules'
import Mappings from './pages/Mappings' import Mappings from './pages/Mappings'
import Records from './pages/Records' import Records from './pages/Records'
import Log from './pages/Log' import Log from './pages/Log'
const Pivot = lazy(() => import('./pages/Pivot')) import Pivot from './pages/Pivot'
import Remap from './pages/Remap' import Remap from './pages/Remap'
import Stacks from './pages/Stacks' 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() { export default function App() {
const [authed, setAuthed] = useState(false) const [authed, setAuthed] = useState(false)
const [loginUser, setLoginUser] = useState('') const [loginUser, setLoginUser] = useState('')
const [sources, setSources] = useState([]) const [sources, setSources] = useState([])
const [stacks, setStacks] = useState([])
const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '') const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '')
const [selectedStack, setSelectedStack] = useState(null)
const [sidebarExpanded, setSidebarExpanded] = useState(() => localStorage.getItem('df_sidebar') !== 'collapsed') const [sidebarExpanded, setSidebarExpanded] = useState(() => localStorage.getItem('df_sidebar') !== 'collapsed')
// Sets of names whose dfv view is out of sync with current definitions // Sets of names whose dfv view is out of sync with current definitions
const [staleSources, setStaleSources] = useState(new Set()) const [staleSources, setStaleSources] = useState(new Set())
@ -52,6 +37,7 @@ export default function App() {
if (!source && s.length > 0) setSource(s[0].name) if (!source && s.length > 0) setSource(s[0].name)
setAuthed(true) setAuthed(true)
setLoginUser(user) setLoginUser(user)
api.getStacks().then(setStacks).catch(() => {})
} }
function handleLogout() { function handleLogout() {
@ -61,11 +47,17 @@ export default function App() {
setAuthed(false) setAuthed(false)
setLoginUser('') setLoginUser('')
setSources([]) setSources([])
setStacks([])
setSelectedStack(null)
setStaleSources(new Set()) setStaleSources(new Set())
setStaleStacks(new Set()) setStaleStacks(new Set())
setReprocessSources(new Set()) setReprocessSources(new Set())
} }
function refreshStacks() {
api.getStacks().then(setStacks).catch(() => {})
}
// Load initial stale state from DB once on login // Load initial stale state from DB once on login
useEffect(() => { useEffect(() => {
if (!authed) return if (!authed) return
@ -135,20 +127,22 @@ export default function App() {
<BrowserRouter> <BrowserRouter>
<div className="flex h-screen"> <div className="flex h-screen">
<div className="hidden md:flex">
<Sidebar <Sidebar
expanded={sidebarExpanded} expanded={sidebarExpanded}
setExpanded={setSidebarExpanded} setExpanded={setSidebarExpanded}
loginUser={loginUser} loginUser={loginUser}
onLogout={handleLogout} onLogout={handleLogout}
sources={sources}
/> />
</div>
{/* Main */} {/* Main */}
<div className="flex-1 overflow-hidden flex flex-col min-w-0"> <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) && ( {(staleSources.size > 0 || staleStacks.size > 0) && (
<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"> <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">
<span className="font-medium">View out of sync:</span> <span className="font-medium">View out of sync:</span>
{[...staleSources].map(name => ( {[...staleSources].map(name => (
<span key={name} className="flex items-center gap-1"> <span key={name} className="flex items-center gap-1">
@ -156,20 +150,20 @@ export default function App() {
<button <button
onClick={() => handleGenerateSource(name)} onClick={() => handleGenerateSource(name)}
disabled={generating[`src:${name}`]} disabled={generating[`src:${name}`]}
className="px-1.5 py-0.5 rounded bg-warn-line hover:bg-warn-line disabled:opacity-50 font-medium" className="px-1.5 py-0.5 rounded bg-amber-200 hover:bg-amber-300 disabled:opacity-50 font-medium"
> >
{generating[`src:${name}`] ? '…' : 'Generate'} {generating[`src:${name}`] ? '…' : 'Generate'}
</button> </button>
</span> </span>
))} ))}
{staleSources.size > 0 && staleStacks.size > 0 && <span className="text-warn">|</span>} {staleSources.size > 0 && staleStacks.size > 0 && <span className="text-amber-400">|</span>}
{[...staleStacks].map(name => ( {[...staleStacks].map(name => (
<span key={name} className="flex items-center gap-1"> <span key={name} className="flex items-center gap-1">
stack: {name} stack: {name}
<button <button
onClick={() => handleGenerateStack(name)} onClick={() => handleGenerateStack(name)}
disabled={generating[`stk:${name}`]} disabled={generating[`stk:${name}`]}
className="px-1.5 py-0.5 rounded bg-warn-line hover:bg-warn-line disabled:opacity-50 font-medium" className="px-1.5 py-0.5 rounded bg-amber-200 hover:bg-amber-300 disabled:opacity-50 font-medium"
> >
{generating[`stk:${name}`] ? '…' : 'Generate'} {generating[`stk:${name}`] ? '…' : 'Generate'}
</button> </button>
@ -178,7 +172,7 @@ export default function App() {
</div> </div>
)} )}
{reprocessSources.size > 0 && ( {reprocessSources.size > 0 && (
<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"> <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">
<span className="font-medium">Mappings updated:</span> <span className="font-medium">Mappings updated:</span>
{[...reprocessSources].map(name => ( {[...reprocessSources].map(name => (
<span key={name} className="flex items-center gap-1"> <span key={name} className="flex items-center gap-1">
@ -195,34 +189,22 @@ export default function App() {
</div> </div>
)} )}
<div className="flex-1 overflow-auto pb-14 md:pb-0"> <div className="flex-1 overflow-auto">
<Suspense fallback={<div className="p-6 text-sm text-muted">Loading</div>}>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/sources" replace />} /> <Route path="/" element={<Navigate to="/sources" replace />} />
<Route path="/sources" element={<Sources source={source} sources={sources} setSources={setSources} setSource={setSource} />} />
<Route path="/sources" element={<SourceList sources={sources} setSources={setSources} setSource={setSource} />} /> <Route path="/import" element={<Import source={source} />} />
<Route path="/sources/:name" element={<SourceTabs sources={sources} />}> <Route path="/rules" element={<Rules source={source} onStale={markSourceStale} />} />
<Route index element={<Navigate to="records" replace />} /> <Route path="/mappings" element={<Mappings source={source} onNeedsReprocess={markNeedsReprocess} />} />
<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="/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 />} /> <Route path="/log" element={<Log />} />
</Routes> </Routes>
</Suspense>
</div> </div>
</div> </div>
</div> </div>
<BottomNav />
</BrowserRouter> </BrowserRouter>
) )
} }

View File

@ -66,18 +66,12 @@ export const api = {
fd.append('file', file) fd.append('file', file)
return request('POST', `/sources/${name}/import`, fd, true) return request('POST', `/sources/${name}/import`, fd, true)
}, },
syncSimpleFin: (name, opts = {}) => { syncTeller: (name, opts = {}) => {
const params = new URLSearchParams(opts) const params = new URLSearchParams(opts)
return request('POST', `/sources/${name}/sync${params.toString() ? `?${params}` : ''}`) return request('POST', `/sources/${name}/sync${params.toString() ? `?${params}` : ''}`)
}, },
getSimpleFinSample: (accountId, days) => { getTellerAccounts: (tokenEnv) =>
const params = new URLSearchParams({ account_id: accountId }) request('GET', `/sources/teller-accounts${tokenEnv ? `?token_env=${encodeURIComponent(tokenEnv)}` : ''}`),
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`), transform: (name) => request('POST', `/sources/${name}/transform`),
reprocess: (name) => request('POST', `/sources/${name}/reprocess`), reprocess: (name) => request('POST', `/sources/${name}/reprocess`),
generateView: (name) => request('POST', `/sources/${name}/view`), generateView: (name) => request('POST', `/sources/${name}/view`),

View File

@ -1,40 +0,0 @@
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

@ -1,28 +0,0 @@
// 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

@ -1,13 +0,0 @@
// 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,47 +1,120 @@
import { Fragment, useMemo } from 'react'
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import useTheme from '../theme.jsx'
import { NAV } from './navItems.jsx'
// Same distinction the Import page makes: a source is either on a bank feed or const NAV = [
// it gets CSVs uploaded to it. {
const feedIcon = ( to: '/sources',
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"> label: 'Sources',
<path d="M4 10.5a4.5 4.5 0 0 1 4.5 4.5"/> icon: (
<path d="M4 6a9 9 0 0 1 9 9"/> <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<circle cx="4.2" cy="14.8" r="1.2" fill="currentColor" stroke="none"/> <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> </svg>
) ),
},
const csvIcon = ( {
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"> to: '/import',
<path d="M4 1.5h5l3 3v10H4z"/> label: 'Import',
<polyline points="9,1.5 9,4.5 12,4.5"/> 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> </svg>
) ),
},
export default function Sidebar({ expanded, setExpanded, loginUser, onLogout, sources = [] }) { {
const { dark, setDark } = useTheme() to: '/rules',
label: 'Rules',
// Bank feeds first, then CSV sources, alphabetical within each group icon: (
const navSources = useMemo(() => ( <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
sources <polyline points="6,7 2,10 6,13"/>
.map(s => ({ ...s, isFeed: !!s.config?.simplefin?.account_id })) <polyline points="14,7 18,10 14,13"/>
.sort((a, b) => <line x1="12" y1="4" x2="8" y2="16"/>
(b.isFeed - a.isFeed) || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) </svg>
) ),
), [sources]) },
{
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>
),
},
]
export default function Sidebar({ expanded, setExpanded, loginUser, onLogout }) {
return ( return (
<div <div
className="bg-surface border-r border-line flex flex-col shrink-0 overflow-hidden transition-all duration-150" className="bg-white border-r border-gray-200 flex flex-col shrink-0 overflow-hidden transition-all duration-150"
style={{ width: expanded ? 200 : 48 }} style={{ width: expanded ? 200 : 48 }}
> >
{/* Header */} {/* Header */}
<div className="h-12 flex items-center px-3 border-b border-line-soft gap-2 shrink-0"> <div className="h-12 flex items-center px-3 border-b border-gray-100 gap-2 shrink-0">
<button <button
onClick={() => setExpanded(e => !e)} onClick={() => setExpanded(e => !e)}
className="w-8 h-8 flex items-center justify-center rounded hover:bg-raised text-muted shrink-0" className="w-8 h-8 flex items-center justify-center rounded hover:bg-gray-100 text-gray-400 shrink-0"
title="Toggle sidebar" title="Toggle sidebar"
> >
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@ -51,7 +124,7 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout, so
</svg> </svg>
</button> </button>
<span <span
className="text-xs font-semibold text-ink-soft tracking-wide uppercase whitespace-nowrap transition-opacity duration-100" className="text-xs font-semibold text-gray-600 tracking-wide uppercase whitespace-nowrap transition-opacity duration-100"
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none' }} style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none' }}
> >
Dataflow Dataflow
@ -59,18 +132,17 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout, so
</div> </div>
{/* Nav */} {/* Nav */}
<nav className="flex flex-col gap-0.5 p-2 flex-1 overflow-y-auto"> <nav className="flex flex-col gap-0.5 p-2 flex-1">
{NAV.map(({ to, label, icon }) => ( {NAV.map(({ to, label, icon }) => (
<Fragment key={to}>
<NavLink <NavLink
key={to}
to={to} to={to}
end={to === '/sources'}
title={!expanded ? label : undefined} title={!expanded ? label : undefined}
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-3 px-2 py-2 rounded w-full transition-colors ${ `flex items-center gap-3 px-2 py-2 rounded w-full transition-colors ${
isActive isActive
? 'bg-accent-soft text-accent' ? 'bg-blue-50 text-blue-700'
: 'text-muted hover:bg-raised hover:text-ink' : 'text-gray-500 hover:bg-gray-100 hover:text-gray-800'
}` }`
} }
> >
@ -82,67 +154,13 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout, so
{label} {label}
</span> </span>
</NavLink> </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> </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 */} {/* User / logout */}
<div className="border-t border-line-soft px-3 py-2.5 flex items-center gap-2 shrink-0 overflow-hidden"> <div className="border-t border-gray-100 px-3 py-2.5 flex items-center gap-2 shrink-0 overflow-hidden">
<div <div
className="w-6 h-6 rounded-full bg-raised text-muted flex items-center justify-center shrink-0 text-xs font-medium" className="w-6 h-6 rounded-full bg-gray-200 text-gray-500 flex items-center justify-center shrink-0 text-xs font-medium"
title={!expanded ? loginUser : undefined} title={!expanded ? loginUser : undefined}
> >
{loginUser ? loginUser[0].toUpperCase() : '?'} {loginUser ? loginUser[0].toUpperCase() : '?'}
@ -151,10 +169,10 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout, so
className="flex-1 flex items-center justify-between min-w-0 transition-opacity duration-100" 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' }} style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none', width: expanded ? 'auto' : 0, overflow: 'hidden' }}
> >
<span className="text-xs text-muted truncate">{loginUser}</span> <span className="text-xs text-gray-400 truncate">{loginUser}</span>
<button <button
onClick={onLogout} onClick={onLogout}
className="text-xs text-muted hover:text-danger ml-2 shrink-0" className="text-xs text-gray-400 hover:text-red-500 ml-2 shrink-0"
> >
Sign out Sign out
</button> </button>

View File

@ -1,60 +0,0 @@
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

@ -0,0 +1,73 @@
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

@ -1,70 +0,0 @@
// 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,42 +1,5 @@
@import "tailwindcss"; @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 { :root, .light {
--bg-primary: #f3f4f6; --bg-primary: #f3f4f6;
--bg-secondary: #ffffff; --bg-secondary: #ffffff;
@ -48,16 +11,6 @@
--border-light: #f3f4f6; --border-light: #f3f4f6;
--accent-bg: #eff6ff; --accent-bg: #eff6ff;
--accent-text: #1d4ed8; --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: /* Dark palette tuned to Perspective's "Pro Dark" theme:
@ -74,17 +27,6 @@
--border-light: #3b3f46; --border-light: #3b3f46;
--accent-bg: rgba(39, 113, 170, 0.32); --accent-bg: rgba(39, 113, 170, 0.32);
--accent-text: #4778c2; --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 { body {
@ -94,14 +36,62 @@ body {
color: var(--text-primary); color: var(--text-primary);
} }
/* Bare border utilities have no colour of their own */ .dark .bg-white { background-color: var(--bg-secondary); }
.border, .border-t, .border-b, .border-l, .border-r { border-color: var(--border-color); } .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); }
/* Form controls don't inherit the surface token on their own */ /* Status accents — desaturated to sit on Pro Dark's neutral background */
input, select, textarea { .dark .bg-green-50 { background-color: #1a3d2c; }
background-color: var(--bg-secondary); .dark .text-green-600 { color: #6ee7b7; }
color: var(--text-primary); .dark .text-green-700 { color: #6ee7b7; }
border-color: var(--border-color); .dark .text-green-400 { color: #6ee7b7; }
} .dark .bg-amber-50 { background-color: #3a2e14; }
.dark .text-amber-800 { color: #f5c66f; }
::selection { background-color: var(--accent-bg); color: var(--text-primary); } .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; }

View File

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

View File

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

View File

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

View File

@ -68,13 +68,13 @@ function AutocompleteInput({ value, onChange, onEnter, suggestions = [], classNa
<div <div
ref={listRef} ref={listRef}
style={{ position: 'fixed', top: dropPos.top, left: dropPos.left, minWidth: dropPos.minWidth, zIndex: 9999 }} style={{ position: 'fixed', top: dropPos.top, left: dropPos.left, minWidth: dropPos.minWidth, zIndex: 9999 }}
className="bg-surface border border-line rounded shadow-lg max-h-48 overflow-y-auto" className="bg-white border border-gray-200 rounded shadow-lg max-h-48 overflow-y-auto"
> >
{filtered.map((s, i) => ( {filtered.map((s, i) => (
<div <div
key={s} key={s}
className={`px-2 py-1 text-xs cursor-pointer whitespace-nowrap ${ className={`px-2 py-1 text-xs cursor-pointer whitespace-nowrap ${
i === highlighted ? 'bg-accent-soft text-accent' : 'text-ink-soft hover:bg-raised' i === highlighted ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'
}`} }`}
onMouseDown={e => { e.preventDefault(); select(s) }} onMouseDown={e => { e.preventDefault(); select(s) }}
> >
@ -100,11 +100,11 @@ function SortHeader({ col, label, sortBy, onSort, className = '' }) {
const active = sortBy?.col === col const active = sortBy?.col === col
return ( return (
<th <th
className={`px-3 py-2 font-medium cursor-pointer select-none hover:text-ink-soft ${className}`} className={`px-3 py-2 font-medium cursor-pointer select-none hover:text-gray-600 ${className}`}
onClick={() => onSort(col)} onClick={() => onSort(col)}
> >
{label} {label}
<span className="ml-1 text-muted">{active ? (sortBy.dir === 'asc' ? '↑' : '↓') : '↕'}</span> <span className="ml-1 text-gray-300">{active ? (sortBy.dir === 'asc' ? '↑' : '↓') : '↕'}</span>
</th> </th>
) )
} }
@ -354,18 +354,18 @@ export default function Mappings({ source, onNeedsReprocess }) {
} }
} }
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div> if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
const displayRows = sortedRows(filteredRows) const displayRows = sortedRows(filteredRows)
return ( return (
<div> <div>
{/* Sticky control bar */} {/* Sticky control bar */}
<div className="sticky top-0 z-10 bg-surface border-b border-line px-6 py-3 flex items-center gap-3 flex-wrap"> <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-ink-soft">{source}</span> <span className="text-sm font-medium text-gray-700">{source}</span>
<select <select
className="text-sm border border-line rounded px-2 py-1.5 focus:outline-none focus:border-accent" className="text-sm border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:border-blue-400"
value={selectedRule} value={selectedRule}
onChange={e => setSelectedRule(e.target.value)} onChange={e => setSelectedRule(e.target.value)}
> >
@ -374,7 +374,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
</select> </select>
{selectedRule && ( {selectedRule && (
<div className="flex bg-raised rounded p-0.5"> <div className="flex bg-gray-100 rounded p-0.5">
{[ {[
{ key: 'all', label: `All (${allValues.length})` }, { key: 'all', label: `All (${allValues.length})` },
{ key: 'unmapped', label: `Unmapped (${unmappedCount})` }, { key: 'unmapped', label: `Unmapped (${unmappedCount})` },
@ -382,7 +382,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
].map(({ key, label }) => ( ].map(({ key, label }) => (
<button key={key} onClick={() => setFilter(key)} <button key={key} onClick={() => setFilter(key)}
className={`text-xs px-3 py-1 rounded transition-colors ${ className={`text-xs px-3 py-1 rounded transition-colors ${
filter === key ? 'bg-surface text-ink shadow-sm' : 'text-muted' filter === key ? 'bg-white text-gray-800 shadow-sm' : 'text-gray-500'
}`}> }`}>
{label} {label}
</button> </button>
@ -393,15 +393,15 @@ export default function Mappings({ source, onNeedsReprocess }) {
{selectedRule && ( {selectedRule && (
<div className="relative"> <div className="relative">
<input <input
className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-accent ${ className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-blue-400 ${
rowFilterError ? 'border-danger-line bg-danger-soft' : rowFilter ? 'border-accent-line' : 'border-line' rowFilterError ? 'border-red-400 bg-red-50' : rowFilter ? 'border-blue-300' : 'border-gray-200'
}`} }`}
placeholder="filter regex…" placeholder="filter regex…"
value={rowFilter} value={rowFilter}
onChange={e => setRowFilter(e.target.value)} onChange={e => setRowFilter(e.target.value)}
/> />
{rowFilter && !rowFilterError && ( {rowFilter && !rowFilterError && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted"> <span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-gray-400">
{filteredRows.length} {filteredRows.length}
</span> </span>
)} )}
@ -435,12 +435,12 @@ export default function Mappings({ source, onNeedsReprocess }) {
alert(err.message) alert(err.message)
} }
}} }}
className="text-sm px-3 py-1.5 border border-line rounded hover:bg-raised text-ink-soft" className="text-sm px-3 py-1.5 border border-gray-200 rounded hover:bg-gray-50 text-gray-600"
> >
Export TSV Export TSV
</button> </button>
)} )}
<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' : ''}`}> <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' : ''}`}>
{importing ? 'Importing…' : 'Import TSV'} {importing ? 'Importing…' : 'Import TSV'}
<input type="file" accept=".tsv,.txt" className="hidden" onChange={handleImportCSV} /> <input type="file" accept=".tsv,.txt" className="hidden" onChange={handleImportCSV} />
</label> </label>
@ -450,24 +450,24 @@ export default function Mappings({ source, onNeedsReprocess }) {
{/* Content */} {/* Content */}
<div className="p-6"> <div className="p-6">
{!selectedRule && ( {!selectedRule && (
<p className="text-sm text-muted">Select a rule to view mappings.</p> <p className="text-sm text-gray-400">Select a rule to view mappings.</p>
)} )}
{selectedRule && loading && ( {selectedRule && loading && (
<p className="text-sm text-muted">Loading</p> <p className="text-sm text-gray-400">Loading</p>
)} )}
{selectedRule && !loading && allValues.length === 0 && ( {selectedRule && !loading && allValues.length === 0 && (
<p className="text-sm text-muted">No extracted values for this rule. Run a transform first.</p> <p className="text-sm text-gray-400">No extracted values for this rule. Run a transform first.</p>
)} )}
{selectedRule && !loading && allValues.length > 0 && ( {selectedRule && !loading && allValues.length > 0 && (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
{/* Bulk assign bar */} {/* Bulk assign bar */}
{selected.size > 0 && ( {selected.size > 0 && (
<div className="flex items-center gap-2 mb-2 p-2 bg-accent-soft border border-accent-line rounded flex-wrap"> <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-accent font-medium whitespace-nowrap">{selected.size} selected</span> <span className="text-xs text-blue-700 font-medium whitespace-nowrap">{selected.size} selected</span>
{cols.map(col => ( {cols.map(col => (
<AutocompleteInput <AutocompleteInput
key={col} key={col}
className="border border-accent-line rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-accent bg-surface" className="border border-blue-300 rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-blue-500 bg-white"
placeholder={col} placeholder={col}
value={bulkDraft[col] || ''} value={bulkDraft[col] || ''}
onChange={v => setBulkDraft(d => ({ ...d, [col]: v }))} onChange={v => setBulkDraft(d => ({ ...d, [col]: v }))}
@ -483,15 +483,15 @@ export default function Mappings({ source, onNeedsReprocess }) {
</button> </button>
<button <button
onClick={() => { setSelected(new Set()); setBulkDraft({}) }} onClick={() => { setSelected(new Set()); setBulkDraft({}) }}
className="text-xs text-accent hover:text-accent" className="text-xs text-blue-400 hover:text-blue-600"
> >
cancel cancel
</button> </button>
</div> </div>
)} )}
<table className="w-full text-xs bg-surface border border-line rounded"> <table className="w-full text-xs bg-white border border-gray-200 rounded">
<thead> <thead>
<tr className="text-left text-muted border-b border-line-soft bg-raised"> <tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50">
<th className="px-2 py-2 w-6"> <th className="px-2 py-2 w-6">
<input <input
type="checkbox" type="checkbox"
@ -511,7 +511,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
{extraCols.map((col, idx) => ( {extraCols.map((col, idx) => (
<th key={`extra-${idx}`} className="px-3 py-2 font-medium"> <th key={`extra-${idx}`} className="px-3 py-2 font-medium">
<input <input
className="border border-line rounded px-1 py-0.5 w-24 focus:outline-none focus:border-accent font-normal" className="border border-gray-200 rounded px-1 py-0.5 w-24 focus:outline-none focus:border-blue-400 font-normal"
value={col} value={col}
placeholder="new key" placeholder="new key"
onChange={e => setExtraCols(ec => { const c = [...ec]; c[idx] = e.target.value; return c })} 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"> <th className="px-2 py-2">
<button <button
onClick={() => setExtraCols(ec => [...ec, ''])} onClick={() => setExtraCols(ec => [...ec, ''])}
className="text-muted hover:text-ink-soft font-medium" className="text-gray-400 hover:text-gray-700 font-medium"
title="Add column" title="Add column"
>+</button> >+</button>
</th> </th>
@ -536,7 +536,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
const isSaving = saving[k] const isSaving = saving[k]
const isSelected = selected.has(k) const isSelected = selected.has(k)
const hasDraft = !!(drafts[k] && Object.keys(drafts[k]).length > 0) const hasDraft = !!(drafts[k] && Object.keys(drafts[k]).length > 0)
const rowBg = isSelected ? 'bg-accent-soft' : hasDraft ? 'bg-accent-soft' : row.is_mapped ? '' : 'bg-warn-soft' const rowBg = isSelected ? 'bg-blue-50' : hasDraft ? 'bg-blue-50' : row.is_mapped ? '' : 'bg-yellow-50'
function handleRowClick(e) { function handleRowClick(e) {
if (e.target.closest('input,button,a,select')) return if (e.target.closest('input,button,a,select')) return
@ -571,7 +571,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
key={k} key={k}
ref={el => rowRefs.current[k] = el} ref={el => rowRefs.current[k] = el}
tabIndex={0} tabIndex={0}
className={`border-t border-line-soft hover:bg-raised cursor-pointer outline-none ${rowBg}`} className={`border-t border-gray-50 hover:bg-gray-50 cursor-pointer outline-none ${rowBg}`}
onClick={handleRowClick} onClick={handleRowClick}
onKeyDown={handleRowKeyDown} onKeyDown={handleRowKeyDown}
> >
@ -586,13 +586,13 @@ export default function Mappings({ source, onNeedsReprocess }) {
}} }}
/> />
</td> </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 font-mono text-gray-800 whitespace-nowrap">{displayValue(row.extracted_value)}</td>
<td className="px-3 py-1.5 text-right text-muted">{row.record_count}</td> <td className="px-3 py-1.5 text-right text-gray-400">{row.record_count}</td>
{cols.map(col => ( {cols.map(col => (
<td key={col} className="px-3 py-1.5"> <td key={col} className="px-3 py-1.5">
<AutocompleteInput <AutocompleteInput
className={`border rounded px-2 py-1 w-full min-w-24 focus:outline-none focus:border-accent ${ className={`border rounded px-2 py-1 w-full min-w-24 focus:outline-none focus:border-blue-400 ${
hasDraft ? 'border-accent-line' : row.is_mapped ? 'border-line' : 'border-warn-line' hasDraft ? 'border-blue-300' : row.is_mapped ? 'border-gray-200' : 'border-yellow-300'
}`} }`}
value={cellVal(col)} value={cellVal(col)}
onChange={v => setCellValue(row.extracted_value, col, v)} 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"> <td className="px-3 py-1.5 whitespace-nowrap">
{samples.length > 0 && ( {samples.length > 0 && (
<button <button
className="text-accent hover:text-accent" className="text-blue-400 hover:text-blue-600"
onClick={() => setSampleOpen(s => ({ ...s, [k]: !s[k] }))} onClick={() => setSampleOpen(s => ({ ...s, [k]: !s[k] }))}
> >
{sampleOpen[k] ? 'hide' : 'show'} {sampleOpen[k] ? 'hide' : 'show'}
@ -624,7 +624,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
{row.is_mapped && ( {row.is_mapped && (
<button <button
onClick={() => deleteRow(row)} onClick={() => deleteRow(row)}
className="text-danger hover:text-danger text-base leading-none" className="text-red-400 hover:text-red-600 text-base leading-none"
title="Remove mapping" title="Remove mapping"
>×</button> >×</button>
)} )}
@ -634,21 +634,21 @@ export default function Mappings({ source, onNeedsReprocess }) {
{sampleOpen[k] && (() => { {sampleOpen[k] && (() => {
const sampleCols = [...new Set(samples.flatMap(r => Object.keys(r)))] const sampleCols = [...new Set(samples.flatMap(r => Object.keys(r)))]
return ( return (
<tr key={`${k}-sample`} className="border-t border-line-soft bg-raised"> <tr key={`${k}-sample`} className="border-t border-gray-50 bg-gray-50">
<td colSpan={3 + cols.length + 4} className="px-3 py-2"> <td colSpan={3 + cols.length + 4} className="px-3 py-2">
<table className="w-full text-xs border border-line-soft rounded bg-surface"> <table className="w-full text-xs border border-gray-100 rounded bg-white">
<thead> <thead>
<tr className="bg-raised border-b border-line-soft"> <tr className="bg-gray-50 border-b border-gray-100">
{sampleCols.map(c => ( {sampleCols.map(c => (
<th key={c} className="px-2 py-1 text-left font-medium text-muted whitespace-nowrap">{c}</th> <th key={c} className="px-2 py-1 text-left font-medium text-gray-400 whitespace-nowrap">{c}</th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{samples.map((rec, i) => ( {samples.map((rec, i) => (
<tr key={i} className="border-t border-line-soft"> <tr key={i} className="border-t border-gray-50">
{sampleCols.map(c => ( {sampleCols.map(c => (
<td key={c} className="px-2 py-1 font-mono text-ink-soft whitespace-nowrap"> <td key={c} className="px-2 py-1 font-mono text-gray-600 whitespace-nowrap">
{rec[c] != null ? String(rec[c]) : ''} {rec[c] != null ? String(rec[c]) : ''}
</td> </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 }) viewer.restore({ table: selectedView, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG })
} }
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div> if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
const cols = inspectedRows?.length ? Object.keys(inspectedRows[0]) : [] 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"> <div className="w-full h-full flex flex-col">
{/* Layouts sub-bar */} {/* Layouts sub-bar */}
<div className="flex items-center gap-2 px-3 h-9 bg-surface border-b border-line shrink-0 text-xs"> <div className="flex items-center gap-2 px-3 h-9 bg-white border-b border-gray-200 shrink-0 text-xs">
{layouts.map(l => ( {layouts.map(l => (
<div key={l.id} <div key={l.id}
onClick={() => applyLayout(l)} onClick={() => applyLayout(l)}
className={`flex items-center gap-1 rounded px-2 py-0.5 cursor-pointer border transition-colors className={`flex items-center gap-1 rounded px-2 py-0.5 cursor-pointer border transition-colors
${activeLayoutId === l.id ${activeLayoutId === l.id
? 'bg-accent-soft border-accent-line text-accent' ? 'bg-blue-50 border-blue-300 text-blue-700'
: 'bg-surface border-line text-ink-soft hover:border-line'}`}> : 'bg-white border-gray-200 text-gray-600 hover:border-gray-400'}`}>
{l.layout_name} {l.layout_name}
<button <button
onClick={(e) => handleDelete(l, e)} onClick={(e) => handleDelete(l, e)}
className="text-muted hover:text-danger leading-none ml-0.5 text-sm">×</button> className="text-gray-300 hover:text-red-400 leading-none ml-0.5 text-sm">×</button>
</div> </div>
))} ))}
{activeLayoutId !== null && !showSaveAs && ( {activeLayoutId !== null && !showSaveAs && (
<button onClick={handleSaveOver} <button onClick={handleSaveOver}
className="text-accent hover:text-accent border border-accent-line rounded px-2 py-0.5"> className="text-blue-500 hover:text-blue-700 border border-blue-200 rounded px-2 py-0.5">
Save Save
</button> </button>
)} )}
@ -446,27 +446,27 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
onChange={e => setSaveAsName(e.target.value)} onChange={e => setSaveAsName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }} onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }}
placeholder="Layout name…" placeholder="Layout name…"
className="border border-line rounded px-2 py-0.5 w-36 focus:outline-none focus:border-accent" className="border border-gray-300 rounded px-2 py-0.5 w-36 focus:outline-none focus:border-blue-400"
/> />
<button onClick={handleSaveAs} className="text-accent hover:text-accent px-1">Save</button> <button onClick={handleSaveAs} className="text-blue-600 hover:text-blue-800 px-1">Save</button>
<button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-muted hover:text-ink-soft px-1">Cancel</button> <button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-gray-400 hover:text-gray-600 px-1">Cancel</button>
</div> </div>
) : ( ) : (
<button <button
onClick={() => setShowSaveAs(true)} onClick={() => setShowSaveAs(true)}
className="text-muted hover:text-ink-soft border border-dashed border-line rounded px-2 py-0.5"> className="text-gray-400 hover:text-gray-600 border border-dashed border-gray-200 rounded px-2 py-0.5">
+ Save as + Save as
</button> </button>
)} )}
{activeLayoutId !== null && ( {activeLayoutId !== null && (
<button onClick={handleResetToDefault} className="text-muted hover:text-muted ml-1">reset</button> <button onClick={handleResetToDefault} className="text-gray-300 hover:text-gray-500 ml-1">reset</button>
)} )}
{layoutMsg && <span className="text-ok ml-1">{layoutMsg}</span>} {layoutMsg && <span className="text-green-600 ml-1">{layoutMsg}</span>}
<div className="ml-auto flex items-center gap-1"> <div className="ml-auto flex items-center gap-1">
<span className="text-muted">depth:</span> <span className="text-gray-400">depth:</span>
{[0, 1, 2, 3].map(d => ( {[0, 1, 2, 3].map(d => (
<button key={d} onClick={async () => { <button key={d} onClick={async () => {
const v = viewerRef.current; if (!v) return const v = viewerRef.current; if (!v) return
@ -475,7 +475,7 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
const p = await v.getPlugin() const p = await v.getPlugin()
await p.draw(view) await p.draw(view)
expandDepthRef.current = d expandDepthRef.current = d
}} className="border border-line rounded px-1.5 py-0.5 text-muted hover:border-line"> }} className="border border-gray-200 rounded px-1.5 py-0.5 text-gray-500 hover:border-gray-400">
{d} {d}
</button> </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 flex min-h-0">
<div className="relative flex-1"> <div className="relative flex-1">
{status === 'loading' && ( {status === 'loading' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-raised"> <div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
<p className="text-sm text-muted">Loading</p> <p className="text-sm text-gray-400">Loading</p>
</div> </div>
)} )}
{status === 'error' && ( {status === 'error' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-raised"> <div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
<p className="text-sm text-danger">Error: {error}</p> <p className="text-sm text-red-500">Error: {error}</p>
</div> </div>
)} )}
{status === 'noview' && ( {status === 'noview' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-raised"> <div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
<p className="text-sm text-muted">No view data generate a view and transform records first.</p> <p className="text-sm text-gray-400">No view data generate a view and transform records first.</p>
</div> </div>
)} )}
<perspective-viewer <perspective-viewer
@ -509,7 +509,7 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
{inspectedRows && clickDetail && ( {inspectedRows && clickDetail && (
<div <div
style={{ width: paneWidth }} style={{ width: paneWidth }}
className="relative border-l border-line bg-surface flex flex-col overflow-hidden flex-shrink-0" className="relative border-l border-gray-200 bg-white flex flex-col overflow-hidden flex-shrink-0"
> >
{/* Drag-to-resize handle on left edge */} {/* Drag-to-resize handle on left edge */}
<div <div
@ -529,27 +529,27 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
/> />
{/* Header: breadcrumb + row count + controls */} {/* Header: breadcrumb + row count + controls */}
<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 justify-between pl-3 pr-2 py-2 border-b border-gray-100 flex-shrink-0">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
{cellCoords.length > 0 && ( {cellCoords.length > 0 && (
<span className="text-xs text-ink-soft font-mono font-semibold truncate"> <span className="text-xs text-gray-700 font-mono font-semibold truncate">
{cellCoords.join(' ')} {cellCoords.join(' ')}
</span> </span>
)} )}
<span className="text-xs text-muted flex-shrink-0"> <span className="text-xs text-gray-400 flex-shrink-0">
{inspectedRows.length} row{inspectedRows.length !== 1 ? 's' : ''} {inspectedRows.length} row{inspectedRows.length !== 1 ? 's' : ''}
</span> </span>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<button onClick={() => setDecimals(d => Math.max(0, d - 1))} <button onClick={() => setDecimals(d => Math.max(0, d - 1))}
className="text-xs text-muted hover:text-ink-soft w-4 text-center"></button> className="text-xs text-gray-400 hover:text-gray-600 w-4 text-center"></button>
<span className="text-xs text-muted w-4 text-center">{decimals}</span> <span className="text-xs text-gray-400 w-4 text-center">{decimals}</span>
<button onClick={() => setDecimals(d => Math.min(8, d + 1))} <button onClick={() => setDecimals(d => Math.min(8, d + 1))}
className="text-xs text-muted hover:text-ink-soft w-4 text-center">+</button> className="text-xs text-gray-400 hover:text-gray-600 w-4 text-center">+</button>
</div> </div>
<button onClick={() => { setInspectedRows(null); setClickDetail(null); lastClickKeyRef.current = null }} <button onClick={() => { setInspectedRows(null); setClickDetail(null); lastClickKeyRef.current = null }}
className="text-muted hover:text-muted leading-none text-lg">×</button> className="text-gray-300 hover:text-gray-500 leading-none text-lg">×</button>
</div> </div>
</div> </div>
@ -558,10 +558,10 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
{(() => { {(() => {
const userFilters = (clickDetail.eventFilters || []).filter(([f]) => !coordFields.has(f)) const userFilters = (clickDetail.eventFilters || []).filter(([f]) => !coordFields.has(f))
return userFilters.length > 0 ? ( return userFilters.length > 0 ? (
<div className="px-3 py-2 border-b border-line-soft"> <div className="px-3 py-2 border-b border-gray-100">
<div className="text-xs text-muted uppercase tracking-wide mb-1">Filters</div> <div className="text-xs text-gray-400 uppercase tracking-wide mb-1">Filters</div>
{userFilters.map((f, i) => ( {userFilters.map((f, i) => (
<div key={i} className="text-xs text-muted py-0.5 font-mono">{f.join(' ')}</div> <div key={i} className="text-xs text-gray-500 py-0.5 font-mono">{f.join(' ')}</div>
))} ))}
</div> </div>
) : null ) : null
@ -572,13 +572,13 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
<div className="overflow-auto"> <div className="overflow-auto">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead> <thead>
<tr className="text-left text-muted border-b border-line-soft bg-raised sticky top-0"> <tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50 sticky top-0">
{cols.map(c => { {cols.map(c => {
const active = sortCol === c const active = sortCol === c
return ( return (
<th key={c} <th key={c}
onClick={() => { if (active) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSortCol(c); setSortDir('asc') } }} 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-ink-soft"> className="px-2 py-1 font-medium whitespace-nowrap cursor-pointer select-none hover:text-gray-600">
{c}{active ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''} {c}{active ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''}
</th> </th>
) )
@ -587,12 +587,12 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
</thead> </thead>
<tbody> <tbody>
{sortedRows.map((row, i) => ( {sortedRows.map((row, i) => (
<tr key={i} className="border-t border-line-soft hover:bg-raised"> <tr key={i} className="border-t border-gray-50 hover:bg-gray-50">
{cols.map(c => { {cols.map(c => {
const f = formatVal(row[c], decimals) const f = formatVal(row[c], decimals)
return ( return (
<td key={c} className="px-2 py-1 font-mono whitespace-nowrap text-ink-soft max-w-40 truncate"> <td key={c} className="px-2 py-1 font-mono whitespace-nowrap text-gray-700 max-w-40 truncate">
{f == null ? <span className="text-muted"></span> : f} {f == null ? <span className="text-gray-300"></span> : f}
</td> </td>
) )
})} })}
@ -601,7 +601,7 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
</tbody> </tbody>
{Object.keys(totals).length > 0 && ( {Object.keys(totals).length > 0 && (
<tfoot> <tfoot>
<tr className="border-t-2 border-line bg-raised font-semibold text-ink-soft sticky bottom-0"> <tr className="border-t-2 border-gray-200 bg-gray-50 font-semibold text-gray-700 sticky bottom-0">
{cols.map(c => ( {cols.map(c => (
<td key={c} className="px-2 py-1 font-mono whitespace-nowrap text-right"> <td key={c} className="px-2 py-1 font-mono whitespace-nowrap text-right">
{totals[c] != null ? formatVal(totals[c], decimals) : ''} {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 && ( {open && filtered.length > 0 && dropPos && (
<div ref={listRef} <div ref={listRef}
style={{ position: 'fixed', top: dropPos.top, left: dropPos.left, minWidth: dropPos.minWidth, zIndex: 9999 }} style={{ position: 'fixed', top: dropPos.top, left: dropPos.left, minWidth: dropPos.minWidth, zIndex: 9999 }}
className="bg-surface border border-line rounded shadow-lg max-h-40 overflow-y-auto"> className="bg-white border border-gray-200 rounded shadow-lg max-h-40 overflow-y-auto">
{filtered.map((s, i) => ( {filtered.map((s, i) => (
<div key={s} <div key={s}
className={`px-2 py-1 text-xs cursor-pointer whitespace-nowrap ${i === highlighted ? 'bg-accent-soft text-accent' : 'text-ink-soft hover:bg-raised'}`} 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'}`}
onMouseDown={e => { e.preventDefault(); select(s) }}>{s}</div> onMouseDown={e => { e.preventDefault(); select(s) }}>{s}</div>
))} ))}
</div> </div>
@ -283,7 +283,7 @@ export default function Records({ source }) {
} }
} }
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div> if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
const displayCols = gridCols(rows.length > 0 ? Object.keys(rows[0]) : cols) const displayCols = gridCols(rows.length > 0 ? Object.keys(rows[0]) : cols)
const visCols = gridCols(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 h-full min-h-0 overflow-hidden">
<div className="flex-1 overflow-auto p-6 min-w-0"> <div className="flex-1 overflow-auto p-6 min-w-0">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold text-ink">Records {source}</h1> <h1 className="text-xl font-semibold text-gray-800">Records {source}</h1>
{exists && rows.length > 0 && ( {exists && rows.length > 0 && (
<span className="text-xs text-muted font-mono">dfv.{source}</span> <span className="text-xs text-gray-400 font-mono">dfv.{source}</span>
)} )}
</div> </div>
{/* Filter bar */} {/* Filter bar */}
{exists !== false && visCols.length > 0 && ( {exists !== false && visCols.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2 items-center"> <div className="mb-4 flex flex-wrap gap-2 items-center">
<span className="text-xs text-muted font-medium mr-1">DB query:</span> <span className="text-xs text-gray-400 font-medium mr-1">DB query:</span>
{filters.map((f, i) => ( {filters.map((f, i) => (
<div key={i} className="flex items-center gap-1 bg-surface border border-line rounded px-2 py-1"> <div key={i} className="flex items-center gap-1 bg-white border border-gray-200 rounded px-2 py-1">
<select <select
className="text-xs text-ink-soft border-0 focus:outline-none bg-transparent" className="text-xs text-gray-600 border-0 focus:outline-none bg-transparent"
value={f.col} value={f.col}
onChange={e => updateFilter(i, 'col', e.target.value)} onChange={e => updateFilter(i, 'col', e.target.value)}
> >
{visCols.map(c => <option key={c} value={c}>{c}</option>)} {visCols.map(c => <option key={c} value={c}>{c}</option>)}
</select> </select>
<span className="text-xs text-muted mx-0.5">~*</span> <span className="text-xs text-gray-300 mx-0.5">~*</span>
<input <input
className="text-xs font-mono border-0 focus:outline-none w-36 bg-transparent" className="text-xs font-mono border-0 focus:outline-none w-36 bg-transparent"
placeholder="regex…" placeholder="regex…"
value={f.pattern} value={f.pattern}
onChange={e => updateFilter(i, 'pattern', e.target.value)} onChange={e => updateFilter(i, 'pattern', e.target.value)}
/> />
<button onClick={() => removeFilter(i)} className="text-muted hover:text-muted ml-1 leading-none">×</button> <button onClick={() => removeFilter(i)} className="text-gray-300 hover:text-gray-500 ml-1 leading-none">×</button>
</div> </div>
))} ))}
<button onClick={addFilter} <button onClick={addFilter}
className="text-xs text-muted hover:text-ink-soft border border-dashed border-line rounded px-2 py-1"> className="text-xs text-gray-400 hover:text-gray-600 border border-dashed border-gray-200 rounded px-2 py-1">
+ filter + filter
</button> </button>
{filters.length > 0 && ( {filters.length > 0 && (
<button onClick={() => { setFilters([]); setOffset(0); load(0, sort.col, sort.dir, []) }} <button onClick={() => { setFilters([]); setOffset(0); load(0, sort.col, sort.dir, []) }}
className="text-xs text-muted hover:text-danger">clear</button> className="text-xs text-gray-400 hover:text-red-500">clear</button>
)} )}
</div> </div>
)} )}
@ -343,24 +343,24 @@ export default function Records({ source }) {
{/* Bulk select + override bar */} {/* Bulk select + override bar */}
{exists && visCols.length > 0 && ( {exists && visCols.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2 items-center"> <div className="mb-4 flex flex-wrap gap-2 items-center">
<span className="text-xs text-muted font-medium mr-1">Bulk select:</span> <span className="text-xs text-gray-400 font-medium mr-1">Bulk select:</span>
<input <input
className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-accent ${ className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-blue-400 ${
rowFilter ? 'border-accent-line' : 'border-line' rowFilter ? 'border-blue-300' : 'border-gray-200'
}`} }`}
placeholder="regex on loaded rows…" placeholder="regex on loaded rows…"
value={rowFilter} value={rowFilter}
onChange={e => setRowFilter(e.target.value)} onChange={e => setRowFilter(e.target.value)}
/> />
{rowFilter && ( {rowFilter && (
<span className="text-xs text-muted">{selected.size} of {rows.length} rows selected</span> <span className="text-xs text-gray-400">{selected.size} of {rows.length} rows selected</span>
)} )}
{selected.size > 0 && ( {selected.size > 0 && (
<div className="flex items-center gap-2 ml-4 p-2 bg-accent-soft border border-accent-line rounded flex-wrap"> <div className="flex items-center gap-2 ml-4 p-2 bg-blue-50 border border-blue-200 rounded flex-wrap">
{allOverrideCols.map(col => ( {allOverrideCols.map(col => (
<AutocompleteInput <AutocompleteInput
key={col} key={col}
className="border border-accent-line rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-accent bg-surface" className="border border-blue-300 rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-blue-500 bg-white"
placeholder={col} placeholder={col}
value={bulkDraft[col] || ''} value={bulkDraft[col] || ''}
onChange={v => setBulkDraft(d => ({ ...d, [col]: v }))} onChange={v => setBulkDraft(d => ({ ...d, [col]: v }))}
@ -395,7 +395,7 @@ export default function Records({ source }) {
</button> </button>
<button <button
onClick={() => { setSelected(new Set()); setBulkDraft({}); setRowFilter('') }} onClick={() => { setSelected(new Set()); setBulkDraft({}); setRowFilter('') }}
className="text-xs text-accent hover:text-accent" className="text-xs text-blue-400 hover:text-blue-600"
> >
cancel cancel
</button> </button>
@ -404,25 +404,25 @@ export default function Records({ source }) {
</div> </div>
)} )}
{loading && <p className="text-sm text-muted">Loading</p>} {loading && <p className="text-sm text-gray-400">Loading</p>}
{!loading && viewError && <p className="text-sm text-danger">View error: {viewError} check field types in Sources.</p>} {!loading && viewError && <p className="text-sm text-red-500">View error: {viewError} check field types in Sources.</p>}
{!loading && exists === false && ( {!loading && exists === false && (
<p className="text-sm text-muted"> <p className="text-sm text-gray-400">
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>. 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> </p>
)} )}
{!loading && exists && rows.length === 0 && ( {!loading && exists && rows.length === 0 && (
<p className="text-sm text-muted"> <p className="text-sm text-gray-400">
{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.'} {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> </p>
)} )}
{!loading && exists && rows.length > 0 && ( {!loading && exists && rows.length > 0 && (
<> <>
<div className="bg-surface border border-line rounded overflow-auto mb-4"> <div className="bg-white border border-gray-200 rounded overflow-auto mb-4">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-left text-xs text-muted border-b border-line-soft bg-raised"> <tr className="text-left text-xs text-gray-400 border-b border-gray-100 bg-gray-50">
<th className="px-2 py-2 w-8"> <th className="px-2 py-2 w-8">
<input <input
type="checkbox" type="checkbox"
@ -438,9 +438,9 @@ export default function Records({ source }) {
const active = sort.col === col const active = sort.col === col
return ( return (
<th key={col} onClick={() => toggleSort(col)} <th key={col} onClick={() => toggleSort(col)}
className="px-3 py-2 font-medium whitespace-nowrap cursor-pointer select-none hover:text-ink-soft"> className="px-3 py-2 font-medium whitespace-nowrap cursor-pointer select-none hover:text-gray-600">
{col} {col}
<span className="ml-1 text-muted">{active ? (sort.dir === 'asc' ? '▲' : '▼') : '⇅'}</span> <span className="ml-1 text-gray-300">{active ? (sort.dir === 'asc' ? '▲' : '▼') : '⇅'}</span>
</th> </th>
) )
})} })}
@ -453,8 +453,8 @@ export default function Records({ source }) {
const isPanelSelected = selectedRow?.id != null && selectedRow.id === row.id const isPanelSelected = selectedRow?.id != null && selectedRow.id === row.id
return ( return (
<tr key={i} onClick={() => openPanel(row)} <tr key={i} onClick={() => openPanel(row)}
className={`border-t border-line-soft cursor-pointer transition-colors className={`border-t border-gray-50 cursor-pointer transition-colors
${isPanelSelected ? 'bg-accent-soft' : isRowSelected ? 'bg-accent-soft' : isOverridden ? 'bg-warn-soft hover:bg-warn-soft' : 'hover:bg-raised'}`}> ${isPanelSelected ? 'bg-blue-50' : isRowSelected ? 'bg-blue-50' : isOverridden ? 'bg-amber-50 hover:bg-amber-100' : 'hover:bg-gray-50'}`}>
<td className="px-2 py-2"> <td className="px-2 py-2">
<input <input
type="checkbox" type="checkbox"
@ -469,8 +469,8 @@ export default function Records({ source }) {
{displayCols.map((col, j) => { {displayCols.map((col, j) => {
const formatted = formatVal(row[col]) const formatted = formatVal(row[col])
return ( return (
<td key={j} className="px-3 py-2 text-xs text-ink-soft whitespace-nowrap max-w-48 truncate"> <td key={j} className="px-3 py-2 text-xs text-gray-600 whitespace-nowrap max-w-48 truncate">
{formatted === null ? <span className="text-muted"></span> : formatted} {formatted === null ? <span className="text-gray-300"></span> : formatted}
</td> </td>
) )
})} })}
@ -481,12 +481,12 @@ export default function Records({ source }) {
</table> </table>
</div> </div>
<div className="flex items-center gap-3 text-sm text-muted"> <div className="flex items-center gap-3 text-sm text-gray-500">
<button onClick={prev} disabled={offset === 0} <button onClick={prev} disabled={offset === 0}
className="px-3 py-1 border border-line rounded hover:bg-raised disabled:opacity-40"> Prev</button> className="px-3 py-1 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-40"> Prev</button>
<span>{offset + 1}{offset + rows.length}</span> <span>{offset + 1}{offset + rows.length}</span>
<button onClick={next} disabled={rows.length < LIMIT} <button onClick={next} disabled={rows.length < LIMIT}
className="px-3 py-1 border border-line rounded hover:bg-raised disabled:opacity-40">Next </button> className="px-3 py-1 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-40">Next </button>
</div> </div>
</> </>
)} )}
@ -494,58 +494,58 @@ export default function Records({ source }) {
{/* Panel */} {/* Panel */}
{panelOpen && ( {panelOpen && (
<div className="w-80 border-l border-line bg-surface flex flex-col overflow-hidden flex-shrink-0"> <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-line-soft"> <div className="flex items-center justify-between px-3 py-2 border-b border-gray-100">
<span className="text-xs font-semibold text-ink-soft uppercase tracking-wide">Record</span> <span className="text-xs font-semibold text-gray-600 uppercase tracking-wide">Record</span>
<button onClick={closePanel} className="text-muted hover:text-muted leading-none text-lg">×</button> <button onClick={closePanel} className="text-gray-300 hover:text-gray-500 leading-none text-lg">×</button>
</div> </div>
{panelLoading && <p className="text-xs text-muted p-3">Loading</p>} {panelLoading && <p className="text-xs text-gray-400 p-3">Loading</p>}
{selectedRecord && !panelLoading && ( {selectedRecord && !panelLoading && (
<div className="flex-1 overflow-y-auto flex flex-col min-h-0"> <div className="flex-1 overflow-y-auto flex flex-col min-h-0">
{panelMsg && ( {panelMsg && (
<div className={`text-xs px-3 py-2 border-b border-line-soft ${panelMsg.ok ? 'text-ok' : 'text-danger'}`}> <div className={`text-xs px-3 py-2 border-b border-gray-100 ${panelMsg.ok ? 'text-green-600' : 'text-red-500'}`}>
{panelMsg.text} {panelMsg.text}
</div> </div>
)} )}
{/* Raw fields — read only */} {/* Raw fields — read only */}
<div className="border-b border-line-soft"> <div className="border-b border-gray-100">
<div className="px-3 py-1.5 bg-raised border-b border-line-soft"> <div className="px-3 py-1.5 bg-gray-50 border-b border-gray-100">
<span className="text-xs font-medium text-muted uppercase tracking-wide">Raw</span> <span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Raw</span>
</div> </div>
{Object.entries(selectedRecord.data || {}).map(([field, val]) => ( {Object.entries(selectedRecord.data || {}).map(([field, val]) => (
<div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-line-soft first:border-t-0"> <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-muted w-28 shrink-0 truncate">{field}</span> <span className="text-xs font-mono text-gray-400 w-28 shrink-0 truncate">{field}</span>
<span className="text-xs font-mono text-muted truncate">{formatVal(val) ?? <span className="text-muted"></span>}</span> <span className="text-xs font-mono text-gray-500 truncate">{formatVal(val) ?? <span className="text-gray-300"></span>}</span>
</div> </div>
))} ))}
</div> </div>
{/* Transformed fields — read only delta */} {/* Transformed fields — read only delta */}
<div className="border-b border-line-soft"> <div className="border-b border-gray-100">
<div className="px-3 py-1.5 bg-raised border-b border-line-soft"> <div className="px-3 py-1.5 bg-gray-50 border-b border-gray-100">
<span className="text-xs font-medium text-muted uppercase tracking-wide">Transformed</span> <span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Transformed</span>
</div> </div>
{Object.entries(selectedRecord.transformed || {}).filter(([k]) => !HIDDEN_COLS.has(k)).length === 0 {Object.entries(selectedRecord.transformed || {}).filter(([k]) => !HIDDEN_COLS.has(k)).length === 0
? <div className="px-3 py-2 text-xs text-muted">No rule output yet.</div> ? <div className="px-3 py-2 text-xs text-gray-300">No rule output yet.</div>
: Object.entries(selectedRecord.transformed || {}).filter(([k]) => !HIDDEN_COLS.has(k)).map(([field, val]) => ( : 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-line-soft first:border-t-0"> <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-muted w-28 shrink-0 truncate">{field}</span> <span className="text-xs font-mono text-gray-400 w-28 shrink-0 truncate">{field}</span>
<span className="text-xs font-mono text-accent truncate">{formatVal(val) ?? <span className="text-muted"></span>}</span> <span className="text-xs font-mono text-blue-600 truncate">{formatVal(val) ?? <span className="text-gray-300"></span>}</span>
</div> </div>
)) ))
} }
</div> </div>
{/* Overrides — editable */} {/* Overrides — editable */}
<div className="flex-1 border-b border-line-soft"> <div className="flex-1 border-b border-gray-100">
<div className="flex items-center justify-between px-3 py-1.5 bg-raised border-b border-line-soft"> <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-muted uppercase tracking-wide">Overrides</span> <span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Overrides</span>
<button <button
onClick={() => setExtraCols(ec => [...ec, ''])} onClick={() => setExtraCols(ec => [...ec, ''])}
className="text-muted hover:text-ink-soft font-medium text-sm leading-none" className="text-gray-400 hover:text-gray-700 font-medium text-sm leading-none"
title="Add field">+</button> title="Add field">+</button>
</div> </div>
<table className="w-full text-xs"> <table className="w-full text-xs">
@ -559,14 +559,14 @@ export default function Records({ source }) {
const placeholder = formatVal(selectedRecord.transformed?.[col]) ?? '' const placeholder = formatVal(selectedRecord.transformed?.[col]) ?? ''
const suggestions = [...(globalValues[col] || [])].sort() const suggestions = [...(globalValues[col] || [])].sort()
return ( return (
<tr key={col} className="border-t border-line-soft"> <tr key={col} className="border-t border-gray-50">
<td className="px-3 py-1.5 w-28 shrink-0"> <td className="px-3 py-1.5 w-28 shrink-0">
<span className="font-mono text-muted truncate block">{col}</span> <span className="font-mono text-gray-500 truncate block">{col}</span>
</td> </td>
<td className="px-1 py-1.5"> <td className="px-1 py-1.5">
<AutocompleteInput <AutocompleteInput
className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${ className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${
override ? 'border-warn-line bg-warn-soft text-warn' : 'border-line text-ink-soft' override ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-gray-200 text-gray-600'
}`} }`}
value={override} value={override}
placeholder={placeholder} placeholder={placeholder}
@ -579,7 +579,7 @@ export default function Records({ source }) {
{override && ( {override && (
<button <button
onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })} onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })}
className="text-muted hover:text-danger leading-none text-base">×</button> className="text-gray-300 hover:text-red-400 leading-none text-base">×</button>
)} )}
</td> </td>
</tr> </tr>
@ -589,10 +589,10 @@ export default function Records({ source }) {
const val = overrideDraft[col] ?? '' const val = overrideDraft[col] ?? ''
const suggestions = [...(globalValues[col] || [])].sort() const suggestions = [...(globalValues[col] || [])].sort()
return ( return (
<tr key={`extra-${i}`} className="border-t border-line-soft"> <tr key={`extra-${i}`} className="border-t border-gray-50">
<td className="px-3 py-1.5 w-28 shrink-0"> <td className="px-3 py-1.5 w-28 shrink-0">
<input <input
className="w-full text-xs font-mono border border-line rounded px-1 py-0.5 focus:outline-none focus:border-accent" className="w-full text-xs font-mono border border-gray-200 rounded px-1 py-0.5 focus:outline-none focus:border-blue-400"
value={col} value={col}
placeholder="field name" placeholder="field name"
onChange={e => { onChange={e => {
@ -610,7 +610,7 @@ export default function Records({ source }) {
<td className="px-1 py-1.5"> <td className="px-1 py-1.5">
<AutocompleteInput <AutocompleteInput
className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${ className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${
val ? 'border-warn-line bg-warn-soft text-warn' : 'border-line text-ink-soft' val ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-gray-200 text-gray-600'
}`} }`}
value={val} value={val}
onChange={v => setOverrideDraft(d => ({ ...d, [col]: v }))} onChange={v => setOverrideDraft(d => ({ ...d, [col]: v }))}
@ -622,7 +622,7 @@ export default function Records({ source }) {
{val && ( {val && (
<button <button
onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })} onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })}
className="text-muted hover:text-danger leading-none text-base">×</button> className="text-gray-300 hover:text-red-400 leading-none text-base">×</button>
)} )}
</td> </td>
</tr> </tr>
@ -632,7 +632,7 @@ export default function Records({ source }) {
</table> </table>
</div> </div>
<div className="flex gap-2 px-3 py-2 border-t border-line-soft shrink-0"> <div className="flex gap-2 px-3 py-2 border-t border-gray-100 shrink-0">
<button <button
onClick={handleSaveOverrides} onClick={handleSaveOverrides}
disabled={panelSaving || !isDirty} disabled={panelSaving || !isDirty}
@ -643,7 +643,7 @@ export default function Records({ source }) {
<button <button
onClick={handleClearOverrides} onClick={handleClearOverrides}
disabled={panelSaving} disabled={panelSaving}
className="text-xs border border-line rounded px-3 py-1.5 text-muted hover:border-danger-line hover:text-danger disabled:opacity-40"> 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">
Clear Clear
</button> </button>
)} )}

View File

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

View File

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

View File

@ -1,424 +0,0 @@
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>
)
}

View File

@ -1,388 +0,0 @@
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>
)
}

590
ui/src/pages/Sources.jsx Normal file
View File

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