Stacks: per-source amount/date fields, mapping grid UI, dfv view generation with source balance CTEs

- SQL: upsert_stack_source gains amount_field/date_field params; calibrate_balance queries dfv.{source} directly (no stack view needed); generate_stack_view builds per-source CTEs with source_balance, outer net_balance; information_schema check for missing columns
- API: pass amount_field/date_field through upsert route; calibrate accepts source_name
- UI: mapping grid table (rows=fields, cols=sources); per-source amount/date/sign in Sources section; auto-populate output columns on first source config; horizontal stack chips above full-width config panel; calibration auto-saves before opening, editable offset input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-18 21:17:12 -04:00
parent f63a0ec0e5
commit f941c5ae4a
3 changed files with 516 additions and 326 deletions

View File

@ -67,9 +67,10 @@ module.exports = (pool) => {
// Add or update a source in a stack // Add or update a source in a stack
router.put('/:name/sources/:source', async (req, res, next) => { router.put('/:name/sources/:source', async (req, res, next) => {
try { try {
const { field_map, amount_sign, balance_offset } = req.body; const { field_map, amount_sign, balance_offset, amount_field, date_field } = req.body;
const n = v => v != null ? lit(v) : 'NULL';
const result = await pool.query( const result = await pool.query(
`SELECT * FROM upsert_stack_source(${lit(req.params.name)}, ${lit(req.params.source)}, ${lit(JSON.stringify(field_map || {}))}, ${lit(amount_sign ?? 1)}, ${lit(balance_offset ?? 0)})` `SELECT * FROM upsert_stack_source(${lit(req.params.name)}, ${lit(req.params.source)}, ${lit(JSON.stringify(field_map || {}))}, ${lit(amount_sign ?? 1)}, ${lit(balance_offset ?? 0)}, ${n(amount_field)}, ${n(date_field)})`
); );
res.json(result.rows[0]); res.json(result.rows[0]);
} catch (err) { } catch (err) {

View File

@ -26,20 +26,26 @@ CREATE TABLE IF NOT EXISTS dataflow.stack_sources (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
stack_name TEXT NOT NULL REFERENCES dataflow.stacks(name) ON DELETE CASCADE, stack_name TEXT NOT NULL REFERENCES dataflow.stacks(name) ON DELETE CASCADE,
source_name TEXT NOT NULL REFERENCES dataflow.sources(name) ON DELETE CASCADE, source_name TEXT NOT NULL REFERENCES dataflow.sources(name) ON DELETE CASCADE,
-- Maps canonical field name → field name in records.transformed -- Maps other canonical field names → source view column names (not amount/date — those are explicit)
field_map JSONB NOT NULL DEFAULT '{}', field_map JSONB NOT NULL DEFAULT '{}',
-- Multiply amount by this before summing (1 = as-is, -1 = flip sign) -- Which column in dfv.{source} is the amount, and its sign (+1/-1)
amount_field TEXT,
amount_sign INTEGER NOT NULL DEFAULT 1, amount_sign INTEGER NOT NULL DEFAULT 1,
-- Seed balance for this source — added as a constant to the combined running total -- Which column in dfv.{source} is the date
date_field TEXT,
-- Calibration offset added to this source's running balance
balance_offset NUMERIC NOT NULL DEFAULT 0, balance_offset NUMERIC NOT NULL DEFAULT 0,
UNIQUE (stack_name, source_name) UNIQUE (stack_name, source_name)
); );
-- Migrations: add columns that may be missing from earlier deploys -- Migrations: add columns that may be missing from earlier deploys
ALTER TABLE dataflow.stack_sources ADD COLUMN IF NOT EXISTS balance_offset NUMERIC NOT NULL DEFAULT 0; ALTER TABLE dataflow.stack_sources ADD COLUMN IF NOT EXISTS balance_offset NUMERIC NOT NULL DEFAULT 0;
ALTER TABLE dataflow.stack_sources ADD COLUMN IF NOT EXISTS amount_field TEXT;
ALTER TABLE dataflow.stack_sources ADD COLUMN IF NOT EXISTS date_field TEXT;
-- Drop old 3-arg calibrate_balance signature if it exists before recreating with 4 args -- Drop old signatures before recreating
DROP FUNCTION IF EXISTS calibrate_balance(TEXT, DATE, NUMERIC); DROP FUNCTION IF EXISTS calibrate_balance(TEXT, DATE, NUMERIC);
DROP FUNCTION IF EXISTS upsert_stack_source(TEXT, TEXT, JSONB, INTEGER, NUMERIC);
------------------------------------------------------ ------------------------------------------------------
-- Function: list_stacks -- Function: list_stacks
@ -89,7 +95,9 @@ RETURNS TABLE (
'id', ss.id, 'id', ss.id,
'source_name', ss.source_name, 'source_name', ss.source_name,
'field_map', ss.field_map, 'field_map', ss.field_map,
'amount_field', ss.amount_field,
'amount_sign', ss.amount_sign, 'amount_sign', ss.amount_sign,
'date_field', ss.date_field,
'balance_offset', ss.balance_offset 'balance_offset', ss.balance_offset
) ORDER BY ss.source_name ) ORDER BY ss.source_name
) FILTER (WHERE ss.id IS NOT NULL), '[]') ) FILTER (WHERE ss.id IS NOT NULL), '[]')
@ -152,14 +160,18 @@ CREATE OR REPLACE FUNCTION upsert_stack_source(
p_source_name TEXT, p_source_name TEXT,
p_field_map JSONB DEFAULT '{}', p_field_map JSONB DEFAULT '{}',
p_amount_sign INTEGER DEFAULT 1, p_amount_sign INTEGER DEFAULT 1,
p_balance_offset NUMERIC DEFAULT 0 p_balance_offset NUMERIC DEFAULT 0,
p_amount_field TEXT DEFAULT NULL,
p_date_field TEXT DEFAULT NULL
) RETURNS dataflow.stack_sources AS $$ ) RETURNS dataflow.stack_sources AS $$
INSERT INTO dataflow.stack_sources (stack_name, source_name, field_map, amount_sign, balance_offset) INSERT INTO dataflow.stack_sources (stack_name, source_name, field_map, amount_sign, balance_offset, amount_field, date_field)
VALUES (p_stack_name, p_source_name, p_field_map, p_amount_sign, p_balance_offset) VALUES (p_stack_name, p_source_name, p_field_map, p_amount_sign, p_balance_offset, p_amount_field, p_date_field)
ON CONFLICT (stack_name, source_name) DO UPDATE SET ON CONFLICT (stack_name, source_name) DO UPDATE SET
field_map = EXCLUDED.field_map, field_map = EXCLUDED.field_map,
amount_sign = EXCLUDED.amount_sign, amount_sign = EXCLUDED.amount_sign,
balance_offset = EXCLUDED.balance_offset balance_offset = EXCLUDED.balance_offset,
amount_field = EXCLUDED.amount_field,
date_field = EXCLUDED.date_field
RETURNING *; RETURNING *;
$$ LANGUAGE sql; $$ LANGUAGE sql;
@ -175,47 +187,40 @@ $$ LANGUAGE sql;
------------------------------------------------------ ------------------------------------------------------
-- Function: calibrate_balance -- Function: calibrate_balance
-- Given a known good balance at a specific date, compute the offset needed. -- Queries dfv.{source} directly using per-source amount/date fields.
-- Returns: {computed_at_date, known_balance, suggested_offset} -- No stack view required.
------------------------------------------------------ ------------------------------------------------------
CREATE OR REPLACE FUNCTION calibrate_balance( CREATE OR REPLACE FUNCTION calibrate_balance(
p_stack_name TEXT, p_stack_name TEXT,
p_source_name TEXT, -- specific source to calibrate, or NULL for combined p_source_name TEXT,
p_as_of_date DATE, p_as_of_date DATE,
p_known_balance NUMERIC p_known_balance NUMERIC
) RETURNS JSON AS $$ ) RETURNS JSON AS $$
DECLARE DECLARE
v_stack dataflow.stacks%ROWTYPE; v_src dataflow.stack_sources%ROWTYPE;
v_running NUMERIC := 0; v_running NUMERIC;
v_other_offsets NUMERIC := 0; v_sql TEXT;
BEGIN BEGIN
SELECT * INTO v_stack FROM dataflow.stacks WHERE name = p_stack_name; SELECT * INTO v_src
IF NOT FOUND THEN
RETURN json_build_object('success', false, 'error', 'Stack not found');
END IF;
IF v_stack.amount_field IS NULL OR v_stack.date_field IS NULL THEN
RETURN json_build_object('success', false, 'error', 'Stack must have amount_field and date_field set');
END IF;
-- Sum amount * sign for the target source(s) up to as_of_date
SELECT COALESCE(SUM(
(rec.transformed ->> (ss.field_map ->> v_stack.amount_field))::numeric
* ss.amount_sign
), 0)
INTO v_running
FROM dataflow.stack_sources ss
JOIN dataflow.records rec ON rec.source_name = ss.source_name
WHERE ss.stack_name = p_stack_name
AND (p_source_name IS NULL OR ss.source_name = p_source_name)
AND rec.transformed IS NOT NULL
AND (rec.transformed ->> (ss.field_map ->> v_stack.date_field))::date <= p_as_of_date;
-- For combined calibration, include existing offsets from other sources
IF p_source_name IS NOT NULL THEN
SELECT COALESCE(SUM(balance_offset), 0) INTO v_other_offsets
FROM dataflow.stack_sources FROM dataflow.stack_sources
WHERE stack_name = p_stack_name AND source_name != p_source_name; WHERE stack_name = p_stack_name AND source_name = p_source_name;
IF NOT FOUND THEN
RETURN json_build_object('success', false, 'error', 'Source not in stack');
END IF; END IF;
IF v_src.amount_field IS NULL OR v_src.date_field IS NULL THEN
RETURN json_build_object('success', false, 'error', 'Set amount and date fields on this source first');
END IF;
BEGIN
v_sql := format(
'SELECT COALESCE(SUM(%I * %s), 0) FROM dfv.%I WHERE %I <= %L::date',
v_src.amount_field, v_src.amount_sign, p_source_name, v_src.date_field, p_as_of_date
);
EXECUTE v_sql INTO v_running;
EXCEPTION WHEN undefined_table THEN
RETURN json_build_object('success', false, 'error', 'Source view not found — generate the source view first');
END;
RETURN json_build_object( RETURN json_build_object(
'success', true, 'success', true,
@ -223,7 +228,6 @@ BEGIN
'as_of_date', p_as_of_date, 'as_of_date', p_as_of_date,
'known_balance', p_known_balance, 'known_balance', p_known_balance,
'computed_sum', v_running, 'computed_sum', v_running,
'other_offsets', v_other_offsets,
'suggested_offset', p_known_balance - v_running 'suggested_offset', p_known_balance - v_running
); );
END; END;
@ -231,8 +235,9 @@ $$ LANGUAGE plpgsql STABLE;
------------------------------------------------------ ------------------------------------------------------
-- Function: generate_stack_view -- Function: generate_stack_view
-- Builds a UNION ALL view in dfv schema from all member sources. -- Builds a WITH ... UNION ALL view in dfv schema from existing dfv source views.
-- Includes running_balance if amount_field and date_field are set. -- Each source CTE applies amount_sign and computes a per-source running balance.
-- Outer SELECT adds net_balance across all sources.
------------------------------------------------------ ------------------------------------------------------
CREATE OR REPLACE FUNCTION generate_stack_view(p_stack_name TEXT) CREATE OR REPLACE FUNCTION generate_stack_view(p_stack_name TEXT)
RETURNS JSON AS $$ RETURNS JSON AS $$
@ -240,13 +245,17 @@ DECLARE
v_stack dataflow.stacks%ROWTYPE; v_stack dataflow.stacks%ROWTYPE;
v_src dataflow.stack_sources%ROWTYPE; v_src dataflow.stack_sources%ROWTYPE;
v_field JSONB; v_field JSONB;
v_parts TEXT[] := '{}'; v_ctes TEXT[] := '{}';
v_cte_names TEXT[] := '{}';
v_select TEXT; v_select TEXT;
v_col TEXT; v_col TEXT;
v_src_field TEXT; v_src_field TEXT;
v_amt_src TEXT;
v_date_src TEXT;
v_view TEXT; v_view TEXT;
v_sql TEXT; v_sql TEXT;
v_has_bal BOOLEAN; v_has_bal BOOLEAN;
v_canon_cols TEXT;
BEGIN BEGIN
SELECT * INTO v_stack FROM dataflow.stacks WHERE name = p_stack_name; SELECT * INTO v_stack FROM dataflow.stacks WHERE name = p_stack_name;
IF NOT FOUND THEN IF NOT FOUND THEN
@ -255,40 +264,63 @@ BEGIN
v_has_bal := v_stack.amount_field IS NOT NULL AND v_stack.date_field IS NOT NULL; v_has_bal := v_stack.amount_field IS NOT NULL AND v_stack.date_field IS NOT NULL;
-- Build one SELECT per source -- Build one CTE per source querying dfv.{source} directly
FOR v_src IN FOR v_src IN
SELECT * FROM dataflow.stack_sources WHERE stack_name = p_stack_name ORDER BY source_name SELECT * FROM dataflow.stack_sources WHERE stack_name = p_stack_name ORDER BY source_name
LOOP LOOP
v_select := format('SELECT %L AS _source, rec.id AS _id', v_src.source_name); v_select := format('SELECT %L AS _source, id AS _id', v_src.source_name);
-- Canonical fields, mapped to source field names
FOR v_field IN SELECT * FROM jsonb_array_elements(v_stack.fields) FOR v_field IN SELECT * FROM jsonb_array_elements(v_stack.fields)
LOOP LOOP
v_col := v_field->>'name'; v_col := v_field->>'name';
v_src_field := COALESCE(v_src.field_map->>v_col, v_col);
v_select := v_select || ', ' || CASE v_field->>'type' IF v_has_bal AND v_col = v_stack.amount_field THEN
WHEN 'numeric' THEN -- Use per-source amount_field with sign applied
format('(rec.transformed->>%L)::numeric AS %I', v_src_field, v_col) IF v_src.amount_field IS NULL THEN
WHEN 'date' THEN v_select := v_select || format(', NULL::%s AS %I', v_field->>'type', v_col);
format('(rec.transformed->>%L)::date AS %I', v_src_field, v_col)
ELSE ELSE
format('rec.transformed->>%L AS %I', v_src_field, v_col) v_select := v_select || format(', %I * %s AS %I', v_src.amount_field, v_src.amount_sign, v_col);
END; END IF;
ELSIF v_has_bal AND v_col = v_stack.date_field THEN
-- Use per-source date_field
IF v_src.date_field IS NULL THEN
v_select := v_select || format(', NULL::date AS %I', v_col);
ELSE
v_select := v_select || format(', %I AS %I', v_src.date_field, v_col);
END IF;
ELSE
-- Other canonical fields: use field_map or same name, NULL if column doesn't exist
v_src_field := COALESCE(v_src.field_map->>v_col, v_col);
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'dfv'
AND table_name = v_src.source_name
AND column_name = v_src_field
) THEN
v_select := v_select || format(', %I AS %I', v_src_field, v_col);
ELSE
v_select := v_select || format(', NULL::text AS %I', v_col);
END IF;
END IF;
END LOOP; END LOOP;
-- amount_sign column for running balance -- Per-source running balance with calibration offset baked in
v_select := v_select || format(', %s::integer AS _sign, %s::numeric AS _src_offset', v_src.amount_sign, v_src.balance_offset); IF v_has_bal AND v_src.amount_field IS NOT NULL AND v_src.date_field IS NOT NULL THEN
v_select := v_select || format( v_select := v_select || format(
' FROM dataflow.records rec WHERE rec.source_name = %L AND rec.transformed IS NOT NULL', ', SUM(%I * %s) OVER (ORDER BY %I ASC, id ASC) + %s AS source_balance',
v_src.source_name v_src.amount_field, v_src.amount_sign, v_src.date_field, v_src.balance_offset
); );
ELSE
v_select := v_select || ', NULL::numeric AS source_balance';
END IF;
v_parts := v_parts || v_select; v_select := v_select || format(' FROM dfv.%I', v_src.source_name);
v_ctes := v_ctes || format('%I AS (%s)', v_src.source_name, v_select);
v_cte_names := v_cte_names || quote_ident(v_src.source_name);
END LOOP; END LOOP;
IF array_length(v_parts, 1) IS NULL THEN IF array_length(v_ctes, 1) IS NULL THEN
RETURN json_build_object('success', false, 'error', 'Stack has no sources'); RETURN json_build_object('success', false, 'error', 'Stack has no sources');
END IF; END IF;
@ -296,38 +328,36 @@ BEGIN
v_view := 'dfv.' || quote_ident(p_stack_name); v_view := 'dfv.' || quote_ident(p_stack_name);
EXECUTE format('DROP VIEW IF EXISTS %s', v_view); EXECUTE format('DROP VIEW IF EXISTS %s', v_view);
-- Wrap in outer SELECT that adds running_balance if configured v_canon_cols := (
SELECT string_agg(quote_ident(f->>'name'), ', ')
FROM jsonb_array_elements(v_stack.fields) f
);
IF v_has_bal THEN IF v_has_bal THEN
-- running_balance = cumulative sum of (amount * sign) + per-source seed offsets + stack-level offset -- net_balance: cumulative signed amount across all sources + stack-level offset
-- Each row's _src_offset is that source's balance_offset, summed cumulatively so it's added once
-- per source in the window rather than per row. We use a trick: sum(_src_offset / count_of_source_rows)
-- is complex, so instead we add SUM(DISTINCT _src_offset per _source) as a constant via subquery.
-- Simpler: treat each source offset as a lump added to its first row only via ROW_NUMBER trick.
-- Cleanest: add total of all source offsets as a constant (valid when each source is calibrated
-- relative to its own transaction history, not to each other).
v_sql := format( v_sql := format(
'CREATE VIEW %s AS ' 'CREATE VIEW %s AS '
'SELECT _source, _id, %s, ' 'WITH %s, _stacked AS (SELECT * FROM %s) '
'SUM((%I)::numeric * _sign) OVER (ORDER BY %I ASC, _id ASC) ' 'SELECT _source, _id, %s, source_balance, '
'+ (SELECT COALESCE(SUM(balance_offset),0) FROM dataflow.stack_sources WHERE stack_name = %L) ' 'SUM(%I) OVER (ORDER BY %I ASC, _id ASC) + %s AS net_balance '
'+ %s AS running_balance ' 'FROM _stacked',
'FROM (%s) _stacked',
v_view, v_view,
(SELECT string_agg(quote_ident(f->>'name'), ', ') array_to_string(v_ctes, ', '),
FROM jsonb_array_elements(v_stack.fields) f), array_to_string(v_cte_names, ' UNION ALL SELECT * FROM '),
v_canon_cols,
v_stack.amount_field, v_stack.amount_field,
v_stack.date_field, v_stack.date_field,
p_stack_name, v_stack.balance_offset
v_stack.balance_offset,
array_to_string(v_parts, ' UNION ALL ')
); );
ELSE ELSE
v_sql := format( v_sql := format(
'CREATE VIEW %s AS SELECT _source, _id, %s FROM (%s) _stacked', 'CREATE VIEW %s AS '
'WITH %s, _stacked AS (SELECT * FROM %s) '
'SELECT _source, _id, %s FROM _stacked',
v_view, v_view,
(SELECT string_agg(quote_ident(f->>'name'), ', ') array_to_string(v_ctes, ', '),
FROM jsonb_array_elements(v_stack.fields) f), array_to_string(v_cte_names, ' UNION ALL SELECT * FROM '),
array_to_string(v_parts, ' UNION ALL ') v_canon_cols
); );
END IF; END IF;
@ -361,7 +391,7 @@ BEGIN
BEGIN BEGIN
v_sql := format( v_sql := format(
'SELECT running_balance FROM %s ORDER BY %I DESC, _id DESC LIMIT 1', 'SELECT net_balance FROM %s ORDER BY %I DESC, _id DESC LIMIT 1',
v_view, v_stack.date_field v_view, v_stack.date_field
); );
EXECUTE v_sql INTO v_balance; EXECUTE v_sql INTO v_balance;

