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:
2026-04-01 15:01:30 -06:00
parent 8a944d1d20
commit d70081159d
4 changed files with 429 additions and 1 deletions
+36 -1
View File
@@ -11,7 +11,7 @@ const session = require('express-session');
const db = 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 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
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../public/index.html'));