This commit is contained in:
gsinghpal
2026-03-09 15:21:22 -04:00
parent a3e85a23ef
commit acd3fc455e
243 changed files with 20459 additions and 4197 deletions

View File

@@ -0,0 +1,410 @@
/* =================================================================
Wheelchair Assessment Form — Dark / Light Compatible
Uses Bootstrap 5.3 CSS custom properties for full theme support.
No color-mix(), no absolute connectors, no transform overlap.
================================================================= */
.wc-assessment-form {
max-width: 1000px;
margin: 0 auto;
padding: 0 15px;
}
/* -----------------------------------------------------------------
Step Indicators — always visible, solid colours, no opacity tricks
----------------------------------------------------------------- */
.wc-steps {
border-bottom: 2px solid var(--bs-border-color, #dee2e6);
padding-bottom: 1rem;
margin-bottom: 1.5rem;
}
.wc-step-indicator {
cursor: pointer;
transition: none;
}
.wc-step-number {
width: 36px;
height: 36px;
background: #e9ecef;
color: #6c757d;
font-weight: 700;
font-size: 14px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
}
.wc-step-label {
font-size: 0.72rem;
color: #6c757d;
letter-spacing: 0.02em;
}
/* Active step — blue circle */
.wc-step-indicator.active .wc-step-number {
background: #0d6efd;
color: #fff;
box-shadow: 0 0 0 4px rgba(13, 110, 253, 0.2);
}
.wc-step-indicator.active .wc-step-label {
color: #0d6efd;
font-weight: 600;
}
/* Completed step — green circle with checkmark */
.wc-step-indicator.completed .wc-step-number {
background: #198754;
color: #fff;
}
.wc-step-indicator.completed .wc-step-label {
color: #198754;
font-weight: 500;
}
/* -----------------------------------------------------------------
Step panels — NO animation (animation creates stacking context
that traps z-index, breaking search dropdowns)
----------------------------------------------------------------- */
.wc-step {
position: relative;
}
/* -----------------------------------------------------------------
Step 1 Section Cards (Client, Equipment)
----------------------------------------------------------------- */
.wc-section-card {
border: 1px solid var(--bs-border-color);
border-radius: 0.75rem;
}
.wc-section-card > .card-header {
background: var(--bs-tertiary-bg);
border-bottom: 1px solid var(--bs-border-color);
padding: 0.65rem 1rem;
border-radius: 0.75rem 0.75rem 0 0;
}
.wc-section-card > .card-header h5 {
font-size: 0.9rem;
font-weight: 600;
color: var(--bs-body-color);
}
.wc-section-card > .card-body {
padding: 1rem;
}
/* -----------------------------------------------------------------
Radio Buttons as Toggles — hardcoded colours, no CSS variables.
JS sets inline styles as belt-and-suspenders.
----------------------------------------------------------------- */
.wc-radio-btn {
position: relative;
padding: 0.5rem 1rem;
border: 2px solid #0d6efd;
color: #0d6efd;
background: transparent;
border-radius: 0.375rem;
cursor: pointer;
font-weight: 500;
transition: background-color 0.15s ease, color 0.15s ease;
}
.wc-radio-btn input[type="radio"] {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
margin: 0;
padding: 0;
}
/* Primary variant — selected */
.wc-radio-btn.active {
background-color: #0d6efd !important;
color: #fff !important;
border-color: #0d6efd !important;
}
/* Secondary variant (.btn-outline-secondary) — unselected */
.wc-radio-btn.btn-outline-secondary {
border-color: #6c757d;
color: #6c757d;
}
/* Secondary variant — selected */
.wc-radio-btn.btn-outline-secondary.active {
background-color: #6c757d !important;
color: #fff !important;
border-color: #6c757d !important;
}
/* -----------------------------------------------------------------
Measurement Fields
----------------------------------------------------------------- */
.wc-measurement-field {
background: var(--bs-tertiary-bg);
transition: border-color 0.2s, box-shadow 0.2s;
border-radius: 0.5rem !important;
}
.wc-measurement-field:focus-within {
border-color: var(--bs-primary) !important;
box-shadow: 0 0 0 3px rgba(var(--bs-primary-rgb), 0.12);
}
.wc-upcharge-badge {
white-space: nowrap;
font-size: 0.75rem;
}
/* -----------------------------------------------------------------
Option Cards (seating sections, ADP options, accessories)
----------------------------------------------------------------- */
.wc-option-card {
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem !important;
background: var(--bs-body-bg);
}
.wc-option-card:hover {
border-color: rgba(var(--bs-primary-rgb), 0.5);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.wc-option-card.border-primary {
border-width: 2px !important;
border-color: var(--bs-primary) !important;
background-color: var(--bs-primary-bg-subtle) !important;
}
.wc-option-card .card-body {
padding: 0.625rem 0.75rem !important;
}
/* Option card label */
.wc-option-label {
font-size: 0.82rem;
line-height: 1.4;
flex: 1;
min-width: 0;
word-wrap: break-word;
}
/* -----------------------------------------------------------------
Search Containers — z-index must beat ALL sibling content below.
position:relative creates a stacking context so the absolute
dropdown inside floats above everything that follows.
----------------------------------------------------------------- */
.wc-search-container {
position: relative;
z-index: 100;
}
/* -----------------------------------------------------------------
Search Dropdowns (client, frame, section product search)
position:absolute + high z-index within the search container
----------------------------------------------------------------- */
.wc-search-results,
#clientSearchResults,
#frameSearchResults {
position: absolute;
z-index: 1060;
width: 100%;
background: var(--bs-body-bg);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
border-radius: 0 0 0.5rem 0.5rem;
border: 1px solid var(--bs-border-color);
border-top: none;
max-height: 300px;
overflow-y: auto;
}
.wc-search-results .list-group-item,
#clientSearchResults .list-group-item,
#frameSearchResults .list-group-item {
padding: 0.625rem 0.875rem;
font-size: 0.875rem;
background: var(--bs-body-bg);
color: var(--bs-body-color);
border-color: var(--bs-border-color);
transition: background 0.1s ease;
cursor: pointer;
}
.wc-search-results .list-group-item:hover,
#clientSearchResults .list-group-item:hover,
#frameSearchResults .list-group-item:hover {
background: var(--bs-tertiary-bg);
}
/* -----------------------------------------------------------------
Selected Frame Card
----------------------------------------------------------------- */
#selectedFrame > .card {
border-left: 4px solid var(--bs-primary);
background: var(--bs-primary-bg-subtle);
border-radius: 0.5rem;
}
/* -----------------------------------------------------------------
Frame Configurator Panel
----------------------------------------------------------------- */
.wc-configurator-panel {
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
padding: 1rem;
margin-top: 0.75rem;
}
.wc-configurator-panel .wc-config-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bs-secondary-color);
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--bs-border-color);
}
.wc-configurator-panel .wc-config-title i {
color: var(--bs-primary);
}
.wc-config-attr-group {
margin-bottom: 0.625rem;
}
.wc-config-attr-group .wc-config-attr-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bs-secondary-color);
margin-bottom: 0.25rem;
}
.wc-config-attr-group .form-select {
border-color: var(--bs-border-color);
background-color: var(--bs-body-bg);
font-size: 0.875rem;
border-radius: 0.375rem;
}
.wc-config-attr-group .form-select:focus {
border-color: var(--bs-primary);
box-shadow: 0 0 0 3px rgba(var(--bs-primary-rgb), 0.12);
}
.wc-variant-resolved {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.35em 0.65em;
font-size: 0.8rem;
font-weight: 500;
background: rgba(var(--bs-success-rgb), 0.1);
color: var(--bs-success);
border: 1px solid rgba(var(--bs-success-rgb), 0.25);
border-radius: 2rem;
margin-top: 0.5rem;
}
/* -----------------------------------------------------------------
Accordion Sections (Step 4 seating)
----------------------------------------------------------------- */
.wc-assessment-form .accordion-button:not(.collapsed) {
background-color: #e8f0fe;
color: #0d6efd;
}
.wc-assessment-form .accordion-item {
border-radius: 0.5rem !important;
margin-bottom: 0.75rem;
border: 1px solid #dee2e6 !important;
overflow: hidden;
}
.wc-assessment-form .accordion-item:last-child {
margin-bottom: 0;
}
.wc-assessment-form .accordion-button {
border-radius: 0.5rem !important;
font-weight: 600;
}
.wc-assessment-form .accordion-button:not(.collapsed) {
border-radius: 0.5rem 0.5rem 0 0 !important;
}
.wc-assessment-form .accordion-body {
position: relative;
}
/* -----------------------------------------------------------------
Review Table (Step 6)
----------------------------------------------------------------- */
#reviewSummary .table th {
background: var(--bs-tertiary-bg);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--bs-secondary-color);
}
/* Review measurements card */
.wc-step[data-step="6"] .card {
border-color: var(--bs-border-color);
border-radius: 0.5rem;
}
.wc-step[data-step="6"] .card-header {
background: var(--bs-tertiary-bg);
border-bottom-color: var(--bs-border-color);
}
/* -----------------------------------------------------------------
Navigation Buttons
----------------------------------------------------------------- */
.wc-assessment-form .border-top {
border-color: var(--bs-border-color) !important;
}
/* -----------------------------------------------------------------
Responsive
----------------------------------------------------------------- */
@media (max-width: 768px) {
.wc-step-label {
display: none;
}
.wc-step-number {
width: 28px;
height: 28px;
font-size: 12px;
}
.wc-radio-btn {
padding: 0.375rem 0.75rem;
font-size: 0.85rem;
}
.wc-option-label {
font-size: 0.8rem;
}
.wc-configurator-panel {
padding: 0.75rem;
}
}

