diff --git a/.claude/agent-memory/react-expert-dev/MEMORY.md b/.claude/agent-memory/react-expert-dev/MEMORY.md
new file mode 100644
index 0000000..637280b
--- /dev/null
+++ b/.claude/agent-memory/react-expert-dev/MEMORY.md
@@ -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.
diff --git a/Project-Assignment.filled.docx b/Project-Assignment.filled.docx
index 2801a3d..ca2ebd1 100644
Binary files a/Project-Assignment.filled.docx and b/Project-Assignment.filled.docx differ
diff --git a/frontend/src/components/ReportEditor/EditorCanvas.jsx b/frontend/src/components/ReportEditor/EditorCanvas.jsx
index b560366..f8e8205 100644
--- a/frontend/src/components/ReportEditor/EditorCanvas.jsx
+++ b/frontend/src/components/ReportEditor/EditorCanvas.jsx
@@ -1,6 +1,7 @@
import React, { useRef, useEffect, useMemo } from 'react';
import TextField from './TextField';
import DBTextField from './DBTextField';
+import ExpressionField from './ExpressionField';
import FrameElement from './FrameElement';
import HorizontalLine from './HorizontalLine';
import VerticalLine from './VerticalLine';
@@ -123,6 +124,19 @@ function EditorCanvas({
fieldPath: ''
};
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') {
if (!frameStart) {
// First click - set start position
@@ -451,6 +465,14 @@ function EditorCanvas({
parentBandId={parentBandId}
/>
);
+ } else if (element.type === 'expr') {
+ return (
+
+ );
} else if (element.type === 'frame') {
return ;
} else if (element.type === 'hline') {
diff --git a/frontend/src/components/ReportEditor/ExpressionEditorPanel.jsx b/frontend/src/components/ReportEditor/ExpressionEditorPanel.jsx
new file mode 100644
index 0000000..3e211b5
--- /dev/null
+++ b/frontend/src/components/ReportEditor/ExpressionEditorPanel.jsx
@@ -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 (
+
+ );
+}
+
+export default ExpressionEditorPanel;
diff --git a/frontend/src/components/ReportEditor/ExpressionField.jsx b/frontend/src/components/ReportEditor/ExpressionField.jsx
new file mode 100644
index 0000000..909ed4b
--- /dev/null
+++ b/frontend/src/components/ReportEditor/ExpressionField.jsx
@@ -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 (
+
+
+ {displayContent || '(No Expr)'}
+
+ {isSingleSelection && (
+
+ )}
+
+ );
+}
+
+export default ExpressionField;
diff --git a/frontend/src/components/ReportEditor/ObjectInspector.css b/frontend/src/components/ReportEditor/ObjectInspector.css
index 66c7057..e94a155 100644
--- a/frontend/src/components/ReportEditor/ObjectInspector.css
+++ b/frontend/src/components/ReportEditor/ObjectInspector.css
@@ -73,3 +73,24 @@
outline: none;
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;
+}
diff --git a/frontend/src/components/ReportEditor/ObjectInspector.jsx b/frontend/src/components/ReportEditor/ObjectInspector.jsx
index 9df48f1..264da30 100644
--- a/frontend/src/components/ReportEditor/ObjectInspector.jsx
+++ b/frontend/src/components/ReportEditor/ObjectInspector.jsx
@@ -2,6 +2,7 @@ import React from 'react';
import { useReportData } from './DataContext';
import { getAvailableObjects, getAvailableFields } from './utils/dataResolver';
import { getAvailableArrays } from './utils/bandRenderer';
+import ExpressionEditorPanel from './ExpressionEditorPanel';
import './ObjectInspector.css';
function ObjectInspector({ element, onUpdate, allElements = [] }) {
@@ -266,6 +267,10 @@ function ObjectInspector({ element, onUpdate, allElements = [] }) {
)}
+ {element.type === 'expr' && (
+
+ )}
+
{/* Frame/Line specific */}
{(element.type === 'frame' || element.type === 'hline' || element.type === 'vline') && (
diff --git a/frontend/src/components/ReportEditor/ResizeHandles.jsx b/frontend/src/components/ReportEditor/ResizeHandles.jsx
index 0ed8f13..7de214c 100644
--- a/frontend/src/components/ReportEditor/ResizeHandles.jsx
+++ b/frontend/src/components/ReportEditor/ResizeHandles.jsx
@@ -19,6 +19,7 @@ function ResizeHandles({ element, onResize, charWidth, charHeight }) {
return ['start', 'end'];
case 'text':
case 'dbtext':
+ case 'expr':
return ['nw', 'ne', 'sw', 'se'];
case 'band':
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;
width = (element.width || Math.max(contentLength, 5)) * charWidth;
height = (element.height || 1) * charHeight;
- } else if (element.type === 'dbtext') {
- // For DB text fields, use stored dimensions
+ } else if (element.type === 'dbtext' || element.type === 'expr') {
+ // For DB text fields and expression fields, use stored dimensions
width = (element.width || 10) * charWidth;
height = (element.height || 1) * charHeight;
} else if (element.type === 'band') {
@@ -190,7 +191,7 @@ function ResizeHandles({ element, onResize, charWidth, charHeight }) {
// Moving end point up/down
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;
switch (dragData.handle) {
diff --git a/frontend/src/components/ReportEditor/Toolbar.jsx b/frontend/src/components/ReportEditor/Toolbar.jsx
index 63e698a..b785ff8 100644
--- a/frontend/src/components/ReportEditor/Toolbar.jsx
+++ b/frontend/src/components/ReportEditor/Toolbar.jsx
@@ -39,6 +39,13 @@ function Toolbar({
>
DB DB Text
+