changes
This commit is contained in:
410
fusion_quotations/static/src/css/quotation_form.css
Normal file
410
fusion_quotations/static/src/css/quotation_form.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
1072
fusion_quotations/static/src/js/quotation_form.js
Normal file
1072
fusion_quotations/static/src/js/quotation_form.js
Normal file
File diff suppressed because it is too large
Load Diff
227
fusion_quotations/static/src/scss/flow_designer.scss
Normal file
227
fusion_quotations/static/src/scss/flow_designer.scss
Normal 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;
|
||||
}
|
||||
415
fusion_quotations/static/src/xml/flow_designer_templates.xml
Normal file
415
fusion_quotations/static/src/xml/flow_designer_templates.xml
Normal 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'">></option>
|
||||
<option value="gte" t-att-selected="selectedNode.decision_operator === 'gte'">≥</option>
|
||||
<option value="lt" t-att-selected="selectedNode.decision_operator === '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>
|
||||
Reference in New Issue
Block a user