View File

@@ -0,0 +1,705 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Component, useState, onWillStart, onMounted, onWillUnmount, useRef } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
/* ──────────────────────────────────────────────────────────────────
Constants
────────────────────────────────────────────────────────────────── */
const NODE_TYPES = {
start: { label: 'Start', icon: 'fa-play', color: '#10b981', width: 140, height: 50 },
end: { label: 'End', icon: 'fa-stop', color: '#ef4444', width: 140, height: 50 },
decision: { label: 'Decision', icon: 'fa-code-fork', color: '#f59e0b', width: 180, height: 80 },
option_group: { label: 'Option Group', icon: 'fa-list-ul', color: '#3b82f6', width: 200, height: 80 },
product_select: { label: 'Product Selection', icon: 'fa-shopping-cart', color: '#8b5cf6', width: 200, height: 80 },
measurement_check: { label: 'Measurement Check', icon: 'fa-ruler', color: '#f97316', width: 200, height: 80 },
action: { label: 'Action', icon: 'fa-bolt', color: '#14b8a6', width: 180, height: 70 },
};
const GRID_SIZE = 20;
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 3;
const PORT_RADIUS = 7;
/* ──────────────────────────────────────────────────────────────────
Utility helpers
────────────────────────────────────────────────────────────────── */
function bezierPath(x1, y1, x2, y2) {
const dx = Math.abs(x2 - x1) * 0.5;
return `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`;
}
function getPortsForNode(node) {
const meta = NODE_TYPES[node.node_type] || NODE_TYPES.action;
const w = meta.width;
const h = meta.height;
const ports = [];
// Input port (all except start)
if (node.node_type !== 'start') {
ports.push({ key: 'in', type: 'input', x: 0, y: h / 2 });
}
// Output ports
if (node.node_type === 'decision') {
ports.push({ key: 'true', type: 'output', x: w, y: h * 0.33, label: 'Yes' });
ports.push({ key: 'false', type: 'output', x: w, y: h * 0.67, label: 'No' });
} else if (node.node_type === 'measurement_check') {
ports.push({ key: 'pass', type: 'output', x: w, y: h * 0.33, label: 'Pass' });
ports.push({ key: 'fail', type: 'output', x: w, y: h * 0.67, label: 'Fail' });
} else if (node.node_type === 'option_group' && node.node_options && node.node_options.length) {
const count = node.node_options.length;
node.node_options.forEach((opt, i) => {
ports.push({
key: opt.port_key || `opt_${opt.sequence || (i * 10 + 10)}`,
type: 'output',
x: w,
y: h * ((i + 1) / (count + 1)),
label: opt.name,
});
});
} else if (node.node_type !== 'end') {
ports.push({ key: 'out', type: 'output', x: w, y: h / 2 });
}
return ports;
}
function snapToGrid(val) {
return Math.round(val / GRID_SIZE) * GRID_SIZE;
}
/* ══════════════════════════════════════════════════════════════════
FlowDesignerAction — main OWL client action
══════════════════════════════════════════════════════════════════ */
export class FlowDesignerAction extends Component {
static template = "fusion_quotations.FlowDesignerAction";
static props = { "*": true };
setup() {
this.orm = useService("orm");
this.actionService = useService("action");
this.notification = useService("notification");
this.svgRef = useRef("svgCanvas");
this.canvasGroupRef = useRef("canvasGroup");
this.flowId = this.props.action?.context?.active_id || false;
this.state = useState({
flowName: '',
equipmentType: '',
nodes: [],
connections: [],
selectedNodeId: null,
selectedConnectionId: null,
dirty: false,
saving: false,
loading: true,
panelOpen: false,
// Viewport
viewX: 0,
viewY: 0,
zoom: 1,
});
// Interaction state (non-reactive, doesn't need re-render)
this._dragging = null; // { nodeId, startX, startY, origX, origY }
this._panning = null; // { startX, startY, origVX, origVY }
this._connecting = null; // { sourceNodeId, sourcePort, tempX, tempY }
this._tempLine = null; // SVG path element for connection preview
onWillStart(async () => {
if (this.flowId) {
await this._loadGraph();
}
});
onMounted(() => {
this._bindCanvasEvents();
});
onWillUnmount(() => {
this._unbindCanvasEvents();
});
}
/* ──────────────────────────────────────────────────────────────
Data loading / saving
────────────────────────────────────────────────────────────── */
async _loadGraph() {
this.state.loading = true;
try {
const graph = await this.orm.call(
'fusion.wc.config.flow',
'load_flow_graph',
[this.flowId]
);
this.state.flowName = graph.name || '';
this.state.equipmentType = graph.equipment_type || '';
this.state.nodes = graph.nodes || [];
this.state.connections = graph.connections || [];
if (graph.canvas) {
this.state.viewX = graph.canvas.x || 0;
this.state.viewY = graph.canvas.y || 0;
this.state.zoom = graph.canvas.zoom || 1;
}
} catch (e) {
this.notification.add(_t("Error loading flow: ") + e.message, { type: "danger" });
console.error("Load flow error:", e);
}
this.state.loading = false;
}
async _saveGraph() {
if (!this.flowId) return;
this.state.saving = true;
try {
await this.orm.call(
'fusion.wc.config.flow',
'save_flow_graph',
[this.flowId, {
canvas: { x: this.state.viewX, y: this.state.viewY, zoom: this.state.zoom },
nodes: this.state.nodes,
connections: this.state.connections,
}]
);
this.state.dirty = false;
this.notification.add(_t("Flow saved successfully"), { type: "success" });
// Reload to get real IDs from server
await this._loadGraph();
} catch (e) {
this.notification.add(_t("Error saving flow: ") + e.message, { type: "danger" });
console.error("Save flow error:", e);
}
this.state.saving = false;
}
/* ──────────────────────────────────────────────────────────────
Coordinate transforms
────────────────────────────────────────────────────────────── */
screenToCanvas(sx, sy) {
const svg = this.svgRef.el;
if (!svg) return { x: sx, y: sy };
const rect = svg.getBoundingClientRect();
return {
x: (sx - rect.left - this.state.viewX) / this.state.zoom,
y: (sy - rect.top - this.state.viewY) / this.state.zoom,
};
}
/* ──────────────────────────────────────────────────────────────
Computed data for template
────────────────────────────────────────────────────────────── */
get transformStr() {
return `translate(${this.state.viewX}, ${this.state.viewY}) scale(${this.state.zoom})`;
}
get nodesWithPorts() {
return this.state.nodes.map(n => {
const meta = NODE_TYPES[n.node_type] || NODE_TYPES.action;
return {
...n,
width: meta.width,
height: meta.height,
meta,
ports: getPortsForNode(n),
isSelected: n.id === this.state.selectedNodeId,
};
});
}
get connectionPaths() {
const nodeMap = {};
for (const n of this.state.nodes) {
const meta = NODE_TYPES[n.node_type] || NODE_TYPES.action;
nodeMap[n.id] = { ...n, width: meta.width, height: meta.height, ports: getPortsForNode(n) };
}
return this.state.connections.map(c => {
const src = nodeMap[c.source_node_id];
const tgt = nodeMap[c.target_node_id];
if (!src || !tgt) return null;
const srcPort = src.ports.find(p => p.key === c.source_port) || src.ports.find(p => p.type === 'output');
const tgtPort = tgt.ports.find(p => p.type === 'input') || { x: 0, y: tgt.height / 2 };
if (!srcPort) return null;
const x1 = src.pos_x + srcPort.x;
const y1 = src.pos_y + srcPort.y;
const x2 = tgt.pos_x + tgtPort.x;
const y2 = tgt.pos_y + tgtPort.y;
const label = c.label || '';
const labelW = Math.max(label.length * 7 + 16, 32); // approx width from char count
return {
...c,
path: bezierPath(x1, y1, x2, y2),
isSelected: c.id === this.state.selectedConnectionId,
midX: (x1 + x2) / 2,
midY: (y1 + y2) / 2,
labelW,
};
}).filter(Boolean);
}
getNodeTypeLabel(nodeType) {
return (NODE_TYPES[nodeType] || {}).label || nodeType;
}
/* ──────────────────────────────────────────────────────────────
Toolbar / Palette actions
────────────────────────────────────────────────────────────── */
onAddNode(ev) {
const nodeType = ev.currentTarget.dataset.nodeType;
if (!nodeType) return;
const meta = NODE_TYPES[nodeType];
if (!meta) return;
// Place in center of current viewport
const svg = this.svgRef.el;
const rect = svg ? svg.getBoundingClientRect() : { width: 800, height: 600 };
const pos = this.screenToCanvas(rect.width / 2, rect.height / 2);
const tempId = 'new_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5);
const newNode = {
id: tempId,
name: meta.label,
node_type: nodeType,
pos_x: snapToGrid(pos.x - meta.width / 2),
pos_y: snapToGrid(pos.y - meta.height / 2),
color: meta.color,
icon: meta.icon,
section_id: false,
section_name: '',
decision_field: '',
decision_operator: '',
decision_value: '',
measurement_field: '',
comparison: '',
threshold_value: 0,
action_type: '',
target_option_ids: [],
target_step: 0,
config_json: '{}',
node_options: [],
};
this.state.nodes.push(newNode);
this.state.selectedNodeId = tempId;
this.state.selectedConnectionId = null;
this.state.panelOpen = true;
this.state.dirty = true;
}
onDeleteSelected() {
if (this.state.selectedConnectionId) {
this.state.connections = this.state.connections.filter(
c => c.id !== this.state.selectedConnectionId
);
this.state.selectedConnectionId = null;
this.state.dirty = true;
}
if (this.state.selectedNodeId) {
const nodeId = this.state.selectedNodeId;
this.state.nodes = this.state.nodes.filter(n => n.id !== nodeId);
this.state.connections = this.state.connections.filter(
c => c.source_node_id !== nodeId && c.target_node_id !== nodeId
);
this.state.selectedNodeId = null;
this.state.panelOpen = false;
this.state.dirty = true;
}
}
onSave() {
this._saveGraph();
}
onZoomIn() {
this.state.zoom = Math.min(MAX_ZOOM, this.state.zoom * 1.2);
}
onZoomOut() {
this.state.zoom = Math.max(MIN_ZOOM, this.state.zoom / 1.2);
}
onZoomReset() {
this.state.zoom = 1;
this.state.viewX = 0;
this.state.viewY = 0;
}
onBack() {
// Navigate back to the flow form / list.
// history.back() is the most reliable way to return to the
// previous Odoo view that opened this client action.
window.history.back();
}
/* ──────────────────────────────────────────────────────────────
Canvas events
────────────────────────────────────────────────────────────── */
_bindCanvasEvents() {
const svg = this.svgRef.el;
if (!svg) return;
this._onMouseDown = this._handleMouseDown.bind(this);
this._onMouseMove = this._handleMouseMove.bind(this);
this._onMouseUp = this._handleMouseUp.bind(this);
this._onWheel = this._handleWheel.bind(this);
this._onKeyDown = this._handleKeyDown.bind(this);
svg.addEventListener('mousedown', this._onMouseDown);
window.addEventListener('mousemove', this._onMouseMove);
window.addEventListener('mouseup', this._onMouseUp);
svg.addEventListener('wheel', this._onWheel, { passive: false });
window.addEventListener('keydown', this._onKeyDown);
}
_unbindCanvasEvents() {
const svg = this.svgRef.el;
if (svg) {
svg.removeEventListener('mousedown', this._onMouseDown);
svg.removeEventListener('wheel', this._onWheel);
}
window.removeEventListener('mousemove', this._onMouseMove);
window.removeEventListener('mouseup', this._onMouseUp);
window.removeEventListener('keydown', this._onKeyDown);
}
/* ──────────────────────────────────────────────────────────────
Port interaction — pure coordinate math.
No DOM queries (elementsFromPoint / ev.target) — works
entirely from component state + getPortsForNode().
────────────────────────────────────────────────────────────── */
/**
* Find the nearest port within hit radius using coordinate math.
* @param {number} cx — x in canvas coordinate space
* @param {number} cy — y in canvas coordinate space
* @param {'input'|'output'} portType
* @returns {{ node: Object, port: Object, x: number, y: number }|null}
*/
_findPortNear(cx, cy, portType) {
const HIT_R = 20; // canvas-unit hit radius (~20px at zoom 1)
const rSq = HIT_R * HIT_R;
let best = null;
let bestDist = Infinity;
for (const node of this.state.nodes) {
const ports = getPortsForNode(node);
for (const port of ports) {
if (port.type !== portType) continue;
const px = node.pos_x + port.x;
const py = node.pos_y + port.y;
const dx = cx - px;
const dy = cy - py;
const distSq = dx * dx + dy * dy;
if (distSq <= rSq && distSq < bestDist) {
bestDist = distSq;
best = { node, port, x: px, y: py };
}
}
}
return best;
}
_handleMouseDown(ev) {
if (ev.button !== 0 && ev.button !== 1) return;
const canvasPos = this.screenToCanvas(ev.clientX, ev.clientY);
// ── 1. Port detection via coordinate math ──
// No DOM queries — purely checks distance to port centres.
if (ev.button === 0) {
const outHit = this._findPortNear(canvasPos.x, canvasPos.y, 'output');
if (outHit) {
this._connecting = {
sourceNodeId: outHit.node.id,
sourcePort: outHit.port.key,
startX: outHit.x,
startY: outHit.y,
};
// Create temp bezier for visual feedback
const svgNs = 'http://www.w3.org/2000/svg';
this._tempLine = document.createElementNS(svgNs, 'path');
this._tempLine.classList.add('fd-connection-temp');
this._tempLine.setAttribute('stroke-width', '2');
this._tempLine.setAttribute('pointer-events', 'none');
const group = this.canvasGroupRef.el;
if (group) group.appendChild(this._tempLine);
ev.preventDefault();
ev.stopPropagation();
return;
}
// Input port click — block drag/pan, do nothing
const inHit = this._findPortNear(canvasPos.x, canvasPos.y, 'input');
if (inHit) {
ev.stopPropagation();
return;
}
}
// If already connecting, ignore other interactions
if (this._connecting) return;
// ── 2. Node drag ──
const target = ev.target;
const nodeGroup = target.closest('.fd-node-group');
if (nodeGroup && ev.button === 0) {
const nodeId = nodeGroup.dataset.nodeId;
const nid = isNaN(nodeId) ? nodeId : parseInt(nodeId);
const node = this.state.nodes.find(n => n.id === nid || String(n.id) === nodeId);
if (node) {
this.state.selectedNodeId = node.id;
this.state.selectedConnectionId = null;
this.state.panelOpen = true;
this._dragging = {
nodeId: node.id,
startX: ev.clientX,
startY: ev.clientY,
origX: node.pos_x,
origY: node.pos_y,
};
ev.stopPropagation();
return;
}
}
// ── 3. Connection click ──
if (target.classList.contains('fd-connection-path') || target.classList.contains('fd-connection-hit')) {
const connId = target.dataset.connId;
if (connId) {
const cid = isNaN(connId) ? connId : parseInt(connId);
this.state.selectedConnectionId = cid;
this.state.selectedNodeId = null;
this.state.panelOpen = false;
ev.stopPropagation();
return;
}
}
// ── 4. Canvas pan ──
if (ev.button === 1 || (ev.button === 0 && !nodeGroup)) {
this.state.selectedNodeId = null;
this.state.selectedConnectionId = null;
this.state.panelOpen = false;
this._panning = {
startX: ev.clientX,
startY: ev.clientY,
origVX: this.state.viewX,
origVY: this.state.viewY,
};
ev.preventDefault();
}
}
_handleMouseMove(ev) {
if (this._dragging) {
const dx = (ev.clientX - this._dragging.startX) / this.state.zoom;
const dy = (ev.clientY - this._dragging.startY) / this.state.zoom;
const node = this.state.nodes.find(n => n.id === this._dragging.nodeId);
if (node) {
node.pos_x = snapToGrid(this._dragging.origX + dx);
node.pos_y = snapToGrid(this._dragging.origY + dy);
}
return;
}
if (this._panning) {
this.state.viewX = this._panning.origVX + (ev.clientX - this._panning.startX);
this.state.viewY = this._panning.origVY + (ev.clientY - this._panning.startY);
return;
}
if (this._connecting && this._tempLine) {
const pos = this.screenToCanvas(ev.clientX, ev.clientY);
this._tempLine.setAttribute('d',
bezierPath(this._connecting.startX, this._connecting.startY, pos.x, pos.y));
return;
}
// Port hover cursor — crosshair when near any port
const svg = this.svgRef.el;
if (svg) {
const canvasPos = this.screenToCanvas(ev.clientX, ev.clientY);
const hit = this._findPortNear(canvasPos.x, canvasPos.y, 'output')
|| this._findPortNear(canvasPos.x, canvasPos.y, 'input');
svg.style.cursor = hit ? 'crosshair' : '';
}
}
_handleMouseUp(ev) {
if (this._dragging) {
this.state.dirty = true;
this._dragging = null;
return;
}
if (this._panning) {
this._panning = null;
return;
}
if (this._connecting) {
const canvasPos = this.screenToCanvas(ev.clientX, ev.clientY);
const inHit = this._findPortNear(canvasPos.x, canvasPos.y, 'input');
if (inHit) {
const srcId = this._connecting.sourceNodeId;
const tgtId = inHit.node.id;
// Prevent self-connection
if (String(tgtId) !== String(srcId)) {
// Check for duplicate
const exists = this.state.connections.some(
c => c.source_node_id === srcId &&
c.target_node_id === tgtId &&
c.source_port === this._connecting.sourcePort
);
if (!exists) {
this.state.connections.push({
id: 'new_conn_' + Date.now(),
source_node_id: srcId,
target_node_id: tgtId,
source_port: this._connecting.sourcePort,
label: '',
condition_json: '{}',
sequence: 10,
});
this.state.dirty = true;
}
}
}
// Clean up temp line
if (this._tempLine && this._tempLine.parentNode) {
this._tempLine.parentNode.removeChild(this._tempLine);
}
this._tempLine = null;
this._connecting = null;
}
}
_handleWheel(ev) {
ev.preventDefault();
const delta = ev.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.state.zoom * delta));
// Zoom toward cursor position
const svg = this.svgRef.el;
const rect = svg.getBoundingClientRect();
const mx = ev.clientX - rect.left;
const my = ev.clientY - rect.top;
const scale = newZoom / this.state.zoom;
this.state.viewX = mx - (mx - this.state.viewX) * scale;
this.state.viewY = my - (my - this.state.viewY) * scale;
this.state.zoom = newZoom;
}
_handleKeyDown(ev) {
if (ev.key === 'Delete' || ev.key === 'Backspace') {
// Only if not typing in an input
if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'TEXTAREA' || ev.target.tagName === 'SELECT') return;
this.onDeleteSelected();
ev.preventDefault();
}
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
ev.preventDefault();
this.onSave();
}
}
/* ──────────────────────────────────────────────────────────────
Properties panel
────────────────────────────────────────────────────────────── */
get selectedNode() {
if (!this.state.selectedNodeId) return null;
return this.state.nodes.find(n => n.id === this.state.selectedNodeId) || null;
}
onPanelClose() {
this.state.panelOpen = false;
this.state.selectedNodeId = null;
}
onNodeFieldChange(ev) {
const node = this.selectedNode;
if (!node) return;
const field = ev.currentTarget.dataset.field;
const value = ev.currentTarget.value;
if (field && node.hasOwnProperty(field)) {
node[field] = value;
this.state.dirty = true;
}
}
onNodeNumberChange(ev) {
const node = this.selectedNode;
if (!node) return;
const field = ev.currentTarget.dataset.field;
const value = parseFloat(ev.currentTarget.value) || 0;
if (field) {
node[field] = value;
this.state.dirty = true;
}
}
/* ──────────────────────────────────────────────────────────────
Node option management (option_group)
────────────────────────────────────────────────────────────── */
onAddNodeOption() {
const node = this.selectedNode;
if (!node) return;
if (!node.node_options) node.node_options = [];
const seq = (node.node_options.length + 1) * 10;
node.node_options.push({
id: 'new_opt_' + Date.now(),
name: 'Option ' + (node.node_options.length + 1),
sequence: seq,
section_option_id: false,
enables_option_ids: [],
disables_option_ids: [],
requires_option_ids: [],
port_key: 'opt_' + seq,
});
this.state.dirty = true;
}
onRemoveNodeOption(ev) {
const node = this.selectedNode;
if (!node || !node.node_options) return;
const idx = parseInt(ev.currentTarget.dataset.index);
if (!isNaN(idx) && idx >= 0 && idx < node.node_options.length) {
const removed = node.node_options.splice(idx, 1)[0];
// Also remove connections using this port
if (removed) {
this.state.connections = this.state.connections.filter(
c => !(c.source_node_id === node.id && c.source_port === removed.port_key)
);
}
this.state.dirty = true;
}
}
onNodeOptionNameChange(ev) {
const node = this.selectedNode;
if (!node || !node.node_options) return;
const idx = parseInt(ev.currentTarget.dataset.index);
if (!isNaN(idx) && idx >= 0 && idx < node.node_options.length) {
node.node_options[idx].name = ev.currentTarget.value;
this.state.dirty = true;
}
}
}
registry.category("actions").add("fusion_flow_designer", FlowDesignerAction);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,227 @@
/* ══════════════════════════════════════════════════════════════════
Flow Designer — Visual Configurator Styles
Uses Bootstrap 5.3 / Odoo CSS custom properties so light + dark
mode are handled automatically — zero manual overrides needed.
══════════════════════════════════════════════════════════════════ */
.fd-designer {
background: var(--bs-body-bg);
height: 100vh !important;
max-height: 100vh;
overflow: hidden;
}
/* ── Toolbar ── */
.fd-toolbar {
background: var(--bs-body-bg);
border-bottom: 1px solid var(--bs-border-color);
min-height: 48px;
z-index: 10;
flex-shrink: 0;
}
.fd-toolbar-title {
font-size: 1rem;
color: var(--bs-body-color);
max-width: 250px;
}
/* Measurement button — no inline styles, uses a custom class */
.fd-btn-measure {
border-color: var(--bs-border-color);
color: var(--bs-body-color);
&:hover,
&:focus {
background-color: var(--bs-tertiary-bg);
border-color: var(--bs-secondary-color);
color: var(--bs-emphasis-color);
}
}
/* ── SVG Canvas ── */
.fd-canvas-wrapper {
flex: 1;
min-height: 0;
}
.fd-svg-canvas {
width: 100%;
height: 100%;
background: var(--bs-secondary-bg);
cursor: grab;
&:active {
cursor: grabbing;
}
}
/* SVG grid dots — themed */
.fd-grid-dot {
fill: var(--bs-border-color);
}
/* ── Nodes ── */
.fd-node-group {
&:hover .fd-node-rect {
filter: brightness(0.97);
}
}
.fd-node-content {
user-select: none;
-webkit-user-select: none;
}
/* Node text adapts to theme */
.fd-node-name {
font-size: 12px;
font-weight: 600;
color: var(--bs-emphasis-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fd-node-detail {
font-size: 10px;
color: var(--bs-secondary-color);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Ports ── */
/* Visible decorative port circle — no pointer events */
.fd-port-visual {
stroke: var(--bs-body-bg);
stroke-width: 2;
pointer-events: none;
transition: r 0.15s ease, stroke-width 0.15s ease;
}
/* Invisible hit area — this is the actual click target */
.fd-port-hit {
fill: transparent;
stroke: none;
stroke-width: 0;
pointer-events: all;
cursor: crosshair;
/* Grow the visible sibling on hover via CSS ~ */
&:hover + .fd-port-visual {
r: 9;
stroke-width: 3;
}
}
/* Input port fill is now set directly as SVG attribute (#8b95a1) */
/* Port labels — concrete color; var() doesn't resolve reliably in SVG */
.fd-port-label {
fill: #8b95a1;
font-size: 9px;
pointer-events: none;
}
/* ── Connections ── */
/* Custom property so both light & dark themes get a visible stroke.
Defined on SVG so markers and all children inherit reliably. */
.fd-svg-canvas {
--fd-conn-stroke: #8b95a1;
--fd-conn-label-bg: rgba(55, 65, 81, 0.85); /* slate-700 @ 85 — visible on dark bg */
--fd-conn-label-color: #e5e7eb; /* gray-200 — bright text */
}
@media (prefers-color-scheme: light) {
.fd-svg-canvas {
--fd-conn-stroke: #9ca3af;
--fd-conn-label-bg: rgba(255, 255, 255, 0.9);
--fd-conn-label-color: #4b5563;
}
}
.fd-connection-path {
transition: stroke 0.15s ease, stroke-width 0.15s ease;
}
.fd-connection-temp {
stroke: var(--fd-conn-stroke);
stroke-width: 2.5;
stroke-dasharray: 8 4;
stroke-linecap: round;
fill: none;
pointer-events: none;
}
/* Label background pill */
.fd-conn-label-bg {
fill: var(--fd-conn-label-bg);
pointer-events: none;
}
.fd-conn-label {
fill: var(--fd-conn-label-color);
user-select: none;
pointer-events: none;
}
/* Arrow marker fills are set directly as SVG attributes
because CSS custom properties don't cascade into marker contexts */
/* ── Properties Panel ── */
.fd-properties-panel {
width: 320px;
min-width: 320px;
background: var(--bs-body-bg);
border-left: 1px solid var(--bs-border-color);
overflow-y: auto;
flex-shrink: 0;
z-index: 5;
display: flex;
flex-direction: column;
}
.fd-panel-header {
background: var(--bs-tertiary-bg);
border-bottom: 1px solid var(--bs-border-color);
min-height: 42px;
flex-shrink: 0;
}
.fd-panel-body {
flex: 1;
overflow-y: auto;
}
.fd-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bs-secondary-color);
margin-bottom: 0.25rem;
}
/* Option list items */
.fd-option-row {
padding: 4px 0;
}
.fd-option-bullet {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
/* ── Loading Overlay ── */
.fd-loading-overlay {
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--bs-body-bg) 85%, transparent);
z-index: 50;
}

