Stacks: live SQL preview, side-by-side layout, cascade stale detection
- Two-column layout: config left, SQL panel right (equal halves) - SQL panel shows formatted SQL (sql-formatter, 4-space indent) - Live preview: SQL updates 400ms after any field/source/mapping change - Run button executes edited SQL directly via new exec-sql endpoint - generate_stack_view gains p_dry_run mode for preview without executing - CASCADE drop detects dependent stacks, marks them stale in DB and status bar - net_balance moved to last column in generated view - Backfill 458 missing dcard rows and 123 missing chase rows from TPS migration bug Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7c63a2ac29
commit
95e63679ef
@ -98,6 +98,14 @@ module.exports = (pool) => {
|
|||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Preview the SQL that would be generated (dry run — does not create the view)
|
||||||
|
router.get('/:name/view-sql', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(`SELECT generate_stack_view(${lit(req.params.name)}, true) 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 {
|
||||||
@ -110,6 +118,33 @@ module.exports = (pool) => {
|
|||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Execute custom SQL for the view (user-edited SQL)
|
||||||
|
router.post('/:name/exec-sql', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { sql } = req.body;
|
||||||
|
if (!sql) return res.status(400).json({ success: false, error: 'sql is required' });
|
||||||
|
await pool.query(`DROP VIEW IF EXISTS dfv.${req.params.name} CASCADE`);
|
||||||
|
await pool.query(sql);
|
||||||
|
await pool.query(`UPDATE dataflow.stacks SET view_generated_at = NOW() WHERE name = ${lit(req.params.name)}`);
|
||||||
|
// Detect stacks whose views were dropped by CASCADE
|
||||||
|
const staleResult = await pool.query(`
|
||||||
|
SELECT array_agg(name) AS names FROM dataflow.stacks
|
||||||
|
WHERE name != ${lit(req.params.name)}
|
||||||
|
AND view_generated_at IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_views WHERE schemaname = 'dfv' AND viewname = name
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
const cascadeStale = staleResult.rows[0].names || [];
|
||||||
|
if (cascadeStale.length) {
|
||||||
|
await pool.query(`UPDATE dataflow.stacks SET view_generated_at = NULL WHERE name = ANY($1)`, [cascadeStale]);
|
||||||
|
}
|
||||||
|
res.json({ success: true, cascade_stale: cascadeStale });
|
||||||
|
} catch (err) {
|
||||||
|
res.json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Calibrate balance offset given a known good balance at a specific date
|
// Calibrate balance offset given a known good balance at a specific date
|
||||||
router.post('/:name/calibrate', async (req, res, next) => {
|
router.post('/:name/calibrate', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -46,6 +46,7 @@ ALTER TABLE dataflow.stack_sources ADD COLUMN IF NOT EXISTS date_field TEXT;
|
|||||||
-- Drop old signatures before recreating
|
-- 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);
|
DROP FUNCTION IF EXISTS upsert_stack_source(TEXT, TEXT, JSONB, INTEGER, NUMERIC);
|
||||||
|
DROP FUNCTION IF EXISTS generate_stack_view(TEXT);
|
||||||
|
|
||||||
------------------------------------------------------
|
------------------------------------------------------
|
||||||
-- Function: list_stacks
|
-- Function: list_stacks
|
||||||
@ -239,7 +240,7 @@ $$ LANGUAGE plpgsql STABLE;
|
|||||||
-- Each source CTE applies amount_sign and computes a per-source running balance.
|
-- Each source CTE applies amount_sign and computes a per-source running balance.
|
||||||
-- Outer SELECT adds net_balance across all sources.
|
-- 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, p_dry_run BOOLEAN DEFAULT false)
|
||||||
RETURNS JSON AS $$
|
RETURNS JSON AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
v_stack dataflow.stacks%ROWTYPE;
|
v_stack dataflow.stacks%ROWTYPE;
|
||||||
@ -258,6 +259,7 @@ DECLARE
|
|||||||
v_canon_cols TEXT;
|
v_canon_cols TEXT;
|
||||||
v_src_bal_cols TEXT;
|
v_src_bal_cols TEXT;
|
||||||
v_total_offset NUMERIC := 0;
|
v_total_offset NUMERIC := 0;
|
||||||
|
v_cascade_stale 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
|
||||||
@ -327,9 +329,7 @@ BEGIN
|
|||||||
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;
|
||||||
|
|
||||||
CREATE SCHEMA IF NOT EXISTS dfv;
|
|
||||||
v_view := 'dfv.' || quote_ident(p_stack_name);
|
v_view := 'dfv.' || quote_ident(p_stack_name);
|
||||||
EXECUTE format('DROP VIEW IF EXISTS %s CASCADE', v_view);
|
|
||||||
|
|
||||||
v_canon_cols := (
|
v_canon_cols := (
|
||||||
SELECT string_agg(quote_ident(f->>'name'), ', ')
|
SELECT string_agg(quote_ident(f->>'name'), ', ')
|
||||||
@ -337,23 +337,21 @@ BEGIN
|
|||||||
);
|
);
|
||||||
|
|
||||||
IF v_has_bal THEN
|
IF v_has_bal THEN
|
||||||
-- net_balance = sum of all signed amounts + total of all source offsets
|
|
||||||
-- This ensures net_balance = sum of per-source carried-forward balances on every row
|
|
||||||
v_sql := format(
|
v_sql := format(
|
||||||
'CREATE VIEW %s AS '
|
'CREATE VIEW %s AS '
|
||||||
'WITH %s, _stacked AS (SELECT * FROM %s) '
|
'WITH %s, _stacked AS (SELECT * FROM %s) '
|
||||||
'SELECT _source, _id, %s, '
|
'SELECT _source, _id, %s, '
|
||||||
'SUM(%I) OVER (ORDER BY %I ASC, _id ASC) + %s AS net_balance, '
|
'%s, '
|
||||||
'%s '
|
'SUM(%I) OVER (ORDER BY %I ASC, _id ASC) + %s AS net_balance '
|
||||||
'FROM _stacked ORDER BY %I DESC, _id DESC',
|
'FROM _stacked ORDER BY %I DESC, _id DESC',
|
||||||
v_view,
|
v_view,
|
||||||
array_to_string(v_ctes, ', '),
|
array_to_string(v_ctes, ', '),
|
||||||
array_to_string(v_cte_names, ' UNION ALL SELECT * FROM '),
|
array_to_string(v_cte_names, ' UNION ALL SELECT * FROM '),
|
||||||
v_canon_cols,
|
v_canon_cols,
|
||||||
|
v_src_bal_cols,
|
||||||
v_stack.amount_field,
|
v_stack.amount_field,
|
||||||
v_stack.date_field,
|
v_stack.date_field,
|
||||||
v_total_offset,
|
v_total_offset,
|
||||||
v_src_bal_cols,
|
|
||||||
v_stack.date_field
|
v_stack.date_field
|
||||||
);
|
);
|
||||||
ELSE
|
ELSE
|
||||||
@ -368,9 +366,31 @@ BEGIN
|
|||||||
);
|
);
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
|
IF NOT p_dry_run THEN
|
||||||
|
CREATE SCHEMA IF NOT EXISTS dfv;
|
||||||
|
EXECUTE format('DROP VIEW IF EXISTS %s CASCADE', v_view);
|
||||||
EXECUTE v_sql;
|
EXECUTE v_sql;
|
||||||
|
|
||||||
RETURN json_build_object('success', true, 'view', v_view, 'sql', v_sql);
|
-- Detect stacks whose views were dropped by CASCADE and mark them stale
|
||||||
|
SELECT array_agg(s.name) INTO v_cascade_stale
|
||||||
|
FROM dataflow.stacks s
|
||||||
|
WHERE s.name != p_stack_name
|
||||||
|
AND s.view_generated_at IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_views v
|
||||||
|
WHERE v.schemaname = 'dfv' AND v.viewname = s.name
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE dataflow.stacks SET view_generated_at = NULL
|
||||||
|
WHERE name = ANY(v_cascade_stale);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN json_build_object(
|
||||||
|
'success', true,
|
||||||
|
'view', v_view,
|
||||||
|
'sql', v_sql,
|
||||||
|
'cascade_stale', COALESCE(to_json(v_cascade_stale), '[]'::json)
|
||||||
|
);
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
@ -410,6 +430,6 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql STABLE;
|
$$ 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(TEXT, BOOLEAN) IS 'Generate a UNION ALL view in dfv schema combining multiple sources with optional running balance; p_dry_run=true returns SQL without executing';
|
||||||
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';
|
COMMENT ON FUNCTION get_stack_balance IS 'Return the current running balance (last row) from the generated dfv view';
|
||||||
|
|||||||
@ -12,7 +12,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.13.2"
|
"react-router-dom": "^7.13.2",
|
||||||
|
"sql-formatter": "^15.7.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
|||||||
@ -121,7 +121,9 @@ export const api = {
|
|||||||
deleteStack: (name) => request('DELETE', `/stacks/${name}`),
|
deleteStack: (name) => request('DELETE', `/stacks/${name}`),
|
||||||
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}`),
|
||||||
|
previewStackSql: (name) => request('GET', `/stacks/${name}/view-sql`),
|
||||||
generateStackView: (name) => request('POST', `/stacks/${name}/view`),
|
generateStackView: (name) => request('POST', `/stacks/${name}/view`),
|
||||||
|
execStackSql: (name, sql) => request('POST', `/stacks/${name}/exec-sql`, { sql }),
|
||||||
getStackBalance: (name) => request('GET', `/stacks/${name}/balance`),
|
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 }),
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,14 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
import { format as formatSql } from 'sql-formatter'
|
||||||
|
|
||||||
|
function prettySql(sql) {
|
||||||
|
try {
|
||||||
|
return formatSql(sql, { language: 'postgresql', tabWidth: 4, keywordCase: 'upper' })
|
||||||
|
} catch {
|
||||||
|
return sql
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const FIELD_TYPES = ['text', 'numeric', 'date']
|
const FIELD_TYPES = ['text', 'numeric', 'date']
|
||||||
|
|
||||||
@ -79,7 +88,7 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
|
|||||||
|
|
||||||
// ── Stack panel ────────────────────────────────────────────────────────────────
|
// ── Stack panel ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated }) {
|
function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSqlGenerated }) {
|
||||||
const members = stack.sources || []
|
const members = stack.sources || []
|
||||||
|
|
||||||
const [label, setLabel] = useState(stack.label || '')
|
const [label, setLabel] = useState(stack.label || '')
|
||||||
@ -126,6 +135,22 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated }) {
|
|||||||
})
|
})
|
||||||
}, [members.map(m => m.source_name).join(',')])
|
}, [members.map(m => m.source_name).join(',')])
|
||||||
|
|
||||||
|
// Live SQL preview — debounced whenever fields, sources, or mappings change
|
||||||
|
const previewTimer = useRef(null)
|
||||||
|
useEffect(() => {
|
||||||
|
clearTimeout(previewTimer.current)
|
||||||
|
previewTimer.current = setTimeout(() => {
|
||||||
|
api.previewStackSql(stack.name)
|
||||||
|
.then(r => { if (r.success) onSqlGenerated?.(r.sql) })
|
||||||
|
.catch(() => {})
|
||||||
|
}, 400)
|
||||||
|
return () => clearTimeout(previewTimer.current)
|
||||||
|
}, [
|
||||||
|
JSON.stringify(fields),
|
||||||
|
JSON.stringify(srcCfg),
|
||||||
|
members.map(m => m.source_name).join(','),
|
||||||
|
])
|
||||||
|
|
||||||
// Auto-detect canonical amount/date field from field types
|
// Auto-detect canonical amount/date field from field types
|
||||||
const amountCanonical = fields.find(f => f.type === 'numeric')?.name || stack.amount_field
|
const amountCanonical = fields.find(f => f.type === 'numeric')?.name || stack.amount_field
|
||||||
const dateCanonical = fields.find(f => f.type === 'date')?.name || stack.date_field
|
const dateCanonical = fields.find(f => f.type === 'date')?.name || stack.date_field
|
||||||
@ -316,7 +341,12 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated }) {
|
|||||||
try {
|
try {
|
||||||
const r = await api.generateStackView(stack.name)
|
const r = await api.generateStackView(stack.name)
|
||||||
setViewResult(r)
|
setViewResult(r)
|
||||||
if (r.success) { fetchBalance(); onViewGenerated?.(stack.name) }
|
if (r.success) {
|
||||||
|
fetchBalance()
|
||||||
|
onViewGenerated?.(stack.name)
|
||||||
|
onSqlGenerated?.(r.sql || '')
|
||||||
|
;(r.cascade_stale || []).forEach(n => onStale?.(n))
|
||||||
|
}
|
||||||
} catch (e) { setError(e.message) }
|
} catch (e) { setError(e.message) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -547,16 +577,11 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated }) {
|
|||||||
</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 && !viewResult.success && (
|
||||||
<div className="text-xs">
|
<p className="text-xs text-red-500">{viewResult.error}</p>
|
||||||
{viewResult.success
|
|
||||||
? <p className="text-green-600 mb-2">View created: <span className="font-mono">{viewResult.view}</span></p>
|
|
||||||
: <p className="text-red-500">{viewResult.error}</p>
|
|
||||||
}
|
|
||||||
{viewResult.sql && (
|
|
||||||
<pre className="bg-gray-50 p-2 rounded overflow-auto text-gray-500 leading-relaxed">{viewResult.sql}</pre>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
{viewResult && viewResult.success && (
|
||||||
|
<p className="text-xs text-green-600">View created: <span className="font-mono">{viewResult.view}</span></p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -581,6 +606,9 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated })
|
|||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [sqlDraft, setSqlDraft] = useState('')
|
||||||
|
const [sqlRunning, setSqlRunning] = useState(false)
|
||||||
|
const [sqlResult, setSqlResult] = useState(null)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const s = await api.getStacks()
|
const s = await api.getStacks()
|
||||||
@ -591,6 +619,8 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated })
|
|||||||
const s = await api.getStack(name)
|
const s = await api.getStack(name)
|
||||||
setStackDetail(s)
|
setStackDetail(s)
|
||||||
setSelected(name)
|
setSelected(name)
|
||||||
|
setSqlDraft('')
|
||||||
|
setSqlResult(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { load() }, [])
|
useEffect(() => { load() }, [])
|
||||||
@ -610,10 +640,24 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated })
|
|||||||
async function deleteStack(name) {
|
async function deleteStack(name) {
|
||||||
if (!confirm(`Delete stack "${name}"?`)) return
|
if (!confirm(`Delete stack "${name}"?`)) return
|
||||||
await api.deleteStack(name)
|
await api.deleteStack(name)
|
||||||
if (selected === name) { setSelected(null); setStackDetail(null) }
|
if (selected === name) { setSelected(null); setStackDetail(null); setSqlDraft(''); setSqlResult(null) }
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runSql() {
|
||||||
|
if (!sqlDraft.trim() || !selected) return
|
||||||
|
setSqlRunning(true); setSqlResult(null)
|
||||||
|
try {
|
||||||
|
const r = await api.execStackSql(selected, sqlDraft)
|
||||||
|
setSqlResult(r)
|
||||||
|
if (r.success) {
|
||||||
|
onStackViewGenerated?.(selected)
|
||||||
|
;(r.cascade_stale || []).forEach(n => onStackStale?.(n))
|
||||||
|
}
|
||||||
|
} catch (e) { setSqlResult({ success: false, error: e.message }) }
|
||||||
|
finally { setSqlRunning(false) }
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{/* Stack list — horizontal row of cards */}
|
{/* Stack list — horizontal row of cards */}
|
||||||
@ -643,9 +687,10 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated })
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Full-width config panel */}
|
|
||||||
{stackDetail ? (
|
{stackDetail ? (
|
||||||
<>
|
<div className="flex gap-6 items-start">
|
||||||
|
{/* Left: config panel */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
<h2 className="text-base font-semibold text-gray-800 mb-4">
|
<h2 className="text-base font-semibold text-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>}
|
||||||
@ -657,8 +702,41 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated })
|
|||||||
onUpdated={() => { load(); loadDetail(stackDetail.name) }}
|
onUpdated={() => { load(); loadDetail(stackDetail.name) }}
|
||||||
onStale={onStackStale}
|
onStale={onStackStale}
|
||||||
onViewGenerated={onStackViewGenerated}
|
onViewGenerated={onStackViewGenerated}
|
||||||
|
onSqlGenerated={sql => { setSqlDraft(prettySql(sql)); setSqlResult(null) }}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
|
|
||||||
|
{/* Right: SQL panel */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="bg-white border border-gray-200 rounded p-4 sticky top-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700">Generated SQL</h3>
|
||||||
|
<button
|
||||||
|
onClick={runSql}
|
||||||
|
disabled={!sqlDraft.trim() || sqlRunning}
|
||||||
|
className="text-sm bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700 disabled:opacity-40">
|
||||||
|
{sqlRunning ? 'Running…' : 'Run'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{sqlDraft ? (
|
||||||
|
<textarea
|
||||||
|
className="w-full font-mono text-xs text-gray-700 bg-gray-50 border border-gray-200 rounded p-2 focus:outline-none focus:border-blue-400 resize-none leading-relaxed"
|
||||||
|
style={{ minHeight: '60vh' }}
|
||||||
|
value={sqlDraft}
|
||||||
|
onChange={e => { setSqlDraft(e.target.value); setSqlResult(null) }}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-400">Generate a view to see the SQL here.</p>
|
||||||
|
)}
|
||||||
|
{sqlResult && (
|
||||||
|
<p className={`text-xs mt-2 ${sqlResult.success ? 'text-green-600' : 'text-red-500'}`}>
|
||||||
|
{sqlResult.success ? 'View updated successfully.' : sqlResult.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user