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:
parent
f63a0ec0e5
commit
f941c5ae4a
@ -67,9 +67,10 @@ module.exports = (pool) => {
|
||||
// Add or update a source in a stack
|
||||
router.put('/:name/sources/:source', async (req, res, next) => {
|
||||
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(
|
||||
`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]);
|
||||
} catch (err) {
|
||||
|
||||
@ -26,20 +26,26 @@ CREATE TABLE IF NOT EXISTS dataflow.stack_sources (
|
||||
id SERIAL PRIMARY KEY,
|
||||
stack_name TEXT NOT NULL REFERENCES dataflow.stacks(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 '{}',
|
||||
-- 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,
|
||||
-- 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,
|
||||
UNIQUE (stack_name, source_name)
|
||||
);
|
||||
|
||||
-- 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 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 upsert_stack_source(TEXT, TEXT, JSONB, INTEGER, NUMERIC);
|
||||
|
||||
------------------------------------------------------
|
||||
-- Function: list_stacks
|
||||
@ -89,7 +95,9 @@ RETURNS TABLE (
|
||||
'id', ss.id,
|
||||
'source_name', ss.source_name,
|
||||
'field_map', ss.field_map,
|
||||
'amount_field', ss.amount_field,
|
||||
'amount_sign', ss.amount_sign,
|
||||
'date_field', ss.date_field,
|
||||
'balance_offset', ss.balance_offset
|
||||
) ORDER BY ss.source_name
|
||||
) FILTER (WHERE ss.id IS NOT NULL), '[]')
|
||||
@ -152,14 +160,18 @@ CREATE OR REPLACE FUNCTION upsert_stack_source(
|
||||
p_source_name TEXT,
|
||||
p_field_map JSONB DEFAULT '{}',
|
||||
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 $$
|
||||
INSERT INTO dataflow.stack_sources (stack_name, source_name, field_map, amount_sign, balance_offset)
|
||||
VALUES (p_stack_name, p_source_name, p_field_map, p_amount_sign, p_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, p_amount_field, p_date_field)
|
||||
ON CONFLICT (stack_name, source_name) DO UPDATE SET
|
||||
field_map = EXCLUDED.field_map,
|
||||
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 *;
|
||||
$$ LANGUAGE sql;
|
||||
|
||||
@ -175,47 +187,40 @@ $$ LANGUAGE sql;
|
||||
|
||||
------------------------------------------------------
|
||||
-- Function: calibrate_balance
|
||||
-- Given a known good balance at a specific date, compute the offset needed.
|
||||
-- Returns: {computed_at_date, known_balance, suggested_offset}
|
||||
-- Queries dfv.{source} directly using per-source amount/date fields.
|
||||
-- No stack view required.
|
||||
------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION calibrate_balance(
|
||||
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_known_balance NUMERIC
|
||||
) RETURNS JSON AS $$
|
||||
DECLARE
|
||||
v_stack dataflow.stacks%ROWTYPE;
|
||||
v_running NUMERIC := 0;
|
||||
v_other_offsets NUMERIC := 0;
|
||||
v_src dataflow.stack_sources%ROWTYPE;
|
||||
v_running NUMERIC;
|
||||
v_sql TEXT;
|
||||
BEGIN
|
||||
SELECT * INTO v_stack FROM dataflow.stacks WHERE name = p_stack_name;
|
||||
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
|
||||
SELECT * INTO v_src
|
||||
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;
|
||||
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(
|
||||
'success', true,
|
||||
@ -223,7 +228,6 @@ BEGIN
|
||||
'as_of_date', p_as_of_date,
|
||||
'known_balance', p_known_balance,
|
||||
'computed_sum', v_running,
|
||||
'other_offsets', v_other_offsets,
|
||||
'suggested_offset', p_known_balance - v_running
|
||||
);
|
||||
END;
|
||||
@ -231,8 +235,9 @@ $$ LANGUAGE plpgsql STABLE;
|
||||
|
||||
------------------------------------------------------
|
||||
-- Function: generate_stack_view
|
||||
-- Builds a UNION ALL view in dfv schema from all member sources.
|
||||
-- Includes running_balance if amount_field and date_field are set.
|
||||
-- Builds a WITH ... UNION ALL view in dfv schema from existing dfv source views.
|
||||
-- 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)
|
||||
RETURNS JSON AS $$
|
||||
@ -240,13 +245,17 @@ DECLARE
|
||||
v_stack dataflow.stacks%ROWTYPE;
|
||||
v_src dataflow.stack_sources%ROWTYPE;
|
||||
v_field JSONB;
|
||||
v_parts TEXT[] := '{}';
|
||||
v_ctes TEXT[] := '{}';
|
||||
v_cte_names TEXT[] := '{}';
|
||||
v_select TEXT;
|
||||
v_col TEXT;
|
||||
v_src_field TEXT;
|
||||
v_amt_src TEXT;
|
||||
v_date_src TEXT;
|
||||
v_view TEXT;
|
||||
v_sql TEXT;
|
||||
v_has_bal BOOLEAN;
|
||||
v_canon_cols TEXT;
|
||||
BEGIN
|
||||
SELECT * INTO v_stack FROM dataflow.stacks WHERE name = p_stack_name;
|
||||
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;
|
||||
|
||||
-- Build one SELECT per source
|
||||
-- Build one CTE per source querying dfv.{source} directly
|
||||
FOR v_src IN
|
||||
SELECT * FROM dataflow.stack_sources WHERE stack_name = p_stack_name ORDER BY source_name
|
||||
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)
|
||||
LOOP
|
||||
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'
|
||||
WHEN 'numeric' THEN
|
||||
format('(rec.transformed->>%L)::numeric AS %I', v_src_field, v_col)
|
||||
WHEN 'date' THEN
|
||||
format('(rec.transformed->>%L)::date AS %I', v_src_field, v_col)
|
||||
IF v_has_bal AND v_col = v_stack.amount_field THEN
|
||||
-- Use per-source amount_field with sign applied
|
||||
IF v_src.amount_field IS NULL THEN
|
||||
v_select := v_select || format(', NULL::%s AS %I', v_field->>'type', v_col);
|
||||
ELSE
|
||||
format('rec.transformed->>%L AS %I', v_src_field, v_col)
|
||||
END;
|
||||
v_select := v_select || format(', %I * %s AS %I', v_src.amount_field, v_src.amount_sign, v_col);
|
||||
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;
|
||||
|
||||
-- amount_sign column for running balance
|
||||
v_select := v_select || format(', %s::integer AS _sign, %s::numeric AS _src_offset', v_src.amount_sign, v_src.balance_offset);
|
||||
|
||||
-- Per-source running balance with calibration offset baked in
|
||||
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(
|
||||
' FROM dataflow.records rec WHERE rec.source_name = %L AND rec.transformed IS NOT NULL',
|
||||
v_src.source_name
|
||||
', SUM(%I * %s) OVER (ORDER BY %I ASC, id ASC) + %s AS source_balance',
|
||||
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;
|
||||
|
||||
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');
|
||||
END IF;
|
||||
|
||||
@ -296,38 +328,36 @@ BEGIN
|
||||
v_view := 'dfv.' || quote_ident(p_stack_name);
|
||||
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
|
||||
-- running_balance = cumulative sum of (amount * sign) + per-source seed offsets + 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).
|
||||
-- net_balance: cumulative signed amount across all sources + stack-level offset
|
||||
v_sql := format(
|
||||
'CREATE VIEW %s AS '
|
||||
'SELECT _source, _id, %s, '
|
||||
'SUM((%I)::numeric * _sign) OVER (ORDER BY %I ASC, _id ASC) '
|
||||
'+ (SELECT COALESCE(SUM(balance_offset),0) FROM dataflow.stack_sources WHERE stack_name = %L) '
|
||||
'+ %s AS running_balance '
|
||||
'FROM (%s) _stacked',
|
||||
'WITH %s, _stacked AS (SELECT * FROM %s) '
|
||||
'SELECT _source, _id, %s, source_balance, '
|
||||
'SUM(%I) OVER (ORDER BY %I ASC, _id ASC) + %s AS net_balance '
|
||||
'FROM _stacked',
|
||||
v_view,
|
||||
(SELECT string_agg(quote_ident(f->>'name'), ', ')
|
||||
FROM jsonb_array_elements(v_stack.fields) f),
|
||||
array_to_string(v_ctes, ', '),
|
||||
array_to_string(v_cte_names, ' UNION ALL SELECT * FROM '),
|
||||
v_canon_cols,
|
||||
v_stack.amount_field,
|
||||
v_stack.date_field,
|
||||
p_stack_name,
|
||||
v_stack.balance_offset,
|
||||
array_to_string(v_parts, ' UNION ALL ')
|
||||
v_stack.balance_offset
|
||||
);
|
||||
ELSE
|
||||
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,
|
||||
(SELECT string_agg(quote_ident(f->>'name'), ', ')
|
||||
FROM jsonb_array_elements(v_stack.fields) f),
|
||||
array_to_string(v_parts, ' UNION ALL ')
|
||||
array_to_string(v_ctes, ', '),
|
||||
array_to_string(v_cte_names, ' UNION ALL SELECT * FROM '),
|
||||
v_canon_cols
|
||||
);
|
||||
END IF;
|
||||
|
||||
@ -361,7 +391,7 @@ BEGIN
|
||||
|
||||
BEGIN
|
||||
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
|
||||
);
|
||||
EXECUTE v_sql INTO v_balance;
|
||||
|
||||
@ -24,14 +24,14 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
|
||||
}
|
||||
|
||||
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="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>
|
||||
</div>
|
||||
<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>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
@ -49,9 +49,7 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
|
||||
{loading ? 'Calculating…' : 'Calculate'}
|
||||
</button>
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
{result && !result.success && (
|
||||
<p className="text-xs text-red-500">{result.error}</p>
|
||||
)}
|
||||
{result && !result.success && <p className="text-xs text-red-500">{result.error}</p>}
|
||||
{result && result.success && (
|
||||
<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>
|
||||
@ -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"
|
||||
value={applyOffset} onChange={e => setApplyOffset(e.target.value)} />
|
||||
</div>
|
||||
<button onClick={() => onApply(parseFloat(applyOffset))}
|
||||
disabled={applyOffset === ''}
|
||||
<button onClick={() => onApply(parseFloat(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">
|
||||
Apply offset
|
||||
</button>
|
||||
@ -80,124 +77,68 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Source member row ──────────────────────────────────────────────────────────
|
||||
// ── Stack panel ────────────────────────────────────────────────────────────────
|
||||
|
||||
function SourceRow({ stack, member, stackFields, onSave, onRemove }) {
|
||||
const [map, setMap] = useState(member.field_map || {})
|
||||
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)
|
||||
function StackPanel({ stack, sources, onUpdated }) {
|
||||
const members = stack.sources || []
|
||||
|
||||
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 [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 [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 [currentBalance, setCurrentBalance] = useState(null)
|
||||
const [netBalance, setNetBalance] = useState(null)
|
||||
const [balanceError, setBalanceError] = useState('')
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [mappingsDirty, setMappingsDirty] = useState(false)
|
||||
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('')
|
||||
try {
|
||||
await api.updateStack(stack.name, updates)
|
||||
onUpdated()
|
||||
} catch (e) { setError(e.message) }
|
||||
try { await api.updateStack(stack.name, { label }); onUpdated() }
|
||||
catch (e) { setError(e.message) }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
async function saveLabel() {
|
||||
await saveStack({ label, amount_field: amountField, date_field: dateField, fields })
|
||||
}
|
||||
|
||||
async function saveBalance() {
|
||||
await saveStack({ label, amount_field: amountField, date_field: dateField, fields })
|
||||
}
|
||||
|
||||
// ── Fields ──
|
||||
async function addField() {
|
||||
if (!newField.name) return
|
||||
const updated = [...fields, { name: newField.name, type: newField.type }]
|
||||
@ -207,32 +148,167 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
|
||||
onUpdated()
|
||||
}
|
||||
|
||||
function removeField(name) {
|
||||
async function removeField(name) {
|
||||
const updated = fields.filter(f => f.name !== name)
|
||||
setFields(updated)
|
||||
if (amountField === name) setAmountField('')
|
||||
if (dateField === name) setDateField('')
|
||||
api.updateStack(stack.name, { fields: updated }).then(onUpdated)
|
||||
await api.updateStack(stack.name, { fields: updated })
|
||||
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() {
|
||||
if (!addingSrc) return
|
||||
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('')
|
||||
onUpdated()
|
||||
}
|
||||
|
||||
async function saveSource(src, field_map, amount_sign, balance_offset) {
|
||||
await api.upsertStackSource(stack.name, src, { field_map, amount_sign, balance_offset })
|
||||
function handleSrcAmountField(srcName, value) {
|
||||
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) {
|
||||
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() {
|
||||
setViewResult(null); setCurrentBalance(null); setBalanceError('')
|
||||
setViewResult(null); setNetBalance(null); setBalanceError('')
|
||||
try {
|
||||
const r = await api.generateStackView(stack.name)
|
||||
setViewResult(r)
|
||||
@ -244,13 +320,12 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
|
||||
setBalanceError('')
|
||||
try {
|
||||
const r = await api.getStackBalance(stack.name)
|
||||
if (r.success) setCurrentBalance(r.balance)
|
||||
if (r.success) setNetBalance(r.balance)
|
||||
else setBalanceError(r.error)
|
||||
} catch (e) { setBalanceError(e.message) }
|
||||
}
|
||||
|
||||
const availableSources = sources.filter(s => !members.find(m => m.source_name === s.name))
|
||||
const fieldNames = fields.map(f => f.name)
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@ -275,12 +350,65 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
|
||||
|
||||
{/* Sources */}
|
||||
<div className="bg-white border border-gray-200 rounded p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Sources</h3>
|
||||
<div className="space-y-3 mb-3">
|
||||
{members.map(m => (
|
||||
<SourceRow key={m.source_name} stack={stack} member={m} stackFields={fields}
|
||||
onSave={saveSource} onRemove={removeSource} />
|
||||
))}
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">Sources</h3>
|
||||
<p className="text-xs text-gray-400 mb-3">Each source contributes rows to the combined view. Set the sign to flip the direction of amounts (e.g. credit card charges are positive in the source but should subtract from your balance). The offset adjusts the running balance — use Calibrate to compute it from a known good balance.</p>
|
||||
<div className="space-y-2 mb-3">
|
||||
{members.map(m => {
|
||||
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>}
|
||||
</div>
|
||||
{availableSources.length > 0 && (
|
||||
@ -296,24 +424,85 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Output fields */}
|
||||
{/* Output columns mapping grid */}
|
||||
<div className="bg-white border border-gray-200 rounded p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Output fields</h3>
|
||||
{fields.length > 0 && (
|
||||
<div className="space-y-1 mb-3">
|
||||
{fields.map(f => (
|
||||
<div key={f.name} className="flex items-center gap-2 text-xs">
|
||||
<span className="font-mono text-gray-700 w-32">{f.name}</span>
|
||||
<span className="text-gray-400">{f.type}</span>
|
||||
<button onClick={() => removeField(f.name)} className="text-red-400 hover:text-red-600 ml-auto">✕</button>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">Output columns</h3>
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
Each row is a column in the combined view. Each source column shows which field from that source maps to it.
|
||||
The first <span className="text-blue-500">numeric</span> field drives the running balance; the first <span className="text-green-600">date</span> field drives the ordering.
|
||||
Both <span className="font-mono">source_balance</span> (per-source) and <span className="font-mono">net_balance</span> (combined) are always included in the generated view.
|
||||
Drag rows to reorder.
|
||||
</p>
|
||||
|
||||
{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>
|
||||
)}
|
||||
{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"
|
||||
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()} />
|
||||
<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 }))}>
|
||||
@ -321,37 +510,16 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
|
||||
</select>
|
||||
<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>
|
||||
|
||||
{/* Running balance config */}
|
||||
<div className="bg-white border border-gray-200 rounded p-4">
|
||||
<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}
|
||||
{mappingsDirty && (
|
||||
<button onClick={saveMappings} 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'}
|
||||
{saving ? 'Saving…' : 'Save mappings'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Generate view + current balance */}
|
||||
{/* Generate view + balance */}
|
||||
<div className="bg-white border border-gray-200 rounded p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700">View</h3>
|
||||
@ -366,17 +534,15 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentBalance !== null && (
|
||||
{netBalance !== null && (
|
||||
<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">
|
||||
{Number(currentBalance).toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
{Number(netBalance).toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{balanceError && <p className="text-xs text-gray-400 mb-3">{balanceError}</p>}
|
||||
|
||||
{viewResult && (
|
||||
<div className="text-xs">
|
||||
{viewResult.success
|
||||
@ -384,12 +550,20 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
|
||||
: <p className="text-red-500">{viewResult.error}</p>
|
||||
}
|
||||
{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>
|
||||
|
||||
{calibratingSource && (
|
||||
<CalibrateModal
|
||||
stack={stack}
|
||||
sourceName={calibratingSource}
|
||||
onClose={() => setCalibratingSource(null)}
|
||||
onApply={offset => applyCalibration(calibratingSource, offset)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -422,7 +596,7 @@ export default function Stacks({ sources }) {
|
||||
if (!newName) return
|
||||
setError('')
|
||||
try {
|
||||
await api.createStack({ name: newName, fields: [], balance_offset: 0 })
|
||||
await api.createStack({ name: newName, fields: [] })
|
||||
setNewName(''); setCreating(false)
|
||||
await load()
|
||||
loadDetail(newName)
|
||||
@ -437,51 +611,38 @@ export default function Stacks({ sources }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 flex gap-6 max-w-5xl">
|
||||
{/* Stack list */}
|
||||
<div className="w-52 flex-shrink-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<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">
|
||||
<div className="p-6">
|
||||
{/* Stack list — horizontal row of cards */}
|
||||
<div className="flex items-center gap-2 mb-5 flex-wrap">
|
||||
<h1 className="text-sm font-semibold text-gray-800 mr-1">Stacks</h1>
|
||||
{stacks.map(s => (
|
||||
<div key={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'}`}>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{s.label || s.name}</div>
|
||||
<div className="text-xs text-gray-400">{s.source_count} source{s.source_count !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded border cursor-pointer text-xs group transition-colors ${selected === s.name ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50'}`}>
|
||||
<span className="font-medium">{s.label || s.name}</span>
|
||||
<span className="text-gray-400">{s.source_count}s</span>
|
||||
<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>
|
||||
))}
|
||||
{stacks.length === 0 && !creating && (
|
||||
<p className="text-xs text-gray-400 px-2">No stacks yet.</p>
|
||||
{creating ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input autoFocus className="border border-blue-400 rounded px-2 py-1 text-xs focus:outline-none w-32"
|
||||
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>
|
||||
|
||||
{/* Detail panel */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Full-width config panel */}
|
||||
{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 && <span className="text-sm text-gray-400 font-normal ml-2">{stackDetail.name}</span>}
|
||||
</h2>
|
||||
@ -490,13 +651,11 @@ export default function Stacks({ sources }) {
|
||||
stack={stackDetail}
|
||||
sources={sources}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user