feat: visual layout editor for check field positioning
- SVG canvas showing all layout fields scaled to check dimensions - Click or dropdown to select a field; drag to reposition - Sidebar shows X/Y coordinates in decimal inches with fraction equivalents (¼", ½", ¹⁄₁₆", etc.) - End X/Y inputs appear for Line and Graph fields - Nudge buttons move selected field by ¹⁄₁₆" per click - Auto-saves on drag end; debounced save on input/nudge changes - Visible toggle hides fields from PDF without deleting them - Admin-only Reset to Default wipes and re-seeds the layout - Accessible to editor+ role via ⊞ button in account header
This commit is contained in:
@@ -88,6 +88,10 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-wide { width: min(720px, 96vw); }
|
.modal-wide { width: min(720px, 96vw); }
|
||||||
|
.modal-layout-editor { width: min(980px, 96vw); }
|
||||||
|
.layout-editor-body { flex-direction: row !important; padding: 0 !important; gap: 0; overflow: hidden; min-height: 340px; }
|
||||||
|
#layout-canvas-container { flex: 1; min-width: 0; padding: 12px; overflow: hidden; background: var(--bg); }
|
||||||
|
#layout-sidebar { width: 200px; flex-shrink: 0; padding: 12px; border-left: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; overflow-y: auto; background: var(--surface); }
|
||||||
|
|
||||||
.qbo-tabs {
|
.qbo-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -85,6 +85,7 @@
|
|||||||
<span class="header-brand" id="company-name">ezcheck</span>
|
<span class="header-brand" id="company-name">ezcheck</span>
|
||||||
<select id="account-switcher" class="account-switcher" title="Switch account"></select>
|
<select id="account-switcher" class="account-switcher" title="Switch account"></select>
|
||||||
<button id="btn-account-settings" class="btn-header-icon" title="Account settings" data-admin-only>⚙</button>
|
<button id="btn-account-settings" class="btn-header-icon" title="Account settings" data-admin-only>⚙</button>
|
||||||
|
<button id="btn-layout-editor" class="btn-header-icon" title="Edit check layout" data-editor-only>⊞</button>
|
||||||
<button id="btn-add-account" class="btn-header-icon" title="Add checking account" data-admin-only>+</button>
|
<button id="btn-add-account" class="btn-header-icon" title="Add checking account" data-admin-only>+</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
@@ -743,6 +744,75 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Layout Editor Modal -->
|
||||||
|
<div id="layout-editor-overlay" class="modal-overlay"></div>
|
||||||
|
<div id="layout-editor-modal" class="modal modal-layout-editor" role="dialog" aria-labelledby="layout-editor-title">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="layout-editor-title">Layout Editor</h2>
|
||||||
|
<button id="btn-close-layout-editor" class="btn-icon" title="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body layout-editor-body">
|
||||||
|
<div id="layout-canvas-container"><!-- SVG rendered by JS --></div>
|
||||||
|
<div id="layout-sidebar">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="layout-field-select">Field</label>
|
||||||
|
<select id="layout-field-select" style="font-size:12px"></select>
|
||||||
|
</div>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="layout-field-visible"> Visible
|
||||||
|
</label>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="layout-field-x">X Position</label>
|
||||||
|
<div style="display:flex;align-items:center;gap:6px">
|
||||||
|
<input type="number" id="layout-field-x" step="0.0625" min="0" max="8.5" style="width:72px">
|
||||||
|
<span id="layout-field-x-frac" style="font-size:11px;color:var(--text-muted)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="layout-field-y">Y Position</label>
|
||||||
|
<div style="display:flex;align-items:center;gap:6px">
|
||||||
|
<input type="number" id="layout-field-y" step="0.0625" min="0" max="3.5" style="width:72px">
|
||||||
|
<span id="layout-field-y-frac" style="font-size:11px;color:var(--text-muted)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="layout-end-pos-group" hidden>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="layout-field-x2">End X</label>
|
||||||
|
<div style="display:flex;align-items:center;gap:6px">
|
||||||
|
<input type="number" id="layout-field-x2" step="0.0625" min="0" max="8.5" style="width:72px">
|
||||||
|
<span id="layout-field-x2-frac" style="font-size:11px;color:var(--text-muted)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="layout-field-y2">End Y</label>
|
||||||
|
<div style="display:flex;align-items:center;gap:6px">
|
||||||
|
<input type="number" id="layout-field-y2" step="0.0625" min="0" max="3.5" style="width:72px">
|
||||||
|
<span id="layout-field-y2-frac" style="font-size:11px;color:var(--text-muted)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--text-muted);margin-bottom:6px">Nudge ¹⁄₁₆"</div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(3,28px);grid-template-rows:repeat(3,28px);gap:3px;justify-content:center">
|
||||||
|
<span></span>
|
||||||
|
<button id="nudge-up" class="btn-sm btn-secondary" style="padding:0;text-align:center">↑</button>
|
||||||
|
<span></span>
|
||||||
|
<button id="nudge-left" class="btn-sm btn-secondary" style="padding:0;text-align:center">←</button>
|
||||||
|
<span></span>
|
||||||
|
<button id="nudge-right" class="btn-sm btn-secondary" style="padding:0;text-align:center">→</button>
|
||||||
|
<span></span>
|
||||||
|
<button id="nudge-down" class="btn-sm btn-secondary" style="padding:0;text-align:center">↓</button>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="layout-save-status" style="font-size:11px;color:var(--text-muted);text-align:center;min-height:14px"></div>
|
||||||
|
<div style="margin-top:auto;padding-top:8px;border-top:1px solid var(--border)">
|
||||||
|
<button id="btn-layout-reset" class="btn-secondary btn-sm" style="width:100%;font-size:11px" data-admin-only>↺ Reset to Default</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/js/app.js"></script>
|
<script src="/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1813,9 +1813,328 @@ async function init() {
|
|||||||
// Add checking account
|
// Add checking account
|
||||||
document.getElementById('btn-add-account').addEventListener('click', openWizard);
|
document.getElementById('btn-add-account').addEventListener('click', openWizard);
|
||||||
|
|
||||||
|
// Layout editor
|
||||||
|
document.getElementById('btn-layout-editor').addEventListener('click', openLayoutEditor);
|
||||||
|
document.getElementById('btn-close-layout-editor').addEventListener('click', closeLayoutEditor);
|
||||||
|
document.getElementById('layout-editor-overlay').addEventListener('click', closeLayoutEditor);
|
||||||
|
document.getElementById('layout-field-select').addEventListener('change', e => selectLayoutField(parseInt(e.target.value, 10)));
|
||||||
|
document.getElementById('layout-field-x').addEventListener('input', onLayoutSidebarChange);
|
||||||
|
document.getElementById('layout-field-y').addEventListener('input', onLayoutSidebarChange);
|
||||||
|
document.getElementById('layout-field-x2').addEventListener('input', onLayoutSidebarChange);
|
||||||
|
document.getElementById('layout-field-y2').addEventListener('input', onLayoutSidebarChange);
|
||||||
|
document.getElementById('layout-field-visible').addEventListener('change', onLayoutSidebarChange);
|
||||||
|
document.getElementById('nudge-up').addEventListener('click', () => nudgeLayoutField( 0, -1));
|
||||||
|
document.getElementById('nudge-down').addEventListener('click', () => nudgeLayoutField( 0, 1));
|
||||||
|
document.getElementById('nudge-left').addEventListener('click', () => nudgeLayoutField(-1, 0));
|
||||||
|
document.getElementById('nudge-right').addEventListener('click', () => nudgeLayoutField( 1, 0));
|
||||||
|
document.getElementById('btn-layout-reset').addEventListener('click', resetLayoutToDefault);
|
||||||
|
|
||||||
// Initial auth check → loads app if already signed in
|
// Initial auth check → loads app if already signed in
|
||||||
const authed = await checkAuth();
|
const authed = await checkAuth();
|
||||||
if (authed) await loadAccounts();
|
if (authed) await loadAccounts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Layout Editor ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let layoutState = { fields: [], selectedId: null, scale: 80 };
|
||||||
|
let layoutDrag = null;
|
||||||
|
let layoutSaveTimer = null;
|
||||||
|
|
||||||
|
const FIELD_LABELS = {
|
||||||
|
'Company Name': 'Account Name (line 1)',
|
||||||
|
'Company Name2': 'Account Address (line 2)',
|
||||||
|
'Company Name3': 'Account City/State (line 3)',
|
||||||
|
'Company Name4': 'Account Phone/Web (line 4)',
|
||||||
|
'Check Number': 'Check Number',
|
||||||
|
'Date Label': 'Date Label',
|
||||||
|
'Date': 'Date',
|
||||||
|
'Pay To Label': '"Pay To" Label',
|
||||||
|
'Payee Name': 'Payee Name',
|
||||||
|
'Dollar Sign': 'Dollar Sign ($)',
|
||||||
|
'Amount': 'Amount (numeric)',
|
||||||
|
'Text Amount': 'Amount (written)',
|
||||||
|
'Dollars Label': '"Dollars" Label',
|
||||||
|
'Bank Information': 'Bank Information',
|
||||||
|
'Bank Transit Code': 'Transit Code',
|
||||||
|
'Payee Address': 'Payee Address',
|
||||||
|
'Memo Label': 'Memo Label',
|
||||||
|
'Memo': 'Memo',
|
||||||
|
'Auth Signature Label': '"Authorized Signature" Label',
|
||||||
|
'Payee Line': 'Line: Payee',
|
||||||
|
'Amount Box Top': 'Line: Amount Box (top)',
|
||||||
|
'Amount Box Left': 'Line: Amount Box (left)',
|
||||||
|
'Amount Box Bottom': 'Line: Amount Box (bottom)',
|
||||||
|
'Text Amount Line': 'Line: Written Amount',
|
||||||
|
'Memo Line': 'Line: Memo',
|
||||||
|
'Signature Line': 'Line: Signature',
|
||||||
|
};
|
||||||
|
const FIELD_COLORS = { Regular: '#2563eb', Text: '#16a34a', Line: '#b45309', Graph: '#7c3aed' };
|
||||||
|
|
||||||
|
function fieldLabel(f) { return FIELD_LABELS[f.field_name] || f.field_name; }
|
||||||
|
function round16(v) { return Math.round(v * 16) / 16; }
|
||||||
|
function clampIn(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||||||
|
|
||||||
|
const FRAC_MAP = [
|
||||||
|
[0,''], [1/16,'¹⁄₁₆'], [1/8,'⅛'], [3/16,'³⁄₁₆'],
|
||||||
|
[1/4,'¼'], [5/16,'⁵⁄₁₆'], [3/8,'⅜'], [7/16,'⁷⁄₁₆'],
|
||||||
|
[1/2,'½'], [9/16,'⁹⁄₁₆'], [5/8,'⅝'], [11/16,'¹¹⁄₁₆'],
|
||||||
|
[3/4,'¾'], [13/16,'¹³⁄₁₆'], [7/8,'⅞'], [15/16,'¹⁵⁄₁₆'],
|
||||||
|
];
|
||||||
|
function toFracStr(val) {
|
||||||
|
const w = Math.floor(val);
|
||||||
|
const dec = val - w;
|
||||||
|
const fr = FRAC_MAP.reduce((a, b) => Math.abs(b[0] - dec) < Math.abs(a[0] - dec) ? b : a);
|
||||||
|
const parts = [];
|
||||||
|
if (w) parts.push(w);
|
||||||
|
if (fr[1]) parts.push(fr[1]);
|
||||||
|
return (parts.length ? parts.join(' ') : '0') + '"';
|
||||||
|
}
|
||||||
|
function setFracEl(id, val) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = toFracStr(val || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLayoutEditor() {
|
||||||
|
if (!state.activeAccountId) return;
|
||||||
|
document.getElementById('layout-editor-overlay').classList.add('open');
|
||||||
|
document.getElementById('layout-editor-modal').classList.add('open');
|
||||||
|
loadLayoutFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLayoutEditor() {
|
||||||
|
document.getElementById('layout-editor-overlay').classList.remove('open');
|
||||||
|
document.getElementById('layout-editor-modal').classList.remove('open');
|
||||||
|
layoutState = { fields: [], selectedId: null, scale: 80 };
|
||||||
|
clearTimeout(layoutSaveTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLayoutFields() {
|
||||||
|
try {
|
||||||
|
layoutState.fields = await apiFetch('GET', `/api/layout/${state.activeAccountId}`);
|
||||||
|
populateLayoutDropdown();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
renderLayoutCanvas();
|
||||||
|
if (layoutState.fields.length > 0) selectLayoutField(layoutState.fields[0].id);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load layout fields:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateLayoutDropdown() {
|
||||||
|
const sel = document.getElementById('layout-field-select');
|
||||||
|
sel.innerHTML = layoutState.fields.map(f =>
|
||||||
|
`<option value="${f.id}">${escHtml(fieldLabel(f))}</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||||
|
function svgEl(tag, attrs, text) {
|
||||||
|
const el = document.createElementNS(SVG_NS, tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
||||||
|
if (text != null) el.textContent = text;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLayoutCanvas() {
|
||||||
|
const container = document.getElementById('layout-canvas-container');
|
||||||
|
const W = container.offsetWidth - 24;
|
||||||
|
if (W <= 0) return;
|
||||||
|
const SCALE = W / 8.5;
|
||||||
|
layoutState.scale = SCALE;
|
||||||
|
const H = 3.5 * SCALE;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
const svg = svgEl('svg', { width: W, height: H, style: 'display:block;user-select:none' });
|
||||||
|
|
||||||
|
// Check boundary and background
|
||||||
|
svg.appendChild(svgEl('rect', { x:0, y:0, width:W, height:H, fill:'#fff', stroke:'#ccc', 'stroke-width':1 }));
|
||||||
|
|
||||||
|
// MICR reference line
|
||||||
|
const micrY = (3.5 - 0.267) * SCALE;
|
||||||
|
svg.appendChild(svgEl('line', { x1:0, y1:micrY, x2:W, y2:micrY, stroke:'#ccc', 'stroke-width':1, 'stroke-dasharray':'4,4' }));
|
||||||
|
svg.appendChild(svgEl('text', { x:4, y:micrY - 3, 'font-size':8, fill:'#bbb', 'font-family':'sans-serif' }, 'MICR'));
|
||||||
|
|
||||||
|
for (const f of layoutState.fields) {
|
||||||
|
const g = createFieldSvgElement(f, SCALE, layoutState.selectedId === f.id);
|
||||||
|
svg.appendChild(g);
|
||||||
|
attachFieldEvents(g, f);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFieldSvgElement(f, scale, selected) {
|
||||||
|
const g = svgEl('g', { 'data-field-id': f.id, style: `cursor:grab;opacity:${f.visible ? 1 : 0.35}` });
|
||||||
|
const color = FIELD_COLORS[f.field_type] || '#888';
|
||||||
|
const sw = selected ? 2 : 1;
|
||||||
|
|
||||||
|
if (f.field_type === 'Line') {
|
||||||
|
const x1 = f.x_pos * scale, y1 = f.y_pos * scale;
|
||||||
|
const x2 = f.x_end_pos * scale, y2 = f.y_end_pos * scale;
|
||||||
|
g.appendChild(svgEl('line', { x1, y1, x2, y2, stroke:'transparent', 'stroke-width':10 }));
|
||||||
|
g.appendChild(svgEl('line', { x1, y1, x2, y2, stroke:color, 'stroke-width': selected ? 2.5 : 1.5 }));
|
||||||
|
if (selected) {
|
||||||
|
g.appendChild(svgEl('circle', { cx:x1, cy:y1, r:3, fill:color }));
|
||||||
|
g.appendChild(svgEl('circle', { cx:x2, cy:y2, r:3, fill:color }));
|
||||||
|
}
|
||||||
|
} else if (f.field_type === 'Graph') {
|
||||||
|
const x = f.x_pos * scale, y = f.y_pos * scale;
|
||||||
|
const w = Math.max(4, (f.x_end_pos - f.x_pos) * scale);
|
||||||
|
const h = Math.max(4, (f.y_end_pos - f.y_pos) * scale);
|
||||||
|
g.appendChild(svgEl('rect', { x, y, width:w, height:h, fill:`${color}20`, stroke:color, 'stroke-width':sw, 'stroke-dasharray':'4,3' }));
|
||||||
|
g.appendChild(svgEl('text', { x:x+2, y:y+10, 'font-size':7, fill:color, 'font-family':'sans-serif' }, f.field_name));
|
||||||
|
} else {
|
||||||
|
const x = f.x_pos * scale, y = f.y_pos * scale;
|
||||||
|
const label = fieldLabel(f);
|
||||||
|
const boxW = Math.max(24, Math.min(label.length * 5.2, 150));
|
||||||
|
const boxH = Math.max(10, (f.font_size || 10) * 0.85);
|
||||||
|
g.appendChild(svgEl('rect', { x, y: y - boxH * 0.9, width:boxW, height:boxH, fill: selected ? `${color}25` : `${color}12`, stroke:color, 'stroke-width':sw }));
|
||||||
|
const lbl = label.length > 22 ? label.slice(0, 20) + '…' : label;
|
||||||
|
g.appendChild(svgEl('text', { x:x+2, y:y-1, 'font-size':7, fill:color, 'font-family':'sans-serif', 'font-weight': selected ? 'bold' : 'normal' }, lbl));
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachFieldEvents(g, f) {
|
||||||
|
g.addEventListener('mousedown', e => {
|
||||||
|
selectLayoutField(f.id);
|
||||||
|
startLayoutDrag(e, f);
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLayoutField(id) {
|
||||||
|
layoutState.selectedId = id;
|
||||||
|
const sel = document.getElementById('layout-field-select');
|
||||||
|
if (sel) sel.value = id;
|
||||||
|
const f = layoutState.fields.find(x => x.id === id);
|
||||||
|
if (f) updateLayoutSidebar(f);
|
||||||
|
renderLayoutCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLayoutSidebar(f) {
|
||||||
|
const fmt = x => (x || 0).toFixed(4);
|
||||||
|
document.getElementById('layout-field-visible').checked = !!f.visible;
|
||||||
|
document.getElementById('layout-field-x').value = fmt(f.x_pos);
|
||||||
|
document.getElementById('layout-field-y').value = fmt(f.y_pos);
|
||||||
|
document.getElementById('layout-field-x2').value = fmt(f.x_end_pos);
|
||||||
|
document.getElementById('layout-field-y2').value = fmt(f.y_end_pos);
|
||||||
|
setFracEl('layout-field-x-frac', f.x_pos);
|
||||||
|
setFracEl('layout-field-y-frac', f.y_pos);
|
||||||
|
setFracEl('layout-field-x2-frac', f.x_end_pos);
|
||||||
|
setFracEl('layout-field-y2-frac', f.y_end_pos);
|
||||||
|
document.getElementById('layout-end-pos-group').hidden =
|
||||||
|
f.field_type !== 'Line' && f.field_type !== 'Graph';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLayoutSidebarChange() {
|
||||||
|
const f = layoutState.fields.find(x => x.id === layoutState.selectedId);
|
||||||
|
if (!f) return;
|
||||||
|
f.x_pos = clampIn(parseFloat(document.getElementById('layout-field-x').value) || 0, 0, 8.5);
|
||||||
|
f.y_pos = clampIn(parseFloat(document.getElementById('layout-field-y').value) || 0, 0, 3.5);
|
||||||
|
f.x_end_pos = clampIn(parseFloat(document.getElementById('layout-field-x2').value) || 0, 0, 8.5);
|
||||||
|
f.y_end_pos = clampIn(parseFloat(document.getElementById('layout-field-y2').value) || 0, 0, 3.5);
|
||||||
|
f.visible = document.getElementById('layout-field-visible').checked ? 1 : 0;
|
||||||
|
setFracEl('layout-field-x-frac', f.x_pos);
|
||||||
|
setFracEl('layout-field-y-frac', f.y_pos);
|
||||||
|
setFracEl('layout-field-x2-frac', f.x_end_pos);
|
||||||
|
setFracEl('layout-field-y2-frac', f.y_end_pos);
|
||||||
|
renderLayoutCanvas();
|
||||||
|
debounceLayoutSave(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLayoutDrag(e, f) {
|
||||||
|
layoutDrag = {
|
||||||
|
fieldId: f.id,
|
||||||
|
origX: f.x_pos, origY: f.y_pos,
|
||||||
|
origX2: f.x_end_pos, origY2: f.y_end_pos,
|
||||||
|
mouseX: e.clientX, mouseY: e.clientY,
|
||||||
|
moveEnd: f.field_type === 'Line' || f.field_type === 'Graph',
|
||||||
|
};
|
||||||
|
const onMove = ev => onLayoutDragMove(ev);
|
||||||
|
const onUp = ev => { onLayoutDragEnd(ev); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLayoutDragMove(e) {
|
||||||
|
if (!layoutDrag) return;
|
||||||
|
const dx = (e.clientX - layoutDrag.mouseX) / layoutState.scale;
|
||||||
|
const dy = (e.clientY - layoutDrag.mouseY) / layoutState.scale;
|
||||||
|
const f = layoutState.fields.find(x => x.id === layoutDrag.fieldId);
|
||||||
|
if (!f) return;
|
||||||
|
f.x_pos = clampIn(round16(layoutDrag.origX + dx), 0, 8.5);
|
||||||
|
f.y_pos = clampIn(round16(layoutDrag.origY + dy), 0, 3.5);
|
||||||
|
if (layoutDrag.moveEnd) {
|
||||||
|
f.x_end_pos = clampIn(round16(layoutDrag.origX2 + dx), 0, 8.5);
|
||||||
|
f.y_end_pos = clampIn(round16(layoutDrag.origY2 + dy), 0, 3.5);
|
||||||
|
}
|
||||||
|
// Update just the dragged element for smooth performance
|
||||||
|
const svg = document.querySelector('#layout-canvas-container svg');
|
||||||
|
if (svg) {
|
||||||
|
const old = svg.querySelector(`[data-field-id="${f.id}"]`);
|
||||||
|
if (old) {
|
||||||
|
const g = createFieldSvgElement(f, layoutState.scale, true);
|
||||||
|
old.replaceWith(g);
|
||||||
|
attachFieldEvents(g, f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateLayoutSidebar(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLayoutDragEnd(e) {
|
||||||
|
if (!layoutDrag) return;
|
||||||
|
const id = layoutDrag.fieldId;
|
||||||
|
layoutDrag = null;
|
||||||
|
const f = layoutState.fields.find(x => x.id === id);
|
||||||
|
if (f) await saveLayoutField(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nudgeLayoutField(dx, dy) {
|
||||||
|
const f = layoutState.fields.find(x => x.id === layoutState.selectedId);
|
||||||
|
if (!f) return;
|
||||||
|
const S = 1 / 16;
|
||||||
|
f.x_pos = clampIn(round16(f.x_pos + dx * S), 0, 8.5);
|
||||||
|
f.y_pos = clampIn(round16(f.y_pos + dy * S), 0, 3.5);
|
||||||
|
if (f.field_type === 'Line' || f.field_type === 'Graph') {
|
||||||
|
f.x_end_pos = clampIn(round16(f.x_end_pos + dx * S), 0, 8.5);
|
||||||
|
f.y_end_pos = clampIn(round16(f.y_end_pos + dy * S), 0, 3.5);
|
||||||
|
}
|
||||||
|
updateLayoutSidebar(f);
|
||||||
|
renderLayoutCanvas();
|
||||||
|
debounceLayoutSave(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
function debounceLayoutSave(f) {
|
||||||
|
clearTimeout(layoutSaveTimer);
|
||||||
|
layoutSaveTimer = setTimeout(() => saveLayoutField(f), 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLayoutField(f) {
|
||||||
|
try {
|
||||||
|
await apiFetch('PUT', `/api/layout/${state.activeAccountId}/${f.id}`, {
|
||||||
|
x_pos: f.x_pos, y_pos: f.y_pos,
|
||||||
|
x_end_pos: f.x_end_pos, y_end_pos: f.y_end_pos,
|
||||||
|
visible: f.visible,
|
||||||
|
});
|
||||||
|
const el = document.getElementById('layout-save-status');
|
||||||
|
if (el) { el.textContent = 'Saved ✓'; setTimeout(() => { if (el) el.textContent = ''; }, 1500); }
|
||||||
|
} catch (err) {
|
||||||
|
const el = document.getElementById('layout-save-status');
|
||||||
|
if (el) el.textContent = 'Save failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetLayoutToDefault() {
|
||||||
|
if (!confirm('Reset all layout fields to default positions? This cannot be undone.')) return;
|
||||||
|
try {
|
||||||
|
await apiFetch('POST', `/api/layout/${state.activeAccountId}/reset`);
|
||||||
|
await loadLayoutFields();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Reset failed: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|||||||
+36
-1
@@ -11,7 +11,7 @@ const session = require('express-session');
|
|||||||
|
|
||||||
const db = require('./db/database');
|
const db = require('./db/database');
|
||||||
const { seedLayoutFields } = require('./db/database');
|
const { seedLayoutFields } = require('./db/database');
|
||||||
const { requireAuth, requireAdmin, canAccessAccount } = require('./middleware/auth');
|
const { requireAuth, requireAdmin, canAccessAccount, isEditorForAccount } = require('./middleware/auth');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const upload = multer({ dest: os.tmpdir() });
|
const upload = multer({ dest: os.tmpdir() });
|
||||||
@@ -261,6 +261,41 @@ app.post('/api/import', requireAdmin, upload.single('mdbfile'), (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Layout editor routes ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/layout/:accountId — all layout_fields for an account
|
||||||
|
app.get('/api/layout/:accountId', requireAuth, (req, res) => {
|
||||||
|
const accountId = parseInt(req.params.accountId, 10);
|
||||||
|
if (!canAccessAccount(req.session, accountId)) return res.status(403).json({ error: 'Access denied.' });
|
||||||
|
const fields = db.prepare('SELECT * FROM layout_fields WHERE account_id = ? ORDER BY id').all(accountId);
|
||||||
|
res.json(fields);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/layout/:accountId/:fieldId — update position/visibility of one field
|
||||||
|
app.put('/api/layout/:accountId/:fieldId', requireAuth, (req, res) => {
|
||||||
|
const accountId = parseInt(req.params.accountId, 10);
|
||||||
|
const fieldId = parseInt(req.params.fieldId, 10);
|
||||||
|
if (!isEditorForAccount(req.session, accountId)) return res.status(403).json({ error: 'Write access required.' });
|
||||||
|
const { x_pos, y_pos, x_end_pos, y_end_pos, visible } = req.body;
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE layout_fields SET x_pos=?, y_pos=?, x_end_pos=?, y_end_pos=?, visible=?
|
||||||
|
WHERE id=? AND account_id=?
|
||||||
|
`).run(
|
||||||
|
parseFloat(x_pos) || 0, parseFloat(y_pos) || 0,
|
||||||
|
parseFloat(x_end_pos) || 0, parseFloat(y_end_pos) || 0,
|
||||||
|
visible ? 1 : 0, fieldId, accountId
|
||||||
|
);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/layout/:accountId/reset — wipe and re-seed default layout (admin only)
|
||||||
|
app.post('/api/layout/:accountId/reset', requireAdmin, (req, res) => {
|
||||||
|
const accountId = parseInt(req.params.accountId, 10);
|
||||||
|
db.prepare('DELETE FROM layout_fields WHERE account_id = ?').run(accountId);
|
||||||
|
seedLayoutFields(accountId);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
// Catch-all: serve index.html
|
// Catch-all: serve index.html
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||||
|
|||||||
Reference in New Issue
Block a user