View File

@ -24,14 +24,14 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
} }
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" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-white rounded-lg shadow-xl w-96 p-5" onClick={e => e.stopPropagation()}> <div className="bg-white rounded-lg shadow-xl w-96 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 balance</span> <span className="text-sm font-semibold text-gray-700">Calibrate {sourceName}</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"></button> <button onClick={onClose} className="text-gray-400 hover:text-gray-600"></button>
</div> </div>
<p className="text-xs text-gray-500 mb-3"> <p className="text-xs text-gray-500 mb-3">
Enter a known good balance at a specific date. The system will compute the offset needed. Enter a known good balance at a specific date. The system will compute the offset needed to make the running balance match.
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
@ -49,9 +49,7 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
{loading ? 'Calculating…' : 'Calculate'} {loading ? 'Calculating…' : 'Calculate'}
</button> </button>
{error && <p className="text-xs text-red-500">{error}</p>} {error && <p className="text-xs text-red-500">{error}</p>}
{result && !result.success && ( {result && !result.success && <p className="text-xs text-red-500">{result.error}</p>}
<p className="text-xs text-red-500">{result.error}</p>
)}
{result && result.success && ( {result && result.success && (
<div className="bg-gray-50 rounded p-3 text-xs space-y-1"> <div className="bg-gray-50 rounded p-3 text-xs space-y-1">
<div className="flex justify-between"><span className="text-gray-500">Computed sum at date</span><span className="font-mono">{Number(result.computed_sum).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span></div> <div className="flex justify-between"><span className="text-gray-500">Computed sum at date</span><span className="font-mono">{Number(result.computed_sum).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span></div>
@ -67,8 +65,7 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
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-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400"
value={applyOffset} onChange={e => setApplyOffset(e.target.value)} /> value={applyOffset} onChange={e => setApplyOffset(e.target.value)} />
</div> </div>
<button onClick={() => onApply(parseFloat(applyOffset))} <button onClick={() => onApply(parseFloat(applyOffset))} disabled={applyOffset === ''}
disabled={applyOffset === ''}
className="w-full text-sm bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700 disabled:opacity-50"> className="w-full text-sm bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700 disabled:opacity-50">
Apply offset Apply offset
</button> </button>
@ -80,124 +77,68 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
) )
} }
// Source member row // Stack panel
function SourceRow({ stack, member, stackFields, onSave, onRemove }) { function StackPanel({ stack, sources, onUpdated }) {
const [map, setMap] = useState(member.field_map || {}) const members = stack.sources || []
const [sign, setSign] = useState(member.amount_sign ?? 1)
const [offset, setOffset] = useState(member.balance_offset ?? 0)
const [dirty, setDirty] = useState(false)
const [srcFields, setSrcFields] = useState([])
const [calibrating, setCalibrating] = useState(false)
useEffect(() => {
api.getFields(member.source_name).then(f => setSrcFields(f.map(x => x.key))).catch(() => {})
}, [member.source_name])
function setMapping(canonical, srcField) { setMap(m => ({ ...m, [canonical]: srcField })); setDirty(true) }
function handleSign(v) { setSign(v); setDirty(true) }
function handleOffset(v) { setOffset(v); setDirty(true) }
function save() { onSave(member.source_name, map, sign, offset); setDirty(false) }
function applyCalibration(suggestedOffset) {
setOffset(suggestedOffset)
setDirty(true)
setCalibrating(false)
}
return (
<div className="border border-gray-200 rounded p-3 text-xs">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-gray-700">{member.source_name}</span>
<div className="flex items-center gap-2">
{dirty && (
<button onClick={save} className="text-xs bg-blue-600 text-white px-2 py-0.5 rounded hover:bg-blue-700">Save</button>
)}
<button onClick={() => onRemove(member.source_name)} className="text-red-400 hover:text-red-600">Remove</button>
</div>
</div>
{/* Amount sign + balance offset */}
<div className="flex items-center gap-4 mb-3 pb-2 border-b border-gray-100">
<div className="flex items-center gap-2">
<label className="text-gray-500">Amount sign</label>
<select value={sign} onChange={e => handleSign(parseInt(e.target.value))}
className="border border-gray-200 rounded px-2 py-0.5 focus:outline-none focus:border-blue-400">
<option value={1}>+1 (as-is)</option>
<option value={-1}>1 (flip)</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-gray-500">Balance offset</label>
<input type="number" step="0.01" value={offset}
onChange={e => handleOffset(parseFloat(e.target.value) || 0)}
className="w-28 border border-gray-200 rounded px-2 py-0.5 font-mono focus:outline-none focus:border-blue-400" />
<button onClick={() => setCalibrating(true)}
disabled={!stack.amount_field || !stack.date_field}
title={(!stack.amount_field || !stack.date_field) ? 'Set amount and date fields in Running Balance first' : 'Calibrate balance'}
className="text-gray-400 hover:text-blue-500 underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline">Calibrate</button>
</div>
</div>
{/* Field mappings */}
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-gray-400 font-medium">Stack field</span>
<span className="text-gray-400 font-medium">Source field</span>
{stackFields.map(f => (
<>
<span key={f.name + '_l'} className="text-gray-600 self-center">{f.name}</span>
<select key={f.name + '_s'} value={map[f.name] || ''}
onChange={e => setMapping(f.name, e.target.value)}
className="border border-gray-200 rounded px-2 py-0.5 focus:outline-none focus:border-blue-400">
<option value=""> same name </option>
{srcFields.map(sf => <option key={sf} value={sf}>{sf}</option>)}
</select>
</>
))}
</div>
{calibrating && (
<CalibrateModal stack={stack} sourceName={member.source_name}
onClose={() => setCalibrating(false)} onApply={applyCalibration} />
)}
</div>
)
}
// Stack detail / edit panel
function StackPanel({ stack, sources, onUpdated, onDeleted }) {
const [label, setLabel] = useState(stack.label || '') const [label, setLabel] = useState(stack.label || '')
const [fields, setFields] = useState(stack.fields || []) const [fields, setFields] = useState(stack.fields || [])
const [amountField, setAmountField] = useState(stack.amount_field || '')
const [dateField, setDateField] = useState(stack.date_field || '')
const [members, setMembers] = useState(stack.sources || [])
const [newField, setNewField] = useState({ name: '', type: 'text' }) const [newField, setNewField] = useState({ name: '', type: 'text' })
const [addingSrc, setAddingSrc] = useState('') const [addingSrc, setAddingSrc] = useState('')
// Per-source config: sign, offset, amount_field, date_field, field_map
const [srcCfg, setSrcCfg] = useState(() =>
Object.fromEntries(members.map(m => [m.source_name, {
sign: m.amount_sign ?? 1,
offset: m.balance_offset ?? 0,
amount_field: m.amount_field || '',
date_field: m.date_field || '',
field_map: { ...(m.field_map || {}) },
}]))
)
// Available columns from each source's dfv view
const [srcFields, setSrcFields] = useState({})
// Drag-to-reorder state
const [dragIdx, setDragIdx] = useState(null)
const [dragOverIdx, setDragOverIdx] = useState(null)
// Calibrate
const [calibratingSource, setCalibratingSource] = useState(null)
// View / balance
const [viewResult, setViewResult] = useState(null) const [viewResult, setViewResult] = useState(null)
const [currentBalance, setCurrentBalance] = useState(null) const [netBalance, setNetBalance] = useState(null)
const [balanceError, setBalanceError] = useState('') const [balanceError, setBalanceError] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [mappingsDirty, setMappingsDirty] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
async function saveStack(updates) { // Fetch source columns whenever members change
useEffect(() => {
members.forEach(m => {
api.getFields(m.source_name)
.then(f => setSrcFields(prev => ({ ...prev, [m.source_name]: f.map(x => x.key) })))
.catch(() => {})
})
}, [members.map(m => m.source_name).join(',')])
// Auto-detect canonical amount/date field from field types
const amountCanonical = fields.find(f => f.type === 'numeric')?.name || stack.amount_field
const dateCanonical = fields.find(f => f.type === 'date')?.name || stack.date_field
// Label
async function saveLabel() {
setSaving(true); setError('') setSaving(true); setError('')
try { try { await api.updateStack(stack.name, { label }); onUpdated() }
await api.updateStack(stack.name, updates) catch (e) { setError(e.message) }
onUpdated()
} catch (e) { setError(e.message) }
finally { setSaving(false) } finally { setSaving(false) }
} }
async function saveLabel() { // Fields
await saveStack({ label, amount_field: amountField, date_field: dateField, fields })
}
async function saveBalance() {
await saveStack({ label, amount_field: amountField, date_field: dateField, fields })
}
async function addField() { async function addField() {
if (!newField.name) return if (!newField.name) return
const updated = [...fields, { name: newField.name, type: newField.type }] const updated = [...fields, { name: newField.name, type: newField.type }]
@ -207,32 +148,167 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
onUpdated() onUpdated()
} }
function removeField(name) { async function removeField(name) {
const updated = fields.filter(f => f.name !== name) const updated = fields.filter(f => f.name !== name)
setFields(updated) setFields(updated)
if (amountField === name) setAmountField('') await api.updateStack(stack.name, { fields: updated })
if (dateField === name) setDateField('') onUpdated()
api.updateStack(stack.name, { fields: updated }).then(onUpdated)
} }
// Drag reorder
function handleDragStart(e, idx) {
setDragIdx(idx)
e.dataTransfer.effectAllowed = 'move'
}
function handleDragOver(e, idx) {
e.preventDefault()
setDragOverIdx(idx)
}
async function handleDrop(e, toIdx) {
e.preventDefault()
if (dragIdx === null || dragIdx === toIdx) { setDragIdx(null); setDragOverIdx(null); return }
const updated = [...fields]
const [moved] = updated.splice(dragIdx, 1)
updated.splice(toIdx, 0, moved)
setFields(updated)
setDragIdx(null); setDragOverIdx(null)
await api.updateStack(stack.name, { fields: updated })
onUpdated()
}
// Mapping grid
function getMappingValue(srcName, canonicalName) {
const cfg = srcCfg[srcName] || {}
if (canonicalName === amountCanonical) return cfg.amount_field || ''
if (canonicalName === dateCanonical) return cfg.date_field || ''
return cfg.field_map?.[canonicalName] || ''
}
function setMappingValue(srcName, canonicalName, value) {
setSrcCfg(prev => {
const cfg = { ...prev[srcName] }
if (canonicalName === amountCanonical) cfg.amount_field = value
else if (canonicalName === dateCanonical) cfg.date_field = value
else cfg.field_map = { ...cfg.field_map, [canonicalName]: value }
return { ...prev, [srcName]: cfg }
})
setMappingsDirty(true)
}
function setSrcSign(srcName, sign) {
setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], sign } }))
setMappingsDirty(true)
}
function setSrcOffset(srcName, offset) {
setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], offset } }))
setMappingsDirty(true)
}
async function saveMappings() {
setSaving(true); setError('')
try {
for (const m of members) {
const cfg = srcCfg[m.source_name] || {}
await api.upsertStackSource(stack.name, m.source_name, {
field_map: cfg.field_map || {},
amount_sign: cfg.sign ?? 1,
balance_offset: cfg.offset ?? 0,
amount_field: cfg.amount_field || null,
date_field: cfg.date_field || null,
})
}
// Persist the auto-detected canonical field names on the stack
await api.updateStack(stack.name, {
amount_field: amountCanonical || null,
date_field: dateCanonical || null,
})
setMappingsDirty(false)
onUpdated()
} catch (e) { setError(e.message) }
finally { setSaving(false) }
}
// Sources
async function addSource() { async function addSource() {
if (!addingSrc) return if (!addingSrc) return
await api.upsertStackSource(stack.name, addingSrc, { field_map: {}, amount_sign: 1 }) await api.upsertStackSource(stack.name, addingSrc, { field_map: {}, amount_sign: 1 })
setMembers(m => [...m.filter(x => x.source_name !== addingSrc), { source_name: addingSrc, field_map: {}, amount_sign: 1 }]) setSrcCfg(prev => ({ ...prev, [addingSrc]: { sign: 1, offset: 0, amount_field: '', date_field: '', field_map: {} } }))
// Load fields immediately so dropdowns are ready
try {
const f = await api.getFields(addingSrc)
setSrcFields(prev => ({ ...prev, [addingSrc]: f.map(x => x.key) }))
} catch (e) {}
setAddingSrc('') setAddingSrc('')
onUpdated()
} }
async function saveSource(src, field_map, amount_sign, balance_offset) { function handleSrcAmountField(srcName, value) {
await api.upsertStackSource(stack.name, src, { field_map, amount_sign, balance_offset }) setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], amount_field: value } }))
// Update column type to numeric if a column with this name exists
setFields(prev => prev.map(f => f.name === value ? { ...f, type: 'numeric' } : f))
setMappingsDirty(true)
maybeAutoPopulate(srcName, value, srcCfg[srcName]?.date_field)
}
function handleSrcDateField(srcName, value) {
setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], date_field: value } }))
setFields(prev => prev.map(f => f.name === value ? { ...f, type: 'date' } : f))
setMappingsDirty(true)
maybeAutoPopulate(srcName, srcCfg[srcName]?.amount_field, value)
}
function maybeAutoPopulate(srcName, amtField, dtField) {
if (!amtField || !dtField) return
if (fields.length > 0) return // don't overwrite existing columns
const sourceFields = srcFields[srcName] || []
if (sourceFields.length === 0) return
const newFields = sourceFields.map(sf => ({
name: sf,
type: sf === amtField ? 'numeric' : sf === dtField ? 'date' : 'text',
}))
setFields(newFields)
api.updateStack(stack.name, { fields: newFields, amount_field: amtField, date_field: dtField })
} }
async function removeSource(src) { async function removeSource(src) {
await api.removeStackSource(stack.name, src) await api.removeStackSource(stack.name, src)
setMembers(m => m.filter(x => x.source_name !== src)) setSrcCfg(prev => { const n = { ...prev }; delete n[src]; return n })
onUpdated()
} }
async function handleCalibrate(srcName) {
// Save this source's current config before opening modal
const cfg = srcCfg[srcName] || {}
await api.upsertStackSource(stack.name, srcName, {
field_map: cfg.field_map || {},
amount_sign: cfg.sign ?? 1,
balance_offset: cfg.offset ?? 0,
amount_field: cfg.amount_field || null,
date_field: cfg.date_field || null,
})
setCalibratingSource(srcName)
}
async function applyCalibration(srcName, offset) {
const cfg = srcCfg[srcName] || {}
await api.upsertStackSource(stack.name, srcName, {
field_map: cfg.field_map || {},
amount_sign: cfg.sign ?? 1,
balance_offset: offset,
amount_field: cfg.amount_field || null,
date_field: cfg.date_field || null,
})
setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], offset } }))
setCalibratingSource(null)
onUpdated()
}
// View
async function generateView() { async function generateView() {
setViewResult(null); setCurrentBalance(null); setBalanceError('') setViewResult(null); setNetBalance(null); setBalanceError('')
try { try {
const r = await api.generateStackView(stack.name) const r = await api.generateStackView(stack.name)
setViewResult(r) setViewResult(r)
@ -244,13 +320,12 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
setBalanceError('') setBalanceError('')
try { try {
const r = await api.getStackBalance(stack.name) const r = await api.getStackBalance(stack.name)
if (r.success) setCurrentBalance(r.balance) if (r.success) setNetBalance(r.balance)
else setBalanceError(r.error) else setBalanceError(r.error)
} catch (e) { setBalanceError(e.message) } } catch (e) { setBalanceError(e.message) }
} }
const availableSources = sources.filter(s => !members.find(m => m.source_name === s.name)) const availableSources = sources.filter(s => !members.find(m => m.source_name === s.name))
const fieldNames = fields.map(f => f.name)
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@ -275,12 +350,65 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
{/* Sources */} {/* Sources */}
<div className="bg-white border border-gray-200 rounded p-4"> <div className="bg-white border border-gray-200 rounded p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Sources</h3> <h3 className="text-sm font-semibold text-gray-700 mb-1">Sources</h3>
<div className="space-y-3 mb-3"> <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>
{members.map(m => ( <div className="space-y-2 mb-3">
<SourceRow key={m.source_name} stack={stack} member={m} stackFields={fields} {members.map(m => {
onSave={saveSource} onRemove={removeSource} /> const cfg = srcCfg[m.source_name] || {}
))} const sf = srcFields[m.source_name] || []
const canCalibrate = !!cfg.amount_field && !!cfg.date_field
return (
<div key={m.source_name} className="border border-gray-100 rounded px-3 py-2 text-xs space-y-2">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-700 flex-1">{m.source_name}</span>
<button onClick={() => removeSource(m.source_name)} className="text-red-300 hover:text-red-500">Remove</button>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5">
<div>
<label className="text-gray-400 block mb-0.5">Amount field</label>
<select value={cfg.amount_field || ''}
onChange={e => handleSrcAmountField(m.source_name, e.target.value)}
className="w-full border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400">
<option value=""> select </option>
{sf.map(f => <option key={f} value={f}>{f}</option>)}
</select>
</div>
<div>
<label className="text-gray-400 block mb-0.5">Sign</label>
<select value={cfg.sign ?? 1}
onChange={e => { setSrcSign(m.source_name, parseInt(e.target.value)); setMappingsDirty(true) }}
className="w-full border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400">
<option value={1}>+1 (as-is)</option>
<option value={-1}>1 (flip)</option>
</select>
</div>
<div>
<label className="text-gray-400 block mb-0.5">Date field</label>
<select value={cfg.date_field || ''}
onChange={e => handleSrcDateField(m.source_name, e.target.value)}
className="w-full border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400">
<option value=""> select </option>
{sf.map(f => <option key={f} value={f}>{f}</option>)}
</select>
</div>
<div>
<label className="text-gray-400 block mb-0.5">Balance offset</label>
<div className="flex items-center gap-1">
<input type="number" step="0.01" value={cfg.offset ?? 0}
onChange={e => { setSrcOffset(m.source_name, parseFloat(e.target.value) || 0); setMappingsDirty(true) }}
className="flex-1 border border-gray-200 rounded px-1.5 py-0.5 font-mono focus:outline-none focus:border-blue-400" />
<button onClick={() => handleCalibrate(m.source_name)}
disabled={!canCalibrate}
title={!canCalibrate ? 'Set amount and date fields first' : 'Calibrate balance'}
className="text-blue-400 hover:text-blue-600 underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline">
Calibrate
</button>
</div>
</div>
</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-gray-400">No sources added yet.</p>}
</div> </div>
{availableSources.length > 0 && ( {availableSources.length > 0 && (
@ -296,24 +424,85 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
)} )}
</div> </div>
{/* Output fields */} {/* Output columns mapping grid */}
<div className="bg-white border border-gray-200 rounded p-4"> <div className="bg-white border border-gray-200 rounded p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Output fields</h3> <h3 className="text-sm font-semibold text-gray-700 mb-1">Output columns</h3>
{fields.length > 0 && ( <p className="text-xs text-gray-400 mb-3">
<div className="space-y-1 mb-3"> Each row is a column in the combined view. Each source column shows which field from that source maps to it.
{fields.map(f => ( 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.
<div key={f.name} className="flex items-center gap-2 text-xs"> 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.
<span className="font-mono text-gray-700 w-32">{f.name}</span> Drag rows to reorder.
<span className="text-gray-400">{f.type}</span> </p>
<button onClick={() => removeField(f.name)} className="text-red-400 hover:text-red-600 ml-auto"></button>
</div> {members.length === 0 ? (
<p className="text-xs text-gray-400 mb-3">Add sources above first.</p>
) : (
<div className="overflow-x-auto mb-3">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="border-b border-gray-200">
<th className="w-5 pb-2"></th>
<th className="text-left text-gray-400 font-normal pb-2 pr-4">Column</th>
<th className="text-left text-gray-400 font-normal pb-2 pr-4">Type</th>
{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 className="w-5 pb-2"></th>
</tr>
</thead>
<tbody>
{fields.map((f, idx) => {
const isAmount = f.name === amountCanonical
const isDate = f.name === dateCanonical
return (
<tr key={f.name}
draggable
onDragStart={e => handleDragStart(e, idx)}
onDragOver={e => handleDragOver(e, idx)}
onDrop={e => handleDrop(e, idx)}
onDragEnd={() => { setDragIdx(null); setDragOverIdx(null) }}
className={`border-b border-gray-50 ${dragOverIdx === idx && dragIdx !== idx ? 'bg-blue-50' : ''}`}>
<td className="py-1.5 pr-1 text-gray-300 cursor-grab select-none"></td>
<td className="py-1.5 pr-4 font-mono text-gray-700 whitespace-nowrap">
{f.name}
{isAmount && <span className="ml-1.5 text-blue-500 font-sans font-normal">amount</span>}
{isDate && <span className="ml-1.5 text-green-600 font-sans font-normal">date</span>}
</td>
<td className="py-1.5 pr-4 text-gray-400">{f.type}</td>
{members.map(m => (
<td key={m.source_name} className="py-1.5 pr-3">
<div className="flex items-center gap-1">
<select
value={getMappingValue(m.source_name, f.name)}
onChange={e => setMappingValue(m.source_name, f.name, e.target.value)}
className="border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400 min-w-0 flex-1">
<option value=""> same name </option>
{(srcFields[m.source_name] || []).map(sf => (
<option key={sf} value={sf}>{sf}</option>
))}
</select>
</div>
</td>
))}
<td className="py-1.5">
<button onClick={() => removeField(f.name)} className="text-red-300 hover:text-red-500"></button>
</td>
</tr>
)
})}
{fields.length === 0 && (
<tr><td colSpan={3 + members.length} className="py-3 text-gray-400 text-center">No columns defined yet add one below.</td></tr>
)}
</tbody>
</table>
</div> </div>
)} )}
{fields.length === 0 && <p className="text-xs text-gray-400 mb-3">No output fields defined yet.</p>}
<div className="flex gap-2"> {/* Add field */}
<div className="flex gap-2 mb-3">
<input className="flex-1 border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400" <input className="flex-1 border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
placeholder="field name" value={newField.name} onChange={e => setNewField(f => ({ ...f, name: e.target.value }))} placeholder="column name" value={newField.name}
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-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
value={newField.type} onChange={e => setNewField(f => ({ ...f, type: e.target.value }))}> value={newField.type} onChange={e => setNewField(f => ({ ...f, type: e.target.value }))}>
@ -321,37 +510,16 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
</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-gray-100 px-3 py-1 rounded hover:bg-gray-200 text-gray-700">Add</button>
</div> </div>
</div>
{/* Running balance config */} {mappingsDirty && (
<div className="bg-white border border-gray-200 rounded p-4"> <button onClick={saveMappings} disabled={saving}
<h3 className="text-sm font-semibold text-gray-700 mb-1">Running balance</h3>
<p className="text-xs text-gray-400 mb-3">Select which output fields drive the running balance. Define output fields above first.</p>
<div className="grid grid-cols-2 gap-3 mb-3">
<div>
<label className="text-xs text-gray-500 block mb-1">Amount field</label>
<select className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
value={amountField} onChange={e => setAmountField(e.target.value)}>
<option value=""> none </option>
{fieldNames.map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">Date field</label>
<select className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
value={dateField} onChange={e => setDateField(e.target.value)}>
<option value=""> none </option>
{fieldNames.map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
</div>
<button onClick={saveBalance} disabled={saving}
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">
{saving ? 'Saving…' : 'Save'} {saving ? 'Saving…' : 'Save mappings'}
</button> </button>
)}
</div> </div>
{/* Generate view + current balance */} {/* Generate view + balance */}
<div className="bg-white border border-gray-200 rounded p-4"> <div className="bg-white border border-gray-200 rounded p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700">View</h3> <h3 className="text-sm font-semibold text-gray-700">View</h3>
@ -366,17 +534,15 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
</button> </button>
</div> </div>
</div> </div>
{netBalance !== null && (
{currentBalance !== 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 running balance</span> <span className="text-xs text-gray-500">Current net balance</span>
<span className="text-lg font-mono font-semibold text-gray-800"> <span className="text-lg font-mono font-semibold text-gray-800">
{Number(currentBalance).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-gray-400 mb-3">{balanceError}</p>}
{viewResult && ( {viewResult && (
<div className="text-xs"> <div className="text-xs">
{viewResult.success {viewResult.success
@ -384,12 +550,20 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
: <p className="text-red-500">{viewResult.error}</p> : <p className="text-red-500">{viewResult.error}</p>
} }
{viewResult.sql && ( {viewResult.sql && (
<pre className="bg-gray-50 p-2 rounded overflow-auto text-gray-500 text-xs leading-relaxed">{viewResult.sql}</pre> <pre className="bg-gray-50 p-2 rounded overflow-auto text-gray-500 leading-relaxed">{viewResult.sql}</pre>
)} )}
</div> </div>
)} )}
</div> </div>
{calibratingSource && (
<CalibrateModal
stack={stack}
sourceName={calibratingSource}
onClose={() => setCalibratingSource(null)}
onApply={offset => applyCalibration(calibratingSource, offset)}
/>
)}
</div> </div>
) )
} }
@ -422,7 +596,7 @@ export default function Stacks({ sources }) {
if (!newName) return if (!newName) return
setError('') setError('')
try { try {
await api.createStack({ name: newName, fields: [], balance_offset: 0 }) await api.createStack({ name: newName, fields: [] })
setNewName(''); setCreating(false) setNewName(''); setCreating(false)
await load() await load()
loadDetail(newName) loadDetail(newName)
@ -437,51 +611,38 @@ export default function Stacks({ sources }) {
} }
return ( return (
<div className="p-6 flex gap-6 max-w-5xl"> <div className="p-6">
{/* Stack list */} {/* Stack list — horizontal row of cards */}
<div className="w-52 flex-shrink-0"> <div className="flex items-center gap-2 mb-5 flex-wrap">
<div className="flex items-center justify-between mb-3"> <h1 className="text-sm font-semibold text-gray-800 mr-1">Stacks</h1>
<h1 className="text-sm font-semibold text-gray-800">Stacks</h1>
<button onClick={() => setCreating(true)} className="text-xs text-blue-500 hover:text-blue-700">+ New</button>
</div>
{creating && (
<div className="mb-3">
<input autoFocus className="w-full border border-blue-400 rounded px-2 py-1 text-sm focus:outline-none mb-1"
placeholder="stack name" value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') createStack(); if (e.key === 'Escape') setCreating(false) }} />
{error && <p className="text-xs text-red-500">{error}</p>}
<div className="flex gap-1">
<button onClick={createStack} className="text-xs bg-blue-600 text-white px-2 py-0.5 rounded">Create</button>
<button onClick={() => setCreating(false)} className="text-xs text-gray-400 px-2 py-0.5">Cancel</button>
</div>
</div>
)}
<div className="space-y-0.5">
{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 justify-between px-2 py-1.5 rounded cursor-pointer text-sm group ${selected === s.name ? 'bg-blue-50 text-blue-700' : 'text-gray-600 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-blue-300 bg-blue-50 text-blue-700' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50'}`}>
<div className="min-w-0"> <span className="font-medium">{s.label || s.name}</span>
<div className="truncate font-medium">{s.label || s.name}</div> <span className="text-gray-400">{s.source_count}s</span>
<div className="text-xs text-gray-400">{s.source_count} source{s.source_count !== 1 ? 's' : ''}</div>
</div>
<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 text-xs ml-1 flex-shrink-0"></button> className="opacity-0 group-hover:opacity-100 text-red-300 hover:text-red-500 leading-none"></button>
</div> </div>
))} ))}
{stacks.length === 0 && !creating && ( {creating ? (
<p className="text-xs text-gray-400 px-2">No stacks yet.</p> <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"
placeholder="stack name" value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') createStack(); if (e.key === 'Escape') setCreating(false) }} />
<button onClick={createStack} className="text-xs bg-blue-600 text-white px-2 py-1 rounded">Create</button>
<button onClick={() => setCreating(false)} className="text-xs text-gray-400 px-1"></button>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
) : (
<button onClick={() => setCreating(true)} className="text-xs text-blue-500 hover:text-blue-700 px-2 py-1.5">+ New</button>
)} )}
</div> </div>
</div>
{/* Detail panel */} {/* Full-width config panel */}
<div className="flex-1 min-w-0">
{stackDetail ? ( {stackDetail ? (
<> <>
<h2 className="text-lg font-semibold text-gray-800 mb-4"> <h2 className="text-base font-semibold text-gray-800 mb-4">
{stackDetail.label || stackDetail.name} {stackDetail.label || stackDetail.name}
{stackDetail.label && <span className="text-sm text-gray-400 font-normal ml-2">{stackDetail.name}</span>} {stackDetail.label && <span className="text-sm text-gray-400 font-normal ml-2">{stackDetail.name}</span>}
</h2> </h2>
@ -490,13 +651,11 @@ export default function Stacks({ sources }) {
stack={stackDetail} stack={stackDetail}
sources={sources} sources={sources}
onUpdated={() => { load(); loadDetail(stackDetail.name) }} onUpdated={() => { load(); loadDetail(stackDetail.name) }}
onDeleted={() => { setSelected(null); setStackDetail(null); load() }}
/> />
</> </>
) : ( ) : (
<p className="text-sm text-gray-400">Select a stack or create one.</p> <p className="text-sm text-gray-400">Select a stack or create one.</p>
)} )}
</div> </div>
</div>
) )
} }