Compare commits
10 Commits
teller-syn
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a1e6edc74d | |||
| 3297acf9db | |||
| 1edfbfac43 | |||
| 8753719db8 | |||
| 356e61d043 | |||
| 4eda1c48b7 | |||
| aa9315fdd2 | |||
| 43d968b248 | |||
| 9f164bcd34 | |||
| 3613037ab5 |
16
.env.example
16
.env.example
@ -9,13 +9,9 @@ DB_PASSWORD=your_password_here
|
|||||||
API_PORT=3000
|
API_PORT=3000
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
# Teller (optional — only needed for API-based bank feeds)
|
# SimpleFIN (optional — only needed for API-based bank feeds)
|
||||||
# Client certificate + key downloaded from the Teller dashboard; every API call
|
# The access URL is the credential: claim a setup token once via
|
||||||
# presents them (mutual TLS). Keep both outside the repo.
|
# POST /api/sources/simplefin-claim and paste the result here.
|
||||||
TELLER_CERT_PATH=/etc/dataflow/teller/certificate.pem
|
# A source picks its bridge with config.simplefin.access_url_env;
|
||||||
TELLER_KEY_PATH=/etc/dataflow/teller/private_key.pem
|
# SIMPLEFIN_ACCESS_URL is the default.
|
||||||
|
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
|
|
||||||
|
|||||||
@ -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 in the sidebar header in `App.jsx`; effect writes `localStorage` and toggles the `.dark` class on `<html>`
|
- **Toggle:** button at the foot of the sidebar (`Sidebar.jsx`), and in `BottomNav.jsx` on mobile; the effect writes `localStorage` and toggles the `.dark` class on `<html>`
|
||||||
- **CSS:** `ui/src/index.css` defines CSS custom properties under `:root` (light) and `.dark`. All Tailwind color overrides are written as `.dark .bg-white { ... }` etc.
|
- **CSS:** `ui/src/index.css` declares semantic tokens under `@theme` (`bg-surface`, `text-ink`, `text-muted`, `border-line`, `text-danger`, …) that resolve to CSS custom properties redefined by `.dark`. **Write components against the tokens, never against literal shades like `bg-white` or `text-gray-400`** — the old per-utility `.dark .bg-white { … }` overrides are gone and must not come back
|
||||||
- **Palette:** dark mode uses Perspective's "Pro Dark" colours (`--bg-primary: #242526`, panels `#2a2c2f`, gridlines `#3b3f46`, text `#c5c9d0`)
|
- **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()`
|
||||||
|
|||||||
42
api/lib/fields.js
Normal file
42
api/lib/fields.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Field inference
|
||||||
|
* Derives a field list and column types from sample records, so a source can be
|
||||||
|
* configured from real data rather than an assumed shape. Used by the CSV
|
||||||
|
* suggest endpoint and by bank-feed sampling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param records array of flat objects
|
||||||
|
* @param limit how many rows to look at
|
||||||
|
* @returns { fields: [{name, type}], sampleRows }
|
||||||
|
*
|
||||||
|
* Keys are unioned across the sample, not read off the first row — API feeds
|
||||||
|
* omit optional keys entirely on records that lack them.
|
||||||
|
*/
|
||||||
|
function inferFields(records, limit = 50) {
|
||||||
|
const sampleRows = records.slice(0, limit);
|
||||||
|
|
||||||
|
const keys = [];
|
||||||
|
for (const row of sampleRows) {
|
||||||
|
for (const key of Object.keys(row)) {
|
||||||
|
if (!keys.includes(key)) keys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = keys.map(key => {
|
||||||
|
const vals = sampleRows.map(r => r[key]).filter(v => v !== '' && v != null);
|
||||||
|
let type = 'text';
|
||||||
|
if (vals.length > 0 && vals.every(v => !isNaN(parseFloat(v)) && isFinite(v) && String(v).charAt(0) !== '0')) {
|
||||||
|
type = 'numeric';
|
||||||
|
} else if (vals.length > 0 && vals.every(v => ISO_DATE_RE.test(String(v)))) {
|
||||||
|
type = 'date';
|
||||||
|
}
|
||||||
|
return { name: key, type };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { fields, sampleRows };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { inferFields };
|
||||||
219
api/lib/simplefin.js
Normal file
219
api/lib/simplefin.js
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* SimpleFIN Bridge client
|
||||||
|
*
|
||||||
|
* Read-only access to linked bank accounts. Auth is a single "access URL" with
|
||||||
|
* credentials embedded (https://user:pass@bridge.simplefin.org/simplefin),
|
||||||
|
* obtained once by claiming a setup token. No certificates, no refresh flow —
|
||||||
|
* the URL is the credential, so it lives in .env and never in the database.
|
||||||
|
*
|
||||||
|
* Protocol: https://www.simplefin.org/protocol.html
|
||||||
|
*/
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT = 30000;
|
||||||
|
|
||||||
|
// The bridge refreshes from banks roughly daily and transactions can land a few
|
||||||
|
// days late, so pulls overlap by design and lean on constraint_key for dedupe.
|
||||||
|
const DEFAULT_DAYS = 10;
|
||||||
|
|
||||||
|
// The bridge hard-caps ranges at 90 days and reports the capping in `errors`;
|
||||||
|
// 89 stays just inside it so a routine backfill doesn't look like data loss. It
|
||||||
|
// also advises staying under 45 days, which it says on anything longer — that
|
||||||
|
// notice is passed through, since it is a real hint about future behaviour.
|
||||||
|
// Omitting start-date entirely is not "everything": it returns only the most
|
||||||
|
// recent handful of transactions, so a start-date is always sent.
|
||||||
|
const MAX_DAYS = 89;
|
||||||
|
|
||||||
|
class SimpleFinError extends Error {
|
||||||
|
constructor(message, status) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SimpleFinError';
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve an access URL from the environment. Sources name their own variable
|
||||||
|
// via config.simplefin.access_url_env so several bridges can coexist.
|
||||||
|
function getAccessUrl(urlEnv) {
|
||||||
|
const name = urlEnv || 'SIMPLEFIN_ACCESS_URL';
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new SimpleFinError(`SimpleFIN access URL not found — set ${name} in .env`, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(value);
|
||||||
|
} catch {
|
||||||
|
throw new SimpleFinError(`${name} is not a valid URL`, 500);
|
||||||
|
}
|
||||||
|
if (!parsed.username) {
|
||||||
|
throw new SimpleFinError(`${name} has no credentials — expected https://user:pass@host/path`, 500);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(accessUrl, path, params) {
|
||||||
|
// Credentials travel in the Authorization header, not the request line.
|
||||||
|
const target = new URL(accessUrl.pathname + path, accessUrl.origin);
|
||||||
|
for (const [k, v] of Object.entries(params || {})) {
|
||||||
|
if (v !== undefined && v !== null) target.searchParams.append(k, String(v));
|
||||||
|
}
|
||||||
|
const auth = Buffer.from(`${decodeURIComponent(accessUrl.username)}:${decodeURIComponent(accessUrl.password)}`).toString('base64');
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(target, {
|
||||||
|
headers: { Authorization: `Basic ${auth}`, Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new SimpleFinError(`SimpleFIN request failed: ${err.message}`, 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
throw new SimpleFinError(
|
||||||
|
'SimpleFIN rejected the access URL — it may have been revoked; claim a new setup token', res.status);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new SimpleFinError(`SimpleFIN returned ${res.status}: ${(await res.text()).slice(0, 200)}`, res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
throw new SimpleFinError('SimpleFIN returned a non-JSON response', 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange a setup token for a permanent access URL. Run once per bridge; the
|
||||||
|
* returned URL goes in .env. The token is base64 of a one-shot claim URL and is
|
||||||
|
* consumed by this call.
|
||||||
|
*/
|
||||||
|
async function claimSetupToken(setupToken) {
|
||||||
|
let claimUrl;
|
||||||
|
try {
|
||||||
|
claimUrl = Buffer.from(String(setupToken).trim(), 'base64').toString('utf8');
|
||||||
|
new URL(claimUrl);
|
||||||
|
} catch {
|
||||||
|
throw new SimpleFinError('Setup token is not valid base64 of a claim URL', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(claimUrl, { method: 'POST', signal: AbortSignal.timeout(REQUEST_TIMEOUT) });
|
||||||
|
} catch (err) {
|
||||||
|
throw new SimpleFinError(`Claim request failed: ${err.message}`, 502);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new SimpleFinError(
|
||||||
|
`Claim returned ${res.status} — setup tokens can only be claimed once`, res.status);
|
||||||
|
}
|
||||||
|
return (await res.text()).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Epoch seconds → YYYY-MM-DD, so dates sort and compare as plain text the way
|
||||||
|
// CSV-imported dates already do. Pending transactions carry posted=0 rather than
|
||||||
|
// omitting it, so falsy epochs are "no date", not 1970-01-01.
|
||||||
|
function toDate(epochSeconds) {
|
||||||
|
if (!epochSeconds) return null;
|
||||||
|
return new Date(epochSeconds * 1000).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten a SimpleFIN transaction into the shallow map the rule engine expects.
|
||||||
|
//
|
||||||
|
// Every scalar the bridge sends is passed through rather than whitelisted, so
|
||||||
|
// institution-specific fields (mcc, and whatever a given bank adds) show up in
|
||||||
|
// the data and in field discovery without a code change here. Only the epoch
|
||||||
|
// timestamps are reshaped, and account context is folded in so records stay
|
||||||
|
// self-describing.
|
||||||
|
function flatten(txn, account) {
|
||||||
|
const out = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(txn)) {
|
||||||
|
if (value === null || value === undefined) continue;
|
||||||
|
if (typeof value === 'object') continue; // nested objects handled below
|
||||||
|
out[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `extra` is free-form and institution-specific; hoist its scalars under a
|
||||||
|
// prefix so they can't collide with the documented fields
|
||||||
|
for (const [key, value] of Object.entries(txn.extra || {})) {
|
||||||
|
if (value !== null && value !== undefined && typeof value !== 'object') {
|
||||||
|
out[`extra_${key}`] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const posted = toDate(txn.posted);
|
||||||
|
const transacted = toDate(txn.transacted_at);
|
||||||
|
|
||||||
|
delete out.posted; // replaced by the YYYY-MM-DD forms below
|
||||||
|
|
||||||
|
// A pending transaction has no posted date yet; fall back to when it was
|
||||||
|
// transacted so every record has something usable to sort and filter on
|
||||||
|
out.date = posted || transacted;
|
||||||
|
out.posted_date = posted;
|
||||||
|
out.transacted_at = transacted;
|
||||||
|
out.pending = txn.pending ? 'true' : 'false';
|
||||||
|
|
||||||
|
out.account_id = account.id;
|
||||||
|
out.account_name = account.name;
|
||||||
|
out.organization = account.org?.name || account.org?.domain;
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAccounts(urlEnv) {
|
||||||
|
const data = await get(getAccessUrl(urlEnv), '/accounts', { 'balances-only': 1 });
|
||||||
|
return {
|
||||||
|
errors: data.errors || [],
|
||||||
|
accounts: (data.accounts || []).map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
organization: a.org?.name || a.org?.domain,
|
||||||
|
currency: a.currency,
|
||||||
|
balance: a.balance,
|
||||||
|
available_balance: a['available-balance'],
|
||||||
|
balance_date: toDate(a['balance-date']),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch transactions for one account.
|
||||||
|
*
|
||||||
|
* days — how far back to ask for; 0 or more than MAX_DAYS means the full 90
|
||||||
|
* includePending — pending transactions get a new id once they post, so they are excluded by default
|
||||||
|
*/
|
||||||
|
async function fetchTransactions({ accountId, accessUrlEnv, days, includePending = false }) {
|
||||||
|
days = Number.isFinite(days) ? days : DEFAULT_DAYS;
|
||||||
|
if (days <= 0 || days > MAX_DAYS) days = MAX_DAYS;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
account: accountId,
|
||||||
|
'start-date': Math.floor(Date.now() / 1000) - days * 86400,
|
||||||
|
};
|
||||||
|
if (includePending) params.pending = 1;
|
||||||
|
|
||||||
|
const data = await get(getAccessUrl(accessUrlEnv), '/accounts', params);
|
||||||
|
|
||||||
|
// The bridge reports per-institution problems here and still returns 200 —
|
||||||
|
// one bank being down must not look like a successful empty pull.
|
||||||
|
const errors = data.errors || [];
|
||||||
|
const account = (data.accounts || []).find(a => a.id === accountId);
|
||||||
|
if (!account) {
|
||||||
|
const detail = errors.length ? ` — bridge reported: ${errors.join('; ')}` : '';
|
||||||
|
throw new SimpleFinError(`Account ${accountId} not returned by SimpleFIN${detail}`, 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
const txns = account.transactions || [];
|
||||||
|
const kept = includePending ? txns : txns.filter(t => !t.pending);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetched: txns.length,
|
||||||
|
errors,
|
||||||
|
records: kept.map(t => flatten(t, account)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listAccounts, fetchTransactions, claimSetupToken, SimpleFinError, DEFAULT_DAYS, MAX_DAYS };
|
||||||
@ -1,167 +0,0 @@
|
|||||||
/**
|
|
||||||
* Teller API client
|
|
||||||
*
|
|
||||||
* Read-only access to enrolled bank accounts. Teller uses mutual TLS: every
|
|
||||||
* request presents the client certificate downloaded from the Teller dashboard,
|
|
||||||
* and the enrollment's access token is sent as the HTTP Basic *username* with an
|
|
||||||
* empty password.
|
|
||||||
*
|
|
||||||
* Uses https.request rather than fetch — Node's global fetch is undici, which
|
|
||||||
* ignores an https.Agent and needs its own dispatcher type for client certs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const https = require('https');
|
|
||||||
|
|
||||||
const API_HOST = 'api.teller.io';
|
|
||||||
|
|
||||||
// Teller returns transactions newest-first with no date filter — only a count
|
|
||||||
// and a cursor. We over-fetch and trim by date on our side.
|
|
||||||
const DEFAULT_COUNT = 200;
|
|
||||||
const DEFAULT_DAYS = 10;
|
|
||||||
|
|
||||||
class TellerError extends Error {
|
|
||||||
constructor(message, status) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'TellerError';
|
|
||||||
this.status = status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _agent = null;
|
|
||||||
|
|
||||||
// The mTLS agent is built once and reused; the cert never changes at runtime.
|
|
||||||
function getAgent() {
|
|
||||||
if (_agent) return _agent;
|
|
||||||
|
|
||||||
const certPath = process.env.TELLER_CERT_PATH;
|
|
||||||
const keyPath = process.env.TELLER_KEY_PATH;
|
|
||||||
if (!certPath || !keyPath) {
|
|
||||||
throw new TellerError('TELLER_CERT_PATH and TELLER_KEY_PATH must be set in .env', 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
let cert, key;
|
|
||||||
try {
|
|
||||||
cert = fs.readFileSync(certPath);
|
|
||||||
key = fs.readFileSync(keyPath);
|
|
||||||
} catch (err) {
|
|
||||||
throw new TellerError(`Cannot read Teller certificate: ${err.message}`, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
_agent = new https.Agent({ cert, key, keepAlive: true });
|
|
||||||
return _agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve an enrollment's access token from the environment. Sources name their
|
|
||||||
// own variable via config.teller.token_env so several banks can coexist.
|
|
||||||
function getToken(tokenEnv) {
|
|
||||||
const name = tokenEnv || 'TELLER_TOKEN';
|
|
||||||
const token = process.env[name];
|
|
||||||
if (!token) {
|
|
||||||
throw new TellerError(`Teller access token not found — set ${name} in .env`, 500);
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
function get(path, token) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = https.request({
|
|
||||||
host: API_HOST,
|
|
||||||
path,
|
|
||||||
method: 'GET',
|
|
||||||
agent: getAgent(),
|
|
||||||
auth: `${token}:`,
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
}, (res) => {
|
|
||||||
let body = '';
|
|
||||||
res.setEncoding('utf8');
|
|
||||||
res.on('data', chunk => { body += chunk; });
|
|
||||||
res.on('end', () => {
|
|
||||||
if (res.statusCode === 401) {
|
|
||||||
return reject(new TellerError(
|
|
||||||
'Teller rejected the access token (401) — the enrollment likely needs to be reconnected through Teller Connect', 401));
|
|
||||||
}
|
|
||||||
if (res.statusCode >= 400) {
|
|
||||||
let detail = body.slice(0, 200);
|
|
||||||
try { detail = JSON.parse(body).error?.message || detail; } catch { /* keep raw body */ }
|
|
||||||
return reject(new TellerError(`Teller returned ${res.statusCode}: ${detail}`, res.statusCode));
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
resolve(JSON.parse(body));
|
|
||||||
} catch {
|
|
||||||
reject(new TellerError('Teller returned a non-JSON response', 502));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', err => reject(new TellerError(`Teller request failed: ${err.message}`, 502)));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flatten a Teller transaction into the shallow string map the rule engine
|
|
||||||
// expects. Nested `details` is hoisted; `links` is dropped.
|
|
||||||
function flatten(txn) {
|
|
||||||
const details = txn.details || {};
|
|
||||||
const counterparty = details.counterparty || {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: txn.id,
|
|
||||||
date: txn.date,
|
|
||||||
description: txn.description,
|
|
||||||
amount: txn.amount,
|
|
||||||
status: txn.status,
|
|
||||||
type: txn.type,
|
|
||||||
running_balance: txn.running_balance,
|
|
||||||
account_id: txn.account_id,
|
|
||||||
category: details.category,
|
|
||||||
processing_status: details.processing_status,
|
|
||||||
counterparty_name: counterparty.name,
|
|
||||||
counterparty_type: counterparty.type,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listAccounts(tokenEnv) {
|
|
||||||
const accounts = await get('/accounts', getToken(tokenEnv));
|
|
||||||
return accounts.map(a => ({
|
|
||||||
id: a.id,
|
|
||||||
name: a.name,
|
|
||||||
type: a.type,
|
|
||||||
subtype: a.subtype,
|
|
||||||
last_four: a.last_four,
|
|
||||||
status: a.status,
|
|
||||||
institution: a.institution?.name,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch transactions for one account.
|
|
||||||
*
|
|
||||||
* days — keep transactions on or after today minus this many days; 0 fetches everything returned
|
|
||||||
* includePending — pending transaction ids change when they post, so they are excluded by default
|
|
||||||
*/
|
|
||||||
async function fetchTransactions({ accountId, tokenEnv, count, days, includePending = false }) {
|
|
||||||
// Callers pass values straight off the query string, so anything unparseable
|
|
||||||
// falls back to the default rather than becoming NaN.
|
|
||||||
count = Number.isFinite(count) ? count : DEFAULT_COUNT;
|
|
||||||
days = Number.isFinite(days) ? days : DEFAULT_DAYS;
|
|
||||||
|
|
||||||
const token = getToken(tokenEnv);
|
|
||||||
const txns = await get(`/accounts/${encodeURIComponent(accountId)}/transactions?count=${count}`, token);
|
|
||||||
|
|
||||||
let kept = txns;
|
|
||||||
if (!includePending) {
|
|
||||||
kept = kept.filter(t => t.status === 'posted');
|
|
||||||
}
|
|
||||||
if (days > 0) {
|
|
||||||
const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
|
|
||||||
kept = kept.filter(t => t.date >= cutoff); // ISO dates compare correctly as strings
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
fetched: txns.length,
|
|
||||||
records: kept.map(flatten),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { listAccounts, fetchTransactions, TellerError, DEFAULT_COUNT, DEFAULT_DAYS };
|
|
||||||
@ -7,7 +7,8 @@ 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 teller = require('../lib/teller');
|
const simplefin = require('../lib/simplefin');
|
||||||
|
const { inferFields } = require('../lib/fields');
|
||||||
|
|
||||||
const upload = multer({ storage: multer.memoryStorage() });
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
|
|
||||||
@ -24,14 +25,52 @@ module.exports = (pool) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// List the accounts behind a Teller enrollment — used to find the account_id
|
// SimpleFIN helpers. Declared before /:name so they aren't shadowed by it.
|
||||||
// to put in a source's config. Declared before /:name so it isn't shadowed.
|
|
||||||
router.get('/teller-accounts', async (req, res, next) => {
|
// List the accounts behind a bridge — used to find the account_id for a source
|
||||||
|
router.get('/simplefin-accounts', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const accounts = await teller.listAccounts(req.query.token_env);
|
res.json(await simplefin.listAccounts(req.query.access_url_env));
|
||||||
res.json(accounts);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof teller.TellerError) return res.status(err.status || 502).json({ error: err.message });
|
if (err instanceof simplefin.SimpleFinError) return res.status(err.status || 502).json({ error: err.message });
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sample an account's real transactions and infer its field list — the API
|
||||||
|
// equivalent of uploading a CSV to /suggest. Whatever the account actually
|
||||||
|
// returns is what gets offered, so investment or loan accounts describe
|
||||||
|
// themselves rather than being forced into a checking-account shape.
|
||||||
|
router.get('/simplefin-sample', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { account_id, access_url_env } = req.query;
|
||||||
|
if (!account_id) return res.status(400).json({ error: 'account_id is required' });
|
||||||
|
|
||||||
|
const { fetched, records, errors } = await simplefin.fetchTransactions({
|
||||||
|
accountId: account_id,
|
||||||
|
accessUrlEnv: access_url_env,
|
||||||
|
// The bridge advises staying under 45 days and warns at exactly
|
||||||
|
// 45, so 44 is the largest quiet sample window
|
||||||
|
days: req.query.days !== undefined ? parseInt(req.query.days) : 44,
|
||||||
|
includePending: true, // widen the sample; pending rows can carry extra keys
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fields, sampleRows } = inferFields(records);
|
||||||
|
res.json({ fields, sampleRows, fetched, errors });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof simplefin.SimpleFinError) return res.status(err.status || 502).json({ error: err.message });
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exchange a one-shot setup token for the permanent access URL to put in .env
|
||||||
|
router.post('/simplefin-claim', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { setup_token } = req.body || {};
|
||||||
|
if (!setup_token) return res.status(400).json({ error: 'setup_token is required' });
|
||||||
|
res.json({ access_url: await simplefin.claimSetupToken(setup_token) });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof simplefin.SimpleFinError) return res.status(err.status || 502).json({ error: err.message });
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -65,21 +104,7 @@ 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 ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/;
|
const { fields, sampleRows } = inferFields(records);
|
||||||
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);
|
||||||
@ -152,7 +177,7 @@ module.exports = (pool) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pull transactions from Teller and import them, same as a CSV upload.
|
// Pull transactions from SimpleFIN and import them, same as a CSV upload.
|
||||||
// Safe to re-run: overlapping transactions are skipped by constraint key.
|
// 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 {
|
||||||
@ -160,24 +185,23 @@ 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 || {}).teller;
|
const cfg = (source.config || {}).simplefin;
|
||||||
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 teller.account_id in its config`
|
error: `Source "${req.params.name}" has no simplefin.account_id in its config`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const opts = { ...req.query, ...req.body };
|
const opts = { ...req.query, ...req.body };
|
||||||
const { fetched, records } = await teller.fetchTransactions({
|
const { fetched, errors, records } = await simplefin.fetchTransactions({
|
||||||
accountId: cfg.account_id,
|
accountId: cfg.account_id,
|
||||||
tokenEnv: cfg.token_env,
|
accessUrlEnv: cfg.access_url_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, imported: 0, duplicates: 0 });
|
return res.json({ success: true, fetched, errors, imported: 0, duplicates: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const importResult = await pool.query(
|
const importResult = await pool.query(
|
||||||
@ -185,15 +209,15 @@ module.exports = (pool) => {
|
|||||||
);
|
);
|
||||||
const importData = importResult.rows[0].result;
|
const importData = importResult.rows[0].result;
|
||||||
|
|
||||||
if (!importData.success) return res.json({ ...importData, fetched });
|
if (!importData.success) return res.json({ ...importData, fetched, errors });
|
||||||
|
|
||||||
const transformResult = await pool.query(
|
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, transform: transformResult.rows[0].result });
|
res.json({ ...importData, fetched, errors, transform: transformResult.rows[0].result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof teller.TellerError) return res.status(err.status || 502).json({ error: err.message });
|
if (err instanceof simplefin.SimpleFinError) return res.status(err.status || 502).json({ error: err.message });
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
116
docs/spec.md
116
docs/spec.md
@ -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
|
||||||
teller.js — Teller API client (mutual TLS) for bank transaction pulls
|
simplefin.js — SimpleFIN Bridge client 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,11 +59,15 @@ api/
|
|||||||
ui/
|
ui/
|
||||||
src/
|
src/
|
||||||
api.js — fetch wrapper, credential management
|
api.js — fetch wrapper, credential management
|
||||||
App.jsx — root: login gate, sidebar, source selector, routing
|
App.jsx — root: login gate, routing, stale/reprocess banners
|
||||||
|
index.css — semantic colour tokens for light and dark
|
||||||
pages/
|
pages/
|
||||||
Login.jsx — username/password form
|
Login.jsx — username/password form
|
||||||
Sources.jsx — source CRUD, field config, view generation
|
SourceList.jsx — source list and the create dialog
|
||||||
Import.jsx — CSV upload, Teller sync, and import log
|
SourceDetail.jsx — one source: connection, fields, view, maintenance
|
||||||
|
Bridge.jsx — SimpleFIN accounts, balances, and subtotals
|
||||||
|
ImportHub.jsx — all sources with sync / upload actions
|
||||||
|
Import.jsx — CSV upload, SimpleFIN sync, and import log
|
||||||
Rules.jsx — rule CRUD with live pattern preview
|
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
|
||||||
@ -71,7 +75,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, StatusBar
|
components/ — Sidebar, BottomNav, navItems, SourceTabs, Section, SampleTable
|
||||||
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
|
||||||
@ -113,38 +117,42 @@ CSV file → parse in Node.js → import_records(source, data)
|
|||||||
→ apply_transformations() runs automatically on new records
|
→ apply_transformations() runs automatically on new records
|
||||||
```
|
```
|
||||||
|
|
||||||
### Teller sync (API-based bank feeds)
|
### SimpleFIN sync (API-based bank feeds)
|
||||||
```
|
```
|
||||||
POST /api/sources/:name/sync → api/lib/teller.js
|
POST /api/sources/:name/sync → api/lib/simplefin.js
|
||||||
→ GET api.teller.io/accounts/:id/transactions (mutual TLS + access token)
|
→ GET {access_url}/accounts?account=…&start-date=… (Basic auth)
|
||||||
→ drop pending, trim to the last N days, flatten nested `details`
|
→ drop pending, flatten transactions, fold in account context
|
||||||
→ 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.** Teller uses mutual TLS. Every request presents the client
|
- **Authentication.** A SimpleFIN access URL *is* the credential — it carries
|
||||||
certificate and key from the Teller dashboard (`TELLER_CERT_PATH`,
|
its own username and password (`https://user:pass@bridge.simplefin.org/simplefin`).
|
||||||
`TELLER_KEY_PATH`), with the enrollment's access token as the HTTP Basic
|
You claim it once from a setup token (`POST /api/sources/simplefin-claim`,
|
||||||
*username* and an empty password. Access tokens come from the Teller Connect
|
which consumes the token) and store it in `.env`, one variable per bridge. It
|
||||||
browser flow, run once per bank, and live in `.env` — one variable per
|
is deliberately **not** stored in the database, which `manage.py` offers to reset.
|
||||||
enrollment. They are deliberately **not** stored in the database, which
|
- **Source config.** A source opts in by having `simplefin` in its `config` JSONB:
|
||||||
`manage.py` offers to reset.
|
`{"simplefin": {"account_id": "ACT-…", "access_url_env": "SIMPLEFIN_ACCESS_URL",
|
||||||
- **Source config.** A source opts in by having `teller` in its `config` JSONB:
|
"days": 10}}`. Only `account_id` is required. `GET /api/sources/simplefin-accounts`
|
||||||
`{"teller": {"account_id": "acc_…", "token_env": "TELLER_TOKEN_HUNTINGTON",
|
lists the accounts behind a bridge so you can find the id.
|
||||||
"days": 10, "count": 200}}`. Only `account_id` is required; `token_env`
|
- **`constraint_fields` should be `['id']`.** SimpleFIN assigns each transaction a
|
||||||
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's id changes when it posts, so importing it would produce
|
pending transaction gets a different id once it posts, so importing it would
|
||||||
a duplicate under a different key a day or two later.
|
produce a duplicate under a different key a day or two later.
|
||||||
- **Rolling window, not a cursor.** Teller's transaction endpoint takes a count,
|
- **Bridge errors are surfaced, not swallowed.** SimpleFIN returns HTTP 200 with
|
||||||
not a date range, so the route over-fetches (`count`, default 200) and trims
|
an `errors` array when an institution is failing. Those errors ride along in
|
||||||
to `days` (default 10, `0` for everything returned). Re-running the same
|
the sync response so a broken connection doesn't read as a successful empty
|
||||||
window is harmless; late-arriving transactions get picked up.
|
pull; the Import page shows them in orange above the counts.
|
||||||
|
- **Refresh cadence and the 90-day wall.** The bridge polls banks roughly daily
|
||||||
|
and transactions can take a few days to appear, so the pull asks for a rolling
|
||||||
|
window (`days`, default 10) rather than tracking a cursor. The window has hard
|
||||||
|
limits: SimpleFIN caps any range at 90 days and advises staying under 45, so
|
||||||
|
`MAX_DAYS` is 89 and `days=0` or anything larger clamps to it. A `start-date`
|
||||||
|
is **always** sent — omitting it does not mean "everything available", it
|
||||||
|
returns only the few most recent transactions.
|
||||||
- **Cron.** A daily pull is just the endpoint:
|
- **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`
|
||||||
|
|
||||||
@ -209,8 +217,9 @@ All routes are under `/api`. Every route requires HTTP Basic Auth. The `GET /hea
|
|||||||
| DELETE | /api/sources/:name | Delete source and all its data |
|
| 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 Teller and import them (`?days=`, `?count=`, `?include_pending=`) |
|
| POST | /api/sources/:name/sync | Pull transactions from SimpleFIN and import them (`?days=`, `?include_pending=`) |
|
||||||
| GET | /api/sources/teller-accounts | List accounts behind a Teller enrollment (`?token_env=`) |
|
| GET | /api/sources/simplefin-accounts | List accounts behind a bridge (`?access_url_env=`) |
|
||||||
|
| POST | /api/sources/simplefin-claim | Exchange a setup token for a permanent access URL |
|
||||||
| GET | /api/sources/import-log | Import history across all sources |
|
| GET | /api/sources/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 |
|
||||||
@ -322,11 +331,43 @@ Built with React + Vite + Tailwind CSS. Compiled output goes to `public/`. The s
|
|||||||
3. On 401 response, credentials are cleared and the login screen is shown.
|
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** — View and edit source configuration. Shows all known field names and their origins (raw data, schema, rules, mappings). Checkboxes control which fields are constraint fields and which appear in the output view. Supports CSV upload to auto-detect fields.
|
- **Sources** (`SourceList.jsx`) — Lists every source with its constraint fields and a
|
||||||
|
badge for bank feeds; clicking opens it. "New source" opens the create dialog, which can
|
||||||
|
seed fields from a CSV sample or from a linked SimpleFIN account.
|
||||||
|
|
||||||
- **Import** — Upload a CSV to import records into the selected source. Transformations run automatically on new records. Shows import log with inserted/duplicate counts, expandable key detail, checkbox selection, and delete with confirmation. Sources with `config.teller.account_id` also get a Sync panel — a window selector (10/30/90 days or everything) and a "Sync now" button that pulls from the bank API through the same import path.
|
- **Source detail** (`SourceDetail.jsx`) — The Setup tab, grouped into titled panels:
|
||||||
|
Connection (link/unlink a SimpleFIN account, warns when constraint fields aren't `id`),
|
||||||
|
Fields and view (all known field names and their origins, with checkboxes for constraint
|
||||||
|
fields and view columns), Sample rows, Maintenance (reprocess), and Delete source.
|
||||||
|
|
||||||
|
- **Bridge** (`Bridge.jsx`) — Every account behind the SimpleFIN credential with balances,
|
||||||
|
the source each maps to, and subtotals split into banking versus retirement (recognised by
|
||||||
|
keyword on the account and institution names). Queries SimpleFIN only when Refresh is
|
||||||
|
pressed. Balances render in accounting style with negatives in parentheses.
|
||||||
|
|
||||||
|
- **Import** (`ImportHub.jsx`) — Top-level entry point for the frequent job. Lists every
|
||||||
|
source with record counts and last import date, a Sync button for bank feeds, and an
|
||||||
|
upload link for CSV sources.
|
||||||
|
|
||||||
|
- **Source › Import tab** — Upload a CSV to import records into the selected source. Transformations run automatically on new records. Shows import log with inserted/duplicate counts, expandable key detail, checkbox selection, and delete with confirmation. Sources with `config.simplefin.account_id` also get a Sync panel — a window selector (10/30/45 days or a full backfill) and a "Sync now" button that pulls from the bank API through the same import path.
|
||||||
|
|
||||||
- **Rules** — Create and manage regex rules. Live preview fires automatically (debounced 500ms) as pattern/field/flags are edited, showing match results against real records. Rules can be enabled/disabled by toggle.
|
- **Rules** — Create and manage regex rules. Live preview fires automatically (debounced 500ms) as pattern/field/flags are edited, showing match results against real records. Rules can be enabled/disabled by toggle.
|
||||||
|
|
||||||
@ -353,7 +394,8 @@ Built with React + Vite + Tailwind CSS. Compiled output goes to `public/`. The s
|
|||||||
|
|
||||||
See `docs/perspective.md` for the full technical reference on controlling Perspective programmatically.
|
See `docs/perspective.md` for the full technical reference on controlling Perspective programmatically.
|
||||||
|
|
||||||
- **Stacks** — Named unions of multiple sources. Each stack defines a field mapping (how source fields map to common output columns), an amount field, a date field, and an optional balance offset. The view-data endpoint unions the underlying source views and computes a running balance sorted by date. The Pivot page supports stacks as well as individual sources, with layouts stored in the same `pivot_layouts` table.
|
- **Stacks** — Named unions of multiple sources, each chip linking to its pivot at
|
||||||
|
`/stacks/:name/pivot`. Each stack defines a field mapping (how source fields map to common output columns), an amount field, a date field, and an optional balance offset. The view-data endpoint unions the underlying source views and computes a running balance sorted by date. The Pivot page supports stacks as well as individual sources, with layouts stored in the same `pivot_layouts` table.
|
||||||
|
|
||||||
- **Log** — Global import log across all sources. Same expandable key detail and delete capability as the Import page, plus a source name column.
|
- **Log** — Global import log across all sources. Same expandable key detail and delete capability as the Import page, plus a source name column.
|
||||||
|
|
||||||
@ -392,7 +434,9 @@ Shows current status on every screen:
|
|||||||
|
|
||||||
9. **Set login credentials** — Prompts for username and password, bcrypt-hashes the password via `node -e "require('bcrypt')..."`, and writes `LOGIN_USER` and `LOGIN_PASSWORD_HASH` to `.env`. Requires Node.js and bcrypt npm package to be installed.
|
9. **Set login credentials** — Prompts for username and password, bcrypt-hashes the password via `node -e "require('bcrypt')..."`, and writes `LOGIN_USER` and `LOGIN_PASSWORD_HASH` to `.env`. Requires Node.js and bcrypt npm package to be installed.
|
||||||
|
|
||||||
10. **Uninstall** — Reverses everything the other options install, in reverse order: stops/disables/removes the systemd unit, removes the nginx site and reloads nginx, drops the database and its user (prompts for admin credentials), then deletes `.env`, `public/`, and `node_modules`. Lists exactly what it found before doing anything and requires typing `delete` to proceed. The repository itself is left in place.
|
10. **Claim SimpleFIN setup token** — Prompts for a setup token (hidden input), exchanges it for a permanent access URL via `api/lib/simplefin.js`, and writes `SIMPLEFIN_ACCESS_URL` to `.env`. Warns and confirms before replacing an existing URL. Setup tokens are single-use, so a failed claim generally means a new token is needed. Prints only the bridge host, never the embedded credentials.
|
||||||
|
|
||||||
|
11. **Uninstall** — Reverses everything the other options install, in reverse order: stops/disables/removes the systemd unit, removes the nginx site and reloads nginx, drops the database and its user (prompts for admin credentials), then deletes `.env`, `public/`, and `node_modules`. Lists exactly what it found before doing anything and requires typing `delete` to proceed. The repository itself is left in place.
|
||||||
|
|
||||||
**Key behaviors:**
|
**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.
|
||||||
@ -416,9 +460,7 @@ 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
|
||||||
|
|
||||||
TELLER_CERT_PATH Teller client certificate (only for Teller sync)
|
SIMPLEFIN_ACCESS_URL Default SimpleFIN bridge URL; per-source override via config.simplefin.access_url_env
|
||||||
TELLER_KEY_PATH Teller private key
|
|
||||||
TELLER_TOKEN Default Teller access token; per-source override via config.teller.token_env
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
53
manage.py
53
manage.py
@ -902,6 +902,58 @@ 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 = [
|
||||||
@ -914,6 +966,7 @@ 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),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -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>ui</title>
|
<title>Dataflow</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -1,26 +1,41 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, createElement, lazy, Suspense } from 'react'
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate, useParams } 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 Sources from './pages/Sources'
|
import SourceList from './pages/SourceList'
|
||||||
|
import SourceDetail from './pages/SourceDetail'
|
||||||
|
import Bridge from './pages/Bridge'
|
||||||
|
import ImportHub from './pages/ImportHub'
|
||||||
import Import from './pages/Import'
|
import 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'
|
||||||
import Pivot from './pages/Pivot'
|
const Pivot = lazy(() => import('./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())
|
||||||
@ -37,7 +52,6 @@ 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() {
|
||||||
@ -47,17 +61,11 @@ 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
|
||||||
@ -127,22 +135,20 @@ 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-amber-50 border-b border-amber-200 px-4 py-1.5 text-xs text-amber-800 flex flex-wrap items-center gap-x-3 gap-y-1">
|
<div className="bg-warn-soft border-b border-warn-line px-4 py-1.5 text-xs text-warn flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
<span className="font-medium">View out of sync:</span>
|
<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">
|
||||||
@ -150,20 +156,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-amber-200 hover:bg-amber-300 disabled:opacity-50 font-medium"
|
className="px-1.5 py-0.5 rounded bg-warn-line hover:bg-warn-line disabled:opacity-50 font-medium"
|
||||||
>
|
>
|
||||||
{generating[`src:${name}`] ? '…' : 'Generate'}
|
{generating[`src:${name}`] ? '…' : 'Generate'}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{staleSources.size > 0 && staleStacks.size > 0 && <span className="text-amber-400">|</span>}
|
{staleSources.size > 0 && staleStacks.size > 0 && <span className="text-warn">|</span>}
|
||||||
{[...staleStacks].map(name => (
|
{[...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-amber-200 hover:bg-amber-300 disabled:opacity-50 font-medium"
|
className="px-1.5 py-0.5 rounded bg-warn-line hover:bg-warn-line disabled:opacity-50 font-medium"
|
||||||
>
|
>
|
||||||
{generating[`stk:${name}`] ? '…' : 'Generate'}
|
{generating[`stk:${name}`] ? '…' : 'Generate'}
|
||||||
</button>
|
</button>
|
||||||
@ -172,7 +178,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{reprocessSources.size > 0 && (
|
{reprocessSources.size > 0 && (
|
||||||
<div className="bg-blue-50 border-b border-blue-200 px-4 py-1.5 text-xs text-blue-800 flex flex-wrap items-center gap-x-3 gap-y-1">
|
<div className="bg-accent-soft border-b border-accent-line px-4 py-1.5 text-xs text-accent flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
<span className="font-medium">Mappings updated:</span>
|
<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">
|
||||||
@ -189,22 +195,34 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto pb-14 md:pb-0">
|
||||||
|
<Suspense fallback={<div className="p-6 text-sm text-muted">Loading…</div>}>
|
||||||
<Routes>
|
<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="/import" element={<Import source={source} />} />
|
<Route path="/sources" element={<SourceList sources={sources} setSources={setSources} setSource={setSource} />} />
|
||||||
<Route path="/rules" element={<Rules source={source} onStale={markSourceStale} />} />
|
<Route path="/sources/:name" element={<SourceTabs sources={sources} />}>
|
||||||
<Route path="/mappings" element={<Mappings source={source} onNeedsReprocess={markNeedsReprocess} />} />
|
<Route index element={<Navigate to="records" replace />} />
|
||||||
|
<Route path="setup" element={<SourceDetail sources={sources} setSources={setSources} />} />
|
||||||
|
<Route path="import" element={<ScopedToSource component={Import} />} />
|
||||||
|
<Route path="rules" element={<ScopedToSource component={Rules} onStale={markSourceStale} />} />
|
||||||
|
<Route path="mappings" element={<ScopedToSource component={Mappings} onNeedsReprocess={markNeedsReprocess} />} />
|
||||||
|
<Route path="records" element={<ScopedToSource component={Records} />} />
|
||||||
|
<Route path="pivot" element={<ScopedToSource component={Pivot} />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/import" element={<ImportHub sources={sources} />} />
|
||||||
|
<Route path="/bridge" element={<Bridge sources={sources} />} />
|
||||||
|
<Route path="/stacks" element={<Stacks sources={sources} onStackStale={markStackStale} onStackViewGenerated={clearStackStale} />} />
|
||||||
|
<Route path="/stacks/:name/pivot" element={<StackPivot />} />
|
||||||
<Route path="/remap" element={<Remap />} />
|
<Route path="/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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,12 +66,18 @@ 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)
|
||||||
},
|
},
|
||||||
syncTeller: (name, opts = {}) => {
|
syncSimpleFin: (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}` : ''}`)
|
||||||
},
|
},
|
||||||
getTellerAccounts: (tokenEnv) =>
|
getSimpleFinSample: (accountId, days) => {
|
||||||
request('GET', `/sources/teller-accounts${tokenEnv ? `?token_env=${encodeURIComponent(tokenEnv)}` : ''}`),
|
const params = new URLSearchParams({ account_id: accountId })
|
||||||
|
if (days !== undefined) params.set('days', days)
|
||||||
|
return request('GET', `/sources/simplefin-sample?${params}`)
|
||||||
|
},
|
||||||
|
getSimpleFinAccounts: (accessUrlEnv) =>
|
||||||
|
request('GET', `/sources/simplefin-accounts${accessUrlEnv ? `?access_url_env=${encodeURIComponent(accessUrlEnv)}` : ''}`),
|
||||||
|
claimSimpleFinToken: (setup_token) => request('POST', '/sources/simplefin-claim', { setup_token }),
|
||||||
transform: (name) => request('POST', `/sources/${name}/transform`),
|
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`),
|
||||||
|
|||||||
40
ui/src/components/BottomNav.jsx
Normal file
40
ui/src/components/BottomNav.jsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { NavLink } from 'react-router-dom'
|
||||||
|
import useTheme from '../theme.jsx'
|
||||||
|
import { NAV } from './navItems.jsx'
|
||||||
|
|
||||||
|
// Phone-sized replacement for the sidebar: same destinations, thumb-reachable.
|
||||||
|
// Hidden from md: upward, where the sidebar takes over.
|
||||||
|
export default function BottomNav() {
|
||||||
|
const { dark, setDark } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="md:hidden fixed bottom-0 inset-x-0 z-20 bg-surface border-t border-line flex justify-around items-stretch h-14 pb-[env(safe-area-inset-bottom)]">
|
||||||
|
{NAV.map(({ to, label, icon }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex-1 flex flex-col items-center justify-center gap-0.5 text-[10px] ${
|
||||||
|
isActive ? 'text-accent' : 'text-muted'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>{icon}</span>
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => setDark(d => !d)}
|
||||||
|
className="flex-1 flex flex-col items-center justify-center gap-0.5 text-[10px] text-muted"
|
||||||
|
title={dark ? 'Light mode' : 'Dark mode'}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{dark
|
||||||
|
? <><circle cx="12" cy="12" r="4"/><line x1="12" y1="2" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="22"/><line x1="2" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="22" y2="12"/></>
|
||||||
|
: <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>}
|
||||||
|
</svg>
|
||||||
|
Theme
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
ui/src/components/SampleTable.jsx
Normal file
28
ui/src/components/SampleTable.jsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
// Compact preview of raw record values — used by the source detail page and by
|
||||||
|
// the create dialog when a CSV or bank feed is sampled
|
||||||
|
export default function SampleTable({ rows }) {
|
||||||
|
if (!rows || rows.length === 0) return null
|
||||||
|
const cols = Object.keys(rows[0])
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto border border-line-soft rounded bg-raised max-h-36">
|
||||||
|
<table className="text-xs w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-muted border-b border-line-soft bg-raised sticky top-0">
|
||||||
|
{cols.map(c => <th key={c} className="px-2 py-1 font-medium whitespace-nowrap">{c}</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<tr key={i} className="border-t border-line-soft">
|
||||||
|
{cols.map(c => (
|
||||||
|
<td key={c} className="px-2 py-1 whitespace-nowrap text-ink-soft max-w-32 truncate font-mono">
|
||||||
|
{row[c] == null ? <span className="text-muted">—</span> : String(row[c])}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
ui/src/components/Section.jsx
Normal file
13
ui/src/components/Section.jsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// One titled panel per job, so setup, schema, and destructive actions read as
|
||||||
|
// separate things rather than one flat form
|
||||||
|
export default function Section({ title, description, children }) {
|
||||||
|
return (
|
||||||
|
<section className="bg-surface border border-line rounded p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-ink-soft">{title}</h2>
|
||||||
|
{description
|
||||||
|
? <p className="text-xs text-muted mt-0.5 mb-3">{description}</p>
|
||||||
|
: <div className="mb-3" />}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,120 +1,47 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
const NAV = [
|
// Same distinction the Import page makes: a source is either on a bank feed or
|
||||||
{
|
// it gets CSVs uploaded to it.
|
||||||
to: '/sources',
|
const feedIcon = (
|
||||||
label: 'Sources',
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||||
icon: (
|
<path d="M4 10.5a4.5 4.5 0 0 1 4.5 4.5"/>
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
<path d="M4 6a9 9 0 0 1 9 9"/>
|
||||||
<ellipse cx="10" cy="5.5" rx="7" ry="2.5"/>
|
<circle cx="4.2" cy="14.8" r="1.2" fill="currentColor" stroke="none"/>
|
||||||
<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 = (
|
||||||
to: '/import',
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||||
label: 'Import',
|
<path d="M4 1.5h5l3 3v10H4z"/>
|
||||||
icon: (
|
<polyline points="9,1.5 9,4.5 12,4.5"/>
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
</svg>
|
||||||
<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"/>
|
export default function Sidebar({ expanded, setExpanded, loginUser, onLogout, sources = [] }) {
|
||||||
</svg>
|
const { dark, setDark } = useTheme()
|
||||||
),
|
|
||||||
},
|
// Bank feeds first, then CSV sources, alphabetical within each group
|
||||||
{
|
const navSources = useMemo(() => (
|
||||||
to: '/rules',
|
sources
|
||||||
label: 'Rules',
|
.map(s => ({ ...s, isFeed: !!s.config?.simplefin?.account_id }))
|
||||||
icon: (
|
.sort((a, b) =>
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
(b.isFeed - a.isFeed) || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
|
||||||
<polyline points="6,7 2,10 6,13"/>
|
)
|
||||||
<polyline points="14,7 18,10 14,13"/>
|
), [sources])
|
||||||
<line x1="12" y1="4" x2="8" y2="16"/>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/mappings',
|
|
||||||
label: 'Mappings',
|
|
||||||
icon: (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<line x1="2" y1="7" x2="12" y2="7"/>
|
|
||||||
<polyline points="9,4 12,7 9,10"/>
|
|
||||||
<line x1="8" y1="13" x2="18" y2="13"/>
|
|
||||||
<polyline points="11,10 14,13 11,16"/>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/remap',
|
|
||||||
label: 'Remap',
|
|
||||||
icon: (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<polyline points="2,8 2,4 6,4"/>
|
|
||||||
<path d="M2 4a8 8 0 0 1 14 2"/>
|
|
||||||
<polyline points="18,12 18,16 14,16"/>
|
|
||||||
<path d="M18 16a8 8 0 0 1-14-2"/>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/records',
|
|
||||||
label: 'Records',
|
|
||||||
icon: (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<rect x="2" y="3" width="16" height="14" rx="1.5"/>
|
|
||||||
<line x1="2" y1="8" x2="18" y2="8"/>
|
|
||||||
<line x1="7" y1="8" x2="7" y2="17"/>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/pivot',
|
|
||||||
label: 'Pivot',
|
|
||||||
icon: (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<rect x="2" y="2" width="7" height="7" rx="1"/>
|
|
||||||
<rect x="11" y="2" width="7" height="7" rx="1"/>
|
|
||||||
<rect x="2" y="11" width="7" height="7" rx="1"/>
|
|
||||||
<rect x="11" y="11" width="7" height="7" rx="1"/>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/stacks',
|
|
||||||
label: 'Stacks',
|
|
||||||
icon: (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<polygon points="10,2 18,6 10,10 2,6"/>
|
|
||||||
<polyline points="2,10 10,14 18,10"/>
|
|
||||||
<polyline points="2,14 10,18 18,14"/>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/log',
|
|
||||||
label: 'Log',
|
|
||||||
icon: (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<circle cx="10" cy="10" r="8"/>
|
|
||||||
<polyline points="10,5 10,10 14,12"/>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function Sidebar({ expanded, setExpanded, loginUser, onLogout }) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-white border-r border-gray-200 flex flex-col shrink-0 overflow-hidden transition-all duration-150"
|
className="bg-surface border-r border-line flex flex-col shrink-0 overflow-hidden transition-all duration-150"
|
||||||
style={{ width: expanded ? 200 : 48 }}
|
style={{ width: expanded ? 200 : 48 }}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="h-12 flex items-center px-3 border-b border-gray-100 gap-2 shrink-0">
|
<div className="h-12 flex items-center px-3 border-b border-line-soft gap-2 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded(e => !e)}
|
onClick={() => setExpanded(e => !e)}
|
||||||
className="w-8 h-8 flex items-center justify-center rounded hover:bg-gray-100 text-gray-400 shrink-0"
|
className="w-8 h-8 flex items-center justify-center rounded hover:bg-raised text-muted shrink-0"
|
||||||
title="Toggle sidebar"
|
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">
|
||||||
@ -124,7 +51,7 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout })
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
className="text-xs font-semibold text-gray-600 tracking-wide uppercase whitespace-nowrap transition-opacity duration-100"
|
className="text-xs font-semibold text-ink-soft tracking-wide uppercase whitespace-nowrap transition-opacity duration-100"
|
||||||
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none' }}
|
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none' }}
|
||||||
>
|
>
|
||||||
Dataflow
|
Dataflow
|
||||||
@ -132,17 +59,18 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<nav className="flex flex-col gap-0.5 p-2 flex-1">
|
<nav className="flex flex-col gap-0.5 p-2 flex-1 overflow-y-auto">
|
||||||
{NAV.map(({ to, label, icon }) => (
|
{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-blue-50 text-blue-700'
|
? 'bg-accent-soft text-accent'
|
||||||
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-800'
|
: 'text-muted hover:bg-raised hover:text-ink'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -154,13 +82,67 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout })
|
|||||||
{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-gray-100 px-3 py-2.5 flex items-center gap-2 shrink-0 overflow-hidden">
|
<div className="border-t border-line-soft px-3 py-2.5 flex items-center gap-2 shrink-0 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="w-6 h-6 rounded-full bg-gray-200 text-gray-500 flex items-center justify-center shrink-0 text-xs font-medium"
|
className="w-6 h-6 rounded-full bg-raised text-muted flex items-center justify-center shrink-0 text-xs font-medium"
|
||||||
title={!expanded ? loginUser : undefined}
|
title={!expanded ? loginUser : undefined}
|
||||||
>
|
>
|
||||||
{loginUser ? loginUser[0].toUpperCase() : '?'}
|
{loginUser ? loginUser[0].toUpperCase() : '?'}
|
||||||
@ -169,10 +151,10 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout })
|
|||||||
className="flex-1 flex items-center justify-between min-w-0 transition-opacity duration-100"
|
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-gray-400 truncate">{loginUser}</span>
|
<span className="text-xs text-muted truncate">{loginUser}</span>
|
||||||
<button
|
<button
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
className="text-xs text-gray-400 hover:text-red-500 ml-2 shrink-0"
|
className="text-xs text-muted hover:text-danger ml-2 shrink-0"
|
||||||
>
|
>
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
60
ui/src/components/SourceTabs.jsx
Normal file
60
ui/src/components/SourceTabs.jsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { NavLink, Outlet, useParams, Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
// Everything scoped to one source lives under /sources/:name, so the source is
|
||||||
|
// in the URL rather than in a global selector.
|
||||||
|
// Records is the tab you want nine times out of ten, so it leads and is what
|
||||||
|
// /sources/:name redirects to; Setup is the rare one and sits at the end.
|
||||||
|
const TABS = [
|
||||||
|
{ to: 'records', label: 'Records' },
|
||||||
|
{ to: 'import', label: 'Import' },
|
||||||
|
{ to: 'rules', label: 'Rules' },
|
||||||
|
{ to: 'mappings', label: 'Mappings' },
|
||||||
|
{ to: 'pivot', label: 'Pivot' },
|
||||||
|
{ to: 'setup', label: 'Setup' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function SourceTabs({ sources }) {
|
||||||
|
const { name } = useParams()
|
||||||
|
const sourceObj = sources.find(s => s.name === name)
|
||||||
|
const base = `/sources/${encodeURIComponent(name)}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
<div className="px-4 sm:px-6 pt-4 sm:pt-5 shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link to="/sources" className="text-xs text-muted hover:text-ink-soft">Sources</Link>
|
||||||
|
<span className="text-muted text-xs">/</span>
|
||||||
|
<h1 className="text-xl font-semibold text-ink">{name}</h1>
|
||||||
|
{sourceObj?.config?.simplefin?.account_id && (
|
||||||
|
<span className="text-xs bg-accent-soft text-accent border border-accent-line rounded px-1.5 py-0.5">
|
||||||
|
bank feed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex gap-1 mt-3 border-b border-line overflow-x-auto whitespace-nowrap">
|
||||||
|
{TABS.map(({ to, label, end }) => (
|
||||||
|
<NavLink
|
||||||
|
key={label}
|
||||||
|
to={to ? `${base}/${to}` : base}
|
||||||
|
end={end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`text-sm px-3 py-1.5 -mb-px border-b-2 ${
|
||||||
|
isActive
|
||||||
|
? 'border-accent text-accent font-medium'
|
||||||
|
: 'border-transparent text-muted hover:text-ink-soft'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto min-h-0">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,73 +0,0 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
|
||||||
import useTheme from '../theme.jsx'
|
|
||||||
|
|
||||||
export default function StatusBar({ sources = [], source, setSource, stacks = [], selectedStack, setSelectedStack }) {
|
|
||||||
const { dark, setDark } = useTheme()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white border-b border-gray-200 px-3 h-9 flex items-center gap-3 shrink-0 text-xs">
|
|
||||||
<span className="text-gray-400">Source</span>
|
|
||||||
<select
|
|
||||||
value={source || ''}
|
|
||||||
onChange={e => setSource(e.target.value)}
|
|
||||||
disabled={sources.length === 0}
|
|
||||||
className="border border-gray-200 rounded px-2 py-0.5 bg-white focus:outline-none focus:border-blue-400"
|
|
||||||
>
|
|
||||||
{sources.length === 0
|
|
||||||
? <option value="">— no sources —</option>
|
|
||||||
: sources.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
|
|
||||||
</select>
|
|
||||||
<NavLink
|
|
||||||
to="/sources?new=1"
|
|
||||||
className="text-blue-400 hover:text-blue-600 leading-none"
|
|
||||||
title="New source"
|
|
||||||
>+</NavLink>
|
|
||||||
|
|
||||||
{stacks.length > 0 && (
|
|
||||||
<>
|
|
||||||
<span className="text-gray-200">|</span>
|
|
||||||
<span className="text-gray-400">Stacks</span>
|
|
||||||
{stacks.map(s => (
|
|
||||||
<button
|
|
||||||
key={s.name}
|
|
||||||
onClick={() => setSelectedStack(n => n === s.name ? null : s.name)}
|
|
||||||
className={`rounded px-2 py-0.5 border transition-colors ${
|
|
||||||
selectedStack === s.name
|
|
||||||
? 'bg-purple-50 border-purple-300 text-purple-700'
|
|
||||||
: 'bg-white border-gray-200 text-gray-500 hover:border-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{s.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="ml-auto">
|
|
||||||
<button
|
|
||||||
onClick={() => setDark(d => !d)}
|
|
||||||
className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 text-gray-500"
|
|
||||||
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
|
|
||||||
>
|
|
||||||
{dark ? (
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<circle cx="12" cy="12" r="4"/>
|
|
||||||
<line x1="12" y1="2" x2="12" y2="5"/>
|
|
||||||
<line x1="12" y1="19" x2="12" y2="22"/>
|
|
||||||
<line x1="4.93" y1="4.93" x2="7.05" y2="7.05"/>
|
|
||||||
<line x1="16.95" y1="16.95" x2="19.07" y2="19.07"/>
|
|
||||||
<line x1="2" y1="12" x2="5" y2="12"/>
|
|
||||||
<line x1="19" y1="12" x2="22" y2="12"/>
|
|
||||||
<line x1="4.93" y1="19.07" x2="7.05" y2="16.95"/>
|
|
||||||
<line x1="16.95" y1="7.05" x2="19.07" y2="4.93"/>
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
70
ui/src/components/navItems.jsx
Normal file
70
ui/src/components/navItems.jsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
// Top-level destinations, shared by the desktop sidebar and the mobile bar
|
||||||
|
export const NAV = [
|
||||||
|
{
|
||||||
|
to: '/sources',
|
||||||
|
label: 'Sources',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<ellipse cx="10" cy="5.5" rx="7" ry="2.5"/>
|
||||||
|
<path d="M3 5.5v9c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5v-9"/>
|
||||||
|
<path d="M3 10.5c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/import',
|
||||||
|
label: 'Import',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="10" y1="3" x2="10" y2="14"/>
|
||||||
|
<polyline points="6,10 10,14 14,10"/>
|
||||||
|
<line x1="3" y1="18" x2="17" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/bridge',
|
||||||
|
label: 'Bridge',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M2 13a8 8 0 0 1 16 0"/>
|
||||||
|
<line x1="2" y1="13" x2="18" y2="13"/>
|
||||||
|
<line x1="7" y1="13" x2="7" y2="9"/>
|
||||||
|
<line x1="13" y1="13" x2="13" y2="9"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/remap',
|
||||||
|
label: 'Remap',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="2,8 2,4 6,4"/>
|
||||||
|
<path d="M2 4a8 8 0 0 1 14 2"/>
|
||||||
|
<polyline points="18,12 18,16 14,16"/>
|
||||||
|
<path d="M18 16a8 8 0 0 1-14-2"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/stacks',
|
||||||
|
label: 'Stacks',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="10,2 18,6 10,10 2,6"/>
|
||||||
|
<polyline points="2,10 10,14 18,10"/>
|
||||||
|
<polyline points="2,14 10,18 18,14"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/log',
|
||||||
|
label: 'Log',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="10" cy="10" r="8"/>
|
||||||
|
<polyline points="10,5 10,10 14,12"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
126
ui/src/index.css
126
ui/src/index.css
@ -1,5 +1,42 @@
|
|||||||
@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;
|
||||||
@ -11,6 +48,16 @@
|
|||||||
--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:
|
||||||
@ -27,6 +74,17 @@
|
|||||||
--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 {
|
||||||
@ -36,62 +94,14 @@ body {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .bg-white { background-color: var(--bg-secondary); }
|
/* Bare border utilities have no colour of their own */
|
||||||
.dark .bg-gray-50 { background-color: var(--bg-tertiary); }
|
.border, .border-t, .border-b, .border-l, .border-r { border-color: var(--border-color); }
|
||||||
.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); }
|
|
||||||
|
|
||||||
/* Status accents — desaturated to sit on Pro Dark's neutral background */
|
/* Form controls don't inherit the surface token on their own */
|
||||||
.dark .bg-green-50 { background-color: #1a3d2c; }
|
input, select, textarea {
|
||||||
.dark .text-green-600 { color: #6ee7b7; }
|
background-color: var(--bg-secondary);
|
||||||
.dark .text-green-700 { color: #6ee7b7; }
|
color: var(--text-primary);
|
||||||
.dark .text-green-400 { color: #6ee7b7; }
|
border-color: var(--border-color);
|
||||||
.dark .bg-amber-50 { background-color: #3a2e14; }
|
}
|
||||||
.dark .text-amber-800 { color: #f5c66f; }
|
|
||||||
.dark .border-amber-200 { border-color: #5a4a26; }
|
::selection { background-color: var(--accent-bg); color: var(--text-primary); }
|
||||||
.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; }
|
|
||||||
|
|||||||
176
ui/src/pages/Bridge.jsx
Normal file
176
ui/src/pages/Bridge.jsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { api } from '../api'
|
||||||
|
import Section from '../components/Section.jsx'
|
||||||
|
|
||||||
|
// Accounting style: aligned to 2 decimals, negatives in parentheses
|
||||||
|
function money(value) {
|
||||||
|
const n = parseFloat(value)
|
||||||
|
if (!isFinite(n)) return '—'
|
||||||
|
const abs = Math.abs(n).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
return n < 0 ? `(${abs})` : abs
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimpleFIN doesn't report an account type, so retirement accounts are
|
||||||
|
// recognised from the institution and account names
|
||||||
|
const RETIREMENT_RE = /401\(?k\)?|403\(?b\)?|\bira\b|retirement|pension|profit sharing/i
|
||||||
|
const isRetirement = (a) => RETIREMENT_RE.test(`${a.name} ${a.organization || ''}`)
|
||||||
|
|
||||||
|
// One SimpleFIN bridge covers every linked bank account, so connection state is
|
||||||
|
// a bridge-level concern rather than something to hunt for source by source.
|
||||||
|
export default function Bridge({ sources }) {
|
||||||
|
const [accounts, setAccounts] = useState(null)
|
||||||
|
const [errors, setErrors] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await api.getSimpleFinAccounts()
|
||||||
|
setAccounts(res.accounts || [])
|
||||||
|
setErrors(res.errors || [])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which source, if any, pulls from each account
|
||||||
|
const sourceFor = (accountId) =>
|
||||||
|
sources.find(s => s.config?.simplefin?.account_id === accountId)
|
||||||
|
|
||||||
|
const sum = (list) => list.reduce((t, a) => t + (parseFloat(a.balance) || 0), 0)
|
||||||
|
const banking = (accounts || []).filter(a => !isRetirement(a))
|
||||||
|
const retirement = (accounts || []).filter(isRetirement)
|
||||||
|
// Banking first, then retirement, so each subtotal sits under its own rows
|
||||||
|
const ordered = [...banking, ...retirement]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 sm:p-6 max-w-5xl space-y-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h1 className="text-xl font-semibold text-ink">Bridge</h1>
|
||||||
|
<button
|
||||||
|
onClick={load}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-sm border border-line rounded px-3 py-1.5 text-ink-soft hover:bg-raised hover:border-line disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Refreshing…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Section title="Not connected" description="Claim a setup token with manage.py option 10, then restart the service.">
|
||||||
|
<p className="text-xs text-danger">{error}</p>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<div className="bg-warn-soft border border-warn-line rounded p-3 text-xs text-warn space-y-1">
|
||||||
|
{errors.map((e, i) => <div key={i}>{e}</div>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!accounts && !loading && !error && (
|
||||||
|
<p className="text-sm text-muted">Click Refresh to load balances from SimpleFIN.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{accounts && (
|
||||||
|
<Section
|
||||||
|
title="SimpleFIN accounts"
|
||||||
|
description="Every account behind the bridge credential, and which source pulls from it."
|
||||||
|
>
|
||||||
|
<table className="w-full text-xs hidden sm:table">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-muted border-b border-line-soft">
|
||||||
|
<th className="pb-1 font-medium">Account</th>
|
||||||
|
<th className="pb-1 font-medium">Institution</th>
|
||||||
|
<th className="pb-1 font-medium text-right">Balance</th>
|
||||||
|
<th className="pb-1 pl-4 font-medium">As of</th>
|
||||||
|
<th className="pb-1 font-medium">Source</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ordered.map(a => {
|
||||||
|
const src = sourceFor(a.id)
|
||||||
|
return (
|
||||||
|
<tr key={a.id} className="border-t border-line-soft">
|
||||||
|
<td className="py-1.5 text-ink-soft">{a.name}</td>
|
||||||
|
<td className="py-1.5 text-muted">{a.organization}</td>
|
||||||
|
<td className="py-1.5 text-right font-mono text-ink-soft">{money(a.balance)}</td>
|
||||||
|
<td className="py-1.5 pl-4 text-muted">{a.balance_date}</td>
|
||||||
|
<td className="py-1.5">
|
||||||
|
{src
|
||||||
|
? <Link to={`/sources/${encodeURIComponent(src.name)}`} className="text-accent hover:text-accent">{src.name}</Link>
|
||||||
|
: <span className="text-muted">not linked</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
{banking.length > 0 && (
|
||||||
|
<tr className="border-t border-line">
|
||||||
|
<td className="pt-2 text-muted" colSpan={2}>Banking and cards</td>
|
||||||
|
<td className="pt-2 text-right font-mono text-ink-soft">{money(sum(banking))}</td>
|
||||||
|
<td colSpan={2}></td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{retirement.length > 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className="pt-1 text-muted" colSpan={2}>Retirement</td>
|
||||||
|
<td className="pt-1 text-right font-mono text-ink-soft">{money(sum(retirement))}</td>
|
||||||
|
<td colSpan={2}></td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
<tr className="border-t border-line">
|
||||||
|
<td className="pt-2 text-ink-soft font-medium" colSpan={2}>Total</td>
|
||||||
|
<td className="pt-2 text-right font-mono font-medium text-ink">{money(sum(accounts))}</td>
|
||||||
|
<td colSpan={2}></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{/* Stacked cards for narrow screens */}
|
||||||
|
<div className="sm:hidden divide-y divide-line-soft">
|
||||||
|
{ordered.map(a => {
|
||||||
|
const src = sourceFor(a.id)
|
||||||
|
return (
|
||||||
|
<div key={a.id} className="py-2">
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<span className="text-xs text-ink-soft">{a.name}</span>
|
||||||
|
<span className="text-xs font-mono text-ink">{money(a.balance)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-2 text-xs text-muted">
|
||||||
|
<span>{a.organization}</span>
|
||||||
|
<span>{a.balance_date}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs mt-0.5">
|
||||||
|
{src
|
||||||
|
? <Link to={`/sources/${encodeURIComponent(src.name)}`} className="text-accent">{src.name}</Link>
|
||||||
|
: <span className="text-muted">not linked</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="pt-2 flex justify-between text-xs">
|
||||||
|
<span className="text-muted">Banking and cards</span>
|
||||||
|
<span className="font-mono text-ink-soft">{money(sum(banking))}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pt-1 flex justify-between text-xs border-0">
|
||||||
|
<span className="text-muted">Retirement</span>
|
||||||
|
<span className="font-mono text-ink-soft">{money(sum(retirement))}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pt-2 flex justify-between text-xs font-medium">
|
||||||
|
<span className="text-ink-soft">Total</span>
|
||||||
|
<span className="font-mono text-ink">{money(sum(accounts))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{accounts.length === 0 && <p className="text-xs text-muted">No accounts returned.</p>}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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-gray-50 rounded p-2 font-mono text-xs text-gray-500 space-y-0.5">
|
<div className="max-h-32 overflow-y-auto bg-raised rounded p-2 font-mono text-xs text-muted space-y-0.5">
|
||||||
{keys.map((k, i) => (
|
{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-gray-50 ${selected ? 'bg-red-50' : ''}`}>
|
<tr className={`border-b border-line-soft ${selected ? 'bg-danger-soft' : ''}`}>
|
||||||
<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-gray-400 font-mono">{entry.id}</td>
|
<td className="py-1.5 text-xs text-muted font-mono">{entry.id}</td>
|
||||||
<td className="py-1.5 text-gray-500">{new Date(entry.imported_at).toLocaleString()}</td>
|
<td className="py-1.5 text-muted">{new Date(entry.imported_at).toLocaleString()}</td>
|
||||||
<td className="py-1.5 text-gray-800">{entry.records_imported}</td>
|
<td className="py-1.5 text-ink">{entry.records_imported}</td>
|
||||||
<td className="py-1.5 text-gray-400">{entry.records_duplicate}</td>
|
<td className="py-1.5 text-muted">{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-blue-400 hover:text-blue-600"
|
className="text-xs text-accent hover:text-accent"
|
||||||
>
|
>
|
||||||
{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-red-50' : 'bg-gray-50'}>
|
<tr className={selected ? 'bg-danger-soft' : 'bg-raised'}>
|
||||||
<td colSpan={6} className="px-4 py-3">
|
<td colSpan={6} className="px-4 py-3">
|
||||||
<KeyList keys={insertedKeys} label="Inserted" color="text-green-600" />
|
<KeyList keys={insertedKeys} label="Inserted" color="text-ok" />
|
||||||
<KeyList keys={excludedKeys} label="Excluded" color="text-gray-500" />
|
<KeyList keys={excludedKeys} label="Excluded" color="text-muted" />
|
||||||
</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 [teller, setTeller] = useState(null)
|
const [simplefin, setSimplefin] = 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 => setTeller(s.config?.teller || null)).catch(() => setTeller(null))
|
api.getSource(source).then(s => setSimplefin(s.config?.simplefin || null)).catch(() => setSimplefin(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.syncTeller(source, { days })
|
const res = await api.syncSimpleFin(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-6 text-sm text-gray-400">Select a source first.</div>
|
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-2xl">
|
<div className="p-4 sm:p-6 max-w-2xl">
|
||||||
<h1 className="text-xl font-semibold text-gray-800 mb-6">Import — {source}</h1>
|
<h1 className="text-xl font-semibold text-ink 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-white border border-gray-200 rounded px-4 py-3 flex-1 text-center">
|
<div key={label} className="bg-surface border border-line rounded px-4 py-3 flex-1 text-center">
|
||||||
<div className="text-2xl font-semibold text-gray-800">{value}</div>
|
<div className="text-2xl font-semibold text-ink">{value}</div>
|
||||||
<div className="text-xs text-gray-400 mt-0.5">{label}</div>
|
<div className="text-xs text-muted mt-0.5">{label}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Teller sync — only for sources with a teller account in their config */}
|
{/* SimpleFIN sync — only for sources with a bridge account in their config */}
|
||||||
{teller?.account_id && (
|
{simplefin?.account_id && (
|
||||||
<div className="bg-white border border-gray-200 rounded p-4 mb-4 flex items-center gap-3">
|
<div className="bg-surface border border-line rounded p-4 mb-4 flex items-center gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium text-gray-700">Teller</div>
|
<div className="text-sm font-medium text-ink-soft">SimpleFIN</div>
|
||||||
<div className="text-xs text-gray-400 font-mono">{teller.account_id}</div>
|
<div className="text-xs text-muted font-mono truncate">{simplefin.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-gray-200 rounded px-2 py-1.5 bg-white text-gray-700"
|
className="text-sm border border-line rounded px-2 py-1.5 bg-surface text-ink-soft"
|
||||||
>
|
>
|
||||||
<option value="10">Last 10 days</option>
|
<option value="10">Last 10 days</option>
|
||||||
<option value="30">Last 30 days</option>
|
<option value="30">Last 30 days</option>
|
||||||
<option value="90">Last 90 days</option>
|
<option value="45">Last 45 days</option>
|
||||||
<option value="0">Everything available</option>
|
<option value="89">Backfill (bridge maximum)</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-blue-400 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
|
dragOver ? 'border-accent bg-accent-soft' : 'border-line hover:border-line'
|
||||||
}`}
|
}`}
|
||||||
onDragOver={e => { e.preventDefault(); setDragOver(true) }}
|
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-gray-500">Importing…</p>
|
? <p className="text-sm text-muted">Importing…</p>
|
||||||
: <p className="text-sm text-gray-400">Drop a CSV file here, or click to browse</p>
|
: <p className="text-sm text-muted">Drop a CSV file here, or click to browse</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-500 mb-3">{error}</p>}
|
{error && <p className="text-sm text-danger mb-3">{error}</p>}
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div className={`border rounded p-4 mb-4 text-sm ${result.success === false ? 'bg-red-50 border-red-200' : 'bg-white border-gray-200'}`}>
|
<div className={`border rounded p-4 mb-4 text-sm ${result.success === false ? 'bg-danger-soft border-danger-line' : 'bg-surface border-line'}`}>
|
||||||
{result.success === false ? (
|
{result.success === false ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-red-600 font-medium mb-2">{result.error}</p>
|
<p className="text-danger font-medium mb-2">{result.error}</p>
|
||||||
{result.duplicate_rows && (
|
{result.duplicate_rows && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-red-500 mb-1">Offending rows:</p>
|
<p className="text-xs text-danger mb-1">Offending rows:</p>
|
||||||
<div className="max-h-48 overflow-y-auto bg-white rounded border border-red-100 p-2 font-mono text-xs text-red-700 space-y-0.5">
|
<div className="max-h-48 overflow-y-auto bg-surface rounded border border-danger-line p-2 font-mono text-xs text-danger space-y-0.5">
|
||||||
{result.duplicate_rows.map((row, i) => (
|
{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,24 +259,29 @@ 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-gray-500">{result.fetched} fetched</span>
|
<span className="text-muted">{result.fetched} fetched</span>
|
||||||
<span className="text-gray-400 mx-2">·</span>
|
<span className="text-muted mx-2">·</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="text-green-600 font-medium">{result.imported} imported</span>
|
<span className="text-ok font-medium">{result.imported} imported</span>
|
||||||
<span className="text-gray-400 mx-2">·</span>
|
<span className="text-muted mx-2">·</span>
|
||||||
<span className="text-gray-500">{result.duplicates} duplicates skipped</span>
|
<span className="text-muted">{result.duplicates} duplicates skipped</span>
|
||||||
{result.transform && (
|
{result.transform && (
|
||||||
<>
|
<>
|
||||||
<span className="text-gray-400 mx-2">·</span>
|
<span className="text-muted mx-2">·</span>
|
||||||
<span className="text-gray-500">{result.transform.transformed} transformed</span>
|
<span className="text-muted">{result.transform.transformed} transformed</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-green-600 font-medium">{result.transformed} records transformed</span>
|
<span className="text-ok font-medium">{result.transformed} records transformed</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -301,7 +306,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-gray-700">Import history</h2>
|
<h2 className="text-sm font-semibold text-ink-soft">Import history</h2>
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteSelected}
|
onClick={handleDeleteSelected}
|
||||||
@ -314,7 +319,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-gray-400 border-b border-gray-100">
|
<tr className="text-left text-xs text-muted border-b border-line-soft">
|
||||||
<th className="pb-1 w-6"></th>
|
<th className="pb-1 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>
|
||||||
|
|||||||
129
ui/src/pages/ImportHub.jsx
Normal file
129
ui/src/pages/ImportHub.jsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
// Importing is the frequent job; configuring a source is the rare one. This is
|
||||||
|
// the top-level entry point for the frequent one — every source in one place,
|
||||||
|
// with a sync button for anything on a bank feed.
|
||||||
|
export default function ImportHub({ sources }) {
|
||||||
|
const [stats, setStats] = useState({}) // name -> stats
|
||||||
|
const [lastImport, setLastImport] = useState({}) // name -> ISO timestamp
|
||||||
|
const [busy, setBusy] = useState('')
|
||||||
|
const [results, setResults] = useState({}) // name -> message
|
||||||
|
const [errors, setErrors] = useState({}) // name -> message
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
Promise.all(sources.map(s =>
|
||||||
|
api.getStats(s.name).then(st => [s.name, st]).catch(() => [s.name, null])
|
||||||
|
)).then(pairs => {
|
||||||
|
if (!cancelled) setStats(Object.fromEntries(pairs))
|
||||||
|
})
|
||||||
|
api.getAllImportLog().then(log => {
|
||||||
|
if (cancelled) return
|
||||||
|
const latest = {}
|
||||||
|
for (const entry of log) {
|
||||||
|
if (!latest[entry.source_name] || entry.imported_at > latest[entry.source_name]) {
|
||||||
|
latest[entry.source_name] = entry.imported_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLastImport(latest)
|
||||||
|
}).catch(() => {})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [sources])
|
||||||
|
|
||||||
|
async function sync(name) {
|
||||||
|
setBusy(name)
|
||||||
|
setErrors(e => ({ ...e, [name]: '' }))
|
||||||
|
setResults(r => ({ ...r, [name]: '' }))
|
||||||
|
try {
|
||||||
|
const res = await api.syncSimpleFin(name, { days: 10 })
|
||||||
|
setResults(r => ({
|
||||||
|
...r,
|
||||||
|
[name]: `${res.imported} imported, ${res.duplicates} already had` +
|
||||||
|
(res.errors?.length ? ` — ${res.errors.join('; ')}` : ''),
|
||||||
|
}))
|
||||||
|
api.getStats(name).then(st => setStats(s => ({ ...s, [name]: st }))).catch(() => {})
|
||||||
|
api.getAllImportLog().then(log => {
|
||||||
|
const entry = log.find(l => l.source_name === name)
|
||||||
|
if (entry) setLastImport(l => ({ ...l, [name]: entry.imported_at }))
|
||||||
|
}).catch(() => {})
|
||||||
|
} catch (err) {
|
||||||
|
setErrors(e => ({ ...e, [name]: err.message }))
|
||||||
|
} finally {
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const feeds = sources.filter(s => s.config?.simplefin?.account_id)
|
||||||
|
const manual = sources.filter(s => !s.config?.simplefin?.account_id)
|
||||||
|
|
||||||
|
function Row({ s, isFeed }) {
|
||||||
|
const st = stats[s.name]
|
||||||
|
const when = lastImport[s.name]
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="flex-1 min-w-40">
|
||||||
|
<Link to={`/sources/${encodeURIComponent(s.name)}/import`}
|
||||||
|
className="text-sm font-medium text-ink hover:text-accent">
|
||||||
|
{s.name}
|
||||||
|
</Link>
|
||||||
|
<div className="text-xs text-muted">
|
||||||
|
{st ? `${st.total_records} records` : '—'}
|
||||||
|
{st && Number(st.pending_records) > 0 && ` · ${st.pending_records} untransformed`}
|
||||||
|
{when && ` · last import ${new Date(when).toLocaleDateString()}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results[s.name] && <span className="text-xs text-ok">{results[s.name]}</span>}
|
||||||
|
{errors[s.name] && <span className="text-xs text-danger">{errors[s.name]}</span>}
|
||||||
|
|
||||||
|
{isFeed ? (
|
||||||
|
<button
|
||||||
|
onClick={() => sync(s.name)}
|
||||||
|
disabled={busy === s.name}
|
||||||
|
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy === s.name ? 'Syncing…' : 'Sync'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link to={`/sources/${encodeURIComponent(s.name)}/import`}
|
||||||
|
className="text-sm border border-line rounded px-3 py-1.5 text-ink-soft hover:bg-raised hover:border-line">
|
||||||
|
Upload CSV
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 sm:p-6 max-w-4xl space-y-6">
|
||||||
|
<h1 className="text-xl font-semibold text-ink">Import</h1>
|
||||||
|
|
||||||
|
{feeds.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-ink-soft mb-2">Bank feeds</h2>
|
||||||
|
<div className="bg-surface border border-line rounded divide-y divide-line-soft">
|
||||||
|
{feeds.map(s => <Row key={s.name} s={s} isFeed />)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted mt-1">Syncs pull the last 10 days; use a source’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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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-gray-50 rounded p-2 font-mono text-xs text-gray-500 space-y-0.5">
|
<div className="max-h-32 overflow-y-auto bg-raised rounded p-2 font-mono text-xs text-muted space-y-0.5">
|
||||||
{keys.map((k, i) => (
|
{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-gray-50 hover:bg-gray-50">
|
<tr className="border-b border-line-soft hover:bg-raised">
|
||||||
<td className="py-1.5 text-xs text-gray-400 font-mono pr-3">{entry.id}</td>
|
<td className="py-1.5 text-xs text-muted font-mono pr-3">{entry.id}</td>
|
||||||
<td className="py-1.5 text-gray-700 pr-3">{entry.source_name}</td>
|
<td className="py-1.5 text-ink-soft pr-3">{entry.source_name}</td>
|
||||||
<td className="py-1.5 text-gray-500 pr-3">{new Date(entry.imported_at).toLocaleString()}</td>
|
<td className="py-1.5 text-muted pr-3">{new Date(entry.imported_at).toLocaleString()}</td>
|
||||||
<td className="py-1.5 text-gray-800 pr-3">{entry.records_imported}</td>
|
<td className="py-1.5 text-ink pr-3">{entry.records_imported}</td>
|
||||||
<td className="py-1.5 text-gray-400 pr-3">{entry.records_duplicate}</td>
|
<td className="py-1.5 text-muted 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-blue-400 hover:text-blue-600"
|
className="text-xs text-accent hover:text-accent"
|
||||||
>
|
>
|
||||||
{expanded ? '▲ hide' : '▼ keys'}
|
{expanded ? '▲ hide' : '▼ keys'}
|
||||||
</button>
|
</button>
|
||||||
@ -46,10 +46,10 @@ function LogRow({ entry }) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<tr className="bg-gray-50">
|
<tr className="bg-raised">
|
||||||
<td colSpan={6} className="px-4 py-3">
|
<td colSpan={6} className="px-4 py-3">
|
||||||
<KeyList keys={insertedKeys} label="Inserted" color="text-green-600" />
|
<KeyList keys={insertedKeys} label="Inserted" color="text-ok" />
|
||||||
<KeyList keys={excludedKeys} label="Excluded" color="text-gray-500" />
|
<KeyList keys={excludedKeys} label="Excluded" color="text-muted" />
|
||||||
</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-gray-800 mb-6">Import Log</h1>
|
<h1 className="text-xl font-semibold text-ink mb-6">Import Log</h1>
|
||||||
|
|
||||||
{loading && <p className="text-sm text-gray-400">Loading…</p>}
|
{loading && <p className="text-sm text-muted">Loading…</p>}
|
||||||
|
|
||||||
{!loading && log.length === 0 && (
|
{!loading && log.length === 0 && (
|
||||||
<p className="text-sm text-gray-400">No imports yet.</p>
|
<p className="text-sm text-muted">No imports yet.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{log.length > 0 && (
|
{log.length > 0 && (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-xs text-gray-400 border-b border-gray-100">
|
<tr className="text-left text-xs text-muted border-b border-line-soft">
|
||||||
<th className="pb-1 font-medium pr-3">ID</th>
|
<th className="pb-1 font-medium pr-3">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>
|
||||||
|
|||||||
@ -20,32 +20,32 @@ export default function Login({ onLogin }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen bg-gray-50">
|
<div className="flex items-center justify-center h-screen bg-raised">
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-8 w-80 shadow-sm">
|
<div className="bg-surface border border-line rounded-lg p-8 w-80 shadow-sm">
|
||||||
<h1 className="text-lg font-semibold text-gray-800 mb-6">Dataflow</h1>
|
<h1 className="text-lg font-semibold text-ink 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-gray-500 mb-1">Username</label>
|
<label className="block text-xs text-muted 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-gray-200 rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-400"
|
className="w-full border border-line rounded px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-500 mb-1">Password</label>
|
<label className="block text-xs text-muted mb-1">Password</label>
|
||||||
<input
|
<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-gray-200 rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-400"
|
className="w-full border border-line rounded px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
{error && <p className="text-xs text-danger">{error}</p>}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|||||||
@ -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-white border border-gray-200 rounded shadow-lg max-h-48 overflow-y-auto"
|
className="bg-surface border border-line rounded shadow-lg max-h-48 overflow-y-auto"
|
||||||
>
|
>
|
||||||
{filtered.map((s, i) => (
|
{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-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'
|
i === highlighted ? 'bg-accent-soft text-accent' : 'text-ink-soft hover:bg-raised'
|
||||||
}`}
|
}`}
|
||||||
onMouseDown={e => { e.preventDefault(); select(s) }}
|
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-gray-600 ${className}`}
|
className={`px-3 py-2 font-medium cursor-pointer select-none hover:text-ink-soft ${className}`}
|
||||||
onClick={() => onSort(col)}
|
onClick={() => onSort(col)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
<span className="ml-1 text-gray-300">{active ? (sortBy.dir === 'asc' ? '↑' : '↓') : '↕'}</span>
|
<span className="ml-1 text-muted">{active ? (sortBy.dir === 'asc' ? '↑' : '↓') : '↕'}</span>
|
||||||
</th>
|
</th>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -354,18 +354,18 @@ export default function Mappings({ source, onNeedsReprocess }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
|
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
|
||||||
|
|
||||||
const displayRows = sortedRows(filteredRows)
|
const displayRows = sortedRows(filteredRows)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Sticky control bar */}
|
{/* Sticky control bar */}
|
||||||
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-3 flex-wrap">
|
<div className="sticky top-0 z-10 bg-surface border-b border-line px-6 py-3 flex items-center gap-3 flex-wrap">
|
||||||
<span className="text-sm font-medium text-gray-700">{source}</span>
|
<span className="text-sm font-medium text-ink-soft">{source}</span>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
className="text-sm border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:border-blue-400"
|
className="text-sm border border-line rounded px-2 py-1.5 focus:outline-none focus:border-accent"
|
||||||
value={selectedRule}
|
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-gray-100 rounded p-0.5">
|
<div className="flex bg-raised 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-white text-gray-800 shadow-sm' : 'text-gray-500'
|
filter === key ? 'bg-surface text-ink shadow-sm' : 'text-muted'
|
||||||
}`}>
|
}`}>
|
||||||
{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-blue-400 ${
|
className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-accent ${
|
||||||
rowFilterError ? 'border-red-400 bg-red-50' : rowFilter ? 'border-blue-300' : 'border-gray-200'
|
rowFilterError ? 'border-danger-line bg-danger-soft' : rowFilter ? 'border-accent-line' : 'border-line'
|
||||||
}`}
|
}`}
|
||||||
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-gray-400">
|
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted">
|
||||||
{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-gray-200 rounded hover:bg-gray-50 text-gray-600"
|
className="text-sm px-3 py-1.5 border border-line rounded hover:bg-raised text-ink-soft"
|
||||||
>
|
>
|
||||||
Export TSV
|
Export TSV
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<label className={`text-sm px-3 py-1.5 border border-gray-200 rounded cursor-pointer hover:bg-gray-50 text-gray-600 ${importing ? 'opacity-50 pointer-events-none' : ''}`}>
|
<label className={`text-sm px-3 py-1.5 border border-line rounded cursor-pointer hover:bg-raised text-ink-soft ${importing ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||||
{importing ? 'Importing…' : 'Import TSV'}
|
{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-gray-400">Select a rule to view mappings.</p>
|
<p className="text-sm text-muted">Select a rule to view mappings.</p>
|
||||||
)}
|
)}
|
||||||
{selectedRule && loading && (
|
{selectedRule && loading && (
|
||||||
<p className="text-sm text-gray-400">Loading…</p>
|
<p className="text-sm text-muted">Loading…</p>
|
||||||
)}
|
)}
|
||||||
{selectedRule && !loading && allValues.length === 0 && (
|
{selectedRule && !loading && allValues.length === 0 && (
|
||||||
<p className="text-sm text-gray-400">No extracted values for this rule. Run a transform first.</p>
|
<p className="text-sm text-muted">No extracted values for this rule. Run a transform first.</p>
|
||||||
)}
|
)}
|
||||||
{selectedRule && !loading && allValues.length > 0 && (
|
{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-blue-50 border border-blue-200 rounded flex-wrap">
|
<div className="flex items-center gap-2 mb-2 p-2 bg-accent-soft border border-accent-line rounded flex-wrap">
|
||||||
<span className="text-xs text-blue-700 font-medium whitespace-nowrap">{selected.size} selected</span>
|
<span className="text-xs text-accent font-medium whitespace-nowrap">{selected.size} selected</span>
|
||||||
{cols.map(col => (
|
{cols.map(col => (
|
||||||
<AutocompleteInput
|
<AutocompleteInput
|
||||||
key={col}
|
key={col}
|
||||||
className="border border-blue-300 rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-blue-500 bg-white"
|
className="border border-accent-line rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-accent bg-surface"
|
||||||
placeholder={col}
|
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-blue-400 hover:text-blue-600"
|
className="text-xs text-accent hover:text-accent"
|
||||||
>
|
>
|
||||||
cancel
|
cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<table className="w-full text-xs bg-white border border-gray-200 rounded">
|
<table className="w-full text-xs bg-surface border border-line rounded">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50">
|
<tr className="text-left text-muted border-b border-line-soft bg-raised">
|
||||||
<th className="px-2 py-2 w-6">
|
<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-gray-200 rounded px-1 py-0.5 w-24 focus:outline-none focus:border-blue-400 font-normal"
|
className="border border-line rounded px-1 py-0.5 w-24 focus:outline-none focus:border-accent font-normal"
|
||||||
value={col}
|
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-gray-400 hover:text-gray-700 font-medium"
|
className="text-muted hover:text-ink-soft 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-blue-50' : hasDraft ? 'bg-blue-50' : row.is_mapped ? '' : 'bg-yellow-50'
|
const rowBg = isSelected ? 'bg-accent-soft' : hasDraft ? 'bg-accent-soft' : row.is_mapped ? '' : 'bg-warn-soft'
|
||||||
|
|
||||||
function handleRowClick(e) {
|
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-gray-50 hover:bg-gray-50 cursor-pointer outline-none ${rowBg}`}
|
className={`border-t border-line-soft hover:bg-raised cursor-pointer outline-none ${rowBg}`}
|
||||||
onClick={handleRowClick}
|
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-gray-800 whitespace-nowrap">{displayValue(row.extracted_value)}</td>
|
<td className="px-3 py-1.5 font-mono text-ink whitespace-nowrap">{displayValue(row.extracted_value)}</td>
|
||||||
<td className="px-3 py-1.5 text-right text-gray-400">{row.record_count}</td>
|
<td className="px-3 py-1.5 text-right text-muted">{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-blue-400 ${
|
className={`border rounded px-2 py-1 w-full min-w-24 focus:outline-none focus:border-accent ${
|
||||||
hasDraft ? 'border-blue-300' : row.is_mapped ? 'border-gray-200' : 'border-yellow-300'
|
hasDraft ? 'border-accent-line' : row.is_mapped ? 'border-line' : 'border-warn-line'
|
||||||
}`}
|
}`}
|
||||||
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-blue-400 hover:text-blue-600"
|
className="text-accent hover:text-accent"
|
||||||
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-red-400 hover:text-red-600 text-base leading-none"
|
className="text-danger hover:text-danger 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-gray-50 bg-gray-50">
|
<tr key={`${k}-sample`} className="border-t border-line-soft bg-raised">
|
||||||
<td colSpan={3 + cols.length + 4} className="px-3 py-2">
|
<td colSpan={3 + cols.length + 4} className="px-3 py-2">
|
||||||
<table className="w-full text-xs border border-gray-100 rounded bg-white">
|
<table className="w-full text-xs border border-line-soft rounded bg-surface">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-50 border-b border-gray-100">
|
<tr className="bg-raised border-b border-line-soft">
|
||||||
{sampleCols.map(c => (
|
{sampleCols.map(c => (
|
||||||
<th key={c} className="px-2 py-1 text-left font-medium text-gray-400 whitespace-nowrap">{c}</th>
|
<th key={c} className="px-2 py-1 text-left font-medium text-muted whitespace-nowrap">{c}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{samples.map((rec, i) => (
|
{samples.map((rec, i) => (
|
||||||
<tr key={i} className="border-t border-gray-50">
|
<tr key={i} className="border-t border-line-soft">
|
||||||
{sampleCols.map(c => (
|
{sampleCols.map(c => (
|
||||||
<td key={c} className="px-2 py-1 font-mono text-gray-600 whitespace-nowrap">
|
<td key={c} className="px-2 py-1 font-mono text-ink-soft whitespace-nowrap">
|
||||||
{rec[c] != null ? String(rec[c]) : ''}
|
{rec[c] != null ? String(rec[c]) : ''}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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-6 text-sm text-gray-400">Select a source first.</div>
|
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
|
||||||
|
|
||||||
const cols = inspectedRows?.length ? Object.keys(inspectedRows[0]) : []
|
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-white border-b border-gray-200 shrink-0 text-xs">
|
<div className="flex items-center gap-2 px-3 h-9 bg-surface border-b border-line shrink-0 text-xs">
|
||||||
{layouts.map(l => (
|
{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-blue-50 border-blue-300 text-blue-700'
|
? 'bg-accent-soft border-accent-line text-accent'
|
||||||
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-400'}`}>
|
: 'bg-surface border-line text-ink-soft hover:border-line'}`}>
|
||||||
{l.layout_name}
|
{l.layout_name}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleDelete(l, e)}
|
onClick={(e) => handleDelete(l, e)}
|
||||||
className="text-gray-300 hover:text-red-400 leading-none ml-0.5 text-sm">×</button>
|
className="text-muted hover:text-danger leading-none ml-0.5 text-sm">×</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{activeLayoutId !== null && !showSaveAs && (
|
{activeLayoutId !== null && !showSaveAs && (
|
||||||
<button onClick={handleSaveOver}
|
<button onClick={handleSaveOver}
|
||||||
className="text-blue-500 hover:text-blue-700 border border-blue-200 rounded px-2 py-0.5">
|
className="text-accent hover:text-accent border border-accent-line rounded px-2 py-0.5">
|
||||||
Save
|
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-gray-300 rounded px-2 py-0.5 w-36 focus:outline-none focus:border-blue-400"
|
className="border border-line rounded px-2 py-0.5 w-36 focus:outline-none focus:border-accent"
|
||||||
/>
|
/>
|
||||||
<button onClick={handleSaveAs} className="text-blue-600 hover:text-blue-800 px-1">Save</button>
|
<button onClick={handleSaveAs} className="text-accent hover:text-accent px-1">Save</button>
|
||||||
<button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-gray-400 hover:text-gray-600 px-1">Cancel</button>
|
<button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-muted hover:text-ink-soft px-1">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSaveAs(true)}
|
onClick={() => setShowSaveAs(true)}
|
||||||
className="text-gray-400 hover:text-gray-600 border border-dashed border-gray-200 rounded px-2 py-0.5">
|
className="text-muted hover:text-ink-soft border border-dashed border-line rounded px-2 py-0.5">
|
||||||
+ Save as…
|
+ Save as…
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeLayoutId !== null && (
|
{activeLayoutId !== null && (
|
||||||
<button onClick={handleResetToDefault} className="text-gray-300 hover:text-gray-500 ml-1">reset</button>
|
<button onClick={handleResetToDefault} className="text-muted hover:text-muted ml-1">reset</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{layoutMsg && <span className="text-green-600 ml-1">{layoutMsg}</span>}
|
{layoutMsg && <span className="text-ok ml-1">{layoutMsg}</span>}
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
<span className="text-gray-400">depth:</span>
|
<span className="text-muted">depth:</span>
|
||||||
{[0, 1, 2, 3].map(d => (
|
{[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-gray-200 rounded px-1.5 py-0.5 text-gray-500 hover:border-gray-400">
|
}} className="border border-line rounded px-1.5 py-0.5 text-muted hover:border-line">
|
||||||
{d}
|
{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-gray-50">
|
<div className="absolute inset-0 flex items-center justify-center z-10 bg-raised">
|
||||||
<p className="text-sm text-gray-400">Loading…</p>
|
<p className="text-sm text-muted">Loading…</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
|
<div className="absolute inset-0 flex items-center justify-center z-10 bg-raised">
|
||||||
<p className="text-sm text-red-500">Error: {error}</p>
|
<p className="text-sm text-danger">Error: {error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status === 'noview' && (
|
{status === 'noview' && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
|
<div className="absolute inset-0 flex items-center justify-center z-10 bg-raised">
|
||||||
<p className="text-sm text-gray-400">No view data — generate a view and transform records first.</p>
|
<p className="text-sm text-muted">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-gray-200 bg-white flex flex-col overflow-hidden flex-shrink-0"
|
className="relative border-l border-line bg-surface flex flex-col overflow-hidden flex-shrink-0"
|
||||||
>
|
>
|
||||||
{/* Drag-to-resize handle on left edge */}
|
{/* 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-gray-100 flex-shrink-0">
|
<div className="flex items-center justify-between pl-3 pr-2 py-2 border-b border-line-soft flex-shrink-0">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{cellCoords.length > 0 && (
|
{cellCoords.length > 0 && (
|
||||||
<span className="text-xs text-gray-700 font-mono font-semibold truncate">
|
<span className="text-xs text-ink-soft font-mono font-semibold truncate">
|
||||||
{cellCoords.join(' › ')}
|
{cellCoords.join(' › ')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
<span className="text-xs text-muted flex-shrink-0">
|
||||||
{inspectedRows.length} row{inspectedRows.length !== 1 ? 's' : ''}
|
{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-gray-400 hover:text-gray-600 w-4 text-center">−</button>
|
className="text-xs text-muted hover:text-ink-soft w-4 text-center">−</button>
|
||||||
<span className="text-xs text-gray-400 w-4 text-center">{decimals}</span>
|
<span className="text-xs text-muted 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-gray-400 hover:text-gray-600 w-4 text-center">+</button>
|
className="text-xs text-muted hover:text-ink-soft w-4 text-center">+</button>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => { setInspectedRows(null); setClickDetail(null); lastClickKeyRef.current = null }}
|
<button onClick={() => { setInspectedRows(null); setClickDetail(null); lastClickKeyRef.current = null }}
|
||||||
className="text-gray-300 hover:text-gray-500 leading-none text-lg">×</button>
|
className="text-muted hover:text-muted leading-none text-lg">×</button>
|
||||||
</div>
|
</div>
|
||||||
</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-gray-100">
|
<div className="px-3 py-2 border-b border-line-soft">
|
||||||
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">Filters</div>
|
<div className="text-xs text-muted uppercase tracking-wide mb-1">Filters</div>
|
||||||
{userFilters.map((f, i) => (
|
{userFilters.map((f, i) => (
|
||||||
<div key={i} className="text-xs text-gray-500 py-0.5 font-mono">{f.join(' ')}</div>
|
<div key={i} className="text-xs text-muted py-0.5 font-mono">{f.join(' ')}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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-gray-400 border-b border-gray-100 bg-gray-50 sticky top-0">
|
<tr className="text-left text-muted border-b border-line-soft bg-raised sticky top-0">
|
||||||
{cols.map(c => {
|
{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-gray-600">
|
className="px-2 py-1 font-medium whitespace-nowrap cursor-pointer select-none hover:text-ink-soft">
|
||||||
{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-gray-50 hover:bg-gray-50">
|
<tr key={i} className="border-t border-line-soft hover:bg-raised">
|
||||||
{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-gray-700 max-w-40 truncate">
|
<td key={c} className="px-2 py-1 font-mono whitespace-nowrap text-ink-soft max-w-40 truncate">
|
||||||
{f == null ? <span className="text-gray-300">—</span> : f}
|
{f == null ? <span className="text-muted">—</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-gray-200 bg-gray-50 font-semibold text-gray-700 sticky bottom-0">
|
<tr className="border-t-2 border-line bg-raised font-semibold text-ink-soft sticky bottom-0">
|
||||||
{cols.map(c => (
|
{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) : ''}
|
||||||
|
|||||||
@ -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-white border border-gray-200 rounded shadow-lg max-h-40 overflow-y-auto">
|
className="bg-surface border border-line rounded shadow-lg max-h-40 overflow-y-auto">
|
||||||
{filtered.map((s, i) => (
|
{filtered.map((s, i) => (
|
||||||
<div key={s}
|
<div key={s}
|
||||||
className={`px-2 py-1 text-xs cursor-pointer whitespace-nowrap ${i === highlighted ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'}`}
|
className={`px-2 py-1 text-xs cursor-pointer whitespace-nowrap ${i === highlighted ? 'bg-accent-soft text-accent' : 'text-ink-soft hover:bg-raised'}`}
|
||||||
onMouseDown={e => { e.preventDefault(); select(s) }}>{s}</div>
|
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-6 text-sm text-gray-400">Select a source first.</div>
|
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
|
||||||
|
|
||||||
const displayCols = gridCols(rows.length > 0 ? Object.keys(rows[0]) : cols)
|
const 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-gray-800">Records — {source}</h1>
|
<h1 className="text-xl font-semibold text-ink">Records — {source}</h1>
|
||||||
{exists && rows.length > 0 && (
|
{exists && rows.length > 0 && (
|
||||||
<span className="text-xs text-gray-400 font-mono">dfv.{source}</span>
|
<span className="text-xs text-muted font-mono">dfv.{source}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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-gray-400 font-medium mr-1">DB query:</span>
|
<span className="text-xs text-muted font-medium mr-1">DB query:</span>
|
||||||
{filters.map((f, i) => (
|
{filters.map((f, i) => (
|
||||||
<div key={i} className="flex items-center gap-1 bg-white border border-gray-200 rounded px-2 py-1">
|
<div key={i} className="flex items-center gap-1 bg-surface border border-line rounded px-2 py-1">
|
||||||
<select
|
<select
|
||||||
className="text-xs text-gray-600 border-0 focus:outline-none bg-transparent"
|
className="text-xs text-ink-soft border-0 focus:outline-none bg-transparent"
|
||||||
value={f.col}
|
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-gray-300 mx-0.5">~*</span>
|
<span className="text-xs text-muted 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-gray-300 hover:text-gray-500 ml-1 leading-none">×</button>
|
<button onClick={() => removeFilter(i)} className="text-muted hover:text-muted ml-1 leading-none">×</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button onClick={addFilter}
|
<button onClick={addFilter}
|
||||||
className="text-xs text-gray-400 hover:text-gray-600 border border-dashed border-gray-200 rounded px-2 py-1">
|
className="text-xs text-muted hover:text-ink-soft border border-dashed border-line rounded px-2 py-1">
|
||||||
+ filter
|
+ 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-gray-400 hover:text-red-500">clear</button>
|
className="text-xs text-muted hover:text-danger">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-gray-400 font-medium mr-1">Bulk select:</span>
|
<span className="text-xs text-muted 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-blue-400 ${
|
className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-accent ${
|
||||||
rowFilter ? 'border-blue-300' : 'border-gray-200'
|
rowFilter ? 'border-accent-line' : 'border-line'
|
||||||
}`}
|
}`}
|
||||||
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-gray-400">{selected.size} of {rows.length} rows selected</span>
|
<span className="text-xs text-muted">{selected.size} of {rows.length} rows selected</span>
|
||||||
)}
|
)}
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<div className="flex items-center gap-2 ml-4 p-2 bg-blue-50 border border-blue-200 rounded flex-wrap">
|
<div className="flex items-center gap-2 ml-4 p-2 bg-accent-soft border border-accent-line rounded flex-wrap">
|
||||||
{allOverrideCols.map(col => (
|
{allOverrideCols.map(col => (
|
||||||
<AutocompleteInput
|
<AutocompleteInput
|
||||||
key={col}
|
key={col}
|
||||||
className="border border-blue-300 rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-blue-500 bg-white"
|
className="border border-accent-line rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-accent bg-surface"
|
||||||
placeholder={col}
|
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-blue-400 hover:text-blue-600"
|
className="text-xs text-accent hover:text-accent"
|
||||||
>
|
>
|
||||||
cancel
|
cancel
|
||||||
</button>
|
</button>
|
||||||
@ -404,25 +404,25 @@ export default function Records({ source }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && <p className="text-sm text-gray-400">Loading…</p>}
|
{loading && <p className="text-sm text-muted">Loading…</p>}
|
||||||
{!loading && viewError && <p className="text-sm text-red-500">View error: {viewError} — check field types in Sources.</p>}
|
{!loading && viewError && <p className="text-sm text-danger">View error: {viewError} — check field types in Sources.</p>}
|
||||||
{!loading && exists === false && (
|
{!loading && exists === false && (
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-muted">
|
||||||
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>.
|
No view generated yet. Go to <span className="font-medium text-ink-soft">Sources</span>, check fields as <span className="font-medium text-ink-soft">In view</span>, then click <span className="font-medium text-ink-soft">Generate view</span>.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!loading && exists && rows.length === 0 && (
|
{!loading && exists && rows.length === 0 && (
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-muted">
|
||||||
{filters.some(f => f.col && f.pattern) ? 'No records match the current filters.' : 'View exists but no transformed records yet. Import data and run a transform first.'}
|
{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-white border border-gray-200 rounded overflow-auto mb-4">
|
<div className="bg-surface border border-line rounded overflow-auto mb-4">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-xs text-gray-400 border-b border-gray-100 bg-gray-50">
|
<tr className="text-left text-xs text-muted border-b border-line-soft bg-raised">
|
||||||
<th className="px-2 py-2 w-8">
|
<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-gray-600">
|
className="px-3 py-2 font-medium whitespace-nowrap cursor-pointer select-none hover:text-ink-soft">
|
||||||
{col}
|
{col}
|
||||||
<span className="ml-1 text-gray-300">{active ? (sort.dir === 'asc' ? '▲' : '▼') : '⇅'}</span>
|
<span className="ml-1 text-muted">{active ? (sort.dir === 'asc' ? '▲' : '▼') : '⇅'}</span>
|
||||||
</th>
|
</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-gray-50 cursor-pointer transition-colors
|
className={`border-t border-line-soft cursor-pointer transition-colors
|
||||||
${isPanelSelected ? 'bg-blue-50' : isRowSelected ? 'bg-blue-50' : isOverridden ? 'bg-amber-50 hover:bg-amber-100' : 'hover:bg-gray-50'}`}>
|
${isPanelSelected ? 'bg-accent-soft' : isRowSelected ? 'bg-accent-soft' : isOverridden ? 'bg-warn-soft hover:bg-warn-soft' : 'hover:bg-raised'}`}>
|
||||||
<td className="px-2 py-2">
|
<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-gray-600 whitespace-nowrap max-w-48 truncate">
|
<td key={j} className="px-3 py-2 text-xs text-ink-soft whitespace-nowrap max-w-48 truncate">
|
||||||
{formatted === null ? <span className="text-gray-300">—</span> : formatted}
|
{formatted === null ? <span className="text-muted">—</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-gray-500">
|
<div className="flex items-center gap-3 text-sm text-muted">
|
||||||
<button onClick={prev} disabled={offset === 0}
|
<button onClick={prev} disabled={offset === 0}
|
||||||
className="px-3 py-1 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-40">← Prev</button>
|
className="px-3 py-1 border border-line rounded hover:bg-raised disabled:opacity-40">← Prev</button>
|
||||||
<span>{offset + 1}–{offset + rows.length}</span>
|
<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-gray-200 rounded hover:bg-gray-50 disabled:opacity-40">Next →</button>
|
className="px-3 py-1 border border-line rounded hover:bg-raised disabled:opacity-40">Next →</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -494,58 +494,58 @@ export default function Records({ source }) {
|
|||||||
|
|
||||||
{/* Panel */}
|
{/* Panel */}
|
||||||
{panelOpen && (
|
{panelOpen && (
|
||||||
<div className="w-80 border-l border-gray-200 bg-white flex flex-col overflow-hidden flex-shrink-0">
|
<div className="w-80 border-l border-line bg-surface flex flex-col overflow-hidden flex-shrink-0">
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-100">
|
<div className="flex items-center justify-between px-3 py-2 border-b border-line-soft">
|
||||||
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wide">Record</span>
|
<span className="text-xs font-semibold text-ink-soft uppercase tracking-wide">Record</span>
|
||||||
<button onClick={closePanel} className="text-gray-300 hover:text-gray-500 leading-none text-lg">×</button>
|
<button onClick={closePanel} className="text-muted hover:text-muted leading-none text-lg">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{panelLoading && <p className="text-xs text-gray-400 p-3">Loading…</p>}
|
{panelLoading && <p className="text-xs text-muted p-3">Loading…</p>}
|
||||||
|
|
||||||
{selectedRecord && !panelLoading && (
|
{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-gray-100 ${panelMsg.ok ? 'text-green-600' : 'text-red-500'}`}>
|
<div className={`text-xs px-3 py-2 border-b border-line-soft ${panelMsg.ok ? 'text-ok' : 'text-danger'}`}>
|
||||||
{panelMsg.text}
|
{panelMsg.text}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Raw fields — read only */}
|
{/* Raw fields — read only */}
|
||||||
<div className="border-b border-gray-100">
|
<div className="border-b border-line-soft">
|
||||||
<div className="px-3 py-1.5 bg-gray-50 border-b border-gray-100">
|
<div className="px-3 py-1.5 bg-raised border-b border-line-soft">
|
||||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Raw</span>
|
<span className="text-xs font-medium text-muted 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-gray-50 first:border-t-0">
|
<div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-line-soft first:border-t-0">
|
||||||
<span className="text-xs font-mono text-gray-400 w-28 shrink-0 truncate">{field}</span>
|
<span className="text-xs font-mono text-muted w-28 shrink-0 truncate">{field}</span>
|
||||||
<span className="text-xs font-mono text-gray-500 truncate">{formatVal(val) ?? <span className="text-gray-300">—</span>}</span>
|
<span className="text-xs font-mono text-muted truncate">{formatVal(val) ?? <span className="text-muted">—</span>}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Transformed fields — read only delta */}
|
{/* Transformed fields — read only delta */}
|
||||||
<div className="border-b border-gray-100">
|
<div className="border-b border-line-soft">
|
||||||
<div className="px-3 py-1.5 bg-gray-50 border-b border-gray-100">
|
<div className="px-3 py-1.5 bg-raised border-b border-line-soft">
|
||||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Transformed</span>
|
<span className="text-xs font-medium text-muted 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-gray-300">No rule output yet.</div>
|
? <div className="px-3 py-2 text-xs text-muted">No rule output yet.</div>
|
||||||
: Object.entries(selectedRecord.transformed || {}).filter(([k]) => !HIDDEN_COLS.has(k)).map(([field, val]) => (
|
: Object.entries(selectedRecord.transformed || {}).filter(([k]) => !HIDDEN_COLS.has(k)).map(([field, val]) => (
|
||||||
<div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-gray-50 first:border-t-0">
|
<div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-line-soft first:border-t-0">
|
||||||
<span className="text-xs font-mono text-gray-400 w-28 shrink-0 truncate">{field}</span>
|
<span className="text-xs font-mono text-muted w-28 shrink-0 truncate">{field}</span>
|
||||||
<span className="text-xs font-mono text-blue-600 truncate">{formatVal(val) ?? <span className="text-gray-300">—</span>}</span>
|
<span className="text-xs font-mono text-accent truncate">{formatVal(val) ?? <span className="text-muted">—</span>}</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Overrides — editable */}
|
{/* Overrides — editable */}
|
||||||
<div className="flex-1 border-b border-gray-100">
|
<div className="flex-1 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">
|
<div className="flex items-center justify-between px-3 py-1.5 bg-raised border-b border-line-soft">
|
||||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Overrides</span>
|
<span className="text-xs font-medium text-muted uppercase tracking-wide">Overrides</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setExtraCols(ec => [...ec, ''])}
|
onClick={() => setExtraCols(ec => [...ec, ''])}
|
||||||
className="text-gray-400 hover:text-gray-700 font-medium text-sm leading-none"
|
className="text-muted hover:text-ink-soft font-medium text-sm leading-none"
|
||||||
title="Add field">+</button>
|
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-gray-50">
|
<tr key={col} className="border-t border-line-soft">
|
||||||
<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-gray-500 truncate block">{col}</span>
|
<span className="font-mono text-muted 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-amber-300 bg-amber-50 text-amber-800' : 'border-gray-200 text-gray-600'
|
override ? 'border-warn-line bg-warn-soft text-warn' : 'border-line text-ink-soft'
|
||||||
}`}
|
}`}
|
||||||
value={override}
|
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-gray-300 hover:text-red-400 leading-none text-base">×</button>
|
className="text-muted hover:text-danger 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-gray-50">
|
<tr key={`extra-${i}`} className="border-t border-line-soft">
|
||||||
<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-gray-200 rounded px-1 py-0.5 focus:outline-none focus:border-blue-400"
|
className="w-full text-xs font-mono border border-line rounded px-1 py-0.5 focus:outline-none focus:border-accent"
|
||||||
value={col}
|
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-amber-300 bg-amber-50 text-amber-800' : 'border-gray-200 text-gray-600'
|
val ? 'border-warn-line bg-warn-soft text-warn' : 'border-line text-ink-soft'
|
||||||
}`}
|
}`}
|
||||||
value={val}
|
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-gray-300 hover:text-red-400 leading-none text-base">×</button>
|
className="text-muted hover:text-danger 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-gray-100 shrink-0">
|
<div className="flex gap-2 px-3 py-2 border-t border-line-soft 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-gray-200 rounded px-3 py-1.5 text-gray-500 hover:border-red-300 hover:text-red-500 disabled:opacity-40">
|
className="text-xs border border-line rounded px-3 py-1.5 text-muted hover:border-danger-line hover:text-danger disabled:opacity-40">
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -73,8 +73,8 @@ export default function Remap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl">
|
<div className="p-4 sm:p-6 max-w-4xl">
|
||||||
<h1 className="text-base font-semibold text-gray-800 mb-4">Remap Output Values</h1>
|
<h1 className="text-base font-semibold text-ink 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-gray-300 rounded px-3 py-1.5 w-72 focus:outline-none focus:border-blue-400"
|
className="text-sm border border-line rounded px-3 py-1.5 w-72 focus:outline-none focus:border-accent"
|
||||||
/>
|
/>
|
||||||
<button type="submit" disabled={searching}
|
<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-gray-400">No matching output values found.</p>
|
<p className="text-sm text-muted">No matching output values found.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">
|
<div className="text-xs text-muted uppercase tracking-wide mb-1">
|
||||||
{results.length} result{results.length !== 1 ? 's' : ''} — click one to remap
|
{results.length} result{results.length !== 1 ? 's' : ''} — click one to remap
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-sm border border-gray-200 rounded overflow-hidden">
|
<table className="w-full text-sm border border-line rounded overflow-hidden">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-50 text-left text-xs text-gray-400 uppercase tracking-wide">
|
<tr className="bg-raised text-left text-xs text-muted uppercase tracking-wide">
|
||||||
<th className="px-3 py-2">Field</th>
|
<th className="px-3 py-2">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-gray-100 cursor-pointer transition-colors
|
className={`border-t border-line-soft cursor-pointer transition-colors
|
||||||
${isActive ? 'bg-blue-50' : 'hover:bg-gray-50'}`}>
|
${isActive ? 'bg-accent-soft' : 'hover:bg-raised'}`}>
|
||||||
<td className="px-3 py-2 font-mono text-gray-500">{r.col}</td>
|
<td className="px-3 py-2 font-mono text-muted">{r.col}</td>
|
||||||
<td className="px-3 py-2 font-mono text-gray-800">{r.val}</td>
|
<td className="px-3 py-2 font-mono text-ink">{r.val}</td>
|
||||||
<td className="px-3 py-2 text-right text-gray-400">{r.mapping_count}</td>
|
<td className="px-3 py-2 text-right text-muted">{r.mapping_count}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -132,25 +132,25 @@ export default function Remap() {
|
|||||||
|
|
||||||
{/* Remap panel */}
|
{/* Remap panel */}
|
||||||
{selected && (
|
{selected && (
|
||||||
<div className="border border-gray-200 rounded p-4 mb-6 bg-white">
|
<div className="border border-line rounded p-4 mb-6 bg-surface">
|
||||||
<div className="text-xs text-gray-400 uppercase tracking-wide mb-3">
|
<div className="text-xs text-muted uppercase tracking-wide mb-3">
|
||||||
Remap <span className="font-mono text-gray-600">{selected.col}</span>
|
Remap <span className="font-mono text-ink-soft">{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-gray-400 mb-1">From</div>
|
<div className="text-xs text-muted mb-1">From</div>
|
||||||
<div className="text-sm font-mono bg-gray-50 border border-gray-200 rounded px-3 py-1.5 text-gray-700">
|
<div className="text-sm font-mono bg-raised border border-line rounded px-3 py-1.5 text-ink-soft">
|
||||||
{selected.val}
|
{selected.val}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-300 mt-4">→</div>
|
<div className="text-muted mt-4">→</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-xs text-gray-400 mb-1">To</div>
|
<div className="text-xs text-muted mb-1">To</div>
|
||||||
<input
|
<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-gray-300 rounded px-3 py-1.5 focus:outline-none focus:border-blue-400"
|
className="w-full text-sm font-mono border border-line rounded px-3 py-1.5 focus:outline-none focus:border-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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-green-600' : 'text-red-500'}`}>
|
<div className={`text-sm mb-3 ${msg.ok ? 'text-ok' : 'text-danger'}`}>
|
||||||
{msg.text}
|
{msg.text}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Affected mappings */}
|
{/* Affected mappings */}
|
||||||
{loadingMatches ? (
|
{loadingMatches ? (
|
||||||
<p className="text-xs text-gray-400">Loading…</p>
|
<p className="text-xs text-muted">Loading…</p>
|
||||||
) : matches && matches.length > 0 && (
|
) : matches && matches.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">
|
<div className="text-xs text-muted uppercase tracking-wide mb-1">
|
||||||
Affected mappings
|
Affected mappings
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-xs border border-gray-100 rounded overflow-hidden">
|
<table className="w-full text-xs border border-line-soft rounded overflow-hidden">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-50 text-left text-gray-400">
|
<tr className="bg-raised text-left text-muted">
|
||||||
<th className="px-2 py-1">Source</th>
|
<th className="px-2 py-1">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-gray-50">
|
<tr key={m.id} className="border-t border-line-soft">
|
||||||
<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.source_name}</td>
|
||||||
<td className="px-2 py-1 font-mono text-gray-500">{m.rule_name}</td>
|
<td className="px-2 py-1 font-mono text-muted">{m.rule_name}</td>
|
||||||
<td className="px-2 py-1 font-mono text-gray-700">
|
<td className="px-2 py-1 font-mono text-ink-soft">
|
||||||
{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-gray-700">
|
<td className="px-2 py-1 font-mono text-ink-soft">
|
||||||
{Object.entries(m.output).map(([k, v]) => (
|
{Object.entries(m.output).map(([k, v]) => (
|
||||||
<span key={k} className={k === selected.col ? 'text-blue-600 font-semibold' : ''}>
|
<span key={k} className={k === selected.col ? 'text-accent font-semibold' : ''}>
|
||||||
{k}: {v}{' '}
|
{k}: {v}{' '}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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-white rounded-lg shadow-xl w-3/4 max-w-3xl max-h-[80vh] flex flex-col"
|
<div className="bg-surface rounded-lg shadow-xl w-3/4 max-w-3xl max-h-[80vh] flex flex-col"
|
||||||
onClick={e => e.stopPropagation()}>
|
onClick={e => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
|
<div className="flex items-center justify-between px-5 py-3 border-b border-line-soft">
|
||||||
<span className="text-sm font-medium text-gray-700">
|
<span className="text-sm font-medium text-ink-soft">
|
||||||
Pattern results — <span className="text-gray-500 font-normal">{matched}/{rows.length} matched</span>
|
Pattern results — <span className="text-muted font-normal">{matched}/{rows.length} matched</span>
|
||||||
</span>
|
</span>
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">✕</button>
|
<button onClick={onClose} className="text-muted hover:text-ink-soft text-lg leading-none">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<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-gray-400 border-b border-gray-100">
|
<tr className="text-left text-muted border-b border-line-soft">
|
||||||
<th className="pb-2 font-medium w-1/2 pr-4">Raw value</th>
|
<th className="pb-2 font-medium 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-gray-50">
|
<tr key={i} className="border-t border-line-soft">
|
||||||
<td className="py-1 font-mono text-gray-400 pr-4 break-all">{r.raw_value}</td>
|
<td className="py-1 font-mono text-muted pr-4 break-all">{r.raw_value}</td>
|
||||||
<td className={`py-1 font-mono break-all ${r.extracted_value != null ? 'text-gray-800' : 'text-gray-300'}`}>
|
<td className={`py-1 font-mono break-all ${r.extracted_value != null ? 'text-ink' : 'text-muted'}`}>
|
||||||
{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-white border border-gray-200 rounded p-4 mb-4">
|
<div className="bg-surface border border-line rounded p-4 mb-4">
|
||||||
<h2 className="text-sm font-semibold text-gray-700 mb-3">{editing ? 'Edit rule' : 'New rule'}</h2>
|
<h2 className="text-sm font-semibold text-ink-soft 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-gray-500 block mb-1">Rule name</label>
|
<label className="text-xs text-muted block mb-1">Rule name</label>
|
||||||
<input
|
<input
|
||||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||||
value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
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-gray-500 block mb-1">Sequence</label>
|
<label className="text-xs text-muted block mb-1">Sequence</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||||
value={form.sequence} onChange={e => setForm(f => ({ ...f, sequence: parseInt(e.target.value) || 0 }))}
|
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-gray-500 block mb-1">Input field</label>
|
<label className="text-xs text-muted block mb-1">Input field</label>
|
||||||
{fields.length > 0 ? (
|
{fields.length > 0 ? (
|
||||||
<select
|
<select
|
||||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||||
value={form.field} onChange={e => setForm(f => ({ ...f, field: e.target.value }))}
|
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-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||||
value={form.field} onChange={e => setForm(f => ({ ...f, field: e.target.value }))}
|
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-gray-500 block mb-1">Output field</label>
|
<label className="text-xs text-muted block mb-1">Output field</label>
|
||||||
<input
|
<input
|
||||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||||
value={form.output_field} onChange={e => setForm(f => ({ ...f, output_field: e.target.value }))}
|
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-gray-500 block mb-1">Pattern (regex)</label>
|
<label className="text-xs text-muted block mb-1">Pattern (regex)</label>
|
||||||
<input
|
<input
|
||||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400"
|
className="w-full border border-line rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-accent"
|
||||||
value={form.pattern} onChange={e => setForm(f => ({ ...f, pattern: e.target.value }))}
|
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-gray-500 block mb-1">Function</label>
|
<label className="text-xs text-muted block mb-1">Function</label>
|
||||||
<select
|
<select
|
||||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||||
value={form.function_type} onChange={e => setForm(f => ({ ...f, function_type: e.target.value }))}
|
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-gray-500 block mb-1">Flags</label>
|
<label className="text-xs text-muted block mb-1">Flags</label>
|
||||||
<input
|
<input
|
||||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400"
|
className="w-full border border-line rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-accent"
|
||||||
value={form.flags} onChange={e => setForm(f => ({ ...f, flags: e.target.value }))}
|
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-gray-600 cursor-pointer select-none">
|
<label className="flex items-center gap-2 text-xs text-ink-soft 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-gray-500 block mb-1">Replacement string</label>
|
<label className="text-xs text-muted block mb-1">Replacement string</label>
|
||||||
<input
|
<input
|
||||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400"
|
className="w-full border border-line rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-accent"
|
||||||
value={form.replace_value} onChange={e => setForm(f => ({ ...f, replace_value: e.target.value }))}
|
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-gray-100 rounded p-2 bg-gray-50">
|
<div className="border border-line-soft rounded p-2 bg-raised">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-muted">
|
||||||
{previewing ? 'Testing…' : `${preview.filter(r => r.extracted_value != null).length}/${preview.length} matched`}
|
{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-blue-400 hover:text-blue-600">expand</button>
|
className="text-xs text-accent hover:text-accent">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-gray-100 first:border-0">
|
<tr key={i} className="border-t border-line-soft first:border-0">
|
||||||
<td className="py-0.5 font-mono text-gray-400 truncate max-w-0 w-1/2 pr-3">{r.raw_value}</td>
|
<td className="py-0.5 font-mono text-muted truncate max-w-0 w-1/2 pr-3">{r.raw_value}</td>
|
||||||
<td className={`py-0.5 font-mono truncate ${r.extracted_value != null ? 'text-gray-800' : 'text-gray-300'}`}>
|
<td className={`py-0.5 font-mono truncate ${r.extracted_value != null ? 'text-ink' : 'text-muted'}`}>
|
||||||
{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-red-500">{error}</p>}
|
{error && <p className="text-xs text-danger">{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-gray-500 px-3 py-1.5 rounded hover:bg-gray-100">
|
className="text-sm text-muted px-3 py-1.5 rounded hover:bg-raised">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -310,12 +310,12 @@ export default function Rules({ source, onStale }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
|
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-3xl">
|
<div className="p-4 sm:p-6 max-w-3xl">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-xl font-semibold text-gray-800">Rules — {source}</h1>
|
<h1 className="text-xl font-semibold text-ink">Rules — {source}</h1>
|
||||||
<button onClick={startCreate}
|
<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-gray-400">No rules yet. Add a regex rule to start extracting values.</p>
|
<p className="text-sm text-muted">No rules yet. Add a regex rule to start extracting values.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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-white border border-gray-200 rounded">
|
<div key={rule.id} className="bg-surface border border-line 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-gray-50 select-none"
|
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-raised 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-gray-200'}`}
|
className={`w-8 h-4 rounded-full flex-shrink-0 transition-colors ${rule.enabled ? 'bg-blue-500' : 'bg-raised'}`}
|
||||||
title={rule.enabled ? 'Disable' : 'Enable'}
|
title={rule.enabled ? 'Disable' : 'Enable'}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="font-medium text-gray-800 text-sm">{rule.name}</span>
|
<span className="font-medium text-ink text-sm">{rule.name}</span>
|
||||||
<span className="text-gray-400 text-xs ml-2">seq {rule.sequence}</span>
|
<span className="text-muted text-xs ml-2">seq {rule.sequence}</span>
|
||||||
{!isExpanded && (
|
{!isExpanded && (
|
||||||
<div className="text-xs text-gray-400 mt-0.5 truncate">
|
<div className="text-xs text-muted mt-0.5 truncate">
|
||||||
<span className="font-mono">{rule.field}</span>
|
<span className="font-mono">{rule.field}</span>
|
||||||
<span className="mx-1">→</span>
|
<span className="mx-1">→</span>
|
||||||
<span className="font-mono bg-gray-50 px-1 rounded">{rule.pattern}</span>
|
<span className="font-mono bg-raised px-1 rounded">{rule.pattern}</span>
|
||||||
{rule.flags && <span className="text-blue-400 ml-1">/{rule.flags}</span>}
|
{rule.flags && <span className="text-accent 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-orange-400">(replace)</span>}
|
{rule.function_type === 'replace' && <span className="ml-1 text-warn">(replace)</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-300 flex-shrink-0">{isExpanded ? '▲' : '▼'}</span>
|
<span className="text-xs text-muted flex-shrink-0">{isExpanded ? '▲' : '▼'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded content */}
|
{/* Expanded content */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-t border-gray-100">
|
<div className="border-t border-line-soft">
|
||||||
<div className="px-4 pt-3 pb-1 flex justify-end">
|
<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-red-400 hover:text-red-600">Delete</button>
|
className="text-xs text-danger hover:text-danger">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<FormPanel
|
<FormPanel
|
||||||
|
|||||||
424
ui/src/pages/SourceDetail.jsx
Normal file
424
ui/src/pages/SourceDetail.jsx
Normal file
@ -0,0 +1,424 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { api } from '../api'
|
||||||
|
import Section from '../components/Section.jsx'
|
||||||
|
import SampleTable from '../components/SampleTable.jsx'
|
||||||
|
|
||||||
|
const FIELD_TYPES = ['text', 'numeric', 'date']
|
||||||
|
|
||||||
|
export default function SourceDetail({ sources, setSources }) {
|
||||||
|
const { name: source } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [constraintFields, setConstraintFields] = useState('')
|
||||||
|
const [globalPicklist, setGlobalPicklist] = useState(true)
|
||||||
|
const [schemaFields, setSchemaFields] = useState([])
|
||||||
|
const [stats, setStats] = useState(null)
|
||||||
|
const [sampleRows, setSampleRows] = useState([])
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [reprocessing, setReprocessing] = useState(false)
|
||||||
|
const [generating, setGenerating] = useState(false)
|
||||||
|
const [result, setResult] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [viewName, setViewName] = useState('')
|
||||||
|
const [availableFields, setAvailableFields] = useState([])
|
||||||
|
const [fieldSort, setFieldSort] = useState({ col: 'key', dir: 'asc' })
|
||||||
|
const [bridgeAccounts, setBridgeAccounts] = useState(null)
|
||||||
|
const [bridgeLoading, setBridgeLoading] = useState(false)
|
||||||
|
const [bridgeError, setBridgeError] = useState('')
|
||||||
|
|
||||||
|
const sourceObj = sources.find(s => s.name === source)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sourceObj) return
|
||||||
|
setConstraintFields(sourceObj.constraint_fields?.join(', ') || '')
|
||||||
|
setGlobalPicklist(sourceObj.global_picklist !== false)
|
||||||
|
setSchemaFields((sourceObj.config?.fields || []).map((f, i) => ({ seq: i + 1, ...f })))
|
||||||
|
setViewName(sourceObj.config?.fields?.length ? `dfv.${sourceObj.name}` : '')
|
||||||
|
setResult('')
|
||||||
|
setError('')
|
||||||
|
setStats(null)
|
||||||
|
setAvailableFields([])
|
||||||
|
setSampleRows([])
|
||||||
|
setBridgeAccounts(null)
|
||||||
|
setBridgeError('')
|
||||||
|
api.getStats(sourceObj.name).then(setStats).catch(() => {})
|
||||||
|
api.getFields(sourceObj.name).then(setAvailableFields).catch(() => {})
|
||||||
|
api.getRecords(sourceObj.name, 50).then(rows => setSampleRows(rows.map(r => r.data).filter(Boolean))).catch(() => {})
|
||||||
|
}, [source, sourceObj?.name])
|
||||||
|
|
||||||
|
|
||||||
|
async function handleSave(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const constraint_fields = constraintFields.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
const fields = [...schemaFields.filter(f => f.name)].sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
|
||||||
|
const config = { ...(sourceObj.config || {}), fields }
|
||||||
|
await api.updateSource(sourceObj.name, { constraint_fields, config, global_picklist: globalPicklist })
|
||||||
|
if (fields.length > 0) {
|
||||||
|
const res = await api.generateView(sourceObj.name)
|
||||||
|
if (res.success) setViewName(res.view)
|
||||||
|
}
|
||||||
|
const updated = await api.getSources()
|
||||||
|
setSources(updated)
|
||||||
|
setResult('Saved.')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerateView() {
|
||||||
|
setGenerating(true)
|
||||||
|
setResult('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const constraint_fields = constraintFields.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
const fields = [...schemaFields.filter(f => f.name)].sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
|
||||||
|
const config = { ...(sourceObj.config || {}), fields }
|
||||||
|
await api.updateSource(sourceObj.name, { constraint_fields, config, global_picklist: globalPicklist })
|
||||||
|
const res = await api.generateView(sourceObj.name)
|
||||||
|
if (res.success) {
|
||||||
|
setViewName(res.view)
|
||||||
|
setResult(`View created: ${res.view}`)
|
||||||
|
} else {
|
||||||
|
setError(res.error)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setGenerating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReprocess() {
|
||||||
|
if (!confirm(`Reprocess all records for "${sourceObj.name}"? This will clear and reapply all transformations.`)) return
|
||||||
|
setReprocessing(true)
|
||||||
|
setResult('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await api.reprocess(sourceObj.name)
|
||||||
|
setResult(`Reprocessed ${res.transformed} records.`)
|
||||||
|
api.getStats(sourceObj.name).then(setStats).catch(() => {})
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setReprocessing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!confirm(`Delete source "${sourceObj.name}" and all its data?`)) return
|
||||||
|
try {
|
||||||
|
await api.deleteSource(sourceObj.name)
|
||||||
|
setSources(await api.getSources())
|
||||||
|
navigate('/sources')
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bridge is an external call, so accounts are fetched on demand rather
|
||||||
|
// than on every visit to this page.
|
||||||
|
async function loadBridgeAccounts() {
|
||||||
|
setBridgeLoading(true)
|
||||||
|
setBridgeError('')
|
||||||
|
try {
|
||||||
|
const res = await api.getSimpleFinAccounts()
|
||||||
|
setBridgeAccounts(res.accounts || [])
|
||||||
|
if (res.errors?.length) setBridgeError(res.errors.join('; '))
|
||||||
|
} catch (err) {
|
||||||
|
setBridgeError(err.message)
|
||||||
|
} finally {
|
||||||
|
setBridgeLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes only config — constraint_fields and global_picklist are left NULL so
|
||||||
|
// update_source keeps whatever is already stored.
|
||||||
|
async function handleLinkAccount(accountId) {
|
||||||
|
setError('')
|
||||||
|
setResult('')
|
||||||
|
try {
|
||||||
|
const config = { ...(sourceObj.config || {}) }
|
||||||
|
if (accountId) {
|
||||||
|
config.simplefin = { ...(config.simplefin || {}), account_id: accountId }
|
||||||
|
} else {
|
||||||
|
delete config.simplefin
|
||||||
|
}
|
||||||
|
await api.updateSource(sourceObj.name, { config })
|
||||||
|
setSources(await api.getSources())
|
||||||
|
setResult(accountId ? 'Account linked.' : 'Account unlinked.')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceObj) return <div className="p-4 sm:p-6 text-sm text-muted">Source not found.</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 sm:p-6 max-w-5xl space-y-4">
|
||||||
|
{stats && (
|
||||||
|
<div className="flex gap-4 text-xs">
|
||||||
|
<span className="text-muted"><span className="font-medium text-ink">{stats.total_records}</span> total</span>
|
||||||
|
<span className="text-muted"><span className="font-medium text-ink">{stats.transformed_records}</span> transformed</span>
|
||||||
|
<span className="text-muted"><span className="font-medium text-ink">{stats.pending_records}</span> pending</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bank feed — link this source to a SimpleFIN account */}
|
||||||
|
<Section
|
||||||
|
title="Connection"
|
||||||
|
description="Where this source gets its data. Unlinked sources are filled by CSV upload on the Import page."
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{bridgeAccounts === null ? (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-muted font-mono">
|
||||||
|
{sourceObj.config?.simplefin?.account_id || 'not linked'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={loadBridgeAccounts}
|
||||||
|
disabled={bridgeLoading}
|
||||||
|
className="text-xs border border-line rounded px-2 py-1 text-ink-soft hover:bg-raised hover:border-line disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{bridgeLoading ? 'Loading…' : sourceObj.config?.simplefin?.account_id ? 'Change' : 'Link SimpleFIN account'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={sourceObj.config?.simplefin?.account_id || ''}
|
||||||
|
onChange={e => handleLinkAccount(e.target.value)}
|
||||||
|
className="text-xs border border-line rounded px-2 py-1 bg-surface text-ink-soft"
|
||||||
|
>
|
||||||
|
<option value="">Not linked</option>
|
||||||
|
{bridgeAccounts.map(a => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.name}{a.organization ? ` — ${a.organization}` : ''}{a.balance ? ` (${a.balance})` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bridgeError && <p className="text-xs text-warn mt-1">{bridgeError}</p>}
|
||||||
|
|
||||||
|
{/* Dedupe depends on the transaction id being the constraint key */}
|
||||||
|
{sourceObj.config?.simplefin?.account_id
|
||||||
|
&& sourceObj.constraint_fields?.join(',') !== 'id' && (
|
||||||
|
<p className="text-xs text-warn mt-1">
|
||||||
|
Constraint fields are “{sourceObj.constraint_fields?.join(', ') || 'none'}” — a
|
||||||
|
bank feed should use “id” so re-syncs don’t duplicate rows.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Unified field table */}
|
||||||
|
{availableFields.length > 0 && (
|
||||||
|
<Section
|
||||||
|
title="Fields and view"
|
||||||
|
description="Every field seen in this source's records. Tick which identify a row for deduplication, and which become columns in the generated view."
|
||||||
|
>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-muted border-b border-line-soft">
|
||||||
|
{[
|
||||||
|
{ col: 'key', label: 'Key' },
|
||||||
|
{ col: 'origin', label: 'Origin' },
|
||||||
|
{ col: 'type', label: 'Type' },
|
||||||
|
{ col: 'constraint', label: 'Constraint', center: true },
|
||||||
|
{ col: 'inview', label: 'In view', center: true },
|
||||||
|
{ col: 'seq', label: 'Seq', center: true },
|
||||||
|
].map(({ col, label, center }) => (
|
||||||
|
<th
|
||||||
|
key={col}
|
||||||
|
onClick={() => setFieldSort(s => ({ col, dir: s.col === col && s.dir === 'asc' ? 'desc' : 'asc' }))}
|
||||||
|
className={`pb-1 font-medium cursor-pointer select-none hover:text-ink-soft ${center ? 'text-center' : ''}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<span className="ml-1 text-muted">
|
||||||
|
{fieldSort.col === col ? (fieldSort.dir === 'asc' ? '▲' : '▼') : '⇅'}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[...availableFields].sort((a, b) => {
|
||||||
|
const constraintList = constraintFields.split(',').map(s => s.trim())
|
||||||
|
const aSchema = schemaFields.find(sf => sf.name === a.key)
|
||||||
|
const bSchema = schemaFields.find(sf => sf.name === b.key)
|
||||||
|
let av, bv
|
||||||
|
if (fieldSort.col === 'key') { av = a.key; bv = b.key }
|
||||||
|
else if (fieldSort.col === 'origin') { av = a.origins.join(','); bv = b.origins.join(',') }
|
||||||
|
else if (fieldSort.col === 'type') { av = aSchema?.type || ''; bv = bSchema?.type || '' }
|
||||||
|
else if (fieldSort.col === 'constraint') { av = constraintList.includes(a.key) ? 0 : 1; bv = constraintList.includes(b.key) ? 0 : 1 }
|
||||||
|
else if (fieldSort.col === 'inview') { av = aSchema ? 0 : 1; bv = bSchema ? 0 : 1 }
|
||||||
|
else if (fieldSort.col === 'seq') { av = aSchema?.seq ?? 999; bv = bSchema?.seq ?? 999 }
|
||||||
|
if (av < bv) return fieldSort.dir === 'asc' ? -1 : 1
|
||||||
|
if (av > bv) return fieldSort.dir === 'asc' ? 1 : -1
|
||||||
|
return 0
|
||||||
|
}).map(f => {
|
||||||
|
const isRaw = f.origins.includes('raw')
|
||||||
|
const constraintChecked = constraintFields.split(',').map(s => s.trim()).includes(f.key)
|
||||||
|
const schemaEntry = schemaFields.find(sf => sf.name === f.key)
|
||||||
|
const inView = !!schemaEntry
|
||||||
|
return (
|
||||||
|
<tr key={f.key} className="border-t border-line-soft">
|
||||||
|
<td className="py-1 font-mono text-ink-soft">{f.key}</td>
|
||||||
|
<td className="py-1 text-muted">{f.origins.join(', ')}</td>
|
||||||
|
<td className="py-1">
|
||||||
|
{inView && (
|
||||||
|
<div className="flex gap-1 items-center">
|
||||||
|
<select
|
||||||
|
className="border border-line rounded px-1 py-0.5 text-xs focus:outline-none focus:border-accent"
|
||||||
|
value={schemaEntry.type}
|
||||||
|
onChange={e => setSchemaFields(sf =>
|
||||||
|
sf.map(s => s.name === f.key ? { ...s, type: e.target.value } : s)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className="border border-line rounded px-1 py-0.5 text-xs font-mono w-32 focus:outline-none focus:border-accent"
|
||||||
|
value={schemaEntry.expression || ''}
|
||||||
|
placeholder="{field} * {sign}"
|
||||||
|
onChange={e => setSchemaFields(sf =>
|
||||||
|
sf.map(s => s.name === f.key ? { ...s, expression: e.target.value || undefined } : s)
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-center">
|
||||||
|
{isRaw && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={constraintChecked}
|
||||||
|
onChange={e => {
|
||||||
|
const current = constraintFields.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
const next = e.target.checked
|
||||||
|
? [...current, f.key]
|
||||||
|
: current.filter(k => k !== f.key)
|
||||||
|
setConstraintFields(next.join(', '))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={inView}
|
||||||
|
onChange={e => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
const nextSeq = schemaFields.length > 0
|
||||||
|
? Math.max(...schemaFields.map(s => s.seq ?? 0)) + 1
|
||||||
|
: 1
|
||||||
|
setSchemaFields(sf => [...sf, { name: f.key, type: 'text', seq: nextSeq }])
|
||||||
|
} else {
|
||||||
|
setSchemaFields(sf => sf.filter(s => s.name !== f.key))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-center">
|
||||||
|
{inView && (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-12 border border-line rounded px-1 py-0.5 text-xs text-center focus:outline-none focus:border-accent"
|
||||||
|
value={schemaEntry.seq ?? ''}
|
||||||
|
onChange={e => setSchemaFields(sf =>
|
||||||
|
sf.map(s => s.name === f.key ? { ...s, seq: parseInt(e.target.value) || 0 } : s)
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-3 mt-2 border-t border-line-soft flex-wrap">
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
|
||||||
|
<input type="checkbox" checked={globalPicklist} onChange={e => setGlobalPicklist(e.target.checked)} />
|
||||||
|
Global picklist
|
||||||
|
</label>
|
||||||
|
<form onSubmit={handleSave}>
|
||||||
|
<button type="submit" disabled={saving}
|
||||||
|
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{schemaFields.length > 0 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateView}
|
||||||
|
disabled={generating}
|
||||||
|
className="text-xs bg-green-600 text-white px-2 py-1.5 rounded hover:bg-green-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{generating ? 'Generating…' : 'Generate view'}
|
||||||
|
</button>
|
||||||
|
{viewName && (
|
||||||
|
<code className="text-xs bg-raised px-2 py-1 rounded text-ink-soft">{viewName}</code>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save button when no fields loaded yet */}
|
||||||
|
{availableFields.length === 0 && (
|
||||||
|
<Section
|
||||||
|
title="Fields and view"
|
||||||
|
description="No fields yet — they are discovered from imported records. Import or sync data first."
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
|
||||||
|
<input type="checkbox" checked={globalPicklist} onChange={e => setGlobalPicklist(e.target.checked)} />
|
||||||
|
Global picklist
|
||||||
|
</label>
|
||||||
|
<form onSubmit={handleSave}>
|
||||||
|
<button type="submit" disabled={saving}
|
||||||
|
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sampleRows.length > 0 && (
|
||||||
|
<Section title="Sample rows" description="The most recent imported records, as stored.">
|
||||||
|
<SampleTable rows={sampleRows} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title="Maintenance">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleReprocess}
|
||||||
|
disabled={reprocessing}
|
||||||
|
className="text-sm bg-orange-500 text-white px-3 py-1.5 rounded hover:bg-orange-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{reprocessing ? 'Reprocessing…' : 'Reprocess all records'}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted">Clears and reruns all transformation rules</span>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{result && <p className="text-xs text-ok">{result}</p>}
|
||||||
|
{error && <p className="text-xs text-danger">{error}</p>}
|
||||||
|
|
||||||
|
<Section title="Delete source" description="Removes the source and every record, rule, and mapping belonging to it.">
|
||||||
|
<button onClick={handleDelete}
|
||||||
|
className="text-sm border border-danger-line text-danger px-3 py-1.5 rounded hover:bg-danger-soft hover:border-danger-line">
|
||||||
|
Delete source
|
||||||
|
</button>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
388
ui/src/pages/SourceList.jsx
Normal file
388
ui/src/pages/SourceList.jsx
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { api } from '../api'
|
||||||
|
import SampleTable from '../components/SampleTable.jsx'
|
||||||
|
|
||||||
|
const FIELD_TYPES = ['text', 'numeric', 'date']
|
||||||
|
|
||||||
|
// Ticked into the view by default when a bank feed sample contains them
|
||||||
|
const FEED_DEFAULT_VIEW = ['date', 'description', 'payee', 'amount']
|
||||||
|
|
||||||
|
export default function SourceList({ sources, setSources, setSource }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [form, setForm] = useState({ name: '', constraint_fields: '', fields: [], schema: [], importSample: true })
|
||||||
|
const [createError, setCreateError] = useState('')
|
||||||
|
const [createLoading, setCreateLoading] = useState(false)
|
||||||
|
const [csvFileName, setCsvFileName] = useState('')
|
||||||
|
const [bridgeAccounts, setBridgeAccounts] = useState(null)
|
||||||
|
const [bridgeLoading, setBridgeLoading] = useState(false)
|
||||||
|
const [bridgeError, setBridgeError] = useState('')
|
||||||
|
const [sampleInfo, setSampleInfo] = useState(null)
|
||||||
|
const fileRef = useRef()
|
||||||
|
|
||||||
|
async function loadBridgeAccounts() {
|
||||||
|
setBridgeLoading(true)
|
||||||
|
setBridgeError('')
|
||||||
|
try {
|
||||||
|
const res = await api.getSimpleFinAccounts()
|
||||||
|
setBridgeAccounts(res.accounts || [])
|
||||||
|
if (res.errors?.length) setBridgeError(res.errors.join('; '))
|
||||||
|
} catch (err) {
|
||||||
|
setBridgeError(err.message)
|
||||||
|
} finally {
|
||||||
|
setBridgeLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes only config — constraint_fields and global_picklist are left NULL so
|
||||||
|
// update_source keeps whatever is already stored.
|
||||||
|
async function handleSelectFeedAccount(accountId) {
|
||||||
|
if (!accountId) {
|
||||||
|
// Clearing the feed only resets fields we populated, not a loaded CSV
|
||||||
|
setForm(f => csvFileName ? { ...f, simplefin_account_id: '' } : {
|
||||||
|
...f, simplefin_account_id: '', fields: [], schema: [], constraint_fields: '', sampleRows: [],
|
||||||
|
})
|
||||||
|
setSampleInfo(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setForm(f => ({ ...f, simplefin_account_id: accountId }))
|
||||||
|
setBridgeLoading(true)
|
||||||
|
setBridgeError('')
|
||||||
|
try {
|
||||||
|
const res = await api.getSimpleFinSample(accountId)
|
||||||
|
const names = res.fields.map(f => f.name)
|
||||||
|
setSampleInfo({ fetched: res.fetched, fields: res.fields.length })
|
||||||
|
if (res.errors?.length) setBridgeError(res.errors.join('; '))
|
||||||
|
setForm(f => ({
|
||||||
|
...f,
|
||||||
|
fields: res.fields,
|
||||||
|
sampleRows: res.sampleRows || [],
|
||||||
|
schema: FEED_DEFAULT_VIEW.filter(n => names.includes(n)).map((name, i) => ({
|
||||||
|
name, type: res.fields.find(sf => sf.name === name).type, seq: i + 1,
|
||||||
|
})),
|
||||||
|
// Only default the constraint if the sample actually has an id
|
||||||
|
constraint_fields: f.constraint_fields || (names.includes('id') ? 'id' : ''),
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
setBridgeError(err.message)
|
||||||
|
} finally {
|
||||||
|
setBridgeLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSuggest(e) {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
setCsvFileName(file.name)
|
||||||
|
try {
|
||||||
|
const suggestion = await api.suggestSource(file)
|
||||||
|
setForm(f => ({
|
||||||
|
...f,
|
||||||
|
fields: suggestion.fields,
|
||||||
|
constraint_fields: '',
|
||||||
|
schema: suggestion.fields.map(f => ({ name: f.name, type: f.type, seq: suggestion.fields.indexOf(f) + 1 })),
|
||||||
|
sampleRows: suggestion.sampleRows || []
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
setCreateError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setCreateError('')
|
||||||
|
const constraintArr = form.constraint_fields.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
if (!form.name || constraintArr.length === 0) {
|
||||||
|
setCreateError('Name and at least one constraint field required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCreateLoading(true)
|
||||||
|
try {
|
||||||
|
const config = form.schema.length > 0 ? { fields: form.schema } : {}
|
||||||
|
if (form.simplefin_account_id) {
|
||||||
|
config.simplefin = { account_id: form.simplefin_account_id }
|
||||||
|
}
|
||||||
|
await api.createSource({ name: form.name, constraint_fields: constraintArr, config, global_picklist: form.global_picklist !== false })
|
||||||
|
if (form.schema.length > 0) {
|
||||||
|
await api.generateView(form.name)
|
||||||
|
}
|
||||||
|
if (form.importSample && fileRef.current?.files[0]) {
|
||||||
|
await api.importCSV(form.name, fileRef.current.files[0])
|
||||||
|
}
|
||||||
|
const updated = await api.getSources()
|
||||||
|
setSources(updated)
|
||||||
|
setSource(form.name)
|
||||||
|
setForm({ name: '', constraint_fields: '', fields: [], schema: [], importSample: true, simplefin_account_id: '' })
|
||||||
|
setCreating(false)
|
||||||
|
} catch (err) {
|
||||||
|
setCreateError(err.message)
|
||||||
|
} finally {
|
||||||
|
setCreateLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 sm:p-6 max-w-5xl">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-xl font-semibold text-ink">Sources</h1>
|
||||||
|
{!creating && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setCreating(true); setCreateError('') }}
|
||||||
|
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
New source
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!creating && sources.length === 0 && (
|
||||||
|
<p className="text-sm text-muted">No sources yet. Create one to get started.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!creating && sources.length > 0 && (
|
||||||
|
<div className="bg-surface border border-line rounded divide-y divide-line-soft">
|
||||||
|
{sources.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.name}
|
||||||
|
onClick={() => { setSource(s.name); navigate(`/sources/${encodeURIComponent(s.name)}`) }}
|
||||||
|
className="w-full text-left px-4 py-3 hover:bg-raised flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-ink flex-1">{s.name}</span>
|
||||||
|
{s.config?.simplefin?.account_id && (
|
||||||
|
<span className="text-xs bg-accent-soft text-accent border border-accent-line rounded px-1.5 py-0.5">
|
||||||
|
bank feed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted">
|
||||||
|
{(s.constraint_fields || []).join(', ') || 'no constraint'}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted">›</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<div className="bg-surface border border-line rounded p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-ink-soft mb-3">New source</h2>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<input type="file" accept=".csv" ref={fileRef} onChange={handleSuggest} className="hidden" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
className="text-sm border border-line rounded px-3 py-1.5 text-ink-soft hover:bg-raised hover:border-line"
|
||||||
|
>
|
||||||
|
{csvFileName || 'Choose CSV…'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleCreate} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted block mb-1">Source name</label>
|
||||||
|
<input
|
||||||
|
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||||
|
placeholder="e.g. chase, dcard"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bank feed — optional; picking an account defaults the constraint
|
||||||
|
field to the transaction id, which is what dedupe needs */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted block mb-1">Bank feed (optional)</label>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{bridgeAccounts === null ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadBridgeAccounts}
|
||||||
|
disabled={bridgeLoading}
|
||||||
|
className="text-sm border border-line rounded px-3 py-1.5 text-ink-soft hover:bg-raised hover:border-line disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{bridgeLoading ? 'Loading…' : 'Link SimpleFIN account…'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={form.simplefin_account_id || ''}
|
||||||
|
onChange={e => handleSelectFeedAccount(e.target.value)}
|
||||||
|
className="text-sm border border-line rounded px-3 py-1.5 bg-surface text-ink-soft"
|
||||||
|
>
|
||||||
|
<option value="">No bank feed — CSV import</option>
|
||||||
|
{bridgeAccounts.map(a => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.name}{a.organization ? ` — ${a.organization}` : ''}{a.balance ? ` (${a.balance})` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{bridgeError && <p className="text-xs text-warn mt-1">{bridgeError}</p>}
|
||||||
|
|
||||||
|
{form.simplefin_account_id && sampleInfo && (
|
||||||
|
<div className="mt-2 bg-accent-soft border border-accent-line rounded p-3 text-xs text-ink-soft space-y-1">
|
||||||
|
<p>
|
||||||
|
Read {sampleInfo.fetched} transaction{sampleInfo.fetched === 1 ? '' : 's'} from this
|
||||||
|
account and found {sampleInfo.fields} field{sampleInfo.fields === 1 ? '' : 's'}.
|
||||||
|
The table below lists what this account actually returns — fields it never sends
|
||||||
|
won’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’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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,590 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
|
||||||
import { useSearchParams } from 'react-router-dom'
|
|
||||||
import { api } from '../api'
|
|
||||||
|
|
||||||
const FIELD_TYPES = ['text', 'numeric', 'date']
|
|
||||||
|
|
||||||
function SampleTable({ rows }) {
|
|
||||||
if (!rows || rows.length === 0) return null
|
|
||||||
const cols = Object.keys(rows[0])
|
|
||||||
return (
|
|
||||||
<div className="overflow-auto border border-gray-100 rounded bg-gray-50 max-h-36">
|
|
||||||
<table className="text-xs w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50 sticky top-0">
|
|
||||||
{cols.map(c => <th key={c} className="px-2 py-1 font-medium whitespace-nowrap">{c}</th>)}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((row, i) => (
|
|
||||||
<tr key={i} className="border-t border-gray-100">
|
|
||||||
{cols.map(c => (
|
|
||||||
<td key={c} className="px-2 py-1 whitespace-nowrap text-gray-600 max-w-32 truncate font-mono">
|
|
||||||
{row[c] == null ? <span className="text-gray-300">—</span> : String(row[c])}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Sources({ source, sources, setSources, setSource }) {
|
|
||||||
const [constraintFields, setConstraintFields] = useState('')
|
|
||||||
const [globalPicklist, setGlobalPicklist] = useState(true)
|
|
||||||
const [schemaFields, setSchemaFields] = useState([])
|
|
||||||
const [stats, setStats] = useState(null)
|
|
||||||
const [sampleRows, setSampleRows] = useState([])
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [reprocessing, setReprocessing] = useState(false)
|
|
||||||
const [generating, setGenerating] = useState(false)
|
|
||||||
const [result, setResult] = useState('')
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [viewName, setViewName] = useState('')
|
|
||||||
const [availableFields, setAvailableFields] = useState([])
|
|
||||||
const [fieldSort, setFieldSort] = useState({ col: 'key', dir: 'asc' })
|
|
||||||
const [creating, setCreating] = useState(false)
|
|
||||||
const [form, setForm] = useState({ name: '', constraint_fields: '', fields: [], schema: [], importSample: true })
|
|
||||||
const [createError, setCreateError] = useState('')
|
|
||||||
const [createLoading, setCreateLoading] = useState(false)
|
|
||||||
const [csvFileName, setCsvFileName] = useState('')
|
|
||||||
const fileRef = useRef()
|
|
||||||
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
|
||||||
const sourceObj = sources.find(s => s.name === source)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchParams.get('new') === '1') {
|
|
||||||
setCreating(true)
|
|
||||||
setSearchParams({})
|
|
||||||
}
|
|
||||||
}, [searchParams])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!sourceObj) return
|
|
||||||
setConstraintFields(sourceObj.constraint_fields?.join(', ') || '')
|
|
||||||
setGlobalPicklist(sourceObj.global_picklist !== false)
|
|
||||||
setSchemaFields((sourceObj.config?.fields || []).map((f, i) => ({ seq: i + 1, ...f })))
|
|
||||||
setViewName(sourceObj.config?.fields?.length ? `dfv.${sourceObj.name}` : '')
|
|
||||||
setResult('')
|
|
||||||
setError('')
|
|
||||||
setStats(null)
|
|
||||||
setAvailableFields([])
|
|
||||||
setSampleRows([])
|
|
||||||
api.getStats(sourceObj.name).then(setStats).catch(() => {})
|
|
||||||
api.getFields(sourceObj.name).then(setAvailableFields).catch(() => {})
|
|
||||||
api.getRecords(sourceObj.name, 50).then(rows => setSampleRows(rows.map(r => r.data).filter(Boolean))).catch(() => {})
|
|
||||||
}, [source, sourceObj?.name])
|
|
||||||
|
|
||||||
async function handleSave(e) {
|
|
||||||
e.preventDefault()
|
|
||||||
setSaving(true)
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
const constraint_fields = constraintFields.split(',').map(s => s.trim()).filter(Boolean)
|
|
||||||
const fields = [...schemaFields.filter(f => f.name)].sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
|
|
||||||
const config = { ...(sourceObj.config || {}), fields }
|
|
||||||
await api.updateSource(sourceObj.name, { constraint_fields, config, global_picklist: globalPicklist })
|
|
||||||
if (fields.length > 0) {
|
|
||||||
const res = await api.generateView(sourceObj.name)
|
|
||||||
if (res.success) setViewName(res.view)
|
|
||||||
}
|
|
||||||
const updated = await api.getSources()
|
|
||||||
setSources(updated)
|
|
||||||
setResult('Saved.')
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message)
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleGenerateView() {
|
|
||||||
setGenerating(true)
|
|
||||||
setResult('')
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
const constraint_fields = constraintFields.split(',').map(s => s.trim()).filter(Boolean)
|
|
||||||
const fields = [...schemaFields.filter(f => f.name)].sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
|
|
||||||
const config = { ...(sourceObj.config || {}), fields }
|
|
||||||
await api.updateSource(sourceObj.name, { constraint_fields, config, global_picklist: globalPicklist })
|
|
||||||
const res = await api.generateView(sourceObj.name)
|
|
||||||
if (res.success) {
|
|
||||||
setViewName(res.view)
|
|
||||||
setResult(`View created: ${res.view}`)
|
|
||||||
} else {
|
|
||||||
setError(res.error)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message)
|
|
||||||
} finally {
|
|
||||||
setGenerating(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleReprocess() {
|
|
||||||
if (!confirm(`Reprocess all records for "${sourceObj.name}"? This will clear and reapply all transformations.`)) return
|
|
||||||
setReprocessing(true)
|
|
||||||
setResult('')
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
const res = await api.reprocess(sourceObj.name)
|
|
||||||
setResult(`Reprocessed ${res.transformed} records.`)
|
|
||||||
api.getStats(sourceObj.name).then(setStats).catch(() => {})
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message)
|
|
||||||
} finally {
|
|
||||||
setReprocessing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete() {
|
|
||||||
if (!confirm(`Delete source "${sourceObj.name}" and all its data?`)) return
|
|
||||||
try {
|
|
||||||
await api.deleteSource(sourceObj.name)
|
|
||||||
const updated = await api.getSources()
|
|
||||||
setSources(updated)
|
|
||||||
if (updated.length > 0) setSource(updated[0].name)
|
|
||||||
else setSource('')
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSuggest(e) {
|
|
||||||
const file = e.target.files[0]
|
|
||||||
if (!file) return
|
|
||||||
setCsvFileName(file.name)
|
|
||||||
try {
|
|
||||||
const suggestion = await api.suggestSource(file)
|
|
||||||
setForm(f => ({
|
|
||||||
...f,
|
|
||||||
fields: suggestion.fields,
|
|
||||||
constraint_fields: '',
|
|
||||||
schema: suggestion.fields.map(f => ({ name: f.name, type: f.type, seq: suggestion.fields.indexOf(f) + 1 })),
|
|
||||||
sampleRows: suggestion.sampleRows || []
|
|
||||||
}))
|
|
||||||
} catch (err) {
|
|
||||||
setCreateError(err.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate(e) {
|
|
||||||
e.preventDefault()
|
|
||||||
setCreateError('')
|
|
||||||
const constraintArr = form.constraint_fields.split(',').map(s => s.trim()).filter(Boolean)
|
|
||||||
if (!form.name || constraintArr.length === 0) {
|
|
||||||
setCreateError('Name and at least one constraint field required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setCreateLoading(true)
|
|
||||||
try {
|
|
||||||
const config = form.schema.length > 0 ? { fields: form.schema } : {}
|
|
||||||
await api.createSource({ name: form.name, constraint_fields: constraintArr, config, global_picklist: form.global_picklist !== false })
|
|
||||||
if (form.schema.length > 0) {
|
|
||||||
await api.generateView(form.name)
|
|
||||||
}
|
|
||||||
if (form.importSample && fileRef.current?.files[0]) {
|
|
||||||
await api.importCSV(form.name, fileRef.current.files[0])
|
|
||||||
}
|
|
||||||
const updated = await api.getSources()
|
|
||||||
setSources(updated)
|
|
||||||
setSource(form.name)
|
|
||||||
setForm({ name: '', constraint_fields: '', fields: [], schema: [], importSample: true })
|
|
||||||
setCreating(false)
|
|
||||||
} catch (err) {
|
|
||||||
setCreateError(err.message)
|
|
||||||
} finally {
|
|
||||||
setCreateLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 max-w-5xl">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<h1 className="text-xl font-semibold text-gray-800">
|
|
||||||
{sourceObj ? sourceObj.name : 'Sources'}
|
|
||||||
</h1>
|
|
||||||
<button
|
|
||||||
onClick={() => { setCreating(true); setCreateError('') }}
|
|
||||||
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
New source
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* No source selected */}
|
|
||||||
{!sourceObj && !creating && (
|
|
||||||
<p className="text-sm text-gray-400">No sources yet. Create one to get started.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Source detail */}
|
|
||||||
{sourceObj && !creating && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Stats */}
|
|
||||||
{stats && (
|
|
||||||
<div className="flex gap-4 text-xs">
|
|
||||||
<span className="text-gray-500"><span className="font-medium text-gray-800">{stats.total_records}</span> total</span>
|
|
||||||
<span className="text-gray-500"><span className="font-medium text-gray-800">{stats.transformed_records}</span> transformed</span>
|
|
||||||
<span className="text-gray-500"><span className="font-medium text-gray-800">{stats.pending_records}</span> pending</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Unified field table */}
|
|
||||||
{availableFields.length > 0 && (
|
|
||||||
<div className="pt-2 border-t border-gray-100 space-y-2">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-left text-gray-400 border-b border-gray-100">
|
|
||||||
{[
|
|
||||||
{ col: 'key', label: 'Key' },
|
|
||||||
{ col: 'origin', label: 'Origin' },
|
|
||||||
{ col: 'type', label: 'Type' },
|
|
||||||
{ col: 'constraint', label: 'Constraint', center: true },
|
|
||||||
{ col: 'inview', label: 'In view', center: true },
|
|
||||||
{ col: 'seq', label: 'Seq', center: true },
|
|
||||||
].map(({ col, label, center }) => (
|
|
||||||
<th
|
|
||||||
key={col}
|
|
||||||
onClick={() => setFieldSort(s => ({ col, dir: s.col === col && s.dir === 'asc' ? 'desc' : 'asc' }))}
|
|
||||||
className={`pb-1 font-medium cursor-pointer select-none hover:text-gray-600 ${center ? 'text-center' : ''}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
<span className="ml-1 text-gray-300">
|
|
||||||
{fieldSort.col === col ? (fieldSort.dir === 'asc' ? '▲' : '▼') : '⇅'}
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{[...availableFields].sort((a, b) => {
|
|
||||||
const constraintList = constraintFields.split(',').map(s => s.trim())
|
|
||||||
const aSchema = schemaFields.find(sf => sf.name === a.key)
|
|
||||||
const bSchema = schemaFields.find(sf => sf.name === b.key)
|
|
||||||
let av, bv
|
|
||||||
if (fieldSort.col === 'key') { av = a.key; bv = b.key }
|
|
||||||
else if (fieldSort.col === 'origin') { av = a.origins.join(','); bv = b.origins.join(',') }
|
|
||||||
else if (fieldSort.col === 'type') { av = aSchema?.type || ''; bv = bSchema?.type || '' }
|
|
||||||
else if (fieldSort.col === 'constraint') { av = constraintList.includes(a.key) ? 0 : 1; bv = constraintList.includes(b.key) ? 0 : 1 }
|
|
||||||
else if (fieldSort.col === 'inview') { av = aSchema ? 0 : 1; bv = bSchema ? 0 : 1 }
|
|
||||||
else if (fieldSort.col === 'seq') { av = aSchema?.seq ?? 999; bv = bSchema?.seq ?? 999 }
|
|
||||||
if (av < bv) return fieldSort.dir === 'asc' ? -1 : 1
|
|
||||||
if (av > bv) return fieldSort.dir === 'asc' ? 1 : -1
|
|
||||||
return 0
|
|
||||||
}).map(f => {
|
|
||||||
const isRaw = f.origins.includes('raw')
|
|
||||||
const constraintChecked = constraintFields.split(',').map(s => s.trim()).includes(f.key)
|
|
||||||
const schemaEntry = schemaFields.find(sf => sf.name === f.key)
|
|
||||||
const inView = !!schemaEntry
|
|
||||||
return (
|
|
||||||
<tr key={f.key} className="border-t border-gray-50">
|
|
||||||
<td className="py-1 font-mono text-gray-700">{f.key}</td>
|
|
||||||
<td className="py-1 text-gray-400">{f.origins.join(', ')}</td>
|
|
||||||
<td className="py-1">
|
|
||||||
{inView && (
|
|
||||||
<div className="flex gap-1 items-center">
|
|
||||||
<select
|
|
||||||
className="border border-gray-200 rounded px-1 py-0.5 text-xs focus:outline-none focus:border-blue-400"
|
|
||||||
value={schemaEntry.type}
|
|
||||||
onChange={e => setSchemaFields(sf =>
|
|
||||||
sf.map(s => s.name === f.key ? { ...s, type: e.target.value } : s)
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
className="border border-gray-200 rounded px-1 py-0.5 text-xs font-mono w-32 focus:outline-none focus:border-blue-400"
|
|
||||||
value={schemaEntry.expression || ''}
|
|
||||||
placeholder="{field} * {sign}"
|
|
||||||
onChange={e => setSchemaFields(sf =>
|
|
||||||
sf.map(s => s.name === f.key ? { ...s, expression: e.target.value || undefined } : s)
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-1 text-center">
|
|
||||||
{isRaw && (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={constraintChecked}
|
|
||||||
onChange={e => {
|
|
||||||
const current = constraintFields.split(',').map(s => s.trim()).filter(Boolean)
|
|
||||||
const next = e.target.checked
|
|
||||||
? [...current, f.key]
|
|
||||||
: current.filter(k => k !== f.key)
|
|
||||||
setConstraintFields(next.join(', '))
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-1 text-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={inView}
|
|
||||||
onChange={e => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
const nextSeq = schemaFields.length > 0
|
|
||||||
? Math.max(...schemaFields.map(s => s.seq ?? 0)) + 1
|
|
||||||
: 1
|
|
||||||
setSchemaFields(sf => [...sf, { name: f.key, type: 'text', seq: nextSeq }])
|
|
||||||
} else {
|
|
||||||
setSchemaFields(sf => sf.filter(s => s.name !== f.key))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="py-1 text-center">
|
|
||||||
{inView && (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="w-12 border border-gray-200 rounded px-1 py-0.5 text-xs text-center focus:outline-none focus:border-blue-400"
|
|
||||||
value={schemaEntry.seq ?? ''}
|
|
||||||
onChange={e => setSchemaFields(sf =>
|
|
||||||
sf.map(s => s.name === f.key ? { ...s, seq: parseInt(e.target.value) || 0 } : s)
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-1 flex-wrap">
|
|
||||||
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer">
|
|
||||||
<input type="checkbox" checked={globalPicklist} onChange={e => setGlobalPicklist(e.target.checked)} />
|
|
||||||
Global picklist
|
|
||||||
</label>
|
|
||||||
<form onSubmit={handleSave}>
|
|
||||||
<button type="submit" disabled={saving}
|
|
||||||
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
|
|
||||||
{saving ? 'Saving…' : 'Save'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{schemaFields.length > 0 && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={handleGenerateView}
|
|
||||||
disabled={generating}
|
|
||||||
className="text-xs bg-green-600 text-white px-2 py-1.5 rounded hover:bg-green-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{generating ? 'Generating…' : 'Generate view'}
|
|
||||||
</button>
|
|
||||||
{viewName && (
|
|
||||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded text-gray-600">{viewName}</code>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<SampleTable rows={sampleRows} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Save button when no fields loaded yet */}
|
|
||||||
{availableFields.length === 0 && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer">
|
|
||||||
<input type="checkbox" checked={globalPicklist} onChange={e => setGlobalPicklist(e.target.checked)} />
|
|
||||||
Global picklist
|
|
||||||
</label>
|
|
||||||
<form onSubmit={handleSave}>
|
|
||||||
<button type="submit" disabled={saving}
|
|
||||||
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
|
|
||||||
{saving ? 'Saving…' : 'Save'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Reprocess */}
|
|
||||||
<div className="flex items-center gap-3 pt-2 border-t border-gray-100">
|
|
||||||
<button
|
|
||||||
onClick={handleReprocess}
|
|
||||||
disabled={reprocessing}
|
|
||||||
className="text-sm bg-orange-500 text-white px-3 py-1.5 rounded hover:bg-orange-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{reprocessing ? 'Reprocessing…' : 'Reprocess all records'}
|
|
||||||
</button>
|
|
||||||
<span className="text-xs text-gray-400">Clears and reruns all transformation rules</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{result && <p className="text-xs text-green-600">{result}</p>}
|
|
||||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
|
||||||
|
|
||||||
<div className="pt-2 border-t border-gray-100">
|
|
||||||
<button onClick={handleDelete} className="text-xs text-red-400 hover:text-red-600">
|
|
||||||
Delete source
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Create form */}
|
|
||||||
{creating && (
|
|
||||||
<div className="bg-white border border-gray-200 rounded p-4">
|
|
||||||
<h2 className="text-sm font-semibold text-gray-700 mb-3">New source</h2>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<input type="file" accept=".csv" ref={fileRef} onChange={handleSuggest} className="hidden" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => fileRef.current?.click()}
|
|
||||||
className="text-sm border border-gray-300 rounded px-3 py-1.5 text-gray-600 hover:bg-gray-50 hover:border-gray-400"
|
|
||||||
>
|
|
||||||
{csvFileName || 'Choose CSV…'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleCreate} className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-500 block mb-1">Source name</label>
|
|
||||||
<input
|
|
||||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
|
||||||
value={form.name}
|
|
||||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
|
||||||
placeholder="e.g. chase, dcard"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{form.fields.length > 0 && (
|
|
||||||
<div className="pt-2 border-t border-gray-100 space-y-2">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-left text-gray-400 border-b border-gray-100">
|
|
||||||
<th className="pb-1 font-medium">Key</th>
|
|
||||||
<th className="pb-1 font-medium">Type</th>
|
|
||||||
<th className="pb-1 font-medium text-center">Constraint</th>
|
|
||||||
<th className="pb-1 font-medium text-center">In view</th>
|
|
||||||
<th className="pb-1 font-medium text-center">Seq</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{form.fields.map(f => {
|
|
||||||
const schemaEntry = form.schema.find(s => s.name === f.name)
|
|
||||||
const inView = !!schemaEntry
|
|
||||||
const currentType = schemaEntry?.type || f.type
|
|
||||||
return (
|
|
||||||
<tr key={f.name} className="border-t border-gray-50">
|
|
||||||
<td className="py-1 font-mono text-gray-700">{f.name}</td>
|
|
||||||
<td className="py-1">
|
|
||||||
{inView && (
|
|
||||||
<select
|
|
||||||
className="border border-gray-200 rounded px-1 py-0.5 text-xs focus:outline-none focus:border-blue-400"
|
|
||||||
value={currentType}
|
|
||||||
onChange={e => setForm(ff => ({
|
|
||||||
...ff,
|
|
||||||
schema: ff.schema.map(s => s.name === f.name ? { ...s, type: e.target.value } : s)
|
|
||||||
}))}
|
|
||||||
>
|
|
||||||
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-1 text-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.constraint_fields.split(',').map(s => s.trim()).includes(f.name)}
|
|
||||||
onChange={e => {
|
|
||||||
const current = form.constraint_fields.split(',').map(s => s.trim()).filter(Boolean)
|
|
||||||
const next = e.target.checked
|
|
||||||
? [...current, f.name]
|
|
||||||
: current.filter(n => n !== f.name)
|
|
||||||
setForm(ff => ({ ...ff, constraint_fields: next.join(', ') }))
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="py-1 text-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={inView}
|
|
||||||
onChange={e => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
const nextSeq = form.schema.length > 0
|
|
||||||
? Math.max(...form.schema.map(s => s.seq ?? 0)) + 1
|
|
||||||
: 1
|
|
||||||
setForm(ff => ({ ...ff, schema: [...ff.schema, { name: f.name, type: f.type, seq: nextSeq }] }))
|
|
||||||
} else {
|
|
||||||
setForm(ff => ({ ...ff, schema: ff.schema.filter(s => s.name !== f.name) }))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="py-1 text-center">
|
|
||||||
{inView && (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="w-12 border border-gray-200 rounded px-1 py-0.5 text-xs text-center focus:outline-none focus:border-blue-400"
|
|
||||||
value={schemaEntry.seq ?? ''}
|
|
||||||
onChange={e => setForm(ff => ({
|
|
||||||
...ff,
|
|
||||||
schema: ff.schema.map(s => s.name === f.name ? { ...s, seq: parseInt(e.target.value) || 0 } : s)
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<SampleTable rows={form.sampleRows || []} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{form.fields.length === 0 && (
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-500 block mb-1">Constraint fields (comma-separated)</label>
|
|
||||||
<input
|
|
||||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
|
||||||
value={form.constraint_fields}
|
|
||||||
onChange={e => setForm(f => ({ ...f, constraint_fields: e.target.value }))}
|
|
||||||
placeholder="e.g. date, amount, description"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.global_picklist !== false}
|
|
||||||
onChange={e => setForm(f => ({ ...f, global_picklist: e.target.checked }))}
|
|
||||||
/>
|
|
||||||
Global picklist
|
|
||||||
</label>
|
|
||||||
{form.fields.length > 0 && (
|
|
||||||
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.importSample !== false}
|
|
||||||
onChange={e => setForm(f => ({ ...f, importSample: e.target.checked }))}
|
|
||||||
/>
|
|
||||||
Import sample data
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{createError && <p className="text-xs text-red-500">{createError}</p>}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button type="submit" disabled={createLoading}
|
|
||||||
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
|
|
||||||
{createLoading ? 'Creating…' : 'Create'}
|
|
||||||
</button>
|
|
||||||
<button type="button"
|
|
||||||
onClick={() => { setCreating(false); setCreateError(''); setForm({ name: '', constraint_fields: '', fields: [], schema: [] }) }}
|
|
||||||
className="text-sm text-gray-500 px-3 py-1.5 rounded hover:bg-gray-100">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
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'
|
||||||
@ -53,54 +54,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-white rounded-lg shadow-xl w-[420px] p-5" onClick={e => e.stopPropagation()}>
|
<div className="bg-surface rounded-lg shadow-xl w-[420px] p-5" onClick={e => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<span className="text-sm font-semibold text-gray-700">Calibrate — {sourceName}</span>
|
<span className="text-sm font-semibold text-ink-soft">Calibrate — {sourceName}</span>
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">✕</button>
|
<button onClick={onClose} className="text-muted hover:text-ink-soft">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date */}
|
{/* Date */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="text-xs text-gray-500 block mb-1">As-of date</label>
|
<label className="text-xs text-muted block mb-1">As-of date</label>
|
||||||
<input type="date" className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
<input type="date" className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||||
value={asOf} onChange={e => setAsOf(e.target.value)} />
|
value={asOf} onChange={e => setAsOf(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reconciliation table */}
|
{/* Reconciliation table */}
|
||||||
<div className="bg-gray-50 rounded border border-gray-200 mb-4 text-sm">
|
<div className="bg-raised rounded border border-line mb-4 text-sm">
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
|
<div className="flex items-center justify-between px-3 py-2 border-b border-line">
|
||||||
<span className="text-gray-500 text-xs">Data sum at date</span>
|
<span className="text-muted text-xs">Data sum at date</span>
|
||||||
<span className="font-mono text-gray-700">
|
<span className="font-mono text-ink-soft">
|
||||||
{loading ? <span className="text-gray-300">…</span> : computed !== null ? fmt(computed) : <span className="text-gray-300">—</span>}
|
{loading ? <span className="text-muted">…</span> : computed !== null ? fmt(computed) : <span className="text-muted">—</span>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
|
<div className="flex items-center justify-between px-3 py-2 border-b border-line">
|
||||||
<span className="text-gray-500 text-xs">Known balance</span>
|
<span className="text-muted 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-gray-700 placeholder-gray-300"
|
className="font-mono text-right bg-transparent border-0 focus:outline-none w-36 text-sm text-ink-soft placeholder-gray-300"
|
||||||
placeholder="enter balance"
|
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-gray-200">
|
<div className="flex items-center justify-between px-3 py-2 border-b border-line">
|
||||||
<span className="text-gray-500 text-xs">Current offset</span>
|
<span className="text-muted text-xs">Current offset</span>
|
||||||
<span className="font-mono text-gray-400">{fmt(currentOffset ?? 0)}</span>
|
<span className="font-mono text-muted">{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-gray-700 text-xs">Plug (offset needed)</span>
|
<span className="text-ink-soft text-xs">Plug (offset needed)</span>
|
||||||
<span className={`font-mono ${plug !== null ? 'text-blue-700' : 'text-gray-300'}`}>
|
<span className={`font-mono ${plug !== null ? 'text-accent' : 'text-muted'}`}>
|
||||||
{plug !== null ? fmt(plug) : '—'}
|
{plug !== null ? fmt(plug) : '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-xs text-red-500 mb-3">{error}</p>}
|
{error && <p className="text-xs text-danger mb-3">{error}</p>}
|
||||||
|
|
||||||
{/* Apply */}
|
{/* 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-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400"
|
className="flex-1 border border-line rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-accent"
|
||||||
placeholder="offset to apply"
|
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))}
|
||||||
@ -433,12 +434,12 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
|
||||||
{/* Label */}
|
{/* Label */}
|
||||||
<div className="bg-white border border-gray-200 rounded p-4">
|
<div className="bg-surface border border-line rounded p-4">
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Configuration</h3>
|
<h3 className="text-sm font-semibold text-ink-soft 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-gray-500 block mb-1">Label <span className="text-gray-400">(optional)</span></label>
|
<label className="text-xs text-muted block mb-1">Label <span className="text-muted">(optional)</span></label>
|
||||||
<input className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
<input className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||||
value={label} onChange={e => setLabel(e.target.value)}
|
value={label} onChange={e => setLabel(e.target.value)}
|
||||||
onKeyDown={e => e.key === 'Enter' && saveLabel()} />
|
onKeyDown={e => e.key === 'Enter' && saveLabel()} />
|
||||||
</div>
|
</div>
|
||||||
@ -447,13 +448,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-red-500 mt-2">{error}</p>}
|
{error && <p className="text-xs text-danger mt-2">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sources */}
|
{/* Sources */}
|
||||||
<div className="bg-white border border-gray-200 rounded p-4">
|
<div className="bg-surface border border-line rounded p-4">
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">Sources</h3>
|
<h3 className="text-sm font-semibold text-ink-soft mb-1">Sources</h3>
|
||||||
<p className="text-xs text-gray-400 mb-3">Each source contributes rows to the combined view. Set the sign to flip the direction of amounts (e.g. credit card charges are positive in the source but should subtract from your balance). The offset adjusts the running balance — use Calibrate to compute it from a known good balance.</p>
|
<p className="text-xs text-muted mb-3">Each source contributes rows to the combined view. Set the sign to flip the direction of amounts (e.g. credit card charges are positive in the source but should subtract from your balance). The offset adjusts the running balance — use Calibrate to compute it from a known good balance.</p>
|
||||||
<div className="space-y-2 mb-3">
|
<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] || {}
|
||||||
@ -466,50 +467,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-gray-100 rounded px-3 py-2 text-xs space-y-2 ${srcDragOverIdx === idx && srcDragIdx !== idx ? 'bg-blue-50' : ''}`}>
|
className={`border border-line-soft rounded px-3 py-2 text-xs space-y-2 ${srcDragOverIdx === idx && srcDragIdx !== idx ? 'bg-accent-soft' : ''}`}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-gray-300 cursor-grab select-none">⠿</span>
|
<span className="text-muted cursor-grab select-none">⠿</span>
|
||||||
<span className="font-medium text-gray-700 flex-1">{m.source_name}</span>
|
<span className="font-medium text-ink-soft flex-1">{m.source_name}</span>
|
||||||
<button onClick={() => removeSource(m.source_name)} className="text-red-300 hover:text-red-500">Remove</button>
|
<button onClick={() => removeSource(m.source_name)} className="text-danger hover:text-danger">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-gray-400 block mb-0.5">Amount field</label>
|
<label className="text-muted block mb-0.5">Amount field</label>
|
||||||
<select value={cfg.amount_field || ''}
|
<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-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400">
|
className="w-full border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent">
|
||||||
<option value="">— select —</option>
|
<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-gray-400 block mb-0.5">Sign</label>
|
<label className="text-muted 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-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400">
|
className="w-full border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent">
|
||||||
<option value={1}>+1 (as-is)</option>
|
<option value={1}>+1 (as-is)</option>
|
||||||
<option value={-1}>−1 (flip)</option>
|
<option value={-1}>−1 (flip)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-gray-400 block mb-0.5">Date field</label>
|
<label className="text-muted block mb-0.5">Date field</label>
|
||||||
<select value={cfg.date_field || ''}
|
<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-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400">
|
className="w-full border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent">
|
||||||
<option value="">— select —</option>
|
<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-gray-400 block mb-0.5">Balance offset</label>
|
<label className="text-muted block mb-0.5">Balance offset</label>
|
||||||
<div className="flex items-center gap-1">
|
<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-gray-200 rounded px-1.5 py-0.5 font-mono focus:outline-none focus:border-blue-400" />
|
className="flex-1 border border-line rounded px-1.5 py-0.5 font-mono focus:outline-none focus:border-accent" />
|
||||||
<button onClick={() => handleCalibrate(m.source_name)}
|
<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-blue-400 hover:text-blue-600 underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline">
|
className="text-accent hover:text-accent underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline">
|
||||||
Calibrate
|
Calibrate
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -518,43 +519,43 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{members.length === 0 && <p className="text-xs text-gray-400">No sources added yet.</p>}
|
{members.length === 0 && <p className="text-xs text-muted">No sources added yet.</p>}
|
||||||
</div>
|
</div>
|
||||||
{availableSources.length > 0 && (
|
{availableSources.length > 0 && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select className="flex-1 border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
|
<select className="flex-1 border border-line rounded px-2 py-1 text-sm focus:outline-none focus:border-accent"
|
||||||
value={addingSrc} onChange={e => setAddingSrc(e.target.value)}>
|
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-gray-100 px-3 py-1 rounded hover:bg-gray-200 text-gray-700 disabled:opacity-40">Add</button>
|
className="text-sm bg-raised px-3 py-1 rounded hover:bg-raised text-ink-soft disabled:opacity-40">Add</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Output columns mapping grid */}
|
{/* Output columns mapping grid */}
|
||||||
<div className="bg-white border border-gray-200 rounded p-4">
|
<div className="bg-surface border border-line rounded p-4">
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">Output columns</h3>
|
<h3 className="text-sm font-semibold text-ink-soft mb-1">Output columns</h3>
|
||||||
<p className="text-xs text-gray-400 mb-3">
|
<p className="text-xs text-muted mb-3">
|
||||||
Each row is a column in the combined view. Each source column shows which field from that source maps to it.
|
Each row is a column in the combined view. Each source column shows which field from that source maps to it.
|
||||||
The first <span className="text-blue-500">numeric</span> field drives the running balance; the first <span className="text-green-600">date</span> field drives the ordering.
|
The first <span className="text-accent">numeric</span> field drives the running balance; the first <span className="text-ok">date</span> field drives the ordering.
|
||||||
Both <span className="font-mono">source_balance</span> (per-source) and <span className="font-mono">net_balance</span> (combined) are always included in the generated view.
|
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-gray-400 mb-3">Add sources above first.</p>
|
<p className="text-xs text-muted mb-3">Add sources above first.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto mb-3">
|
<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-gray-200">
|
<tr className="border-b border-line">
|
||||||
<th className="w-5 pb-2"></th>
|
<th className="w-5 pb-2"></th>
|
||||||
<th className="text-left text-gray-400 font-normal pb-2 pr-4">Column</th>
|
<th className="text-left text-muted font-normal pb-2 pr-4">Column</th>
|
||||||
<th className="text-left text-gray-400 font-normal pb-2 pr-4">Type</th>
|
<th className="text-left text-muted font-normal pb-2 pr-4">Type</th>
|
||||||
{members.map(m => (
|
{members.map(m => (
|
||||||
<th key={m.source_name} className="text-left text-gray-400 font-normal pb-2 pr-3 min-w-36">{m.source_name}</th>
|
<th key={m.source_name} className="text-left text-muted font-normal pb-2 pr-3 min-w-36">{m.source_name}</th>
|
||||||
))}
|
))}
|
||||||
<th className="w-5 pb-2"></th>
|
<th className="w-5 pb-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -570,21 +571,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-gray-50 ${dragOverIdx === idx && dragIdx !== idx ? 'bg-blue-50' : ''}`}>
|
className={`border-b border-line-soft ${dragOverIdx === idx && dragIdx !== idx ? 'bg-accent-soft' : ''}`}>
|
||||||
<td className="py-1.5 pr-1 text-gray-300 cursor-grab select-none">⠿</td>
|
<td className="py-1.5 pr-1 text-muted cursor-grab select-none">⠿</td>
|
||||||
<td className="py-1.5 pr-4 font-mono text-gray-700 whitespace-nowrap">
|
<td className="py-1.5 pr-4 font-mono text-ink-soft whitespace-nowrap">
|
||||||
{f.name}
|
{f.name}
|
||||||
{isAmount && <span className="ml-1.5 text-blue-500 font-sans font-normal">amount</span>}
|
{isAmount && <span className="ml-1.5 text-accent font-sans font-normal">amount</span>}
|
||||||
{isDate && <span className="ml-1.5 text-green-600 font-sans font-normal">date</span>}
|
{isDate && <span className="ml-1.5 text-ok font-sans font-normal">date</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 pr-4 text-gray-400">{f.type}</td>
|
<td className="py-1.5 pr-4 text-muted">{f.type}</td>
|
||||||
{members.map(m => (
|
{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-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400 min-w-0 flex-1">
|
className="border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent min-w-0 flex-1">
|
||||||
<option value="">— same name —</option>
|
<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>
|
||||||
@ -594,13 +595,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-red-300 hover:text-red-500">✕</button>
|
<button onClick={() => removeField(f.name)} className="text-danger hover:text-danger">✕</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{fields.length === 0 && (
|
{fields.length === 0 && (
|
||||||
<tr><td colSpan={3 + members.length} className="py-3 text-gray-400 text-center">No columns defined yet — add one below.</td></tr>
|
<tr><td colSpan={3 + members.length} className="py-3 text-muted text-center">No columns defined yet — add one below.</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -609,15 +610,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-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
|
<input className="flex-1 border border-line rounded px-2 py-1 text-sm focus:outline-none focus:border-accent"
|
||||||
placeholder="column name" value={newField.name}
|
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-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
|
<select className="border border-line rounded px-2 py-1 text-sm focus:outline-none focus:border-accent"
|
||||||
value={newField.type} onChange={e => setNewField(f => ({ ...f, type: e.target.value }))}>
|
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-gray-100 px-3 py-1 rounded hover:bg-gray-200 text-gray-700">Add</button>
|
<button onClick={addField} className="text-sm bg-raised px-3 py-1 rounded hover:bg-raised text-ink-soft">Add</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mappingsDirty && (
|
{mappingsDirty && (
|
||||||
@ -629,12 +630,12 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Generate view + balance */}
|
{/* Generate view + balance */}
|
||||||
<div className="bg-white border border-gray-200 rounded p-4">
|
<div className="bg-surface border border-line rounded p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-sm font-semibold text-gray-700">View</h3>
|
<h3 className="text-sm font-semibold text-ink-soft">View</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={fetchBalance}
|
<button onClick={fetchBalance}
|
||||||
className="text-sm bg-gray-100 text-gray-700 px-3 py-1.5 rounded hover:bg-gray-200">
|
className="text-sm bg-raised text-ink-soft px-3 py-1.5 rounded hover:bg-raised">
|
||||||
Refresh balance
|
Refresh balance
|
||||||
</button>
|
</button>
|
||||||
<button onClick={generateView}
|
<button onClick={generateView}
|
||||||
@ -645,18 +646,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-gray-500">Current net balance</span>
|
<span className="text-xs text-muted">Current net balance</span>
|
||||||
<span className="text-lg font-mono font-semibold text-gray-800">
|
<span className="text-lg font-mono font-semibold text-ink">
|
||||||
{Number(netBalance).toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
{Number(netBalance).toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{balanceError && <p className="text-xs text-gray-400 mb-3">{balanceError}</p>}
|
{balanceError && <p className="text-xs text-muted mb-3">{balanceError}</p>}
|
||||||
{viewResult && !viewResult.success && (
|
{viewResult && !viewResult.success && (
|
||||||
<p className="text-xs text-red-500">{viewResult.error}</p>
|
<p className="text-xs text-danger">{viewResult.error}</p>
|
||||||
)}
|
)}
|
||||||
{viewResult && viewResult.success && (
|
{viewResult && viewResult.success && (
|
||||||
<p className="text-xs text-green-600">View created: <span className="font-mono">{viewResult.view}</span></p>
|
<p className="text-xs text-ok">View created: <span className="font-mono">{viewResult.view}</span></p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -747,28 +748,31 @@ 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-gray-800 mr-1">Stacks</h1>
|
<h1 className="text-sm font-semibold text-ink 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-blue-300 bg-blue-50 text-blue-700' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50'}`}>
|
className={`flex items-center gap-2 px-3 py-1.5 rounded border cursor-pointer text-xs group transition-colors ${selected === s.name ? 'border-accent-line bg-accent-soft text-accent' : 'border-line bg-surface text-ink-soft hover:border-line hover:bg-raised'}`}>
|
||||||
<span className="font-medium">{s.label || s.name}</span>
|
<span className="font-medium">{s.label || s.name}</span>
|
||||||
<span className="text-gray-400">{s.source_count}s</span>
|
<span className="text-muted">{s.source_count}s</span>
|
||||||
|
<Link to={`/stacks/${encodeURIComponent(s.name)}/pivot`}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="text-accent underline decoration-transparent hover:decoration-inherit">pivot</Link>
|
||||||
<button onClick={e => { e.stopPropagation(); deleteStack(s.name) }}
|
<button onClick={e => { e.stopPropagation(); deleteStack(s.name) }}
|
||||||
className="opacity-0 group-hover:opacity-100 text-red-300 hover:text-red-500 leading-none">✕</button>
|
className="opacity-0 group-hover:opacity-100 text-danger hover:text-danger leading-none ml-2 pl-2 border-l border-line">✕</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{creating ? (
|
{creating ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<input autoFocus className="border border-blue-400 rounded px-2 py-1 text-xs focus:outline-none w-32"
|
<input autoFocus className="border border-accent rounded px-2 py-1 text-xs focus:outline-none w-32"
|
||||||
placeholder="stack name" value={newName} onChange={e => setNewName(e.target.value)}
|
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-gray-400 px-1">✕</button>
|
<button onClick={() => setCreating(false)} className="text-xs text-muted px-1">✕</button>
|
||||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
{error && <p className="text-xs text-danger">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => setCreating(true)} className="text-xs text-blue-500 hover:text-blue-700 px-2 py-1.5">+ New</button>
|
<button onClick={() => setCreating(true)} className="text-xs text-accent hover:text-accent px-2 py-1.5">+ New</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -776,9 +780,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-gray-800 mb-4">
|
<h2 className="text-base font-semibold text-ink mb-4">
|
||||||
{stackDetail.label || stackDetail.name}
|
{stackDetail.label || stackDetail.name}
|
||||||
{stackDetail.label && <span className="text-sm text-gray-400 font-normal ml-2">{stackDetail.name}</span>}
|
{stackDetail.label && <span className="text-sm text-muted font-normal ml-2">{stackDetail.name}</span>}
|
||||||
</h2>
|
</h2>
|
||||||
<StackPanel
|
<StackPanel
|
||||||
key={stackDetail.name}
|
key={stackDetail.name}
|
||||||
@ -793,9 +797,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-white border border-gray-200 rounded p-4 sticky top-4">
|
<div className="bg-surface border border-line rounded p-4 sticky top-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-sm font-semibold text-gray-700">Generated SQL</h3>
|
<h3 className="text-sm font-semibold text-ink-soft">Generated SQL</h3>
|
||||||
<button
|
<button
|
||||||
onClick={runSql}
|
onClick={runSql}
|
||||||
disabled={!sqlDraft.trim() || sqlRunning}
|
disabled={!sqlDraft.trim() || sqlRunning}
|
||||||
@ -805,17 +809,17 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated, on
|
|||||||
</div>
|
</div>
|
||||||
{sqlDraft ? (
|
{sqlDraft ? (
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full font-mono text-xs text-gray-700 bg-gray-50 border border-gray-200 rounded p-2 focus:outline-none focus:border-blue-400 resize-none leading-relaxed"
|
className="w-full font-mono text-xs text-ink-soft bg-raised border border-line rounded p-2 focus:outline-none focus:border-accent resize-none leading-relaxed"
|
||||||
style={{ minHeight: '60vh' }}
|
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-gray-400">Generate a view to see the SQL here.</p>
|
<p className="text-xs text-muted">Generate a view to see the SQL here.</p>
|
||||||
)}
|
)}
|
||||||
{sqlResult && (
|
{sqlResult && (
|
||||||
<p className={`text-xs mt-2 ${sqlResult.success ? 'text-green-600' : 'text-red-500'}`}>
|
<p className={`text-xs mt-2 ${sqlResult.success ? 'text-ok' : 'text-danger'}`}>
|
||||||
{sqlResult.success ? 'View updated successfully.' : sqlResult.error}
|
{sqlResult.success ? 'View updated successfully.' : sqlResult.error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -823,7 +827,7 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated, on
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-400">Select a stack or create one.</p>
|
<p className="text-sm text-muted">Select a stack or create one.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user