View File

@@ -0,0 +1,415 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<!-- ══════════════════════════════════════════════════════════════════
FlowDesignerAction — Main layout
All colours use Bootstrap/Odoo CSS custom properties so
light + dark mode work automatically.
Node-type accent colours (green, red, amber…) are intentional
design tokens — they stay the same across themes.
══════════════════════════════════════════════════════════════════ -->
<t t-name="fusion_quotations.FlowDesignerAction">
<div class="fd-designer d-flex flex-column h-100">
<!-- ── Top Toolbar ── -->
<div class="fd-toolbar d-flex align-items-center gap-2 px-3 py-2">
<button class="btn btn-sm btn-outline-secondary" t-on-click="onBack">
<i class="fa fa-arrow-left me-1"/>Back
</button>
<div class="fd-toolbar-title fw-bold text-truncate ms-2" t-esc="state.flowName"/>
<span class="badge bg-secondary-subtle text-body-secondary ms-1" t-esc="state.equipmentType"/>
<div class="flex-grow-1"/>
<!-- Add Node Buttons -->
<div class="btn-group">
<button class="btn btn-sm btn-outline-success" data-node-type="start" t-on-click="onAddNode"
title="Add Start Node">
<i class="fa fa-play me-1"/>Start
</button>
<button class="btn btn-sm btn-outline-warning" data-node-type="decision" t-on-click="onAddNode"
title="Add Decision Node">
<i class="fa fa-code-fork me-1"/>Decision
</button>
<button class="btn btn-sm btn-outline-primary" data-node-type="option_group" t-on-click="onAddNode"
title="Add Option Group">
<i class="fa fa-list-ul me-1"/>Options
</button>
<button class="btn btn-sm btn-outline-info" data-node-type="action" t-on-click="onAddNode"
title="Add Action Node">
<i class="fa fa-bolt me-1"/>Action
</button>
<button class="btn btn-sm btn-outline-secondary fd-btn-measure"
data-node-type="measurement_check" t-on-click="onAddNode"
title="Add Measurement Check">
<i class="fa fa-tachometer me-1"/>Measure
</button>
<button class="btn btn-sm btn-outline-danger" data-node-type="end" t-on-click="onAddNode"
title="Add End Node">
<i class="fa fa-stop me-1"/>End
</button>
</div>
<div class="vr mx-1"/>
<!-- Zoom Controls -->
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" t-on-click="onZoomOut" title="Zoom Out">
<i class="fa fa-search-minus"/>
</button>
<button class="btn btn-outline-secondary" t-on-click="onZoomReset" title="Reset Zoom"
style="min-width:55px;">
<t t-esc="Math.round(state.zoom * 100)"/>%
</button>
<button class="btn btn-outline-secondary" t-on-click="onZoomIn" title="Zoom In">
<i class="fa fa-search-plus"/>
</button>
</div>
<div class="vr mx-1"/>
<button class="btn btn-sm btn-outline-danger" t-on-click="onDeleteSelected"
t-att-disabled="!state.selectedNodeId and !state.selectedConnectionId"
title="Delete Selected (Del)">
<i class="fa fa-trash"/>
</button>
<button class="btn btn-sm btn-primary" t-on-click="onSave"
t-att-disabled="state.saving or !state.dirty">
<i class="fa fa-save me-1"/>
<t t-if="state.saving">Saving...</t>
<t t-else="">Save</t>
</button>
<t t-if="state.dirty">
<span class="badge bg-warning text-dark ms-1">Unsaved</span>
</t>
</div>
<!-- ── Canvas + Panel ── -->
<div class="fd-canvas-wrapper d-flex flex-grow-1 overflow-hidden position-relative">
<!-- SVG Canvas -->
<svg class="fd-svg-canvas flex-grow-1" t-ref="svgCanvas" xmlns="http://www.w3.org/2000/svg">
<!-- Grid Pattern -->
<defs>
<pattern id="fd-grid" width="20" height="20" patternUnits="userSpaceOnUse"
t-att-x="state.viewX" t-att-y="state.viewY"
t-att-patternTransform="'scale(' + state.zoom + ')'">
<circle cx="10" cy="10" r="1" class="fd-grid-dot"/>
</pattern>
<!-- Arrow markers — fill set as attribute because CSS custom
properties don't cascade into SVG marker rendering contexts -->
<marker id="fd-arrow" viewBox="0 0 10 6" refX="10" refY="3"
markerWidth="8" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,3 L0,6 z" fill="#8b95a1"/>
</marker>
<marker id="fd-arrow-selected" viewBox="0 0 10 6" refX="10" refY="3"
markerWidth="8" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,3 L0,6 z" fill="var(--bs-primary, #3b82f6)"/>
</marker>
</defs>
<rect width="100%" height="100%" fill="url(#fd-grid)"/>
<!-- Transform group for zoom/pan -->
<g t-ref="canvasGroup" class="fd-canvas-group" t-att-transform="transformStr">
<!-- Connections -->
<t t-foreach="connectionPaths" t-as="conn" t-key="conn.id">
<!-- Invisible fat hit area for click detection -->
<path t-att-d="conn.path" fill="none" stroke="transparent" stroke-width="14"
class="fd-connection-hit" t-att-data-conn-id="conn.id"
style="cursor:pointer;"/>
<!-- Visible bezier line — stroke set as SVG attr for guaranteed rendering -->
<path t-att-d="conn.path"
class="fd-connection-path"
t-att-stroke="conn.isSelected ? 'var(--bs-primary)' : 'var(--fd-conn-stroke, #8b95a1)'"
t-att-stroke-width="conn.isSelected ? '3.5' : '2.5'"
fill="none"
stroke-linecap="round"
t-att-marker-end="conn.isSelected ? 'url(#fd-arrow-selected)' : 'url(#fd-arrow)'"
t-att-data-conn-id="conn.id"
style="pointer-events:none;"/>
<!-- Connection label with background pill -->
<t t-if="conn.label">
<rect t-att-x="conn.midX - conn.labelW / 2"
t-att-y="conn.midY - 18"
t-att-width="conn.labelW" height="20" rx="10"
class="fd-conn-label-bg"/>
<text t-att-x="conn.midX" t-att-y="conn.midY - 5"
text-anchor="middle" font-size="11" font-weight="600"
class="fd-conn-label">
<t t-esc="conn.label"/>
</text>
</t>
</t>
<!-- Nodes -->
<t t-foreach="nodesWithPorts" t-as="node" t-key="node.id">
<g class="fd-node-group" t-att-data-node-id="node.id"
t-att-transform="'translate(' + node.pos_x + ',' + node.pos_y + ')'"
style="cursor:grab;">
<!-- Node body — accent color fill is a design token, stays fixed -->
<rect x="0" y="0" t-att-width="node.width" t-att-height="node.height"
t-att-rx="node.node_type === 'start' or node.node_type === 'end' ? node.height / 2 : 8"
t-att-fill="node.meta.color + '18'"
t-att-stroke="node.isSelected ? 'var(--bs-primary)' : node.meta.color"
t-att-stroke-width="node.isSelected ? '3' : '2'"
class="fd-node-rect"/>
<!-- Icon + Name — pointer-events:none so clicks pass through to ports/rect -->
<foreignObject x="0" y="0" t-att-width="node.width" t-att-height="node.height"
style="pointer-events:none;">
<div xmlns="http://www.w3.org/1999/xhtml" class="fd-node-content"
t-att-style="'display:flex;flex-direction:column;justify-content:center;height:' + node.height + 'px;padding:0 14px;overflow:hidden;pointer-events:none;'">
<div style="display:flex;align-items:center;gap:6px;">
<i t-att-class="'fa ' + (node.icon || 'fa-circle')"
t-att-style="'color:' + node.meta.color + ';font-size:14px;flex-shrink:0;'"/>
<span class="fd-node-name" t-esc="node.name"/>
</div>
<t t-if="node.node_type === 'decision' and node.decision_field">
<div class="fd-node-detail">
<t t-esc="node.decision_field"/>
<t t-if="node.decision_operator"> <t t-esc="node.decision_operator"/> </t>
<t t-if="node.decision_value"> <t t-esc="node.decision_value"/></t>
</div>
</t>
<t t-if="node.node_type === 'action' and node.action_type">
<div class="fd-node-detail">
<t t-esc="node.action_type"/>
</div>
</t>
<t t-if="node.node_type === 'measurement_check' and node.measurement_field">
<div class="fd-node-detail">
<t t-esc="node.measurement_field"/>
<t t-if="node.comparison"> <t t-esc="node.comparison"/> </t>
<t t-if="node.threshold_value"> <t t-esc="node.threshold_value"/></t>
</div>
</t>
<t t-if="node.section_name">
<div class="fd-node-detail">
<i class="fa fa-folder-o me-1"/><t t-esc="node.section_name"/>
</div>
</t>
</div>
</foreignObject>
<!-- Ports -->
<t t-foreach="node.ports" t-as="port" t-key="port.key">
<!-- Invisible hit area — detected by elementsFromPoint in JS -->
<circle t-att-cx="port.x" t-att-cy="port.y" r="15"
fill="transparent" stroke="none"
pointer-events="all"
class="fd-port-hit fd-port"
t-att-data-port-key="port.key"
t-att-data-port-type="port.type"
t-att-data-node-id="'' + node.id"
style="cursor:crosshair;"/>
<!-- Visible port circle — concrete fill for both types -->
<circle t-att-cx="port.x" t-att-cy="port.y" r="7"
t-att-fill="port.type !== 'input' ? node.meta.color : '#8b95a1'"
class="fd-port-visual"
style="pointer-events:none;"/>
<t t-if="port.label">
<text t-att-x="port.x + (port.type === 'output' ? 12 : -12)"
t-att-y="port.y + 4"
t-att-text-anchor="port.type === 'output' ? 'start' : 'end'"
class="fd-port-label"
fill="#8b95a1">
<t t-esc="port.label"/>
</text>
</t>
</t>
</g>
</t>
</g>
</svg>
<!-- ── Properties Panel (right side) ── -->
<t t-if="state.panelOpen and selectedNode">
<div class="fd-properties-panel">
<div class="fd-panel-header d-flex align-items-center justify-content-between px-3 py-2">
<div class="d-flex align-items-center gap-2">
<i t-att-class="'fa ' + (selectedNode.icon || 'fa-circle')"
t-att-style="'color:' + (selectedNode.color || '#3b82f6')"/>
<span class="fw-bold" t-esc="getNodeTypeLabel(selectedNode.node_type)"/>
</div>
<button class="btn btn-sm btn-link text-body-secondary p-0" t-on-click="onPanelClose">
<i class="fa fa-times"/>
</button>
</div>
<div class="fd-panel-body p-3">
<!-- Common: Name -->
<div class="mb-3">
<label class="form-label fd-label">Name</label>
<input type="text" class="form-control form-control-sm"
data-field="name" t-att-value="selectedNode.name"
t-on-change="onNodeFieldChange"/>
</div>
<!-- Decision Fields -->
<t t-if="selectedNode.node_type === 'decision'">
<div class="mb-3">
<label class="form-label fd-label">Decision Field</label>
<select class="form-select form-select-sm" data-field="decision_field"
t-on-change="onNodeFieldChange">
<option value="">-- Select --</option>
<option value="equipment_type" t-att-selected="selectedNode.decision_field === 'equipment_type'">Equipment Type</option>
<option value="wheelchair_type" t-att-selected="selectedNode.decision_field === 'wheelchair_type'">Wheelchair Category</option>
<option value="powerchair_type" t-att-selected="selectedNode.decision_field === 'powerchair_type'">Power Chair Category</option>
<option value="build_type" t-att-selected="selectedNode.decision_field === 'build_type'">Build Type</option>
<option value="client_type" t-att-selected="selectedNode.decision_field === 'client_type'">Client Type</option>
<option value="reason_for_application" t-att-selected="selectedNode.decision_field === 'reason_for_application'">Reason for Application</option>
<option value="seat_width" t-att-selected="selectedNode.decision_field === 'seat_width'">Seat Width</option>
<option value="seat_depth" t-att-selected="selectedNode.decision_field === 'seat_depth'">Seat Depth</option>
<option value="client_weight" t-att-selected="selectedNode.decision_field === 'client_weight'">Client Weight</option>
<option value="back_height" t-att-selected="selectedNode.decision_field === 'back_height'">Back Height</option>
<option value="seat_to_floor" t-att-selected="selectedNode.decision_field === 'seat_to_floor'">Seat to Floor</option>
<option value="leg_rest_length" t-att-selected="selectedNode.decision_field === 'leg_rest_length'">Leg Rest Length</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fd-label">Operator</label>
<select class="form-select form-select-sm" data-field="decision_operator"
t-on-change="onNodeFieldChange">
<option value="">-- Select --</option>
<option value="eq" t-att-selected="selectedNode.decision_operator === 'eq'">=</option>
<option value="neq" t-att-selected="selectedNode.decision_operator === 'neq'"></option>
<option value="gt" t-att-selected="selectedNode.decision_operator === 'gt'">&gt;</option>
<option value="gte" t-att-selected="selectedNode.decision_operator === 'gte'"></option>
<option value="lt" t-att-selected="selectedNode.decision_operator === 'lt'">&lt;</option>
<option value="lte" t-att-selected="selectedNode.decision_operator === 'lte'"></option>
<option value="in" t-att-selected="selectedNode.decision_operator === 'in'">In List</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fd-label">Expected Value</label>
<input type="text" class="form-control form-control-sm"
data-field="decision_value" t-att-value="selectedNode.decision_value"
t-on-change="onNodeFieldChange"
placeholder="For 'In List' use comma-separated"/>
</div>
</t>
<!-- Measurement Check Fields -->
<t t-if="selectedNode.node_type === 'measurement_check'">
<div class="mb-3">
<label class="form-label fd-label">Measurement</label>
<select class="form-select form-select-sm" data-field="measurement_field"
t-on-change="onNodeFieldChange">
<option value="">-- Select --</option>
<option value="seat_width" t-att-selected="selectedNode.measurement_field === 'seat_width'">Seat Width</option>
<option value="seat_depth" t-att-selected="selectedNode.measurement_field === 'seat_depth'">Seat Depth</option>
<option value="back_width" t-att-selected="selectedNode.measurement_field === 'back_width'">Backrest Width</option>
<option value="back_height" t-att-selected="selectedNode.measurement_field === 'back_height'">Back Height</option>
<option value="seat_to_floor" t-att-selected="selectedNode.measurement_field === 'seat_to_floor'">Seat to Floor</option>
<option value="leg_rest_length" t-att-selected="selectedNode.measurement_field === 'leg_rest_length'">Leg Rest Length</option>
<option value="client_weight" t-att-selected="selectedNode.measurement_field === 'client_weight'">Client Weight</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fd-label">Comparison</label>
<select class="form-select form-select-sm" data-field="comparison"
t-on-change="onNodeFieldChange">
<option value="">-- Select --</option>
<option value="gt" t-att-selected="selectedNode.comparison === 'gt'">Greater Than</option>
<option value="gte" t-att-selected="selectedNode.comparison === 'gte'">Greater Than or Equal</option>
<option value="lt" t-att-selected="selectedNode.comparison === 'lt'">Less Than</option>
<option value="eq" t-att-selected="selectedNode.comparison === 'eq'">Equal To</option>
<option value="neq" t-att-selected="selectedNode.comparison === 'neq'">Not Equal To</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fd-label">Threshold</label>
<input type="number" class="form-control form-control-sm"
data-field="threshold_value"
t-att-value="selectedNode.threshold_value"
t-on-change="onNodeNumberChange" step="0.1"/>
</div>
</t>
<!-- Action Fields -->
<t t-if="selectedNode.node_type === 'action'">
<div class="mb-3">
<label class="form-label fd-label">Action Type</label>
<select class="form-select form-select-sm" data-field="action_type"
t-on-change="onNodeFieldChange">
<option value="">-- Select --</option>
<option value="enable" t-att-selected="selectedNode.action_type === 'enable'">Enable Options</option>
<option value="disable" t-att-selected="selectedNode.action_type === 'disable'">Disable Options</option>
<option value="require" t-att-selected="selectedNode.action_type === 'require'">Require Options</option>
<option value="skip_step" t-att-selected="selectedNode.action_type === 'skip_step'">Skip Portal Step</option>
<option value="set_value" t-att-selected="selectedNode.action_type === 'set_value'">Set Field Value</option>
</select>
</div>
<t t-if="selectedNode.action_type === 'skip_step'">
<div class="mb-3">
<label class="form-label fd-label">Target Step</label>
<input type="number" class="form-control form-control-sm"
data-field="target_step"
t-att-value="selectedNode.target_step"
t-on-change="onNodeNumberChange" min="1" max="10"/>
</div>
</t>
</t>
<!-- Option Group — options list -->
<t t-if="selectedNode.node_type === 'option_group'">
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between mb-2">
<label class="form-label fd-label mb-0">Options</label>
<button class="btn btn-sm btn-outline-primary" t-on-click="onAddNodeOption">
<i class="fa fa-plus me-1"/>Add
</button>
</div>
<t t-if="selectedNode.node_options and selectedNode.node_options.length">
<t t-foreach="selectedNode.node_options" t-as="opt" t-key="opt.id">
<div class="fd-option-row d-flex align-items-center gap-2 mb-2">
<span class="fd-option-bullet" t-att-style="'background:' + (selectedNode.color || '#3b82f6')"/>
<input type="text" class="form-control form-control-sm flex-grow-1"
t-att-value="opt.name"
t-att-data-index="opt_index"
t-on-change="onNodeOptionNameChange"/>
<button class="btn btn-sm btn-link text-danger p-0"
t-att-data-index="opt_index"
t-on-click="onRemoveNodeOption">
<i class="fa fa-trash-o"/>
</button>
</div>
</t>
</t>
<t t-else="">
<div class="text-body-secondary small fst-italic">No options yet. Click "Add" to create one.</div>
</t>
</div>
</t>
<!-- Node info footer -->
<div class="mt-4 pt-3 border-top">
<div class="text-body-secondary small">
<div><strong>ID:</strong> <t t-esc="selectedNode.id"/></div>
<div><strong>Position:</strong> (<t t-esc="Math.round(selectedNode.pos_x)"/>, <t t-esc="Math.round(selectedNode.pos_y)"/>)</div>
</div>
</div>
</div>
</div>
</t>
<!-- Loading overlay -->
<t t-if="state.loading">
<div class="fd-loading-overlay d-flex align-items-center justify-content-center">
<div class="text-center">
<i class="fa fa-spinner fa-spin fa-2x text-primary mb-2"/>
<div class="text-body-secondary">Loading flow...</div>
</div>
</div>
</t>
</div>
</div>
</t>
</templates>