expression field added in document editor. a few plans in plans folder

master
kikimor 1 week ago
parent 74663229d8
commit ac6b1f48b4

@ -0,0 +1,52 @@
# ScalesApp React Expert Memory
## Key File Paths
- Report Editor root: `frontend/src/components/ReportEditor/`
- Element models: `models/Element.jsx` — OOP class hierarchy, `fromJSON` has `elementClasses` map
- Element CSS: `elements.css` — one block per element type
- Inspector CSS: `ObjectInspector.css`
- Data resolver utils: `utils/dataResolver.js``resolveDBTextValue`, `getAvailableObjects`, `getAvailableFields`
- Expression evaluator: `utils/expressionEvaluator.js` — recursive-descent, no eval
## Element Type Pattern
Adding a new element type requires touching 8 files:
1. `models/Element.jsx` — new class + register in `elementClasses` map in `fromJSON`
2. `NewElement.jsx` — component file, modeled on `DBTextField.jsx`
3. `ExpressionEditorPanel.jsx` (or equivalent inspector panel)
4. `EditorCanvas.jsx` — import + `renderElement` case + click handler in `handleCanvasClick`
5. `Toolbar.jsx` — tool button
6. `ObjectInspector.jsx` — import panel + render for `element.type === 'xxx'`
7. `elements.css` — CSS block for new class
8. `ResizeHandles.jsx` — add type to `getHandles` switch, `getHandlePosition` branch, and resize logic branch
## ObjectInspector Band-Detection Pattern
When a panel needs to detect if an element is inside a band (for field resolution):
```js
const parentBand = allElements.find(el => el.type === 'band' && el.children && el.children.includes(element.id));
// Nested band handling:
if (parentBand.parentBandId) {
const grandparentBand = allElements.find(el => el.id === parentBand.parentBandId);
// grandparentData[0][parentBand.dataSource] → nestedData array
}
```
## DBTextField / ExpressionField Props Pattern
Both receive: `element, isSelected, isSingleSelection, onSelect, onUpdate, onDelete, onDragStart, onDrag, charWidth, charHeight, previewMode, toolMode, parentBandId`
Uses hooks: `useElementDrag`, `useElementSelection`, `useReportData`, `useBandContext`
## resolveDBTextValue signature
```js
resolveDBTextValue(reportData, objectKey, fieldPath, parentBandId, { currentBandData })
```
- objectKey = `''` for band-relative field access
## ExpressionField Field Ref Format
`[objectKey.fieldPath]` for top-level fields, `[fieldPath]` for band-relative fields.
Strip brackets, split on first `.` to get objectKey and fieldPath.

Binary file not shown.

