Files
css-test/data/templates/config_editor.html
T
paul 35d70a7746 feat: add ReconConfig API and UI for managing configurations
- Added a new API endpoint for managing ReconConfigs at /api/configs, including GET, POST, PUT, and a test URL feature.
- Implemented a new configuration editor UI at /configs for creating and editing ReconConfigs.
- Introduced a new configs list page at /configs to display existing configurations with options to edit.
- Updated base HTML template to include a link to the new configs page.
- Created stub configuration and authentication models for workspace development.
- Added a stub configuration module to handle database configuration without a real database.
2026-05-26 21:58:04 +12:00

722 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block content %}
<div class="dashboard-container dashboard-stack">
<div class="detail-header">
<div class="detail-crumbs">
<a href="/">Dashboard</a>
<a href="/configs">Configs</a>
<span class="muted">{{ title }}</span>
</div>
<h1>{{ title }}</h1>
</div>
{# ── Toast notification ───────────────────────────────── #}
<div id="toast" class="editor-toast" role="status" aria-live="polite"></div>
<form id="config-form" novalidate>
{# ── Top meta strip ───────────────────────────────────── #}
<div class="editor-meta-strip">
<div class="editor-field editor-field-wide">
<label for="f-reference">Reference <span class="req">*</span></label>
<input id="f-reference" name="reference" type="text" placeholder="e.g. fx-settlement"
{% if not is_new %}readonly{% endif %}
value="{{ reference if not is_new else '' }}">
<span class="field-hint">Unique identifier — lowercase, hyphen-separated.</span>
</div>
<div class="editor-field editor-field-wide">
<label for="f-name">Display Name <span class="req">*</span></label>
<input id="f-name" name="name" type="text" placeholder="e.g. FX Settlement Recon">
</div>
<div class="editor-field">
<label for="f-business-process">Business Process</label>
<input id="f-business-process" name="business_process" type="text" placeholder="e.g. Treasury">
</div>
<div class="editor-field">
<label for="f-data-type">Data Type</label>
<input id="f-data-type" name="data_type" type="text" placeholder="e.g. Transaction">
</div>
<div class="editor-field">
<label for="f-pattern">Pattern <span class="req">*</span></label>
<select id="f-pattern" name="pattern">
{% for p in patterns %}
<option value="{{ p }}">{{ p | replace('_', ' ') | title }}</option>
{% endfor %}
</select>
</div>
<div class="editor-field">
<label for="f-status">Status <span class="req">*</span></label>
<select id="f-status" name="status">
{% for s in statuses %}
<option value="{{ s }}">{{ s | title }}</option>
{% endfor %}
</select>
<span class="field-hint">Draft results go to staging tables; Published go to live dashboards.</span>
</div>
<div class="editor-field">
<label for="f-frequency">Frequency</label>
<select id="f-frequency" name="frequency">
{% for f in frequencies %}
<option value="{{ f }}">{{ f }}</option>
{% endfor %}
</select>
</div>
<div class="editor-field">
<label for="f-start-datetime">Schedule Start</label>
<input id="f-start-datetime" name="start_datetime" type="datetime-local">
</div>
<div class="editor-field editor-field-full">
<label for="f-comment">Notes</label>
<textarea id="f-comment" name="comment" rows="2" placeholder="Optional — visible to your team."></textarea>
</div>
</div>{# /meta-strip #}
{# ── Two-column editor body ────────────────────────────── #}
<div class="editor-body">
{# ── Sources column ────────────────────────────────── #}
<section class="editor-section">
<div class="editor-section-header">
<h2>Sources</h2>
<button type="button" class="btn btn-secondary btn-sm" id="btn-add-source">+ Add Source</button>
</div>
<div id="sources-container"></div>
</section>
{# ── Destination column ────────────────────────────── #}
<section class="editor-section">
<div class="editor-section-header">
<h2>Destination</h2>
</div>
<div id="destination-container"></div>
</section>
</div>{# /editor-body #}
{# ── Field mapping ─────────────────────────────────────── #}
<section class="editor-full-section">
<div class="editor-section-header">
<h2>Field Mapping</h2>
<button type="button" class="btn btn-secondary btn-sm" id="btn-add-mapping">+ Add Mapping</button>
</div>
<p class="field-hint" style="margin-bottom:10px">Map source column names to destination column names when they differ. Leave empty if all column names already match.</p>
<div id="field-mapping-container">
<div class="mapping-row mapping-header">
<span>Source Column</span>
<span></span>
<span>Destination Column</span>
<span></span>
</div>
</div>
</section>
{# ── Action bar ─────────────────────────────────────────── #}
<div class="editor-actions">
<a href="/configs" class="btn btn-secondary">Cancel</a>
<button type="button" class="btn btn-secondary" id="btn-preview-json">Preview JSON</button>
<button type="submit" class="btn btn-primary" id="btn-submit">
{{ 'Create Config' if is_new else 'Save Changes' }}
</button>
</div>
</form>
{# ── JSON preview panel (hidden by default) ───────────── #}
<div id="json-preview-panel" class="editor-json-panel" hidden>
<div class="editor-section-header">
<h2>JSON Preview</h2>
<button type="button" class="btn btn-secondary btn-sm" id="btn-close-json">Close</button>
</div>
<pre id="json-preview-content" class="editor-json-pre"></pre>
</div>
</div>
{# ── System Config Card Template ──────────────────────────────────────────── #}
<template id="tpl-syscfg">
<div class="syscfg-card" data-syscfg-index="">
<div class="syscfg-card-header">
<span class="syscfg-card-title">System</span>
<div class="syscfg-card-actions">
<button type="button" class="btn btn-secondary btn-sm btn-remove-syscfg">Remove</button>
</div>
</div>
<div class="syscfg-grid">
<div class="editor-field editor-field-wide">
<label>Name</label>
<input type="text" data-field="name" placeholder="e.g. Core Banking">
</div>
<div class="editor-field">
<label>Day Offset</label>
<input type="number" data-field="day_offset" value="0" min="-365" max="365">
<span class="field-hint">Days ± relative to the job as-at date.</span>
</div>
<div class="editor-field editor-field-wide">
<label>Filter</label>
<input type="text" data-field="filter" placeholder="e.g. status = 'ACTIVE'">
<span class="field-hint">Appended to WHERE clause (SQL) or used as a pandas .query() expression (file).</span>
</div>
<div class="editor-field editor-field-full">
<label>URL <span class="req">*</span></label>
<div class="url-input-row">
<input type="text" data-field="url" placeholder="file:// or mssql:// or oracle:// ..." class="url-input">
<button type="button" class="btn btn-secondary btn-sm btn-test-url">Try URL</button>
</div>
<span class="field-hint">
Supports: <code>file://</code> <code>mssql://</code> <code>oracle://</code> <code>snowflake://</code> <code>duckdb://</code><br>
Date templates: {% raw %}<code>{{as_at_date.strftime('%Y%m%d')}}</code> <code>{{today.strftime('%Y%m%d')}}</code>{% endraw %}
</span>
<div class="url-test-result" hidden></div>
</div>
<div class="editor-field editor-field-full">
<label>SQL Override</label>
<textarea data-field="sql" rows="2" placeholder="Full SQL query — replaces auto-generated SELECT. Required for duckdb://."></textarea>
</div>
<div class="editor-field editor-field-wide">
<label>Comment</label>
<input type="text" data-field="comment" placeholder="Optional note about this system.">
</div>
</div>{# /syscfg-grid #}
{# Schema builder #}
<details class="syscfg-section">
<summary>Schema <span class="syscfg-section-count schema-count">0 columns</span></summary>
<div class="schema-builder">
<div class="kv-table-header">
<span>Column Name</span><span>Type</span><span></span>
</div>
<div class="schema-rows"></div>
<button type="button" class="btn btn-secondary btn-sm btn-add-schema-row">+ Add Column</button>
</div>
</details>
{# CSV Spec #}
<details class="syscfg-section syscfg-csv-spec" hidden>
<summary>CSV Options</summary>
<div class="syscfg-grid">
<div class="editor-field">
<label>Delimiter</label>
<input type="text" data-spec="csv" data-field="delimiter" value="," maxlength="3" style="max-width:60px">
</div>
<div class="editor-field">
<label>Encoding</label>
<input type="text" data-spec="csv" data-field="encoding" value="utf_8" style="max-width:120px">
</div>
<div class="editor-field">
<label>Trailer Rows</label>
<input type="number" data-spec="csv" data-field="trailer_rows" value="0" min="0" style="max-width:80px">
</div>
<div class="editor-field">
<label>Has Header</label>
<label class="toggle-label">
<input type="checkbox" data-spec="csv" data-field="header" checked>
<span class="toggle-track"></span>
</label>
</div>
<div class="editor-field">
<label>Quoting</label>
<label class="toggle-label">
<input type="checkbox" data-spec="csv" data-field="quoting" checked>
<span class="toggle-track"></span>
</label>
</div>
</div>
</details>
{# XML Spec #}
<details class="syscfg-section syscfg-xml-spec" hidden>
<summary>XML Options</summary>
<div class="syscfg-grid">
<div class="editor-field editor-field-wide">
<label>XPath</label>
<input type="text" data-spec="xml" data-field="xpathstr" value="./*">
</div>
<div class="editor-field">
<label>Encoding</label>
<input type="text" data-spec="xml" data-field="encoding" value="utf-8">
</div>
</div>
</details>
{# Advanced fields #}
<details class="syscfg-section">
<summary>Advanced</summary>
<div class="syscfg-grid">
<div class="editor-field editor-field-full">
<label>Index Fields</label>
<input type="text" data-field="index_fields" placeholder="Comma-separated field names used as join keys.">
</div>
<div class="editor-field editor-field-full">
<label>Obfuscate Fields</label>
<input type="text" data-field="obfuscate_fields" placeholder="Comma-separated — values shown as *** in reports.">
</div>
<div class="editor-field editor-field-full">
<label>PCI Redact Fields</label>
<input type="text" data-field="pci_redact_fields" placeholder="Comma-separated — completely stripped from output.">
</div>
<div class="editor-field editor-field-full">
<label>Fixed Width Column Widths</label>
<input type="text" data-field="field_widths" placeholder="Comma-separated integers, e.g. 10,20,5">
<span class="field-hint">Only needed for fixed-width files (not delimited).</span>
</div>
</div>
</details>
</div>{# /syscfg-card #}
</template>
<script>
// ── Schema type options ─────────────────────────────────────────────────────
const SCHEMA_TYPES = {{ schema_types | tojson }};
const IS_NEW = {{ 'true' if is_new else 'false' }};
const CONFIG_REF = {{ (reference | tojson) if not is_new else 'null' }};
// ── Build a syscfg card from a data object ──────────────────────────────────
function buildSyscfgCard(data, role, index) {
const tpl = document.getElementById('tpl-syscfg');
const card = tpl.content.cloneNode(true).querySelector('.syscfg-card');
card.dataset.syscfgIndex = index;
card.dataset.role = role; // 'source' | 'destination'
const titleEl = card.querySelector('.syscfg-card-title');
titleEl.textContent = role === 'destination' ? 'Destination' : `Source ${index + 1}`;
// Remove button (hide for destination)
const removeBtn = card.querySelector('.btn-remove-syscfg');
if (role === 'destination') {
removeBtn.remove();
} else {
removeBtn.addEventListener('click', () => {
card.remove();
reindexSources();
});
}
// Populate simple fields
const fields = ['name','url','day_offset','filter','sql','comment'];
fields.forEach(f => {
const el = card.querySelector(`[data-field="${f}"]`);
if (!el) return;
const val = data[f];
if (val !== undefined && val !== null) el.value = val;
});
// Populate list fields (comma-joined)
['index_fields','obfuscate_fields','pci_redact_fields'].forEach(f => {
const el = card.querySelector(`[data-field="${f}"]`);
if (el && Array.isArray(data[f])) el.value = data[f].join(', ');
});
const fwEl = card.querySelector('[data-field="field_widths"]');
if (fwEl && Array.isArray(data.field_widths)) fwEl.value = data.field_widths.join(', ');
// Schema
const schemaData = data.system_schema || {};
Object.entries(schemaData).forEach(([col, type]) => addSchemaRow(card, col, type));
updateSchemaCount(card);
// CSV spec
if (data.csv_spec) {
card.querySelector('.syscfg-csv-spec').removeAttribute('hidden');
const cs = data.csv_spec;
setSpecField(card, 'csv', 'delimiter', cs.delimiter ?? ',');
setSpecField(card, 'csv', 'encoding', cs.encoding ?? 'utf_8');
setSpecField(card, 'csv', 'trailer_rows', cs.trailer_rows ?? 0);
setSpecCheckbox(card, 'csv', 'header', cs.header !== false);
setSpecCheckbox(card, 'csv', 'quoting', cs.quoting !== false);
}
// XML spec
if (data.xml_spec) {
card.querySelector('.syscfg-xml-spec').removeAttribute('hidden');
setSpecField(card, 'xml', 'xpathstr', data.xml_spec.xpathstr ?? './*');
setSpecField(card, 'xml', 'encoding', data.xml_spec.encoding ?? 'utf-8');
}
// URL change → show/hide specs, clear test result
const urlInput = card.querySelector('.url-input');
urlInput.addEventListener('input', () => {
syncSpecVisibility(card);
card.querySelector('.url-test-result').setAttribute('hidden', '');
});
syncSpecVisibility(card);
// Schema add button
card.querySelector('.btn-add-schema-row').addEventListener('click', () => {
addSchemaRow(card);
updateSchemaCount(card);
});
// Try URL button
card.querySelector('.btn-test-url').addEventListener('click', () => testUrl(card));
return card;
}
function setSpecField(card, spec, field, value) {
const el = card.querySelector(`[data-spec="${spec}"][data-field="${field}"]`);
if (el) el.value = value;
}
function setSpecCheckbox(card, spec, field, checked) {
const el = card.querySelector(`[data-spec="${spec}"][data-field="${field}"]`);
if (el) el.checked = checked;
}
function syncSpecVisibility(card) {
const url = (card.querySelector('.url-input').value || '').toLowerCase();
const isFile = url.startsWith('file://');
const isXml = isFile && /\.xml(\b|$|\?|#)/.test(url);
const isCsv = isFile && !isXml;
card.querySelector('.syscfg-csv-spec').toggleAttribute('hidden', !isCsv);
card.querySelector('.syscfg-xml-spec').toggleAttribute('hidden', !isXml);
}
function addSchemaRow(card, colName, colType) {
const container = card.querySelector('.schema-rows');
const row = document.createElement('div');
row.className = 'schema-row';
const colInput = document.createElement('input');
colInput.type = 'text';
colInput.placeholder = 'column_name';
colInput.value = colName || '';
colInput.addEventListener('input', () => updateSchemaCount(card));
const typeSelect = document.createElement('select');
SCHEMA_TYPES.forEach(t => {
const opt = document.createElement('option');
opt.value = t; opt.textContent = t;
if (t === (colType || 'str')) opt.selected = true;
typeSelect.appendChild(opt);
});
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-secondary btn-sm';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => { row.remove(); updateSchemaCount(card); });
row.append(colInput, typeSelect, removeBtn);
container.appendChild(row);
}
function updateSchemaCount(card) {
const rows = card.querySelectorAll('.schema-rows .schema-row');
const el = card.querySelector('.schema-count');
el.textContent = `${rows.length} column${rows.length !== 1 ? 's' : ''}`;
}
// ── Re-index source card titles after removal ───────────────────────────────
function reindexSources() {
document.querySelectorAll('#sources-container .syscfg-card').forEach((card, i) => {
card.querySelector('.syscfg-card-title').textContent = `Source ${i + 1}`;
card.dataset.syscfgIndex = i;
});
}
// ── Read a syscfg card into a data object ───────────────────────────────────
function readSyscfgCard(card) {
const g = (field) => {
const el = card.querySelector(`[data-field="${field}"]`);
return el ? (el.tagName === 'TEXTAREA' ? el.value : el.value) : '';
};
const toList = (val) => val.split(',').map(s => s.trim()).filter(Boolean);
const toIntList = (val) => val.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
const url = g('url');
const urlLower = url.toLowerCase();
const isFile = urlLower.startsWith('file://');
const isXml = isFile && /\.xml(\b|$)/.test(urlLower);
const schema = {};
card.querySelectorAll('.schema-rows .schema-row').forEach(row => {
const name = row.querySelector('input').value.trim();
const type = row.querySelector('select').value;
if (name) schema[name] = type;
});
const spec = (specName, fields) => {
const obj = {};
let hasSpec = false;
fields.forEach(({ field, isCheck }) => {
const el = card.querySelector(`[data-spec="${specName}"][data-field="${field}"]`);
if (!el) return;
hasSpec = true;
obj[field] = isCheck ? el.checked : (el.type === 'number' ? Number(el.value) : el.value);
});
return hasSpec ? obj : null;
};
const csvSpec = isFile && !isXml ? spec('csv', [
{ field: 'delimiter' },
{ field: 'encoding' },
{ field: 'trailer_rows', isCheck: false },
{ field: 'header', isCheck: true },
{ field: 'quoting', isCheck: true },
]) : null;
const xmlSpec = isXml ? spec('xml', [{ field: 'xpathstr' }, { field: 'encoding' }]) : null;
return {
name: g('name'),
url,
comment: g('comment'),
day_offset: parseInt(g('day_offset') || '0', 10),
system_schema: schema,
filter: g('filter'),
sql: g('sql'),
index_fields: toList(g('index_fields')),
obfuscate_fields: toList(g('obfuscate_fields')),
pci_redact_fields: toList(g('pci_redact_fields')),
field_widths: toIntList(g('field_widths')),
profile_thresholds: [],
csv_spec: csvSpec,
xml_spec: xmlSpec,
};
}
// ── Field mapping ───────────────────────────────────────────────────────────
function addMappingRow(srcVal, dstVal) {
const container = document.getElementById('field-mapping-container');
const row = document.createElement('div');
row.className = 'mapping-row';
const srcInput = document.createElement('input');
srcInput.type = 'text'; srcInput.placeholder = 'source_column'; srcInput.value = srcVal || '';
const arrow = document.createElement('span');
arrow.className = 'mapping-arrow'; arrow.textContent = '→';
const dstInput = document.createElement('input');
dstInput.type = 'text'; dstInput.placeholder = 'destination_column'; dstInput.value = dstVal || '';
const removeBtn = document.createElement('button');
removeBtn.type = 'button'; removeBtn.className = 'btn btn-secondary btn-sm';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => row.remove());
row.append(srcInput, arrow, dstInput, removeBtn);
container.appendChild(row);
}
function readFieldMapping() {
const mapping = {};
document.querySelectorAll('#field-mapping-container .mapping-row').forEach(row => {
const inputs = row.querySelectorAll('input');
const src = inputs[0]?.value.trim();
const dst = inputs[1]?.value.trim();
if (src && dst) mapping[src] = dst;
});
return mapping;
}
// ── Collect full form payload ───────────────────────────────────────────────
function collectPayload() {
const g = (id) => document.getElementById(id)?.value || '';
const sources = Array.from(
document.querySelectorAll('#sources-container .syscfg-card')
).map(card => readSyscfgCard(card));
const destCards = document.querySelectorAll('#destination-container .syscfg-card');
const destination = destCards.length > 0 ? readSyscfgCard(destCards[0]) : {};
return {
reference: g('f-reference'),
name: g('f-name'),
business_process: g('f-business-process'),
data_type: g('f-data-type'),
comment: g('f-comment'),
pattern: g('f-pattern'),
status: g('f-status'),
frequency: g('f-frequency'),
start_datetime: g('f-start-datetime') || null,
sources,
destination,
field_mapping: readFieldMapping(),
};
}
// ── Try URL ─────────────────────────────────────────────────────────────────
async function testUrl(card) {
const url = card.querySelector('.url-input').value.trim();
const resultEl = card.querySelector('.url-test-result');
if (!url) { showToast('Enter a URL first.', 'warn'); return; }
const btn = card.querySelector('.btn-test-url');
btn.disabled = true; btn.textContent = 'Checking…';
resultEl.removeAttribute('hidden');
resultEl.className = 'url-test-result url-test-loading';
resultEl.textContent = 'Checking URL…';
try {
const resp = await fetch('/api/configs/test-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
const data = await resp.json();
renderUrlTestResult(resultEl, data);
} catch (err) {
resultEl.className = 'url-test-result url-test-error';
resultEl.textContent = `Error: ${err.message}`;
} finally {
btn.disabled = false; btn.textContent = 'Try URL';
}
}
function renderUrlTestResult(el, data) {
if (data.type === 'non-file') {
el.className = 'url-test-result url-test-info';
el.textContent = data.message;
return;
}
if (!data.found) {
el.className = 'url-test-result url-test-error';
el.textContent = `Not found: ${data.message || data.resolved}`;
return;
}
el.className = 'url-test-result url-test-ok';
const lines = [`${data.matches} file${data.matches !== 1 ? 's' : ''} matched`];
(data.files || []).forEach(f => {
if (f.error) { lines.push(`${f.path}: ${f.error}`); return; }
const kb = (f.size_bytes / 1024).toFixed(1);
lines.push(` ${f.path.split('/').pop()} ${kb} KB (modified ${f.modified})`);
});
el.textContent = lines.join('\n');
}
// ── Toast ───────────────────────────────────────────────────────────────────
function showToast(msg, type = 'ok') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = `editor-toast editor-toast-${type} editor-toast-visible`;
clearTimeout(el._timer);
el._timer = setTimeout(() => el.classList.remove('editor-toast-visible'), 3500);
}
// ── Form submit ─────────────────────────────────────────────────────────────
document.getElementById('config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const payload = collectPayload();
if (!payload.reference) { showToast('Reference is required.', 'error'); return; }
if (!payload.name) { showToast('Display name is required.', 'error'); return; }
if (payload.sources.length === 0) { showToast('At least one source is required.', 'error'); return; }
const btn = document.getElementById('btn-submit');
btn.disabled = true;
const url = IS_NEW ? '/api/configs/' : `/api/configs/${CONFIG_REF}`;
const method = IS_NEW ? 'POST' : 'PUT';
try {
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
showToast(IS_NEW ? 'Config created!' : 'Config saved!', 'ok');
setTimeout(() => { window.location.href = '/configs'; }, 1200);
} else {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
showToast(`Error ${resp.status}: ${err.detail || JSON.stringify(err)}`, 'error');
}
} catch (err) {
showToast(`Network error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
}
});
// ── JSON preview ────────────────────────────────────────────────────────────
document.getElementById('btn-preview-json').addEventListener('click', () => {
const panel = document.getElementById('json-preview-panel');
document.getElementById('json-preview-content').textContent =
JSON.stringify(collectPayload(), null, 2);
panel.removeAttribute('hidden');
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
document.getElementById('btn-close-json').addEventListener('click', () => {
document.getElementById('json-preview-panel').setAttribute('hidden', '');
});
// ── Add source ──────────────────────────────────────────────────────────────
document.getElementById('btn-add-source').addEventListener('click', () => {
const container = document.getElementById('sources-container');
const idx = container.querySelectorAll('.syscfg-card').length;
container.appendChild(buildSyscfgCard({}, 'source', idx));
});
// ── Add mapping row ─────────────────────────────────────────────────────────
document.getElementById('btn-add-mapping').addEventListener('click', () => addMappingRow());
// ── Initialise from server data or blank ────────────────────────────────────
(async function init() {
let data = null;
if (!IS_NEW && CONFIG_REF) {
try {
const resp = await fetch(`/api/configs/${CONFIG_REF}`);
if (resp.ok) data = await resp.json();
} catch (_) {}
}
if (data) {
// Populate top meta fields
const setField = (id, val) => {
const el = document.getElementById(id);
if (!el || val === null || val === undefined) return;
el.value = val;
};
setField('f-reference', data.reference);
setField('f-name', data.name);
setField('f-business-process', data.business_process);
setField('f-data-type', data.data_type);
setField('f-comment', data.comment || '');
setField('f-pattern', data.pattern);
setField('f-status', data.status);
setField('f-frequency', data.frequency);
if (data.start_datetime) {
// datetime-local needs "YYYY-MM-DDTHH:MM"
setField('f-start-datetime', data.start_datetime.slice(0, 16));
}
// Sources
const srcContainer = document.getElementById('sources-container');
(data.sources || []).forEach((s, i) => {
srcContainer.appendChild(buildSyscfgCard(s, 'source', i));
});
// Destination
const dstContainer = document.getElementById('destination-container');
if (data.destination) {
dstContainer.appendChild(buildSyscfgCard(data.destination, 'destination', 0));
}
// Field mapping
Object.entries(data.field_mapping || {}).forEach(([src, dst]) => addMappingRow(src, dst));
} else {
// Blank form: one default source + destination
document.getElementById('sources-container')
.appendChild(buildSyscfgCard({}, 'source', 0));
document.getElementById('destination-container')
.appendChild(buildSyscfgCard({}, 'destination', 0));
}
})();
</script>
{% endblock %}