Stacks UI: reorder flow, balance dropdowns, current balance display, calibration editable offset
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ef6c6bbbb8
commit
f63a0ec0e5
@ -89,6 +89,14 @@ module.exports = (pool) => {
|
|||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get current running balance from the generated view
|
||||||
|
router.get('/:name/balance', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(`SELECT get_stack_balance(${lit(req.params.name)}) AS result`);
|
||||||
|
res.json(result.rows[0].result);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
// Generate / refresh the dfv view
|
// Generate / refresh the dfv view
|
||||||
router.post('/:name/view', async (req, res, next) => {
|
router.post('/:name/view', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -35,6 +35,12 @@ CREATE TABLE IF NOT EXISTS dataflow.stack_sources (
|
|||||||
UNIQUE (stack_name, source_name)
|
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;
|
||||||
|
|
||||||
|
-- Drop old 3-arg calibrate_balance signature if it exists before recreating with 4 args
|
||||||
|
DROP FUNCTION IF EXISTS calibrate_balance(TEXT, DATE, NUMERIC);
|
||||||
|
|
||||||
------------------------------------------------------
|
------------------------------------------------------
|
||||||
-- Function: list_stacks
|
-- Function: list_stacks
|
||||||
------------------------------------------------------
|
------------------------------------------------------
|
||||||
@ -331,5 +337,42 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
------------------------------------------------------
|
||||||
|
-- Function: get_stack_balance
|
||||||
|
-- Returns the current running balance (last row of the generated view)
|
||||||
|
------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION get_stack_balance(p_stack_name TEXT)
|
||||||
|
RETURNS JSON AS $$
|
||||||
|
DECLARE
|
||||||
|
v_stack dataflow.stacks%ROWTYPE;
|
||||||
|
v_balance NUMERIC;
|
||||||
|
v_view TEXT;
|
||||||
|
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', 'amount_field and date_field must be set');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_view := 'dfv.' || quote_ident(p_stack_name);
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
v_sql := format(
|
||||||
|
'SELECT running_balance FROM %s ORDER BY %I DESC, _id DESC LIMIT 1',
|
||||||
|
v_view, v_stack.date_field
|
||||||
|
);
|
||||||
|
EXECUTE v_sql INTO v_balance;
|
||||||
|
EXCEPTION WHEN undefined_table THEN
|
||||||
|
RETURN json_build_object('success', false, 'error', 'View not generated yet — click Generate first');
|
||||||
|
END;
|
||||||
|
|
||||||
|
RETURN json_build_object('success', true, 'balance', v_balance);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
COMMENT ON FUNCTION generate_stack_view IS 'Generate a UNION ALL view in dfv schema combining multiple sources with optional running balance';
|
COMMENT ON FUNCTION generate_stack_view IS 'Generate a UNION ALL view in dfv schema combining multiple sources with optional running balance';
|
||||||
COMMENT ON FUNCTION calibrate_balance IS 'Given a known good balance at a date, compute the offset to add to balance_offset';
|
COMMENT ON FUNCTION calibrate_balance IS 'Given a known good balance at a date, compute the offset to add to balance_offset';
|
||||||
|
COMMENT ON FUNCTION get_stack_balance IS 'Return the current running balance (last row) from the generated dfv view';
|
||||||
|
|||||||
@ -122,6 +122,7 @@ export const api = {
|
|||||||
upsertStackSource: (name, source, body) => request('PUT', `/stacks/${name}/sources/${source}`, body),
|
upsertStackSource: (name, source, body) => request('PUT', `/stacks/${name}/sources/${source}`, body),
|
||||||
removeStackSource: (name, source) => request('DELETE', `/stacks/${name}/sources/${source}`),
|
removeStackSource: (name, source) => request('DELETE', `/stacks/${name}/sources/${source}`),
|
||||||
generateStackView: (name) => request('POST', `/stacks/${name}/view`),
|
generateStackView: (name) => request('POST', `/stacks/${name}/view`),
|
||||||
|
getStackBalance: (name) => request('GET', `/stacks/${name}/balance`),
|
||||||
calibrateBalance: (name, source, body) => request('POST', `/stacks/${name}/calibrate`, { ...body, source_name: source || null }),
|
calibrateBalance: (name, source, body) => request('POST', `/stacks/${name}/calibrate`, { ...body, source_name: source || null }),
|
||||||
|
|
||||||
// Records
|
// Records
|
||||||
|
|||||||
@ -9,6 +9,7 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
|
|||||||
const [asOf, setAsOf] = useState('')
|
const [asOf, setAsOf] = useState('')
|
||||||
const [known, setKnown] = useState('')
|
const [known, setKnown] = useState('')
|
||||||
const [result, setResult] = useState(null)
|
const [result, setResult] = useState(null)
|
||||||
|
const [applyOffset, setApplyOffset] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
@ -17,6 +18,7 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
|
|||||||
try {
|
try {
|
||||||
const r = await api.calibrateBalance(stack.name, sourceName, { as_of_date: asOf, known_balance: parseFloat(known) })
|
const r = await api.calibrateBalance(stack.name, sourceName, { as_of_date: asOf, known_balance: parseFloat(known) })
|
||||||
setResult(r)
|
setResult(r)
|
||||||
|
if (r.success) setApplyOffset(Number(r.suggested_offset).toFixed(2))
|
||||||
} catch (e) { setError(e.message) }
|
} catch (e) { setError(e.message) }
|
||||||
finally { setLoading(false) }
|
finally { setLoading(false) }
|
||||||
}
|
}
|
||||||
@ -47,18 +49,30 @@ 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 && !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="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>
|
||||||
<div className="flex justify-between"><span className="text-gray-500">Known balance</span><span className="font-mono">{Number(result.known_balance).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span></div>
|
<div className="flex justify-between"><span className="text-gray-500">Known balance</span><span className="font-mono">{Number(result.known_balance).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span></div>
|
||||||
<div className="flex justify-between font-medium"><span className="text-gray-700">Suggested offset</span><span className="font-mono text-blue-700">{Number(result.suggested_offset).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span></div>
|
<div className="flex justify-between font-medium"><span className="text-gray-700">Suggested offset</span><span className="font-mono text-blue-700">{Number(result.suggested_offset).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{result && (
|
{result && result.success && (
|
||||||
<button onClick={() => onApply(result.suggested_offset)}
|
<div className="space-y-2">
|
||||||
className="w-full text-sm bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700">
|
<div>
|
||||||
Apply offset ({Number(result.suggested_offset).toFixed(2)})
|
<label className="text-xs text-gray-500 block mb-1">Offset to apply</label>
|
||||||
</button>
|
<input type="number" step="0.01"
|
||||||
|
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 === ''}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -119,7 +133,10 @@ function SourceRow({ stack, member, stackFields, onSave, onRemove }) {
|
|||||||
<input type="number" step="0.01" value={offset}
|
<input type="number" step="0.01" value={offset}
|
||||||
onChange={e => handleOffset(parseFloat(e.target.value) || 0)}
|
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" />
|
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)} className="text-gray-400 hover:text-blue-500 underline">Calibrate</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -151,28 +168,36 @@ function SourceRow({ stack, member, stackFields, onSave, onRemove }) {
|
|||||||
// ── Stack detail / edit panel ──────────────────────────────────────────────────
|
// ── Stack detail / edit panel ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function StackPanel({ stack, sources, onUpdated, onDeleted }) {
|
function StackPanel({ stack, sources, onUpdated, onDeleted }) {
|
||||||
const [form, setForm] = useState({
|
const [label, setLabel] = useState(stack.label || '')
|
||||||
label: stack.label || '',
|
|
||||||
amount_field: stack.amount_field || '',
|
|
||||||
date_field: stack.date_field || '',
|
|
||||||
})
|
|
||||||
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 [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('')
|
||||||
const [viewResult, setViewResult] = useState(null)
|
const [viewResult, setViewResult] = useState(null)
|
||||||
|
const [currentBalance, setCurrentBalance] = useState(null)
|
||||||
|
const [balanceError, setBalanceError] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
async function saveStack() {
|
async function saveStack(updates) {
|
||||||
setSaving(true); setError('')
|
setSaving(true); setError('')
|
||||||
try {
|
try {
|
||||||
await api.updateStack(stack.name, { ...form, fields })
|
await api.updateStack(stack.name, updates)
|
||||||
onUpdated()
|
onUpdated()
|
||||||
} catch (e) { setError(e.message) }
|
} catch (e) { setError(e.message) }
|
||||||
finally { setSaving(false) }
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
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 }]
|
||||||
@ -185,12 +210,14 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
|
|||||||
function removeField(name) {
|
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('')
|
||||||
|
if (dateField === name) setDateField('')
|
||||||
api.updateStack(stack.name, { fields: updated }).then(onUpdated)
|
api.updateStack(stack.name, { fields: updated }).then(onUpdated)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addSource() {
|
async function addSource() {
|
||||||
if (!addingSrc) return
|
if (!addingSrc) return
|
||||||
const member = 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 }])
|
setMembers(m => [...m.filter(x => x.source_name !== addingSrc), { source_name: addingSrc, field_map: {}, amount_sign: 1 }])
|
||||||
setAddingSrc('')
|
setAddingSrc('')
|
||||||
}
|
}
|
||||||
@ -205,69 +232,45 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function generateView() {
|
async function generateView() {
|
||||||
|
setViewResult(null); setCurrentBalance(null); setBalanceError('')
|
||||||
try {
|
try {
|
||||||
const r = await api.generateStackView(stack.name)
|
const r = await api.generateStackView(stack.name)
|
||||||
setViewResult(r)
|
setViewResult(r)
|
||||||
|
if (r.success) fetchBalance()
|
||||||
} catch (e) { setError(e.message) }
|
} catch (e) { setError(e.message) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchBalance() {
|
||||||
|
setBalanceError('')
|
||||||
|
try {
|
||||||
|
const r = await api.getStackBalance(stack.name)
|
||||||
|
if (r.success) setCurrentBalance(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 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">
|
||||||
{/* Basic config */}
|
|
||||||
|
{/* Label */}
|
||||||
<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">Configuration</h3>
|
<h3 className="text-sm font-semibold text-gray-700 mb-3">Configuration</h3>
|
||||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
<div className="flex gap-3 items-end">
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<label className="text-xs text-gray-500 block mb-1">Label</label>
|
<label className="text-xs text-gray-500 block mb-1">Label <span className="text-gray-400">(optional)</span></label>
|
||||||
<input className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
<input className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
||||||
value={form.label} onChange={e => setForm(f => ({ ...f, label: e.target.value }))} />
|
value={label} onChange={e => setLabel(e.target.value)}
|
||||||
</div>
|
onKeyDown={e => e.key === 'Enter' && saveLabel()} />
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-500 block mb-1">Amount field (for balance)</label>
|
|
||||||
<input className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
|
||||||
value={form.amount_field} onChange={e => setForm(f => ({ ...f, amount_field: e.target.value }))}
|
|
||||||
placeholder="e.g. amount" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-500 block mb-1">Date field (for ordering)</label>
|
|
||||||
<input className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
|
||||||
value={form.date_field} onChange={e => setForm(f => ({ ...f, date_field: e.target.value }))}
|
|
||||||
placeholder="e.g. date" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<button onClick={saveLabel} disabled={saving}
|
||||||
|
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-xs text-red-500 mb-2">{error}</p>}
|
{error && <p className="text-xs text-red-500 mt-2">{error}</p>}
|
||||||
<button onClick={saveStack} disabled={saving}
|
|
||||||
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
|
|
||||||
{saving ? 'Saving…' : 'Save'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Canonical fields */}
|
|
||||||
<div className="bg-white border border-gray-200 rounded p-4">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Canonical 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>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<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 }))}
|
|
||||||
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 }))}>
|
|
||||||
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
|
||||||
</select>
|
|
||||||
<button onClick={addField} className="text-sm bg-gray-100 px-3 py-1 rounded hover:bg-gray-200 text-gray-700">Add</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sources */}
|
{/* Sources */}
|
||||||
@ -278,6 +281,7 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
|
|||||||
<SourceRow key={m.source_name} stack={stack} member={m} stackFields={fields}
|
<SourceRow key={m.source_name} stack={stack} member={m} stackFields={fields}
|
||||||
onSave={saveSource} onRemove={removeSource} />
|
onSave={saveSource} onRemove={removeSource} />
|
||||||
))}
|
))}
|
||||||
|
{members.length === 0 && <p className="text-xs text-gray-400">No sources added yet.</p>}
|
||||||
</div>
|
</div>
|
||||||
{availableSources.length > 0 && (
|
{availableSources.length > 0 && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@ -292,15 +296,87 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Generate view */}
|
{/* Output fields */}
|
||||||
<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-2">
|
<h3 className="text-sm font-semibold text-gray-700 mb-3">Output fields</h3>
|
||||||
<h3 className="text-sm font-semibold text-gray-700">View</h3>
|
{fields.length > 0 && (
|
||||||
<button onClick={generateView}
|
<div className="space-y-1 mb-3">
|
||||||
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700">
|
{fields.map(f => (
|
||||||
Generate / refresh
|
<div key={f.name} className="flex items-center gap-2 text-xs">
|
||||||
</button>
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fields.length === 0 && <p className="text-xs text-gray-400 mb-3">No output fields defined yet.</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<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 }))}
|
||||||
|
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 }))}>
|
||||||
|
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
<button onClick={addField} className="text-sm bg-gray-100 px-3 py-1 rounded hover:bg-gray-200 text-gray-700">Add</button>
|
||||||
</div>
|
</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}
|
||||||
|
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generate view + current 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>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={fetchBalance}
|
||||||
|
className="text-sm bg-gray-100 text-gray-700 px-3 py-1.5 rounded hover:bg-gray-200">
|
||||||
|
Refresh balance
|
||||||
|
</button>
|
||||||
|
<button onClick={generateView}
|
||||||
|
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700">
|
||||||
|
Generate / refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentBalance !== null && (
|
||||||
|
<div className="mb-3 flex items-center gap-3">
|
||||||
|
<span className="text-xs text-gray-500">Current running balance</span>
|
||||||
|
<span className="text-lg font-mono font-semibold text-gray-800">
|
||||||
|
{Number(currentBalance).toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user