@ -1,6 +1,7 @@
import React, { useRef, useEffect, useMemo } from 'react'; import React, { useRef, useEffect, useMemo } from 'react';
import TextField from './TextField'; import TextField from './TextField';
import DBTextField from './DBTextField'; import DBTextField from './DBTextField';
import ExpressionField from './ExpressionField';
import FrameElement from './FrameElement'; import FrameElement from './FrameElement';
import HorizontalLine from './HorizontalLine'; import HorizontalLine from './HorizontalLine';
import VerticalLine from './VerticalLine'; import VerticalLine from './VerticalLine';
@ -123,6 +124,19 @@ function EditorCanvas({
fieldPath: '' fieldPath: ''
}; };
onAddElement(newElement); onAddElement(newElement);
} else if (toolMode === 'addExpr') {
const newElement = {
id: `expr-${Date.now()}`,
type: 'expr',
x: col,
y: row,
width: 10,
height: 1,
expression: '',
dataType: 'general',
alignment: 'left'
};
onAddElement(newElement);
} else if (toolMode === 'addFrame') { } else if (toolMode === 'addFrame') {
if (!frameStart) { if (!frameStart) {
// First click - set start position // First click - set start position
@ -451,6 +465,14 @@ function EditorCanvas({
parentBandId={parentBandId} parentBandId={parentBandId}
/> />
); );
} else if (element.type === 'expr') {
return (
<ExpressionField
{...commonProps}
previewMode={previewMode}
parentBandId={parentBandId}
/>
);
} else if (element.type === 'frame') { } else if (element.type === 'frame') {
return <FrameElement {...commonProps} />; return <FrameElement {...commonProps} />;
} else if (element.type === 'hline') { } else if (element.type === 'hline') {

@ -0,0 +1,116 @@
import React, { useState, useRef } from 'react';
import { getAvailableFields, getAvailableObjects } from './utils/dataResolver';
function ExpressionEditorPanel({ element, onUpdate, reportData, allElements }) {
const [selectedField, setSelectedField] = useState('');
const textareaRef = useRef(null);
const parentBand = allElements.find(el => el.type === 'band' && el.children && el.children.includes(element.id));
let availableFields = [];
if (parentBand && parentBand.dataSource) {
let bandDataArray;
if (parentBand.parentBandId) {
const grandparentBand = allElements.find(el => el.id === parentBand.parentBandId);
if (grandparentBand && grandparentBand.dataSource) {
const grandparentData = reportData?.[grandparentBand.dataSource];
if (Array.isArray(grandparentData) && grandparentData.length > 0) {
const nestedData = grandparentData[0][parentBand.dataSource];
if (Array.isArray(nestedData) && nestedData.length > 0) {
bandDataArray = nestedData;
}
}
}
} else {
bandDataArray = reportData?.[parentBand.dataSource];
}
if (Array.isArray(bandDataArray) && bandDataArray.length > 0) {
availableFields = getAvailableFields(bandDataArray[0]);
}
} else {
const objectKeys = getAvailableObjects(reportData);
objectKeys.forEach(objectKey => {
const fields = getAvailableFields(reportData[objectKey]);
fields.forEach(f => availableFields.push(`${objectKey}.${f}`));
});
}
const insertAtCursor = (text) => {
const ta = textareaRef.current;
if (!ta) { onUpdate({ expression: (element.expression || '') + text }); return; }
const start = ta.selectionStart;
const end = ta.selectionEnd;
const current = element.expression || '';
const next = current.slice(0, start) + text + current.slice(end);
onUpdate({ expression: next });
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = start + text.length;
ta.focus();
});
};
return (
<div className="inspector-section">
<div className="section-title">Expression</div>
<textarea
ref={textareaRef}
value={element.expression || ''}
onChange={(e) => onUpdate({ expression: e.target.value })}
rows={3}
style={{ width: '100%', fontFamily: 'monospace', fontSize: '11px', resize: 'vertical', boxSizing: 'border-box' }}
/>
<select value={selectedField} onChange={e => setSelectedField(e.target.value)} style={{ width: '100%', marginBottom: '3px', boxSizing: 'border-box' }}>
<option value="">-- field --</option>
{availableFields.map(f => <option key={f} value={f}>{f}</option>)}
</select>
<div className="expr-btn-row">
<button onClick={() => { if (selectedField) insertAtCursor(`[${selectedField}]`); }}>Insert Field</button>
</div>
<div className="expr-btn-row">
{['+', '-', '*', '/'].map(op => <button key={op} onClick={() => insertAtCursor(` ${op} `)}>{op}</button>)}
<button onClick={() => insertAtCursor('(')}>(</button>
<button onClick={() => insertAtCursor(')')}>)</button>
</div>
<div className="expr-btn-row">
<button onClick={() => insertAtCursor('IF( , , )')}>IF( , , )</button>
</div>
<div className="expr-btn-row">
{['=', '!=', '>', '<', '>=', '<='].map(op => <button key={op} onClick={() => insertAtCursor(` ${op} `)}>{op}</button>)}
</div>
<div className="expr-btn-row">
<button onClick={() => onUpdate({ expression: '' })}>Clear</button>
</div>
<div className="property-row">
<label>Data Type:</label>
<select value={element.dataType || 'general'} onChange={e => onUpdate({ dataType: e.target.value })}>
<option value="general">General</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="time">Time</option>
<option value="datetime">Date &amp; Time</option>
</select>
</div>
<div className="property-row">
<label>Alignment:</label>
<select value={element.alignment || 'left'} onChange={e => onUpdate({ alignment: e.target.value })}>
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
</select>
</div>
</div>
);
}
export default ExpressionEditorPanel;

@ -0,0 +1,130 @@
import React from 'react';
import { useReportData } from './DataContext';
import { useBandContext } from './BandContext';
import { resolveDBTextValue } from './utils/dataResolver';
import { evaluateExpression } from './utils/expressionEvaluator';
import ResizeHandles from './ResizeHandles';
import { useElementDrag } from './hooks/useElementDrag';
import { useElementSelection } from './hooks/useElementSelection';
import { getElementStyle, isTextMultiLine, getTextWhiteSpace } from './utils/elementUtils';
import './elements.css';
function formatValue(raw, dataType) {
if (raw === '' || raw === null || raw === undefined) return '';
switch (dataType) {
case 'number': {
const n = Number(raw);
return isNaN(n) ? '' : String(n);
}
case 'date': {
const d = new Date(raw);
return isNaN(d.getTime()) ? '' : d.toLocaleDateString();
}
case 'time': {
const d = new Date(raw);
return isNaN(d.getTime()) ? '' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
case 'datetime': {
const d = new Date(raw);
return isNaN(d.getTime()) ? '' : d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
default:
return String(raw);
}
}
function ExpressionField({
element,
isSelected,
isSingleSelection,
onSelect,
onUpdate,
onDelete,
onDragStart,
onDrag,
charWidth,
charHeight,
previewMode = false,
toolMode,
parentBandId = null
}) {
const { reportData } = useReportData();
const { currentBandData } = useBandContext();
const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
isSelected,
onDragStart,
onDrag,
toolMode
});
const { handleClick } = useElementSelection({
isSelected,
onSelect,
onDelete,
toolMode
});
const handleMouseDown = (e) => {
onSelect();
handleDragMouseDown(e, element.id);
};
const handleResize = (updates) => {
onUpdate(updates);
};
let displayContent;
if (previewMode) {
const resolveField = (ref) => {
const inner = ref.slice(1, ref.length - 1);
const dotIndex = inner.indexOf('.');
if (dotIndex === -1) {
return resolveDBTextValue(reportData, '', inner, parentBandId, { currentBandData });
}
const objectKey = inner.slice(0, dotIndex);
const fieldPath = inner.slice(dotIndex + 1);
return resolveDBTextValue(reportData, objectKey, fieldPath, parentBandId, { currentBandData });
};
const raw = evaluateExpression(element.expression, resolveField);
displayContent = formatValue(raw, element.dataType || 'general');
} else {
displayContent = element.expression || '(expr)';
}
const isUnresolved = !element.expression;
const multiLine = isTextMultiLine(element);
const whiteSpace = getTextWhiteSpace(element);
const style = {
...getElementStyle(element, charWidth, charHeight),
minWidth: `${charWidth}px`,
minHeight: `${charHeight}px`,
whiteSpace,
textAlign: element.alignment || 'left',
};
return (
<div
className={`expr-field ${isSelected ? 'selected' : ''} ${isDragging ? 'dragging' : ''} ${isUnresolved ? 'unresolved' : ''}`}
style={style}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
<span className="expr-field-content" style={{ whiteSpace }}>
{displayContent || '(No Expr)'}
</span>
{isSingleSelection && (
<ResizeHandles
element={element}
onResize={handleResize}
charWidth={charWidth}
charHeight={charHeight}
/>
)}
</div>
);
}
export default ExpressionField;

@ -73,3 +73,24 @@
outline: none; outline: none;
border-color: #667eea; border-color: #667eea;
} }
.expr-btn-row {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-bottom: 6px;
}
.expr-btn-row button {
padding: 2px 6px;
font-size: 11px;
border: 1px solid #ccc;
border-radius: 3px;
cursor: pointer;
background: #fff;
}
.expr-btn-row button:hover {
background: #e8eeff;
border-color: #667eea;
}

