expression field added in document editor. a few plans in plans folder
This commit is contained in:
@@ -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 & 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?
|
||||||
|
Claude’s 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
|
||||||
|
|
||||||
|
Claude’s 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.
|
||||||
|
|
||||||
|
Claude’s 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 ~93–101).
|
||||||
|
|
||||||
|
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 & 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.
Reference in New Issue
Block a user