@ -2,6 +2,7 @@ import React from 'react';
import { useReportData } from './DataContext'; import { useReportData } from './DataContext';
import { getAvailableObjects, getAvailableFields } from './utils/dataResolver'; import { getAvailableObjects, getAvailableFields } from './utils/dataResolver';
import { getAvailableArrays } from './utils/bandRenderer'; import { getAvailableArrays } from './utils/bandRenderer';
import ExpressionEditorPanel from './ExpressionEditorPanel';
import './ObjectInspector.css'; import './ObjectInspector.css';
function ObjectInspector({ element, onUpdate, allElements = [] }) { function ObjectInspector({ element, onUpdate, allElements = [] }) {
@ -266,6 +267,10 @@ function ObjectInspector({ element, onUpdate, allElements = [] }) {
</div> </div>
)} )}
{element.type === 'expr' && (
<ExpressionEditorPanel element={element} onUpdate={onUpdate} reportData={reportData} allElements={allElements} />
)}
{/* Frame/Line specific */} {/* Frame/Line specific */}
{(element.type === 'frame' || element.type === 'hline' || element.type === 'vline') && ( {(element.type === 'frame' || element.type === 'hline' || element.type === 'vline') && (
<div className="inspector-section"> <div className="inspector-section">

@ -19,6 +19,7 @@ function ResizeHandles({ element, onResize, charWidth, charHeight }) {
return ['start', 'end']; return ['start', 'end'];
case 'text': case 'text':
case 'dbtext': case 'dbtext':
case 'expr':
return ['nw', 'ne', 'sw', 'se']; return ['nw', 'ne', 'sw', 'se'];
case 'band': case 'band':
return ['s']; // Only bottom handle for height adjustment return ['s']; // Only bottom handle for height adjustment
@ -45,8 +46,8 @@ function ResizeHandles({ element, onResize, charWidth, charHeight }) {
const contentLength = element.content ? element.content.length : 1; const contentLength = element.content ? element.content.length : 1;
width = (element.width || Math.max(contentLength, 5)) * charWidth; width = (element.width || Math.max(contentLength, 5)) * charWidth;
height = (element.height || 1) * charHeight; height = (element.height || 1) * charHeight;
} else if (element.type === 'dbtext') { } else if (element.type === 'dbtext' || element.type === 'expr') {
// For DB text fields, use stored dimensions // For DB text fields and expression fields, use stored dimensions
width = (element.width || 10) * charWidth; width = (element.width || 10) * charWidth;
height = (element.height || 1) * charHeight; height = (element.height || 1) * charHeight;
} else if (element.type === 'band') { } else if (element.type === 'band') {
@ -190,7 +191,7 @@ function ResizeHandles({ element, onResize, charWidth, charHeight }) {
// Moving end point up/down // Moving end point up/down
updates.length = Math.max(MIN_LINE_LENGTH, initial.length + deltaRow); updates.length = Math.max(MIN_LINE_LENGTH, initial.length + deltaRow);
} }
} else if (element.type === 'text' || element.type === 'dbtext') { } else if (element.type === 'text' || element.type === 'dbtext' || element.type === 'expr') {
const initial = dragData.initialElement; const initial = dragData.initialElement;
switch (dragData.handle) { switch (dragData.handle) {

@ -39,6 +39,13 @@ function Toolbar({
> >
DB DB Text DB DB Text
</button> </button>
<button
className={`toolbar-button ${toolMode === 'addExpr' ? 'active' : ''}`}
onClick={() => onToolChange('addExpr')}
title="Add Expression Field"
>
fx
</button>
<button <button
className={`toolbar-button ${toolMode === 'addFrame' ? 'active' : ''}`} className={`toolbar-button ${toolMode === 'addFrame' ? 'active' : ''}`}
onClick={() => onToolChange('addFrame')} onClick={() => onToolChange('addFrame')}

@ -277,6 +277,64 @@ body.dragging-active .db-text-field {
overflow-wrap: break-word; overflow-wrap: break-word;
} }
/* Expression Field elements */
.expr-field {
position: absolute;
cursor: pointer;
padding: 0;
border: 2px solid transparent;
box-sizing: content-box;
font-family: 'Courier New', 'Consolas', monospace;
font-size: 32px;
line-height: 32px;
white-space: nowrap;
word-wrap: break-word;
overflow-wrap: break-word;
overflow: hidden;
letter-spacing: -1.2px;
font-variant-ligatures: none;
background: rgba(103, 126, 234, 0.05);
transition: border-color 0.2s, background 0.2s;
will-change: auto;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
transform: translateX(-2px);
}
body.dragging-active .expr-field {
transition: none;
}
.expr-field:hover {
border-color: rgba(103, 126, 234, 0.3);
}
.expr-field.selected {
border-color: #667eea;
background: rgba(103, 126, 234, 0.1);
}
.expr-field.dragging {
opacity: 0.7;
cursor: grabbing;
transition: none;
}
.expr-field.unresolved {
background: rgba(255, 152, 0, 0.1);
border-color: rgba(255, 152, 0, 0.3);
}
.expr-field-content {
display: block;
pointer-events: none;
width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* Band elements */ /* Band elements */
.band-element { .band-element {
position: absolute; position: absolute;

@ -93,6 +93,7 @@ export class Element {
const elementClasses = { const elementClasses = {
text: TextElement, text: TextElement,
dbtext: DBTextElement, dbtext: DBTextElement,
expr: ExpressionElement,
frame: FrameElement, frame: FrameElement,
hline: HorizontalLineElement, hline: HorizontalLineElement,
vline: VerticalLineElement, vline: VerticalLineElement,
@ -109,6 +110,20 @@ export class Element {
} }
} }
export class ExpressionElement extends Element {
constructor({ id, x, y, width = 10, height = 1, expression = '', dataType = 'general', alignment = 'left' }) {
super({ id, type: 'expr', x, y });
this.width = width;
this.height = height;
this.expression = expression;
this.dataType = dataType;
this.alignment = alignment;
}
getBounds() { return { minX: this.x, minY: this.y, maxX: this.x + this.width - 1, maxY: this.y + this.height - 1 }; }
getDimensions() { return { width: this.width, height: this.height }; }
toJSON() { return { ...super.toJSON(), width: this.width, height: this.height, expression: this.expression, dataType: this.dataType, alignment: this.alignment }; }
}
/** /**
* Database-bound text field element * Database-bound text field element
*/ */

@ -0,0 +1,257 @@
function tokenize(expression) {
const tokens = [];
let i = 0;
const src = expression;
while (i < src.length) {
if (/\s/.test(src[i])) {
i++;
continue;
}
if (src[i] === '[') {
let j = i + 1;
while (j < src.length && src[j] !== ']') j++;
tokens.push({ type: 'FIELD_REF', value: src.slice(i, j + 1) });
i = j + 1;
continue;
}
if (/[0-9]/.test(src[i]) || (src[i] === '.' && /[0-9]/.test(src[i + 1] || ''))) {
let j = i;
while (j < src.length && /[0-9.]/.test(src[j])) j++;
tokens.push({ type: 'NUMBER', value: src.slice(i, j) });
i = j;
continue;
}
if (src[i] === '"' || src[i] === "'") {
const quote = src[i];
let j = i + 1;
while (j < src.length && src[j] !== quote) j++;
tokens.push({ type: 'STRING', value: src.slice(i + 1, j) });
i = j + 1;
continue;
}
if (/[A-Za-z_]/.test(src[i])) {
let j = i;
while (j < src.length && /[A-Za-z0-9_]/.test(src[j])) j++;
const word = src.slice(i, j);
tokens.push({ type: 'IDENT', value: word });
i = j;
continue;
}
if (src[i] === '!' && src[i + 1] === '=') {
tokens.push({ type: 'CMP', value: '!=' });
i += 2;
continue;
}
if (src[i] === '>' && src[i + 1] === '=') {
tokens.push({ type: 'CMP', value: '>=' });
i += 2;
continue;
}
if (src[i] === '<' && src[i + 1] === '=') {
tokens.push({ type: 'CMP', value: '<=' });
i += 2;
continue;
}
if (src[i] === '=') { tokens.push({ type: 'CMP', value: '=' }); i++; continue; }
if (src[i] === '>') { tokens.push({ type: 'CMP', value: '>' }); i++; continue; }
if (src[i] === '<') { tokens.push({ type: 'CMP', value: '<' }); i++; continue; }
if (src[i] === '+' || src[i] === '-' || src[i] === '*' || src[i] === '/') {
tokens.push({ type: 'OP', value: src[i] });
i++;
continue;
}
if (src[i] === '(') { tokens.push({ type: 'LPAREN', value: '(' }); i++; continue; }
if (src[i] === ')') { tokens.push({ type: 'RPAREN', value: ')' }); i++; continue; }
if (src[i] === ',') { tokens.push({ type: 'COMMA', value: ',' }); i++; continue; }
i++;
}
tokens.push({ type: 'EOF', value: '' });
return tokens;
}
function parse(tokens) {
let pos = 0;
const peek = () => tokens[pos];
const consume = () => tokens[pos++];
function parseExpression() {
return parseComparison();
}
function parseComparison() {
const left = parseAdditive();
if (peek().type === 'CMP') {
const op = consume().value;
const right = parseAdditive();
return { kind: 'cmp', op, left, right };
}
return left;
}
function parseAdditive() {
let node = parseMultiplicative();
while (peek().type === 'OP' && (peek().value === '+' || peek().value === '-')) {
const op = consume().value;
const right = parseMultiplicative();
node = { kind: 'binop', op, left: node, right };
}
return node;
}
function parseMultiplicative() {
let node = parseUnary();
while (peek().type === 'OP' && (peek().value === '*' || peek().value === '/')) {
const op = consume().value;
const right = parseUnary();
node = { kind: 'binop', op, left: node, right };
}
return node;
}
function parseUnary() {
if (peek().type === 'OP' && peek().value === '-') {
consume();
const operand = parseUnary();
return { kind: 'unary', op: '-', operand };
}
return parsePrimary();
}
function parsePrimary() {
const tok = peek();
if (tok.type === 'NUMBER') {
consume();
return { kind: 'number', value: parseFloat(tok.value) };
}
if (tok.type === 'STRING') {
consume();
return { kind: 'string', value: tok.value };
}
if (tok.type === 'FIELD_REF') {
consume();
return { kind: 'field', ref: tok.value };
}
if (tok.type === 'IDENT' && tok.value.toUpperCase() === 'IF') {
consume();
if (peek().type !== 'LPAREN') throw new Error('Expected ( after IF');
consume();
const cond = parseExpression();
if (peek().type !== 'COMMA') throw new Error('Expected , in IF');
consume();
const then = parseExpression();
if (peek().type !== 'COMMA') throw new Error('Expected , in IF');
consume();
const els = parseExpression();
if (peek().type !== 'RPAREN') throw new Error('Expected ) after IF');
consume();
return { kind: 'if', cond, then, els };
}
if (tok.type === 'LPAREN') {
consume();
const node = parseExpression();
if (peek().type !== 'RPAREN') throw new Error('Expected )');
consume();
return node;
}
throw new Error(`Unexpected token: ${tok.type} "${tok.value}"`);
}
return parseExpression();
}
function evalNode(node, resolveField) {
switch (node.kind) {
case 'number':
return node.value;
case 'string':
return node.value;
case 'field': {
const raw = resolveField(node.ref);
return raw;
}
case 'unary': {
const val = evalNode(node.operand, resolveField);
const n = Number(val);
return isNaN(n) ? 0 : -n;
}
case 'binop': {
const l = evalNode(node.left, resolveField);
const r = evalNode(node.right, resolveField);
const ln = Number(l);
const rn = Number(r);
const lv = isNaN(ln) ? 0 : ln;
const rv = isNaN(rn) ? 0 : rn;
if (node.op === '+') return lv + rv;
if (node.op === '-') return lv - rv;
if (node.op === '*') return lv * rv;
if (node.op === '/') {
if (rv === 0) throw { divByZero: true };
return lv / rv;
}
return 0;
}
case 'cmp': {
const l = evalNode(node.left, resolveField);
const r = evalNode(node.right, resolveField);
const ln = Number(l);
const rn = Number(r);
const useNum = !isNaN(ln) && !isNaN(rn);
const lv = useNum ? ln : String(l);
const rv = useNum ? rn : String(r);
if (node.op === '=') return lv == rv;
if (node.op === '!=') return lv != rv;
if (node.op === '>') return lv > rv;
if (node.op === '<') return lv < rv;
if (node.op === '>=') return lv >= rv;
if (node.op === '<=') return lv <= rv;
return false;
}
case 'if': {
const condition = evalNode(node.cond, resolveField);
if (condition) {
return evalNode(node.then, resolveField);
} else {
return evalNode(node.els, resolveField);
}
}
default:
throw new Error(`Unknown node kind: ${node.kind}`);
}
}
export function evaluateExpression(expression, resolveField) {
if (!expression || !expression.trim()) return '';
try {
const tokens = tokenize(expression);
const ast = parse(tokens);
const result = evalNode(ast, resolveField);
return result !== undefined && result !== null ? String(result) : '';
} catch (e) {
if (e && e.divByZero) return '#DIV/0';
return '#ERR';
}
}

@ -0,0 +1,82 @@
user: when both tare and gross are filled out, net should be calculated and saved (at backend). doc_number should be generated and assigned to the vehicle record. how will be the document number generated, what is better, use the vehicle id or a generator?
Claudes Plan
Plan: Auto-calculate net + assign doc_number when both tare and gross are set
Context
One vehicle record = one weighing trip. When both tare and gross are recorded, the backend should
automatically compute net = gross - tare, stamp net_date/net_user, and assign a unique
document number formatted as YYYY-NNNNNN (year + 6-digit sequential counter, resetting per year).
No frontend change is needed — the net and doc_number come back in the existing SSE broadcast.
Files to Modify
File Change
backend/api/models.py Add DocumentCounter model
backend/vehicles/models.py Add doc_number field to Vehicle
backend/api/serializers.py Add net, net_date, net_user_name, doc_number to VehicleSerializer
backend/api/views.py Update set_tare and set_gross to compute net + doc_number
new migration in api For DocumentCounter
new migration in vehicles For doc_number on Vehicle
Step 1 — Add DocumentCounter model (backend/api/models.py)
class DocumentCounter(models.Model):
year = models.IntegerField(unique=True)
last_number = models.IntegerField(default=0)
Add a helper function in the same file (or in views.py, but models.py keeps it reusable):
from django.db import transaction
def generate_doc_number(year):
with transaction.atomic():
counter, _ = DocumentCounter.objects.select_for_update().get_or_create(
year=year, defaults={'last_number': 0}
)
counter.last_number += 1
counter.save()
return f"{year}-{counter.last_number:06d}"
Step 2 — Add doc_number to Vehicle (backend/vehicles/models.py)
doc_number = models.CharField(max_length=20, null=True, blank=True, unique=True)
Place after the net_user field.
Step 3 — Update VehicleSerializer (backend/api/serializers.py)
Add to the VehicleSerializer:
net_user_name = serializers.CharField(source='net_user.username', read_only=True, default=None)
Add to fields list:
'net', 'net_date', 'net_user', 'net_user_name', 'doc_number'
Add to read_only_fields:
'net', 'net_date', 'net_user', 'net_user_name', 'doc_number'
Step 4 — Update set_tare and set_gross (backend/api/views.py)
Extract a helper to avoid duplication:
def _maybe_finalize(vehicle, user):
"""If both tare and gross are now set, compute net and assign doc_number."""
if vehicle.tare is not None and vehicle.gross is not None and vehicle.net is None:
vehicle.net = vehicle.gross - vehicle.tare
vehicle.net_date = timezone.now()
vehicle.net_user = user
vehicle.doc_number = generate_doc_number(timezone.now().year)
vehicle.save()
Call _maybe_finalize(vehicle, request.user) at the end of both set_tare and set_gross,
before the SSE broadcast and serializer response (so the response already contains net + doc_number).
Import generate_doc_number from api.models.
Step 5 — Migrations
python manage.py makemigrations api # DocumentCounter
python manage.py makemigrations vehicles # doc_number on Vehicle
python manage.py migrate
Verification
Create a vehicle with no tare/gross → net and doc_number are null
Set tare → net still null (only one weight set)
Set gross → net is calculated, net_date and net_user are set, doc_number is "2026-000001"
Second vehicle completes → doc_number is "2026-000002"
Race condition test (optional): two simultaneous requests → both get unique sequential numbers
Frontend SSE update reflects net and doc_number automatically (no frontend change needed)

@ -0,0 +1,75 @@
user:
let's enhance the users
- admin users can manage the users. add or remove (mark as delete) users, change user permissions.
user permissions are:
- can measure
- can manually measure
- can manage entities
- can edit documents
make the changes to the backend then make the necessary changes to frontend
Claudes Plan
Plan: Enhanced User Management with Granular Permissions
Context
The current User model has a coarse role field (employee/viewer) and an is_admin boolean. The app needs granular permissions so admins can control exactly what each user can do. Additionally, admins need a UI to create, update, and soft-delete users.
New permissions to add:
can_measure can set tare/gross weights
can_manually_measure can manually enter measurements
can_manage_entities can create/edit/delete vehicles
can_edit_documents can create/edit reports
Backend Changes
1. backend/api/models.py
Add 4 boolean fields to the User model (default False):
can_measure = models.BooleanField(default=False)
can_manually_measure = models.BooleanField(default=False)
can_manage_entities = models.BooleanField(default=False)
can_edit_documents = models.BooleanField(default=False)
Keep existing role and is_admin fields intact.
2. backend/api/serializers.py
Add the 4 new fields to both UserSerializer and UserDetailSerializer fields lists.
3. backend/api/views.py
Add a custom DRF permission class IsAdminUser (checks request.user.is_admin)
Apply it to UserViewSet for all actions except me and change_password (which stay IsAuthenticated)
Override destroy to do soft-delete (instance.is_active = False; instance.save()) instead of actual deletion — no data loss
4. Migration
python manage.py makemigrations && python manage.py migrate
Frontend Changes
5. frontend/src/components/Users/UserManager.jsx (new file)
Single-page admin UI with:
Table of all users (username, role, is_active, 4 permission checkboxes)
"Add User" button → inline expandable form (username, password, permissions)
Per-row: Edit (toggle permissions inline) and Deactivate/Reactivate (soft-delete toggle)
Matches existing app style (similar panel pattern to Main.jsx)
6. frontend/src/App.jsx
Add route /users → <UserManager />, guarded so only currentUser.is_admin can access it (redirect to / otherwise).
7. frontend/src/components/Header.jsx
Add "User Manager" nav button, visible only when currentUser?.is_admin === true (matches existing "Report Editor" / "Nomenclature Manager" pattern).
Critical Files
backend/api/models.py User model
backend/api/serializers.py UserSerializer, UserDetailSerializer
backend/api/views.py UserViewSet
frontend/src/App.jsx routing
frontend/src/components/Header.jsx nav
frontend/src/components/Users/UserManager.jsx new component
Patterns to Reuse
ChangePasswordOverlay.jsx style for modal/overlay
api.get/post/patch/delete('/api/users/...') calls from services/api.js
useAuth() hook to read currentUser.is_admin
Two-panel layout pattern from Main.jsx
Verification
Backend: Run python manage.py makemigrations api && python manage.py migrate — should succeed
Backend: GET /api/users/ with non-admin JWT → 403; with admin JWT → 200
Backend: DELETE /api/users/{id}/ → user.is_active becomes False, user still in DB
Frontend: Admin user sees "User Manager" button in header; non-admin does not
Frontend: Admin can create a user with chosen permissions, edit permissions, and deactivate
Frontend: Created user can log in and existing permission flags appear in /api/users/me/ response

@ -0,0 +1,472 @@
ask:Explore Report Editor elements and inspector
Now let me design the implementation plan based on this exploration.
Now let me read a few critical files to verify the plan details before writing the final plan.
Now I have everything I need. Let me write the final plan.
Claudes Plan
Plan: Expression Field Element for Report Editor
Context
The Report Editor currently supports DBTextElement for displaying individual data-bound field values. Users need the ability to compute derived values — e.g., gross - tare, IF(gross > 0, gross - tare, 0) — directly in the report. No expression/formula capability exists today.
Approach
Add a new element type ExpressionElement (type: 'expr') that stores a formula string and evaluates it in preview mode. In design mode it shows the raw expression. The expression editor is embedded inline in the ObjectInspector (no modal needed).
Files to Create
1. frontend/src/components/ReportEditor/utils/expressionEvaluator.js
Standalone recursive-descent parser/evaluator. No eval() or Function() constructor.
Grammar:
expression = comparison
comparison = additive (CMP additive)? ← only one comparator per IF condition
additive = multiplicative (('+' | '-') multiplicative)*
multiplicative = unary (('*' | '/') unary)*
unary = '-' unary | primary
primary = NUMBER | STRING | FIELD_REF | 'IF' '(' expr ',' expr ',' expr ')' | '(' expr ')'
Token types: NUMBER, STRING, FIELD_REF ([...]), IDENT (for IF), OP, CMP, COMMA, LPAREN, RPAREN, EOF
Public API:
export function evaluateExpression(expression, resolveField)
// resolveField('[vehicle.gross]') → actual value (string/number)
// Returns computed value, '#ERR' on parse error, '#DIV/0' on division by zero
The evaluator receives a resolveField(bracketed) closure — not raw data — so it stays decoupled from React context and is independently testable.
Field resolution inside evaluator: Numeric fields become Number(value) for arithmetic; string fields stay as strings. Arithmetic on strings falls back to string concatenation (same as spreadsheet behavior).
Error handling: entire call wrapped in try/catch → returns '#ERR' on any failure.
2. frontend/src/components/ReportEditor/ExpressionField.jsx
Render component for the canvas. Modelled directly on DBTextField.jsx (129 lines).
Same imports: useReportData, useBandContext, resolveDBTextValue, ResizeHandles, useElementDrag, useElementSelection, getElementStyle
Design mode: displayContent = element.expression || '(expr)'
Preview mode:
function buildResolver(reportData, parentBandId, currentBandData) {
return (bracketed) => {
const path = bracketed.slice(1, -1).trim(); // strip [ and ]
const dotIdx = path.indexOf('.');
if (dotIdx === -1) {
return resolveDBTextValue(reportData, '', path, parentBandId, { currentBandData });
}
return resolveDBTextValue(reportData, path.slice(0, dotIdx), path.slice(dotIdx + 1), parentBandId, { currentBandData });
};
}
const rawValue = evaluateExpression(element.expression, buildResolver(...));
const displayContent = formatValue(rawValue, element.dataType || 'general');
formatValue is copied locally from DBTextField.jsx (same function verbatim).
CSS class: expr-field (blue-violet tint rgba(103, 126, 234, 0.05) to distinguish from green db-text-field).
isUnresolved = !element.expression.
3. frontend/src/components/ReportEditor/ExpressionEditorPanel.jsx
Inline panel component used inside ObjectInspector for expr elements. Uses useRef for the textarea to support insert-at-cursor.
UI layout:
[ Textarea showing raw expression — editable directly ]
Insert Field: [ select dropdown ▼ ] [ Insert ]
Operators: [ + ] [ - ] [ * ] [ / ] [ ( ] [ ) ]
[ IF( , , ) ]
Comparisons: [ = ] [ != ] [ > ] [ < ] [ >= ] [ <= ]
[ Clear ]
Data Type: [ general ▼ ] Alignment: [ left ▼ ]
Insert-at-cursor logic:
const insertAtCursor = (text) => {
const ta = textareaRef.current;
const start = ta.selectionStart, end = ta.selectionEnd;
const next = expression.slice(0, start) + text + expression.slice(end);
onUpdate({ expression: next });
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = start + text.length;
ta.focus();
});
};
Available fields list: Built using the same band-detection logic as ObjectInspector does for dbtext — detect parentBand, resolve bandDataArray, call getAvailableFields(). Fields displayed as vehicle.gross, vehicle.tare (or just gross when inside a band). These are the values inserted between [ and ].
Props: { element, onUpdate, reportData, allElements }
Files to Modify
4. frontend/src/components/ReportEditor/models/Element.jsx
Add ExpressionElement class immediately after DBTextElement class (line ~158):
export class ExpressionElement extends Element {
constructor({ id, x, y, width = 10, height = 1, expression = '', dataType = 'general', alignment = 'left' }) {
super({ id, type: 'expr', x, y });
this.width = width;
this.height = height;
this.expression = expression;
this.dataType = dataType;
this.alignment = alignment;
}
getBounds() { return { minX: this.x, minY: this.y, maxX: this.x + this.width - 1, maxY: this.y + this.height - 1 }; }
getDimensions() { return { width: this.width, height: this.height }; }
toJSON() { return { ...super.toJSON(), width: this.width, height: this.height, expression: this.expression, dataType: this.dataType, alignment: this.alignment }; }
}
Add expr: ExpressionElement to the elementClasses map inside fromJSON (line ~93101).
5. frontend/src/components/ReportEditor/EditorCanvas.jsx
Three surgical changes:
Import: import ExpressionField from './ExpressionField';
In renderElement() switch — add after dbtext branch:
} else if (element.type === 'expr') {
return <ExpressionField {...commonProps} previewMode={previewMode} parentBandId={parentBandId} />;
}
In handleCanvasClick — add after addDBText branch:
} else if (toolMode === 'addExpr') {
onAddElement({ id: `expr-${Date.now()}`, type: 'expr', x: col, y: row, width: 10, height: 1, expression: '', dataType: 'general', alignment: 'left' });
}
6. frontend/src/components/ReportEditor/Toolbar.jsx
Add one button after the DB Text button:
<button className={`toolbar-button ${toolMode === 'addExpr' ? 'active' : ''}`}
onClick={() => onToolChange('addExpr')} title="Add Expression Field">
fx
</button>
7. frontend/src/components/ReportEditor/ObjectInspector.jsx
Two changes:
Import ExpressionEditorPanel at top.
Add section after the dbtext block (after line ~73):
{element.type === 'expr' && (
<ExpressionEditorPanel element={element} onUpdate={onUpdate} reportData={reportData} allElements={allElements} />
)}
8. frontend/src/components/ReportEditor/elements.css
Add .expr-field block (identical structure to .db-text-field but with blue-violet rgba(103,126,234,...) colors instead of green).
9. frontend/src/components/ReportEditor/ObjectInspector.css
Add .expr-btn-row utility class for the operator button rows:
.expr-btn-row { display: flex; flex-wrap: wrap; gap: 3px; margin-bottom: 6px; }
.expr-btn-row button { padding: 2px 6px; font-size: 11px; border: 1px solid #ccc; border-radius: 3px; cursor: pointer; background: #fff; }
.expr-btn-row button:hover { background: #e8eeff; border-color: #667eea; }
ResizeHandles coverage
ResizeHandles.jsx handles resize for elements with width/height. Check whether it already covers any type with these properties generically, or if 'dbtext' is explicitly named — if the latter, add || element.type === 'expr' to those conditions.
Verification
Toolbar: Click fx button → canvas cursor changes, click canvas → ExpressionElement created (type 'expr' in elements array).
Design mode: Selected expr element shows raw expression string (e.g., [vehicle.gross] - [vehicle.tare]).
Inspector - field insert: Open ConfigPanel with vehicle data, select expr element, pick vehicle.gross from dropdown, click Insert → [vehicle.gross] appended at cursor in textarea.
Inspector - operators: Click + → + inserted at cursor.
Preview mode (math): Expression [vehicle.gross] - [vehicle.tare] with gross=28500, tare=8500 → displays 20000.
Preview mode (IF): IF([vehicle.tare] > 0, [vehicle.gross] - [vehicle.tare], 0) → displays 20000.
Error cases: Invalid expression → shows #ERR; division by zero → #DIV/0.
Band context: Expr element inside a band resolves [fieldPath] from current band row.
Persistence: Save report → load report → expr element reloads with expression intact.
Resize: Resize handles appear and work the same as on dbtext elements.
claude created a skill to help him implement the plan:
dispatch skill
Base directory for this skill: c:\dev_projects\ScalesApp\.claude\skills\dispatch
You are the **development orchestrator** for this project. Do not implement changes yourself — always dispatch to the appropriate specialized agent.
## Routing
| Work involves... | Agent |
|-------------------------------------------------------------------|-------------------|
| `frontend/` — React, JSX, hooks, components, state, CSS | `react-expert-dev` |
| `backend/` — Django, Python, models, views, serializers, migrations | `python-pro` |
| Both layers | Both, in parallel |
## Rules
1. Each agent is called **exactly once** — never call the same agent twice.
2. For tasks that span both layers, launch both agents in a **single message** using two parallel Task tool calls.
3. Every agent prompt must include the no-subagent constraint (see below).
## Dispatch Steps
1. **Analyze** — identify which directories and layers the task touches.
2. **Compose** a detailed prompt for each relevant agent:
- Full task description and context
- Relevant file paths to examine
- Clear acceptance criteria
- Append this constraint verbatim at the end:
> **CONSTRAINT: Do NOT use the Task tool. Do not spawn any subagents. Complete all work directly using your available file and shell tools.**
3. **Launch** via the Task tool:
- Frontend → `subagent_type: react-expert-dev`
- Backend → `subagent_type: python-pro`
- Both → two calls in a single message (parallel)
4. **Report** — after all agents complete, summarize:
- What each agent did
- Any required follow-up steps (e.g., run migrations, restart services)
- Any issues or pre-existing problems the agents flagged
ARGUMENTS: Add Expression Field element to the Report Editor. This is a frontend-only change.
## Summary
Add a new `ExpressionElement` (type: `'expr'`) to the report editor that evaluates math expressions in preview mode.
## Files to Create
### 1. `frontend/src/components/ReportEditor/utils/expressionEvaluator.js`
Standalone recursive-descent parser/evaluator. No `eval()` or `Function()`.
Grammar:
- `expression = comparison`
- `comparison = additive (CMP additive)?` — one comparator per IF condition
- `additive = multiplicative (('+' | '-') multiplicative)*`
- `multiplicative = unary (('*' | '/') unary)*`
- `unary = '-' unary | primary`
- `primary = NUMBER | STRING | FIELD_REF | 'IF' '(' expr ',' expr ',' expr ')' | '(' expr ')'`
Token types: `NUMBER`, `STRING`, `FIELD_REF` (text wrapped in `[...]`), `IDENT` (for `IF`), `OP` (`+`, `-`, `*`, `/`), `CMP` (`=`, `!=`, `>`, `<`, `>=`, `<=`), `COMMA`, `LPAREN`, `RPAREN`, `EOF`
Public API:
```js
export function evaluateExpression(expression, resolveField)
// resolveField('[vehicle.gross]') → actual value
// Returns computed value, '#ERR' on error, '#DIV/0' on div by zero
```
`resolveField` is a closure passed in — the evaluator doesn't know about React or reportData.
Field refs in arithmetic: `Number(resolveField(ref))` for arithmetic operations; if NaN, treat as 0.
All errors are caught and return `'#ERR'`. Division by zero returns `'#DIV/0'`.
Implement the tokenizer, recursive-descent parser building an AST, and AST evaluator as three separate internal functions. Export only `evaluateExpression`.
### 2. `frontend/src/components/ReportEditor/ExpressionField.jsx`
Render component. Model it exactly on `DBTextField.jsx` (at `frontend/src/components/ReportEditor/DBTextField.jsx` — read it first).
Differences from DBTextField:
- Imports `evaluateExpression` from `./utils/expressionEvaluator`
- Design mode: `displayContent = element.expression || '(expr)'`
- Preview mode: builds a `resolveField` closure using `resolveDBTextValue` from `./utils/dataResolver`, then calls `evaluateExpression(element.expression, resolveField)`
- The resolveField closure: strips `[` and `]` from the bracketed path, splits on first `.` to get objectKey and fieldPath, then calls `resolveDBTextValue(reportData, objectKey, fieldPath, parentBandId, { currentBandData })`. If no `.` in the path, treat as band-relative (objectKey = `''`).
- `isUnresolved = !element.expression`
- CSS class: `expr-field` (not `db-text-field`)
- Copies `formatValue` function verbatim from `DBTextField.jsx`
- Display text inside span: `displayContent || '(No Expr)'`
### 3. `frontend/src/components/ReportEditor/ExpressionEditorPanel.jsx`
Props: `{ element, onUpdate, reportData, allElements }`
Imports: `React`, `{ useState, useRef }`, `{ getAvailableFields, getAvailableObjects }` from `./utils/dataResolver`
Available fields logic (replicates the `dbtext` block in `ObjectInspector.jsx` lines 36-73):
- Detect parentBand from allElements
- If in a band, resolve bandDataArray and call `getAvailableFields(bandDataArray[0])` → fields are band-relative paths (no `objectKey.` prefix)
- If not in a band: for each objectKey from `getAvailableObjects(reportData)`, call `getAvailableFields(reportData[objectKey])` and prefix each with `objectKey.` → produces `vehicle.gross`, `vehicle.tare`, etc.
- Combine all into a flat `availableFields` array of strings
State: `const [selectedField, setSelectedField] = useState('')`
Ref: `const textareaRef = useRef(null)`
Insert-at-cursor:
```js
const insertAtCursor = (text) => {
const ta = textareaRef.current;
if (!ta) { onUpdate({ expression: (element.expression || '') + text }); return; }
const start = ta.selectionStart;
const end = ta.selectionEnd;
const current = element.expression || '';
const next = current.slice(0, start) + text + current.slice(end);
onUpdate({ expression: next });
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = start + text.length;
ta.focus();
});
};
```
UI (use existing inspector CSS classes where possible):
```jsx
<div className="inspector-section">
<div className="section-title">Expression</div>
<textarea ref={textareaRef} value={element.expression || ''}
onChange={(e) => onUpdate({ expression: e.target.value })}
rows={3} style={{ width: '100%', fontFamily: 'monospace', fontSize: '11px', resize: 'vertical', boxSizing: 'border-box' }}
/>
<div className="property-row">
<select value={selectedField} onChange={e => setSelectedField(e.target.value)} style={{ flex: 1 }}>
<option value="">-- field --</option>
{availableFields.map(f => <option key={f} value={f}>{f}</option>)}
</select>
<button onClick={() => { if (selectedField) insertAtCursor(`[${selectedField}]`); }}>Insert</button>
</div>
<div className="expr-btn-row">
{['+', '-', '*', '/'].map(op => <button key={op} onClick={() => insertAtCursor(` ${op} `)}>{op}</button>)}
<button onClick={() => insertAtCursor('(')}>(</button>
<button onClick={() => insertAtCursor(')')}>)</button>
</div>
<div className="expr-btn-row">
<button onClick={() => insertAtCursor('IF( , , )')}>IF( , , )</button>
</div>
<div className="expr-btn-row">
{['=', '!=', '>', '<', '>=', '<='].map(op => <button key={op} onClick={() => insertAtCursor(` ${op} `)}>{op}</button>)}
</div>
<div className="expr-btn-row">
<button onClick={() => onUpdate({ expression: '' })}>Clear</button>
</div>
<div className="property-row">
<label>Data Type:</label>
<select value={element.dataType || 'general'} onChange={e => onUpdate({ dataType: e.target.value })}>
<option value="general">General</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="time">Time</option>
<option value="datetime">Date &amp; Time</option>
</select>
</div>
<div className="property-row">
<label>Alignment:</label>
<select value={element.alignment || 'left'} onChange={e => onUpdate({ alignment: e.target.value })}>
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
</select>
</div>
</div>
```
## Files to Modify
### 4. `frontend/src/components/ReportEditor/models/Element.jsx`
Read the file first. Add `ExpressionElement` class after `DBTextElement` class. Same structure as `DBTextElement` but replaces `objectKey`/`fieldPath` with `expression`:
```js
export class ExpressionElement extends Element {
constructor({ id, x, y, width = 10, height = 1, expression = '', dataType = 'general', alignment = 'left' }) {
super({ id, type: 'expr', x, y });
this.width = width; this.height = height;
this.expression = expression; this.dataType = dataType; this.alignment = alignment;
}
getBounds() { return { minX: this.x, minY: this.y, maxX: this.x + this.width - 1, maxY: this.y + this.height - 1 }; }
getDimensions() { return { width: this.width, height: this.height }; }
toJSON() { return { ...super.toJSON(), width: this.width, height: this.height, expression: this.expression, dataType: this.dataType, alignment: this.alignment }; }
}
```
Also add `expr: ExpressionElement` to the `elementClasses` map in `fromJSON`.
### 5. `frontend/src/components/ReportEditor/EditorCanvas.jsx`
Read the file first. Make 3 surgical changes:
1. Add import: `import ExpressionField from './ExpressionField';`
2. In `renderElement()` — add `expr` case after `dbtext` case (same props pattern as dbtext)
3. In canvas click handler — add `addExpr` tool mode after `addDBText` (creates element with type `'expr'`, x, y, width=10, height=1, expression='', dataType='general', alignment='left')
### 6. `frontend/src/components/ReportEditor/Toolbar.jsx`
Read first. Add `fx` toolbar button after the DB Text button. Same pattern as other tool buttons. `toolMode === 'addExpr'` → active class.
### 7. `frontend/src/components/ReportEditor/ObjectInspector.jsx`
Read first. Two changes:
1. Add import: `import ExpressionEditorPanel from './ExpressionEditorPanel';`
2. After the `dbtext` block (around line 73), add:
```jsx
{element.type === 'expr' && (
<ExpressionEditorPanel element={element} onUpdate={onUpdate} reportData={reportData} allElements={allElements} />
)}
```
### 8. `frontend/src/components/ReportEditor/elements.css`
Read first. Add `.expr-field` block after `.db-text-field` block. Identical structure but use blue-violet `rgba(103, 126, 234, ...)` colors instead of green.
### 9. `frontend/src/components/ReportEditor/ObjectInspector.css`
Read first. Add at the end:
```css
.expr-btn-row {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-bottom: 6px;
}
.expr-btn-row button {
padding: 2px 6px;
font-size: 11px;
border: 1px solid #ccc;
border-radius: 3px;
cursor: pointer;
background: #fff;
}
.expr-btn-row button:hover {
background: #e8eeff;
border-color: #667eea;
}
```
### 10. `frontend/src/components/ReportEditor/ResizeHandles.jsx`
Read first. Find where `'dbtext'` is mentioned and add `'expr'` alongside it — for resize handles support and pixel size calculation.
## Important Notes
- Read every file before modifying it
- Match existing code style exactly
- Do not add comments, docstrings, or cleanup unrelated code
- The evaluator must use recursive-descent parsing only — no eval(), no Function(), no regex-based math evaluation
Expression Field — Done
New files
expressionEvaluator.js — Pure recursive-descent parser/evaluator (no eval). Supports +, -, *, /, IF(cond, a, b), comparisons (=, !=, >, <, >=, <=), field refs [path], numbers and strings.
ExpressionField.jsx — Canvas element component. Design mode shows raw expression; preview mode evaluates it.
ExpressionEditorPanel.jsx — Inline expression builder in the inspector.
Modified files
models/Element.jsx — Added ExpressionElement class + registered 'expr' in fromJSON
EditorCanvas.jsx — Import + addExpr click handler + expr render case
Toolbar.jsx — Added fx button
ObjectInspector.jsx — Renders ExpressionEditorPanel for expr elements
elements.css — Blue-violet .expr-field styles
ObjectInspector.css — .expr-btn-row button styles
ResizeHandles.jsx — 'expr' added alongside 'dbtext' in 3 places
How to use
Click the fx button in the toolbar
Click on the canvas to place an expression element
Select it — the Expression panel appears in the inspector
Pick a field from the dropdown and click Insert, or type directly
Use the operator buttons (+, -, *, /), IF( , , ), and comparison buttons to build the expression
Switch to preview mode to see the computed result
Expression syntax
Example Result
[vehicle.gross] - [vehicle.tare] Subtraction
[vehicle.gross] * 0.1 Multiplication
IF([vehicle.tare] > 0, [vehicle.gross] - [vehicle.tare], 0) Conditional
([vehicle.gross] - [vehicle.tare]) / 1000 Grouped

Binary file not shown.
Loading…
Cancel
Save