2539 lines
86 KiB
JavaScript
2539 lines
86 KiB
JavaScript
import {
|
|
Component,
|
|
onWillRender,
|
|
onWillStart,
|
|
onWillUpdateProps,
|
|
reactive,
|
|
useEffect,
|
|
useExternalListener,
|
|
useRef,
|
|
markup,
|
|
} from "@odoo/owl";
|
|
import { hasTouch, isMobileOS } from "@web/core/browser/feature_detection";
|
|
import { Domain } from "@web/core/domain";
|
|
import {
|
|
getStartOfLocalWeek,
|
|
is24HourFormat,
|
|
serializeDate,
|
|
serializeDateTime,
|
|
} from "@web/core/l10n/dates";
|
|
import { localization } from "@web/core/l10n/localization";
|
|
import { _t } from "@web/core/l10n/translation";
|
|
import { usePopover } from "@web/core/popover/popover_hook";
|
|
import { evaluateBooleanExpr } from "@web/core/py_js/py";
|
|
import { user } from "@web/core/user";
|
|
import { useService } from "@web/core/utils/hooks";
|
|
import { omit, pick } from "@web/core/utils/objects";
|
|
import { debounce, throttleForAnimation } from "@web/core/utils/timing";
|
|
import { url } from "@web/core/utils/urls";
|
|
import { escape } from "@web/core/utils/strings";
|
|
import { useVirtualGrid } from "@web/core/virtual_grid_hook";
|
|
import { formatFloatTime } from "@web/views/fields/formatters";
|
|
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
|
|
import { GanttConnector } from "./gantt_connector";
|
|
import {
|
|
dateAddFixedOffset,
|
|
diffColumn,
|
|
getCellColor,
|
|
getColorIndex,
|
|
localEndOf,
|
|
localStartOf,
|
|
useGanttConnectorDraggable,
|
|
useGanttDraggable,
|
|
useGanttResizable,
|
|
useGanttSelectable,
|
|
useGanttUndraggable,
|
|
useMultiHover,
|
|
} from "./gantt_helpers";
|
|
import { GanttPopover } from "./gantt_popover";
|
|
import { GanttRendererControls } from "./gantt_renderer_controls";
|
|
import { GanttResizeBadge } from "./gantt_resize_badge";
|
|
import { GanttRowProgressBar } from "./gantt_row_progress_bar";
|
|
import { clamp } from "@web/core/utils/numbers";
|
|
|
|
const { DateTime } = luxon;
|
|
|
|
/**
|
|
* @typedef {`__column__${number}`} ColumnId
|
|
* @typedef {`__connector__${number | "new"}`} ConnectorId
|
|
* @typedef {import("./gantt_connector").ConnectorProps} ConnectorProps
|
|
* @typedef {luxon.DateTime} DateTime
|
|
* @typedef {"copy" | "reschedule"} DragActionMode
|
|
* @typedef {"drag" | "locked" | "resize"} InteractionMode
|
|
* @typedef {`__pill__${number}`} PillId
|
|
* @typedef {import("./gantt_model").RowId} RowId
|
|
*
|
|
* @typedef Column
|
|
* @property {ColumnId} id
|
|
* @property {GridPosition} grid
|
|
* @property {boolean} [isToday]
|
|
* @property {DateTime} start
|
|
* @property {DateTime} stop
|
|
*
|
|
* @typedef GridPosition
|
|
* @property {number | number[]} [row]
|
|
* @property {number | number[]} [column]
|
|
*
|
|
* @typedef Group
|
|
* @property {boolean} break
|
|
* @property {number} col
|
|
* @property {Pill[]} pills
|
|
* @property {number} aggregateValue
|
|
* @property {GridPosition} grid
|
|
*
|
|
* @typedef GanttRendererProps
|
|
* @property {import("./gantt_model").GanttModel} model
|
|
* @property {Document} arch
|
|
* @property {string} class
|
|
* @property {(context: Record<string, any>)} create
|
|
* @property {{ content?: Point }} [scrollPosition]
|
|
* @property {{ el: HTMLDivElement | null }} [contentRef]
|
|
*
|
|
* @typedef HoveredInfo
|
|
* @property {Element | null} connector
|
|
* @property {HTMLElement | null} hoverable
|
|
* @property {HTMLElement | null} pill
|
|
*
|
|
* @typedef Interaction
|
|
* @property {InteractionMode | null} mode
|
|
* @property {DragActionMode} dragAction
|
|
*
|
|
* @typedef Pill
|
|
* @property {PillId} id
|
|
* @property {boolean} disableStartResize
|
|
* @property {boolean} disableStopResize
|
|
* @property {boolean} highlighted
|
|
* @property {number} leftMargin
|
|
* @property {number} level
|
|
* @property {string} name
|
|
* @property {DateTime} startDate
|
|
* @property {DateTime} stopDate
|
|
* @property {GridPosition} grid
|
|
* @property {RelationalRecord} record
|
|
* @property {number} _color
|
|
* @property {number} _progress
|
|
*
|
|
* @typedef Point
|
|
* @property {number} [x]
|
|
* @property {number} [y]
|
|
*
|
|
* @typedef {Record<string, any>} RelationalRecord
|
|
* @property {number | false} id
|
|
*
|
|
* @typedef ResizeBadge
|
|
* @property {Point & { right?: number }} position
|
|
* @property {number} diff
|
|
* @property {string} scale
|
|
*
|
|
* @typedef {import("./gantt_model").Row & {
|
|
* grid: GridPosition,
|
|
* pills: Pill[],
|
|
* cellColors?: Record<string, string>,
|
|
* thumbnailUrl?: string
|
|
* }} Row
|
|
*
|
|
* @typedef SubColumn
|
|
* @property {ColumnId} columnId
|
|
* @property {boolean} [isToday]
|
|
* @property {DateTime} start
|
|
* @property {DateTime} stop
|
|
*/
|
|
|
|
/** @type {[Omit<InteractionMode, "drag"> | DragActionMode, string][]} */
|
|
const INTERACTION_CLASSNAMES = [
|
|
["connect", "o_connect"],
|
|
["copy", "o_copying"],
|
|
["locked", "o_grabbing_locked"],
|
|
["reschedule", "o_grabbing"],
|
|
["resize", "o_resizing"],
|
|
];
|
|
const NEW_CONNECTOR_ID = "__connector__new";
|
|
|
|
/**
|
|
* Gantt Renderer
|
|
*
|
|
* @extends {Component<GanttRendererProps, any>}
|
|
*/
|
|
export class GanttRenderer extends Component {
|
|
static components = {
|
|
GanttConnector,
|
|
GanttRendererControls,
|
|
GanttResizeBadge,
|
|
GanttRowProgressBar,
|
|
Popover: GanttPopover,
|
|
};
|
|
static props = [
|
|
"model",
|
|
"arch",
|
|
"class",
|
|
"create",
|
|
"openDialog",
|
|
"scrollPosition?",
|
|
"contentRef?",
|
|
];
|
|
|
|
static template = "web_gantt.GanttRenderer";
|
|
static connectorCreatorTemplate = "web_gantt.GanttRenderer.ConnectorCreator";
|
|
static headerTemplate = "web_gantt.GanttRenderer.Header";
|
|
static pillTemplate = "web_gantt.GanttRenderer.Pill";
|
|
static groupPillTemplate = "web_gantt.GanttRenderer.GroupPill";
|
|
static rowContentTemplate = "web_gantt.GanttRenderer.RowContent";
|
|
static rowHeaderTemplate = "web_gantt.GanttRenderer.RowHeader";
|
|
static totalRowTemplate = "web_gantt.GanttRenderer.TotalRow";
|
|
|
|
static getRowHeaderWidth = (width) => 100 / (width > 768 ? 6 : 3);
|
|
|
|
setup() {
|
|
this.model = this.props.model;
|
|
|
|
this.gridRef = useRef("grid");
|
|
this.cellContainerRef = useRef("cellContainer");
|
|
|
|
this.actionService = useService("action");
|
|
this.dialogService = useService("dialog");
|
|
this.notificationService = useService("notification");
|
|
|
|
this.is24HourFormat = is24HourFormat();
|
|
|
|
/** @type {HoveredInfo} */
|
|
this.hovered = {
|
|
connector: null,
|
|
hoverable: null,
|
|
pill: null,
|
|
};
|
|
|
|
/** @type {Interaction} */
|
|
this.interaction = reactive(
|
|
{
|
|
mode: null,
|
|
dragAction: "reschedule",
|
|
},
|
|
() => this.onInteractionChange()
|
|
);
|
|
this.onInteractionChange(); // Used to hook into "interaction"
|
|
/** @type {Record<ConnectorId, ConnectorProps>} */
|
|
this.connectors = reactive({});
|
|
this.progressBarsReactive = reactive({ hoveredRowId: null });
|
|
/** @type {ResizeBadge} */
|
|
this.resizeBadgeReactive = reactive({});
|
|
|
|
/** @type {Object[]} */
|
|
this.columnsGroups = [];
|
|
/** @type {Column[]} */
|
|
this.columns = [];
|
|
/** @type {Pill[]} */
|
|
this.extraPills = [];
|
|
/** @type {Record<PillId, Pill>} */
|
|
this.pills = {}; // mapping to retrieve pills from pill ids
|
|
/** @type {Row[]} */
|
|
this.rows = [];
|
|
/** @type {SubColumn[]} */
|
|
this.subColumns = [];
|
|
/** @type {Record<RowId, Pill[]>} */
|
|
this.rowPills = {};
|
|
|
|
this.mappingColToColumn = new Map();
|
|
this.mappingColToSubColumn = new Map();
|
|
this.cursorPosition = {
|
|
x: 0,
|
|
y: 0,
|
|
};
|
|
const position = "bottom";
|
|
this.popover = usePopover(this.constructor.components.Popover, {
|
|
position,
|
|
onPositioned: (el, { direction }) => {
|
|
if (direction !== position) {
|
|
return;
|
|
}
|
|
const { left, right } = el.getBoundingClientRect();
|
|
if ((0 <= left && right <= window.innerWidth) || window.innerWidth < right - left) {
|
|
return;
|
|
}
|
|
const { left: pillLeft, right: pillRight } =
|
|
this.popover.target.getBoundingClientRect();
|
|
const middle =
|
|
(clamp(pillLeft, 0, window.innerWidth) +
|
|
clamp(pillRight, 0, window.innerWidth)) /
|
|
2;
|
|
el.style.left = `0px`;
|
|
const { width } = el.getBoundingClientRect();
|
|
el.style.left = `${middle - width / 2}px`;
|
|
},
|
|
onClose: () => {
|
|
delete this.popover.target;
|
|
},
|
|
});
|
|
|
|
this.throttledComputeHoverParams = throttleForAnimation((ev) =>
|
|
this.computeHoverParams(ev)
|
|
);
|
|
|
|
useExternalListener(window, "keydown", (ev) => this.onWindowKeyDown(ev));
|
|
useExternalListener(window, "keyup", (ev) => this.onWindowKeyUp(ev));
|
|
|
|
useExternalListener(
|
|
window,
|
|
"resize",
|
|
debounce(() => {
|
|
this.shouldComputeSomeWidths = true;
|
|
this.render();
|
|
}, 100)
|
|
);
|
|
|
|
useMultiHover({
|
|
ref: this.gridRef,
|
|
selector: ".o_gantt_group",
|
|
related: ["data-row-id"],
|
|
className: "o_gantt_group_hovered",
|
|
});
|
|
|
|
// Draggable pills
|
|
this.cellForDrag = { el: null, part: 0 };
|
|
const dragState = useGanttDraggable({
|
|
enable: () => Boolean(this.cellForDrag.el),
|
|
// Refs and selectors
|
|
ref: this.gridRef,
|
|
hoveredCell: this.cellForDrag,
|
|
elements: ".o_draggable",
|
|
ignore: ".o_resize_handle,.o_connector_creator_bullet",
|
|
cells: ".o_gantt_cell",
|
|
// Style classes
|
|
cellDragClassName: "o_gantt_cell o_drag_hover",
|
|
ghostClassName: "o_dragged_pill_ghost",
|
|
addStickyCoordinates: (rows, columns) => {
|
|
this.stickyGridRows = Object.assign({}, ...rows.map((row) => ({ [row]: true })));
|
|
this.stickyGridColumns = Object.assign(
|
|
{},
|
|
...columns.map((column) => ({ [column]: true }))
|
|
);
|
|
this.setSomeGridStyleProperties();
|
|
},
|
|
// Handlers
|
|
onDragStart: ({ pill }) => {
|
|
this.popover.close();
|
|
this.setStickyPill(pill);
|
|
this.interaction.mode = "drag";
|
|
},
|
|
onDragEnd: () => {
|
|
this.setStickyPill();
|
|
this.interaction.mode = null;
|
|
},
|
|
onDrop: (params) => this.dragPillDrop(params),
|
|
});
|
|
|
|
// Un-draggable pills
|
|
const unDragState = useGanttUndraggable({
|
|
// Refs and selectors
|
|
ref: this.gridRef,
|
|
elements: ".o_undraggable",
|
|
ignore: ".o_resize_handle,.o_connector_creator_bullet",
|
|
edgeScrolling: { enabled: false },
|
|
// Handlers
|
|
onDragStart: () => {
|
|
this.interaction.mode = "locked";
|
|
},
|
|
onDragEnd: () => {
|
|
this.interaction.mode = null;
|
|
},
|
|
});
|
|
|
|
// Cells selection
|
|
const selectState = useGanttSelectable({
|
|
enable: () => {
|
|
const { canCellCreate, canPlan } = this.model.metaData;
|
|
return Boolean(this.cellForDrag.el) && (canCellCreate || canPlan);
|
|
},
|
|
ref: this.gridRef,
|
|
hoveredCell: this.cellForDrag,
|
|
elements: ".o_gantt_cell:not(.o_gantt_group)",
|
|
edgeScrolling: { speed: 40, threshold: 150, direction: "horizontal" },
|
|
rtl: () => localization.direction === "rtl",
|
|
onDrop: ({ rowId, startCol, stopCol }) => {
|
|
const { canPlan } = this.model.metaData;
|
|
if (canPlan) {
|
|
this.onPlan(rowId, startCol, stopCol);
|
|
} else {
|
|
this.onCreate(rowId, startCol, stopCol);
|
|
}
|
|
},
|
|
});
|
|
|
|
// Resizable pills
|
|
const resizeState = useGanttResizable({
|
|
// Refs and selectors
|
|
ref: this.gridRef,
|
|
hoveredCell: this.cellForDrag,
|
|
elements: ".o_resizable",
|
|
innerPills: ".o_gantt_pill",
|
|
cells: ".o_gantt_cell",
|
|
// Other params
|
|
handles: "o_resize_handle",
|
|
edgeScrolling: { speed: 40, threshold: 150, direction: "horizontal" },
|
|
showHandles: (pillEl) => {
|
|
const pill = this.pills[pillEl.dataset.pillId];
|
|
const hideHandles = this.connectorDragState.dragging;
|
|
return {
|
|
start: !pill.disableStartResize && !hideHandles,
|
|
end: !pill.disableStopResize && !hideHandles,
|
|
};
|
|
},
|
|
rtl: () => localization.direction === "rtl",
|
|
precision: () => this.model.metaData.scale.cellPart,
|
|
// Handlers
|
|
onDragStart: ({ pill, addClass }) => {
|
|
this.popover.close();
|
|
this.setStickyPill(pill);
|
|
addClass(pill, "o_resized");
|
|
this.interaction.mode = "resize";
|
|
},
|
|
onDrag: ({ pill, grabbedHandle, diff }) => {
|
|
const rect = pill.getBoundingClientRect();
|
|
const position = { top: rect.y + rect.height };
|
|
if (grabbedHandle === "left") {
|
|
position.left = rect.x;
|
|
} else {
|
|
position.right = document.body.offsetWidth - rect.x - rect.width;
|
|
}
|
|
const { cellTime, unitDescription } = this.model.metaData.scale;
|
|
Object.assign(this.resizeBadgeReactive, {
|
|
position,
|
|
diff: diff * cellTime,
|
|
scale: unitDescription,
|
|
});
|
|
},
|
|
onDragEnd: ({ pill, removeClass }) => {
|
|
delete this.resizeBadgeReactive.position;
|
|
delete this.resizeBadgeReactive.diff;
|
|
delete this.resizeBadgeReactive.scale;
|
|
this.setStickyPill();
|
|
removeClass(pill, "o_resized");
|
|
this.interaction.mode = null;
|
|
},
|
|
onDrop: (params) => this.resizePillDrop(params),
|
|
});
|
|
|
|
// Draggable connector
|
|
let initialPillId;
|
|
this.connectorDragState = useGanttConnectorDraggable({
|
|
ref: this.gridRef,
|
|
elements: ".o_connector_creator_bullet",
|
|
parentWrapper: ".o_gantt_cells .o_gantt_pill_wrapper",
|
|
onDragStart: ({ sourcePill, x, y, addClass }) => {
|
|
this.popover.close();
|
|
initialPillId = sourcePill.dataset.pillId;
|
|
addClass(sourcePill, "o_connector_creator_lock");
|
|
this.setConnector({
|
|
id: NEW_CONNECTOR_ID,
|
|
highlighted: true,
|
|
sourcePoint: { left: x, top: y },
|
|
targetPoint: { left: x, top: y },
|
|
});
|
|
this.setStickyPill(sourcePill);
|
|
this.interaction.mode = "connect";
|
|
},
|
|
onDrag: ({ connectorCenter, x, y }) => {
|
|
this.setConnector({
|
|
id: NEW_CONNECTOR_ID,
|
|
sourcePoint: { left: connectorCenter.x, top: connectorCenter.y },
|
|
targetPoint: { left: x, top: y },
|
|
});
|
|
},
|
|
onDragEnd: () => {
|
|
this.setConnector({ id: NEW_CONNECTOR_ID, sourcePoint: null, targetPoint: null });
|
|
this.setStickyPill();
|
|
this.interaction.mode = null;
|
|
},
|
|
onDrop: ({ target }) => {
|
|
if (initialPillId === target.dataset.pillId) {
|
|
return;
|
|
}
|
|
const { id: masterId } = this.pills[initialPillId].record;
|
|
const { id: slaveId } = this.pills[target.dataset.pillId].record;
|
|
this.model.createDependency(masterId, slaveId);
|
|
},
|
|
});
|
|
|
|
this.dragStates = [dragState, unDragState, resizeState, selectState];
|
|
|
|
onWillStart(this.computeDerivedParams);
|
|
onWillUpdateProps(this.computeDerivedParams);
|
|
|
|
this.virtualGrid = useVirtualGrid({
|
|
scrollableRef: this.props.contentRef,
|
|
initialScroll: this.props.scrollPosition,
|
|
bufferCoef: 0.1,
|
|
onChange: (changed) => {
|
|
if ("columnsIndexes" in changed) {
|
|
this.shouldComputeGridColumns = true;
|
|
}
|
|
if ("rowsIndexes" in changed) {
|
|
this.shouldComputeGridRows = true;
|
|
}
|
|
this.render();
|
|
},
|
|
});
|
|
|
|
onWillRender(this.onWillRender);
|
|
|
|
useEffect(
|
|
(content) => {
|
|
content.addEventListener("scroll", this.throttledComputeHoverParams);
|
|
return () => {
|
|
content.removeEventListener("scroll", this.throttledComputeHoverParams);
|
|
};
|
|
},
|
|
() => [this.gridRef.el?.parentElement]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (this.useFocusDate) {
|
|
this.useFocusDate = false;
|
|
this.focusDate(this.model.metaData.focusDate);
|
|
}
|
|
});
|
|
|
|
this.env.getCurrentFocusDateCallBackRecorder.add(this, this.getCurrentFocusDate.bind(this));
|
|
}
|
|
|
|
//-------------------------------------------------------------------------
|
|
// Getters
|
|
//-------------------------------------------------------------------------
|
|
|
|
get controlsProps() {
|
|
return {
|
|
displayExpandCollapseButtons: this.rows[0]?.isGroup, // all rows on same level have same type
|
|
model: this.model,
|
|
focusToday: () => this.focusToday(),
|
|
getCurrentFocusDate: () => this.getCurrentFocusDate(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @returns {boolean}
|
|
*/
|
|
get hasRowHeaders() {
|
|
const { groupedBy } = this.model.metaData;
|
|
const { displayMode } = this.model.displayParams;
|
|
return groupedBy.length || displayMode === "sparse";
|
|
}
|
|
|
|
get isDragging() {
|
|
return this.dragStates.some((s) => s.dragging);
|
|
}
|
|
|
|
/**
|
|
* @returns {boolean}
|
|
*/
|
|
get isTouchDevice() {
|
|
return isMobileOS() || hasTouch();
|
|
}
|
|
|
|
//-------------------------------------------------------------------------
|
|
// Methods
|
|
//-------------------------------------------------------------------------
|
|
|
|
/**
|
|
*
|
|
* @param {Object} param
|
|
* @param {Object} param.grid
|
|
*/
|
|
addCoordinatesToCoarseGrid({ grid }) {
|
|
if (grid.row) {
|
|
this.coarseGridRows[this.getFirstGridRow({ grid })] = true;
|
|
this.coarseGridRows[this.getLastGridRow({ grid })] = true;
|
|
}
|
|
if (grid.column) {
|
|
this.coarseGridCols[this.getFirstGridCol({ grid })] = true;
|
|
this.coarseGridCols[this.getLastGridCol({ grid })] = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Pill} pill
|
|
* @param {Group} group
|
|
*/
|
|
addTo(pill, group) {
|
|
group.pills.push(pill);
|
|
group.aggregateValue++; // pill count
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Conditional function for aggregating pills when grouping the gantt view
|
|
* The first, unused parameter is added in case it's needed when overwriting the method.
|
|
* @param {Row} row
|
|
* @param {Group} group
|
|
* @returns {boolean}
|
|
*/
|
|
shouldAggregate(row, group) {
|
|
return Boolean(group.pills.length);
|
|
}
|
|
|
|
/**
|
|
* Aggregates overlapping pills in group rows.
|
|
*
|
|
* @param {Pill[]} pills
|
|
* @param {Row} row
|
|
*/
|
|
aggregatePills(pills, row) {
|
|
/** @type {Record<number, Group>} */
|
|
const groups = {};
|
|
function getGroup(col) {
|
|
if (!(col in groups)) {
|
|
groups[col] = {
|
|
break: false,
|
|
col,
|
|
pills: [],
|
|
aggregateValue: 0,
|
|
grid: { column: [col, col + 1] },
|
|
};
|
|
// group.break = true means that the group cannot be merged with the previous one
|
|
// We will merge groups that can be merged together (if this.shouldMergeGroups returns true)
|
|
}
|
|
return groups[col];
|
|
}
|
|
|
|
for (const pill of pills) {
|
|
let addedInPreviousCol = false;
|
|
let col;
|
|
for (col = this.getFirstGridCol(pill); col < this.getLastGridCol(pill); col++) {
|
|
const group = getGroup(col);
|
|
const added = this.addTo(pill, group);
|
|
if (addedInPreviousCol !== added) {
|
|
group.break = true;
|
|
}
|
|
addedInPreviousCol = added;
|
|
}
|
|
// here col = this.getLastGridCol(pill)
|
|
if (addedInPreviousCol && col <= this.columnCount) {
|
|
const group = getGroup(col);
|
|
group.break = true;
|
|
}
|
|
}
|
|
|
|
const filteredGroups = Object.values(groups).filter((g) => this.shouldAggregate(row, g));
|
|
|
|
if (this.shouldMergeGroups()) {
|
|
return this.mergeGroups(filteredGroups);
|
|
}
|
|
|
|
return filteredGroups;
|
|
}
|
|
|
|
/**
|
|
* Compute minimal levels required to display all pills without overlapping.
|
|
* Side effect: level key is modified in pills.
|
|
*
|
|
* @param {Pill[]} pills
|
|
*/
|
|
calculatePillsLevel(pills) {
|
|
const firstPill = pills[0];
|
|
firstPill.level = 0;
|
|
const levels = [
|
|
{
|
|
pills: [firstPill],
|
|
maxCol: this.getLastGridCol(firstPill) - 1,
|
|
},
|
|
];
|
|
for (const currentPill of pills.slice(1)) {
|
|
const lastCol = this.getLastGridCol(currentPill) - 1;
|
|
for (let l = 0; l < levels.length; l++) {
|
|
const level = levels[l];
|
|
if (this.getFirstGridCol(currentPill) > level.maxCol) {
|
|
currentPill.level = l;
|
|
level.pills.push(currentPill);
|
|
level.maxCol = lastCol;
|
|
break;
|
|
}
|
|
}
|
|
if (isNaN(currentPill.level)) {
|
|
currentPill.level = levels.length;
|
|
levels.push({
|
|
pills: [currentPill],
|
|
maxCol: lastCol,
|
|
});
|
|
}
|
|
}
|
|
return levels.length;
|
|
}
|
|
|
|
makeSubColumn(start, delta, cellTime, time) {
|
|
const subCellStart = dateAddFixedOffset(start, { [time]: delta * cellTime });
|
|
const subCellStop = dateAddFixedOffset(start, {
|
|
[time]: (delta + 1) * cellTime,
|
|
seconds: -1,
|
|
});
|
|
return { start: subCellStart, stop: subCellStop };
|
|
}
|
|
|
|
computeVisibleColumns() {
|
|
const [firstIndex, lastIndex] = this.virtualGrid.columnsIndexes;
|
|
this.columnsGroups = [];
|
|
this.columns = [];
|
|
this.subColumns = [];
|
|
this.coarseGridCols = {
|
|
1: true,
|
|
[this.columnCount * this.model.metaData.scale.cellPart + 1]: true,
|
|
};
|
|
|
|
const { globalStart, globalStop, scale } = this.model.metaData;
|
|
const { cellPart, interval, unit } = scale;
|
|
|
|
const now = DateTime.local();
|
|
|
|
const nowStart = now.startOf(interval);
|
|
const nowEnd = now.endOf(interval);
|
|
|
|
const groupsLeftBound = DateTime.max(
|
|
globalStart,
|
|
localStartOf(globalStart.plus({ [interval]: firstIndex }), unit)
|
|
);
|
|
const groupsRightBound = DateTime.min(
|
|
localEndOf(globalStart.plus({ [interval]: lastIndex }), unit),
|
|
globalStop
|
|
);
|
|
let currentGroup = null;
|
|
for (let j = firstIndex; j <= lastIndex; j++) {
|
|
const columnId = `__column__${j + 1}`;
|
|
const col = j * cellPart + 1;
|
|
const { start, stop } = this.getColumnFromColNumber(col);
|
|
const column = {
|
|
id: columnId,
|
|
grid: { column: [col, col + cellPart] },
|
|
start,
|
|
stop,
|
|
};
|
|
const isToday = nowStart <= start && start <= nowEnd;
|
|
if (isToday) {
|
|
column.isToday = true;
|
|
}
|
|
this.columns.push(column);
|
|
|
|
for (let i = 0; i < cellPart; i++) {
|
|
const subColumn = this.getSubColumnFromColNumber(col + i);
|
|
this.subColumns.push({ ...subColumn, isToday, columnId });
|
|
this.coarseGridCols[col + i] = true;
|
|
}
|
|
|
|
const groupStart = localStartOf(start, unit);
|
|
if (!currentGroup || !groupStart.equals(currentGroup.start)) {
|
|
const groupId = `__group__${this.columnsGroups.length + 1}`;
|
|
const startingBound = DateTime.max(groupsLeftBound, groupStart);
|
|
const endingBound = DateTime.min(groupsRightBound, localEndOf(groupStart, unit));
|
|
const [groupFirstCol, groupLastCol] = this.getGridColumnFromDates(
|
|
startingBound,
|
|
endingBound
|
|
);
|
|
currentGroup = {
|
|
id: groupId,
|
|
grid: { column: [groupFirstCol, groupLastCol] },
|
|
start: groupStart,
|
|
};
|
|
this.columnsGroups.push(currentGroup);
|
|
this.coarseGridCols[groupFirstCol] = true;
|
|
this.coarseGridCols[groupLastCol] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
computeVisibleRows() {
|
|
this.coarseGridRows = {
|
|
1: true,
|
|
[this.getLastGridRow(this.rows[this.rows.length - 1])]: true,
|
|
};
|
|
const [rowStart, rowEnd] = this.virtualGrid.rowsIndexes;
|
|
this.rowsToRender = new Set();
|
|
for (const row of this.rows) {
|
|
const [first, last] = row.grid.row;
|
|
if (last <= rowStart + 1 || first > rowEnd + 1) {
|
|
continue;
|
|
}
|
|
this.addToRowsToRender(row);
|
|
}
|
|
}
|
|
|
|
getFirstGridCol({ grid }) {
|
|
const [first] = grid.column;
|
|
return first;
|
|
}
|
|
|
|
getLastGridCol({ grid }) {
|
|
const [, last] = grid.column;
|
|
return last;
|
|
}
|
|
|
|
getFirstGridRow({ grid }) {
|
|
const [first] = grid.row;
|
|
return first;
|
|
}
|
|
|
|
getLastGridRow({ grid }) {
|
|
const [, last] = grid.row;
|
|
return last;
|
|
}
|
|
|
|
addToPillsToRender(pill) {
|
|
this.pillsToRender.add(pill);
|
|
this.addCoordinatesToCoarseGrid(pill);
|
|
}
|
|
|
|
addToRowsToRender(row) {
|
|
this.rowsToRender.add(row);
|
|
const [first, last] = row.grid.row;
|
|
for (let i = first; i <= last; i++) {
|
|
this.coarseGridRows[i] = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* give bounds only
|
|
*/
|
|
getVisibleCols() {
|
|
const [columnStart, columnEnd] = this.virtualGrid.columnsIndexes;
|
|
const { cellPart } = this.model.metaData.scale;
|
|
const firstVisibleCol = 1 + cellPart * columnStart;
|
|
const lastVisibleCol = 1 + cellPart * (columnEnd + 1);
|
|
return [firstVisibleCol, lastVisibleCol];
|
|
}
|
|
|
|
/**
|
|
* give bounds only
|
|
*/
|
|
getVisibleRows() {
|
|
const [rowStart, rowEnd] = this.virtualGrid.rowsIndexes;
|
|
const firstVisibleRow = rowStart + 1;
|
|
const lastVisibleRow = rowEnd + 1;
|
|
return [firstVisibleRow, lastVisibleRow];
|
|
}
|
|
|
|
computeVisiblePills() {
|
|
this.pillsToRender = new Set();
|
|
|
|
const [firstVisibleCol, lastVisibleCol] = this.getVisibleCols();
|
|
const [firstVisibleRow, lastVisibleRow] = this.getVisibleRows();
|
|
|
|
const isOut = (pill, filterOnRow = true) =>
|
|
this.getFirstGridCol(pill) > lastVisibleCol ||
|
|
this.getLastGridCol(pill) < firstVisibleCol ||
|
|
(filterOnRow &&
|
|
(this.getFirstGridRow(pill) > lastVisibleRow ||
|
|
this.getLastGridRow(pill) - 1 < firstVisibleRow));
|
|
|
|
const getRowPills = (row, filterOnRow) =>
|
|
(this.rowPills[row.id] || []).filter((pill) => !isOut(pill, filterOnRow));
|
|
|
|
for (const row of this.rowsToRender) {
|
|
for (const rowPill of getRowPills(row)) {
|
|
this.addToPillsToRender(rowPill);
|
|
}
|
|
if (!row.isGroup && row.unavailabilities?.length) {
|
|
row.cellColors = this.getRowCellColors(row);
|
|
}
|
|
}
|
|
|
|
if (this.stickyPillId) {
|
|
this.addToPillsToRender(this.pills[this.stickyPillId]);
|
|
}
|
|
|
|
if (this.totalRow) {
|
|
this.totalRow.pills = getRowPills(this.totalRow, false);
|
|
for (const pill of this.totalRow.pills) {
|
|
this.addCoordinatesToCoarseGrid({ grid: omit(pill.grid, "row") });
|
|
}
|
|
}
|
|
}
|
|
|
|
computeVisibleConnectors() {
|
|
const visibleConnectorIds = new Set([NEW_CONNECTOR_ID]);
|
|
|
|
for (const pill of this.pillsToRender) {
|
|
const row = this.getRowFromPill(pill);
|
|
if (row.isGroup) {
|
|
continue;
|
|
}
|
|
for (const connectorId of this.mappingPillToConnectors[pill.id] || []) {
|
|
visibleConnectorIds.add(connectorId);
|
|
}
|
|
}
|
|
|
|
this.connectorsToRender = [];
|
|
for (const connectorId in this.connectors) {
|
|
if (!visibleConnectorIds.has(connectorId)) {
|
|
continue;
|
|
}
|
|
this.connectorsToRender.push(this.connectors[connectorId]);
|
|
const { sourcePillId, targetPillId } = this.mappingConnectorToPills[connectorId];
|
|
if (sourcePillId) {
|
|
this.addToPillsToRender(this.pills[sourcePillId]);
|
|
}
|
|
if (targetPillId) {
|
|
this.addToPillsToRender(this.pills[targetPillId]);
|
|
}
|
|
}
|
|
}
|
|
|
|
getRowFromPill(pill) {
|
|
return this.rowByIds[pill.rowId];
|
|
}
|
|
|
|
getColInCoarseGridKeys() {
|
|
return Object.keys({ ...this.coarseGridCols, ...this.stickyGridColumns });
|
|
}
|
|
|
|
getRowInCoarseGridKeys() {
|
|
return Object.keys({ ...this.coarseGridRows, ...this.stickyGridRows });
|
|
}
|
|
|
|
computeColsTemplate() {
|
|
const colsTemplate = [];
|
|
const colInCoarseGridKeys = this.getColInCoarseGridKeys();
|
|
for (let i = 0; i < colInCoarseGridKeys.length - 1; i++) {
|
|
const x = +colInCoarseGridKeys[i];
|
|
const y = +colInCoarseGridKeys[i + 1];
|
|
const colName = `c${x}`;
|
|
const width = (y - x) * this.cellPartWidth;
|
|
colsTemplate.push(`[${colName}]minmax(${width}px,1fr)`);
|
|
}
|
|
colsTemplate.push(`[c${colInCoarseGridKeys.at(-1)}]`);
|
|
return colsTemplate.join("");
|
|
}
|
|
|
|
computeRowsTemplate() {
|
|
const rowsTemplate = [];
|
|
const rowInCoarseGridKeys = this.getRowInCoarseGridKeys();
|
|
for (let i = 0; i < rowInCoarseGridKeys.length - 1; i++) {
|
|
const x = +rowInCoarseGridKeys[i];
|
|
const y = +rowInCoarseGridKeys[i + 1];
|
|
const rowName = `r${x}`;
|
|
const height = this.gridRows.slice(x - 1, y - 1).reduce((a, b) => a + b, 0);
|
|
rowsTemplate.push(`[${rowName}]${height}px`);
|
|
}
|
|
rowsTemplate.push(`[r${rowInCoarseGridKeys.at(-1)}]`);
|
|
return rowsTemplate.join("");
|
|
}
|
|
|
|
computeSomeWidths() {
|
|
const { cellPart, minimalColumnWidth } = this.model.metaData.scale;
|
|
this.contentRefWidth = this.props.contentRef.el?.clientWidth ?? document.body.clientWidth;
|
|
const rowHeaderWidthPercentage = this.hasRowHeaders
|
|
? this.constructor.getRowHeaderWidth(this.contentRefWidth)
|
|
: 0;
|
|
this.rowHeaderWidth = this.hasRowHeaders
|
|
? Math.round((rowHeaderWidthPercentage * this.contentRefWidth) / 100)
|
|
: 0;
|
|
const cellContainerWidth = this.contentRefWidth - this.rowHeaderWidth;
|
|
const columnWidth = Math.floor(cellContainerWidth / this.columnCount);
|
|
const rectifiedColumnWidth = Math.max(columnWidth, minimalColumnWidth);
|
|
this.cellPartWidth = Math.floor(rectifiedColumnWidth / cellPart);
|
|
this.columnWidth = this.cellPartWidth * cellPart;
|
|
if (columnWidth <= minimalColumnWidth) {
|
|
// overflow
|
|
this.totalWidth = this.rowHeaderWidth + this.columnWidth * this.columnCount;
|
|
} else {
|
|
this.totalWidth = null;
|
|
}
|
|
}
|
|
|
|
computeDerivedParams() {
|
|
const { rows: modelRows } = this.model.data;
|
|
|
|
if (this.shouldRenderConnectors()) {
|
|
/** @type {Record<number, { masterIds: number[], pills: Record<RowId, Pill> }>} */
|
|
this.mappingRecordToPillsByRow = {};
|
|
/** @type {Record<RowId, Record<number, Pill>>} */
|
|
this.mappingRowToPillsByRecord = {};
|
|
/** @type {Record<ConnectorId, { sourcePillId: PillId, targetPillId: PillId }>} */
|
|
this.mappingConnectorToPills = {};
|
|
/** @type {Record<PillId, ConnectorId>} */
|
|
this.mappingPillToConnectors = {};
|
|
}
|
|
|
|
const { globalStart, globalStop, scale, startDate, stopDate } = this.model.metaData;
|
|
this.columnCount = diffColumn(globalStart, globalStop, scale.interval);
|
|
if (
|
|
!this.currentStartDate ||
|
|
diffColumn(this.currentStartDate, startDate, "day") ||
|
|
diffColumn(this.currentStopDate, stopDate, "day") ||
|
|
this.currentScaleId !== scale.id
|
|
) {
|
|
this.useFocusDate = true;
|
|
this.mappingColToColumn = new Map();
|
|
this.mappingColToSubColumn = new Map();
|
|
}
|
|
this.currentStartDate = startDate;
|
|
this.currentStopDate = stopDate;
|
|
this.currentScaleId = scale.id;
|
|
|
|
this.currentGridRow = 1;
|
|
this.gridRows = [];
|
|
this.nextPillId = 1;
|
|
|
|
this.pills = {}; // mapping to retrieve pills from pill ids
|
|
this.rows = [];
|
|
this.rowPills = {};
|
|
this.rowByIds = {};
|
|
|
|
const prePills = this.getPills();
|
|
|
|
let pillsToProcess = [...prePills];
|
|
for (const row of modelRows) {
|
|
const result = this.processRow(row, pillsToProcess);
|
|
this.rows.push(...result.rows);
|
|
pillsToProcess = result.pillsToProcess;
|
|
}
|
|
|
|
const { displayTotalRow } = this.model.metaData;
|
|
if (displayTotalRow) {
|
|
this.totalRow = this.getTotalRow(prePills);
|
|
}
|
|
|
|
if (this.shouldRenderConnectors()) {
|
|
this.initializeConnectors();
|
|
this.generateConnectors();
|
|
}
|
|
|
|
this.shouldComputeSomeWidths = true;
|
|
this.shouldComputeGridColumns = true;
|
|
this.shouldComputeGridRows = true;
|
|
}
|
|
|
|
computeDerivedParamsFromHover() {
|
|
const { scale } = this.model.metaData;
|
|
|
|
const { connector, hoverable, pill } = this.hovered;
|
|
|
|
// Update cell in drag
|
|
const isCellHovered = hoverable?.matches(".o_gantt_cell");
|
|
this.cellForDrag.el = isCellHovered ? hoverable : null;
|
|
this.cellForDrag.part = 0;
|
|
if (isCellHovered && scale.cellPart > 1) {
|
|
const rect = hoverable.getBoundingClientRect();
|
|
const x = Math.floor(rect.x);
|
|
const width = Math.floor(rect.width);
|
|
this.cellForDrag.part = Math.floor(
|
|
(this.cursorPosition.x - x) / (width / scale.cellPart)
|
|
);
|
|
if (localization.direction === "rtl") {
|
|
this.cellForDrag.part = scale.cellPart - 1 - this.cellForDrag.part;
|
|
}
|
|
}
|
|
|
|
if (this.isDragging) {
|
|
this.progressBarsReactive.hoveredRowId = null;
|
|
return;
|
|
}
|
|
|
|
if (!this.connectorDragState.dragging) {
|
|
// Highlight connector
|
|
const hoveredConnectorId = connector?.dataset.connectorId;
|
|
for (const connectorId in this.connectors) {
|
|
if (connectorId !== hoveredConnectorId) {
|
|
this.toggleConnectorHighlighting(connectorId, false);
|
|
}
|
|
}
|
|
if (hoveredConnectorId) {
|
|
this.progressBarsReactive.hoveredRowId = null;
|
|
return this.toggleConnectorHighlighting(hoveredConnectorId, true);
|
|
}
|
|
}
|
|
|
|
// Highlight pill
|
|
const hoveredPillId = pill?.dataset.pillId;
|
|
for (const pillId in this.pills) {
|
|
if (pillId !== hoveredPillId) {
|
|
this.togglePillHighlighting(pillId, false);
|
|
}
|
|
}
|
|
this.togglePillHighlighting(hoveredPillId, true);
|
|
|
|
// Update progress bars
|
|
this.progressBarsReactive.hoveredRowId = hoverable ? hoverable.dataset.rowId : null;
|
|
}
|
|
|
|
/**
|
|
* @param {ConnectorId} connectorId
|
|
*/
|
|
deleteConnector(connectorId) {
|
|
delete this.connectors[connectorId];
|
|
delete this.mappingConnectorToPills[connectorId];
|
|
}
|
|
|
|
/**
|
|
* @param {Object} params
|
|
* @param {Element} params.pill
|
|
* @param {Element} params.cell
|
|
* @param {number} params.diff
|
|
*/
|
|
async dragPillDrop({ pill, cell, diff }) {
|
|
const { rowId } = cell.dataset;
|
|
const { dateStartField, dateStopField, scale } = this.model.metaData;
|
|
const { cellTime, time } = scale;
|
|
const { record } = this.pills[pill.dataset.pillId];
|
|
const params = this.getScheduleParams(pill);
|
|
|
|
params.start =
|
|
diff && dateAddFixedOffset(record[dateStartField], { [time]: cellTime * diff });
|
|
params.stop =
|
|
diff && dateAddFixedOffset(record[dateStopField], { [time]: cellTime * diff });
|
|
params.rowId = rowId;
|
|
|
|
const schedule = this.model.getSchedule(params);
|
|
|
|
if (this.interaction.dragAction === "copy") {
|
|
await this.model.copy(record.id, schedule, this.openPlanDialogCallback);
|
|
} else {
|
|
await this.model.reschedule(record.id, schedule, this.openPlanDialogCallback);
|
|
}
|
|
|
|
// If the pill lands on a closed group -> open it
|
|
if (cell.classList.contains("o_gantt_group") && this.model.isClosed(rowId)) {
|
|
this.model.toggleRow(rowId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Partial<Pill>} pill
|
|
* @returns {Pill}
|
|
*/
|
|
enrichPill(pill) {
|
|
const { colorField, fields, pillDecorations, progressField } = this.model.metaData;
|
|
|
|
pill.displayName = this.getDisplayName(pill);
|
|
|
|
const classes = [];
|
|
|
|
if (pillDecorations) {
|
|
const pillContext = Object.assign({}, user.context);
|
|
for (const [fieldName, value] of Object.entries(pill.record)) {
|
|
const field = fields[fieldName];
|
|
switch (field.type) {
|
|
case "date": {
|
|
pillContext[fieldName] = value ? serializeDate(value) : false;
|
|
break;
|
|
}
|
|
case "datetime": {
|
|
pillContext[fieldName] = value ? serializeDateTime(value) : false;
|
|
break;
|
|
}
|
|
default: {
|
|
pillContext[fieldName] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const decoration in pillDecorations) {
|
|
const expr = pillDecorations[decoration];
|
|
if (evaluateBooleanExpr(expr, pillContext)) {
|
|
classes.push(decoration);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (colorField) {
|
|
pill._color = getColorIndex(pill.record[colorField]);
|
|
classes.push(`o_gantt_color_${pill._color}`);
|
|
}
|
|
|
|
if (progressField) {
|
|
pill._progress = pill.record[progressField] || 0;
|
|
}
|
|
|
|
pill.className = classes.join(" ");
|
|
|
|
return pill;
|
|
}
|
|
|
|
focusDate(date, ifInBounds) {
|
|
const { globalStart, globalStop } = this.model.metaData;
|
|
const diff = date.diff(globalStart);
|
|
const totalDiff = globalStop.diff(globalStart);
|
|
const factor = diff / totalDiff;
|
|
if (ifInBounds && (factor < 0 || 1 < factor)) {
|
|
return false;
|
|
}
|
|
const rtlFactor = localization.direction === "rtl" ? -1 : 1;
|
|
const scrollLeft =
|
|
factor * this.cellContainerRef.el.clientWidth +
|
|
this.rowHeaderWidth -
|
|
(this.contentRefWidth + this.rowHeaderWidth) / 2;
|
|
this.props.contentRef.el.scrollLeft = rtlFactor * scrollLeft;
|
|
return true;
|
|
}
|
|
|
|
focusFirstPill(rowId) {
|
|
const pill = this.rowPills[rowId][0];
|
|
if (pill) {
|
|
const col = this.getFirstGridCol(pill);
|
|
const { start: date } = this.getColumnFromColNumber(col);
|
|
this.focusDate(date);
|
|
}
|
|
}
|
|
|
|
focusToday() {
|
|
return this.focusDate(DateTime.local().startOf("day"), true);
|
|
}
|
|
|
|
generateConnectors() {
|
|
this.nextConnectorId = 1;
|
|
this.setConnector({
|
|
id: NEW_CONNECTOR_ID,
|
|
highlighted: true,
|
|
sourcePoint: null,
|
|
targetPoint: null,
|
|
});
|
|
for (const slaveId in this.mappingRecordToPillsByRow) {
|
|
const { masterIds, pills: slavePills } = this.mappingRecordToPillsByRow[slaveId];
|
|
for (const masterId of masterIds) {
|
|
if (!(masterId in this.mappingRecordToPillsByRow)) {
|
|
continue;
|
|
}
|
|
const { pills: masterPills } = this.mappingRecordToPillsByRow[masterId];
|
|
for (const [slaveRowId, targetPill] of Object.entries(slavePills)) {
|
|
for (const [masterRowId, sourcePill] of Object.entries(masterPills)) {
|
|
if (
|
|
masterRowId === slaveRowId ||
|
|
!(
|
|
slaveId in this.mappingRowToPillsByRecord[masterRowId] ||
|
|
masterId in this.mappingRowToPillsByRecord[slaveRowId]
|
|
) ||
|
|
Object.keys(this.mappingRecordToPillsByRow[slaveId].pills).every(
|
|
(rowId) =>
|
|
rowId !== masterRowId &&
|
|
masterId in this.mappingRowToPillsByRecord[rowId]
|
|
) ||
|
|
Object.keys(this.mappingRecordToPillsByRow[masterId].pills).every(
|
|
(rowId) =>
|
|
rowId !== slaveRowId &&
|
|
slaveId in this.mappingRowToPillsByRecord[rowId]
|
|
)
|
|
) {
|
|
const masterRecord = sourcePill.record;
|
|
const slaveRecord = targetPill.record;
|
|
this.setConnector(
|
|
{ alert: this.getConnectorAlert(masterRecord, slaveRecord) },
|
|
sourcePill.id,
|
|
targetPill.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Group} group
|
|
* @param {Group} previousGroup
|
|
*/
|
|
getAggregateValue(group, previousGroup) {
|
|
// both groups have the same pills by construction
|
|
// here the aggregateValue is the pill count
|
|
return group.aggregateValue;
|
|
}
|
|
|
|
/**
|
|
* @param {number} startCol
|
|
* @param {number} stopCol
|
|
* @param {boolean} [roundUpStop=true]
|
|
*/
|
|
getColumnStartStop(startCol, stopCol, roundUpStop = true) {
|
|
const { start } = this.getColumnFromColNumber(startCol);
|
|
let { stop } = this.getColumnFromColNumber(stopCol);
|
|
if (roundUpStop) {
|
|
stop = stop.plus({ millisecond: 1 });
|
|
}
|
|
return { start, stop };
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {number} masterRecord
|
|
* @param {number} slaveRecord
|
|
* @returns {import("./gantt_connector").ConnectorAlert | null}
|
|
*/
|
|
getConnectorAlert(masterRecord, slaveRecord) {
|
|
const { dateStartField, dateStopField } = this.model.metaData;
|
|
if (slaveRecord[dateStartField] < masterRecord[dateStopField]) {
|
|
if (slaveRecord[dateStartField] < masterRecord[dateStartField]) {
|
|
return "error";
|
|
} else {
|
|
return "warning";
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param {Row} row
|
|
* @param {Column} column
|
|
* @return {Object}
|
|
*/
|
|
ganttCellAttClass(row, column) {
|
|
return {
|
|
o_sample_data_disabled: this.isDisabled(row),
|
|
o_gantt_today: column.isToday,
|
|
o_gantt_group: row.isGroup,
|
|
o_gantt_hoverable: this.isHoverable(row),
|
|
o_group_open: !this.model.isClosed(row.id),
|
|
};
|
|
}
|
|
|
|
getCurrentFocusDate() {
|
|
const { globalStart, globalStop } = this.model.metaData;
|
|
const rtlFactor = localization.direction === "rtl" ? -1 : 1;
|
|
const cellGridMiddleX =
|
|
rtlFactor * this.props.contentRef.el.scrollLeft +
|
|
(this.contentRefWidth + this.rowHeaderWidth) / 2;
|
|
const factor =
|
|
(cellGridMiddleX - this.rowHeaderWidth) / this.cellContainerRef.el.clientWidth;
|
|
const totalDiff = globalStop.diff(globalStart);
|
|
const diff = factor * totalDiff;
|
|
const focusDate = globalStart.plus(diff);
|
|
return focusDate;
|
|
}
|
|
|
|
/**
|
|
* @param {"top"|"bottom"} vertical the vertical alignment of the connector creator
|
|
* @returns {{ vertical: "top"|"bottom", horizontal: "left"|"right" }}
|
|
*/
|
|
getConnectorCreatorAlignment(vertical) {
|
|
const alignment = { vertical };
|
|
if (localization.direction === "rtl") {
|
|
alignment.horizontal = vertical === "top" ? "right" : "left";
|
|
} else {
|
|
alignment.horizontal = vertical === "top" ? "left" : "right";
|
|
}
|
|
return alignment;
|
|
}
|
|
|
|
/**
|
|
* Get schedule parameters
|
|
*
|
|
* @param {Element} pill
|
|
* @returns {Object} - An object containing parameters needed for scheduling the pill.
|
|
*/
|
|
getScheduleParams(pill) {
|
|
return {};
|
|
}
|
|
|
|
/**
|
|
* This function will add a 'label' property to each
|
|
* non-consolidated pill included in the pills list.
|
|
* This new property is a string meant to replace
|
|
* the text displayed on a pill.
|
|
*
|
|
* @param {Pill} pill
|
|
*/
|
|
getDisplayName(pill) {
|
|
const { computePillDisplayName, dateStartField, dateStopField, scale } =
|
|
this.model.metaData;
|
|
const { id: scaleId } = scale;
|
|
const { record } = pill;
|
|
|
|
if (!computePillDisplayName) {
|
|
return record.display_name;
|
|
}
|
|
|
|
const startDate = record[dateStartField];
|
|
const stopDate = record[dateStopField];
|
|
const yearlessDateFormat = omit(DateTime.DATE_SHORT, "year");
|
|
|
|
const spanAccrossDays =
|
|
stopDate.startOf("day") > startDate.startOf("day") &&
|
|
startDate.endOf("day").diff(startDate, "hours").toObject().hours >= 3 &&
|
|
stopDate.diff(stopDate.startOf("day"), "hours").toObject().hours >= 3;
|
|
const spanAccrossWeeks = getStartOfLocalWeek(stopDate) > getStartOfLocalWeek(startDate);
|
|
const spanAccrossMonths = stopDate.startOf("month") > startDate.startOf("month");
|
|
|
|
/** @type {string[]} */
|
|
const labelElements = [];
|
|
|
|
// Start & End Dates
|
|
if (scaleId === "year" && !spanAccrossDays) {
|
|
labelElements.push(startDate.toLocaleString(yearlessDateFormat));
|
|
} else if (
|
|
(scaleId === "day" && spanAccrossDays) ||
|
|
(scaleId === "week" && spanAccrossWeeks) ||
|
|
(scaleId === "month" && spanAccrossMonths) ||
|
|
(scaleId === "year" && spanAccrossDays)
|
|
) {
|
|
labelElements.push(startDate.toLocaleString(yearlessDateFormat));
|
|
labelElements.push(stopDate.toLocaleString(yearlessDateFormat));
|
|
}
|
|
|
|
// Start & End Times
|
|
if (record.allocated_hours && !spanAccrossDays && ["week", "month"].includes(scaleId)) {
|
|
const durationStr = this.getDurationStr(record);
|
|
labelElements.push(startDate.toFormat("t"), `${stopDate.toFormat("t")}${durationStr}`);
|
|
}
|
|
|
|
// Original Display Name
|
|
if (scaleId !== "month" || !record.allocated_hours || spanAccrossDays) {
|
|
labelElements.push(record.display_name);
|
|
}
|
|
|
|
return labelElements.filter((el) => !!el).join(" - ");
|
|
}
|
|
|
|
/**
|
|
* @param {RelationalRecord} record
|
|
*/
|
|
getDurationStr(record) {
|
|
const durationStr = formatFloatTime(record.allocated_hours, {
|
|
noLeadingZeroHour: true,
|
|
}).replace(/(:00|:)/g, "h");
|
|
return ` (${durationStr})`;
|
|
}
|
|
|
|
/**
|
|
* @param {Pill} pill
|
|
*/
|
|
getGroupPillDisplayName(pill) {
|
|
return pill.aggregateValue;
|
|
}
|
|
|
|
/**
|
|
* @param {{ column?: [number, number], row?: [number, number] }} position
|
|
*/
|
|
getGridPosition(position) {
|
|
const style = [];
|
|
const keys = Object.keys(pick(position, "column", "row"));
|
|
for (const key of keys) {
|
|
const prefix = key.slice(0, 1);
|
|
const [first, last] = position[key];
|
|
style.push(`grid-${key}:${prefix}${first}/${prefix}${last}`);
|
|
}
|
|
return style.join(";");
|
|
}
|
|
|
|
setSomeGridStyleProperties() {
|
|
const rowsTemplate = this.computeRowsTemplate();
|
|
const colsTemplate = this.computeColsTemplate();
|
|
this.gridRef.el.style.setProperty("--Gantt__GridRows-grid-template-rows", rowsTemplate);
|
|
this.gridRef.el.style.setProperty(
|
|
"--Gantt__GridColumns-grid-template-columns",
|
|
colsTemplate
|
|
);
|
|
}
|
|
|
|
getGridStyle() {
|
|
const rowsTemplate = this.computeRowsTemplate();
|
|
const colsTemplate = this.computeColsTemplate();
|
|
const style = {
|
|
"--Gantt__RowHeader-width": `${this.rowHeaderWidth}px`,
|
|
"--Gantt__Pill-height": "35px",
|
|
"--Gantt__Thumbnail-max-height": "16px",
|
|
"--Gantt__GridRows-grid-template-rows": rowsTemplate,
|
|
"--Gantt__GridColumns-grid-template-columns": colsTemplate,
|
|
};
|
|
if (this.totalWidth !== null) {
|
|
style.width = `${this.totalWidth}px`;
|
|
}
|
|
return Object.entries(style)
|
|
.map((entry) => entry.join(":"))
|
|
.join(";");
|
|
}
|
|
|
|
/**
|
|
* @param {RelationalRecord} record
|
|
* @returns {Partial<Pill>}
|
|
*/
|
|
getPill(record) {
|
|
const { canEdit, dateStartField, dateStopField, disableDrag, globalStart, globalStop } =
|
|
this.model.metaData;
|
|
|
|
const startOutside = record[dateStartField] < globalStart;
|
|
|
|
let recordDateStopField = record[dateStopField];
|
|
if (this.model.dateStopFieldIsDate()) {
|
|
recordDateStopField = recordDateStopField.plus({ day: 1 });
|
|
}
|
|
|
|
const stopOutside = recordDateStopField > globalStop;
|
|
|
|
/** @type {DateTime} */
|
|
const pillStartDate = startOutside ? globalStart : record[dateStartField];
|
|
/** @type {DateTime} */
|
|
const pillStopDate = stopOutside ? globalStop : recordDateStopField;
|
|
|
|
const disableStartResize = !canEdit || startOutside;
|
|
const disableStopResize = !canEdit || stopOutside;
|
|
|
|
/** @type {Partial<Pill>} */
|
|
const pill = {
|
|
disableDrag: disableDrag || disableStartResize || disableStopResize,
|
|
disableStartResize,
|
|
disableStopResize,
|
|
grid: { column: this.getGridColumnFromDates(pillStartDate, pillStopDate) },
|
|
record,
|
|
};
|
|
|
|
return pill;
|
|
}
|
|
|
|
getGridColumnFromDates(startDate, stopDate) {
|
|
const { globalStart, scale } = this.model.metaData;
|
|
const { cellPart, interval } = scale;
|
|
const { column: column1, delta: delta1 } = this.getSubColumnFromDate(startDate);
|
|
const { column: column2, delta: delta2 } = this.getSubColumnFromDate(stopDate, false);
|
|
const firstCol = 1 + diffColumn(globalStart, column1, interval) * cellPart + delta1;
|
|
const span = diffColumn(column1, column2, interval) * cellPart + delta2 - delta1;
|
|
return [firstCol, firstCol + span];
|
|
}
|
|
|
|
getSubColumnFromDate(date, onLeft = true) {
|
|
const { interval, cellPart, cellTime, time } = this.model.metaData.scale;
|
|
const column = date.startOf(interval);
|
|
let delta;
|
|
if (onLeft) {
|
|
delta = 0;
|
|
for (let i = 1; i < cellPart; i++) {
|
|
const subCellStart = dateAddFixedOffset(column, { [time]: i * cellTime });
|
|
if (subCellStart <= date) {
|
|
delta += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
delta = cellPart;
|
|
for (let i = cellPart - 1; i >= 0; i--) {
|
|
const subCellStart = dateAddFixedOffset(column, { [time]: i * cellTime });
|
|
if (subCellStart >= date) {
|
|
delta -= 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return { column, delta };
|
|
}
|
|
|
|
getSubColumnFromColNumber(col) {
|
|
let subColumn = this.mappingColToSubColumn.get(col);
|
|
if (!subColumn) {
|
|
const { globalStart, scale } = this.model.metaData;
|
|
const { interval, cellPart, cellTime, time } = scale;
|
|
const delta = (col - 1) % cellPart;
|
|
const columnIndex = (col - 1 - delta) / cellPart;
|
|
const start = globalStart.plus({ [interval]: columnIndex });
|
|
subColumn = this.makeSubColumn(start, delta, cellTime, time);
|
|
this.mappingColToSubColumn.set(col, subColumn);
|
|
}
|
|
return subColumn;
|
|
}
|
|
|
|
getColumnFromColNumber(col) {
|
|
let column = this.mappingColToColumn.get(col);
|
|
if (!column) {
|
|
const { globalStart, scale } = this.model.metaData;
|
|
const { interval, cellPart } = scale;
|
|
const delta = (col - 1) % cellPart;
|
|
const columnIndex = (col - 1 - delta) / cellPart;
|
|
const start = globalStart.plus({ [interval]: columnIndex });
|
|
const stop = start.endOf(interval);
|
|
column = { start, stop };
|
|
this.mappingColToColumn.set(col, column);
|
|
}
|
|
return column;
|
|
}
|
|
|
|
/**
|
|
* @param {PillId} pillId
|
|
*/
|
|
getPillEl(pillId) {
|
|
return this.getPillWrapperEl(pillId).querySelector(".o_gantt_pill");
|
|
}
|
|
|
|
/**
|
|
* @param {Object} group
|
|
* @param {number} maxAggregateValue
|
|
* @param {boolean} consolidate
|
|
*/
|
|
getPillFromGroup(group, maxAggregateValue, consolidate) {
|
|
const { excludeField, field, maxValue } = this.model.metaData.consolidationParams;
|
|
|
|
const minColor = 215;
|
|
const maxColor = 100;
|
|
|
|
const newPill = {
|
|
id: `__pill__${this.nextPillId++}`,
|
|
level: 0,
|
|
aggregateValue: group.aggregateValue,
|
|
grid: group.grid,
|
|
};
|
|
|
|
// Enrich the aggregates with consolidation data
|
|
if (consolidate && field) {
|
|
newPill.consolidationValue = 0;
|
|
for (const pill of group.pills) {
|
|
if (!pill.record[excludeField]) {
|
|
newPill.consolidationValue += pill.record[field];
|
|
}
|
|
}
|
|
newPill.consolidationMaxValue = maxValue;
|
|
newPill.consolidationExceeded =
|
|
newPill.consolidationValue > newPill.consolidationMaxValue;
|
|
}
|
|
|
|
if (consolidate && maxValue) {
|
|
const status = newPill.consolidationExceeded ? "danger" : "success";
|
|
newPill.className = `bg-${status} border-${status}`;
|
|
newPill.displayName = newPill.consolidationValue;
|
|
} else {
|
|
const color =
|
|
minColor -
|
|
Math.round((newPill.aggregateValue - 1) / maxAggregateValue) *
|
|
(minColor - maxColor);
|
|
newPill.style = `background-color:rgba(${color},${color},${color},0.6)`;
|
|
newPill.displayName = this.getGroupPillDisplayName(newPill);
|
|
}
|
|
|
|
return newPill;
|
|
}
|
|
|
|
/**
|
|
* There are two forms of pills: pills comming from fetched records
|
|
* and pills that are some kind of aggregation of the previous.
|
|
*
|
|
* Here we create the pills of the firs type.
|
|
*
|
|
* The basic properties (independent of rows,...) of the pills of
|
|
* the first type should be computed here.
|
|
*
|
|
* @returns {Partial<Pill>[]}
|
|
*/
|
|
getPills() {
|
|
const { records } = this.model.data;
|
|
const { dateStartField } = this.model.metaData;
|
|
const pills = [];
|
|
for (const record of records) {
|
|
const pill = this.getPill(record);
|
|
pills.push(this.enrichPill(pill));
|
|
}
|
|
return pills.sort(
|
|
(p1, p2) =>
|
|
p1.grid.column[0] - p2.grid.column[0] ||
|
|
p1.record[dateStartField] - p2.record[dateStartField]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {PillId} pillId
|
|
*/
|
|
getPillWrapperEl(pillId) {
|
|
const pillSelector = `:scope > [data-pill-id="${pillId}"]`;
|
|
return this.cellContainerRef.el?.querySelector(pillSelector);
|
|
}
|
|
|
|
/**
|
|
* Get domain of records for plan dialog in the gantt view.
|
|
*
|
|
* @param {Object} state
|
|
* @returns {any[][]}
|
|
*/
|
|
getPlanDialogDomain() {
|
|
const { dateStartField, dateStopField } = this.model.metaData;
|
|
const newDomain = Domain.removeDomainLeaves(this.env.searchModel.globalDomain, [
|
|
dateStartField,
|
|
dateStopField,
|
|
]);
|
|
return Domain.and([
|
|
newDomain,
|
|
["|", [dateStartField, "=", false], [dateStopField, "=", false]],
|
|
]).toList({});
|
|
}
|
|
|
|
/**
|
|
* @param {PillId} pillId
|
|
* @param {boolean} onRight
|
|
*/
|
|
getPoint(pillId, onRight) {
|
|
if (localization.direction === "rtl") {
|
|
onRight = !onRight;
|
|
}
|
|
const pillEl = this.getPillEl(pillId);
|
|
const pillRect = pillEl.getBoundingClientRect();
|
|
return {
|
|
left: pillRect.left + (onRight ? pillRect.width : 0),
|
|
top: pillRect.top + pillRect.height / 2,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {Pill} pill
|
|
*/
|
|
getPopoverProps(pill) {
|
|
const { record } = pill;
|
|
const { id: resId, display_name: displayName } = record;
|
|
const { canEdit, dateStartField, dateStopField, popoverArchParams, resModel } =
|
|
this.model.metaData;
|
|
const context = popoverArchParams.bodyTemplate
|
|
? { ...record }
|
|
: /* Default context */ {
|
|
name: displayName,
|
|
start: record[dateStartField].toFormat("f"),
|
|
stop: record[dateStopField].toFormat("f"),
|
|
};
|
|
|
|
return {
|
|
...popoverArchParams,
|
|
title: displayName,
|
|
context,
|
|
resId,
|
|
resModel,
|
|
reload: () => this.model.fetchData(),
|
|
buttons: [
|
|
{
|
|
id: "open_view_edit_dialog",
|
|
text: canEdit ? _t("Edit") : _t("View"),
|
|
class: "btn btn-sm btn-primary",
|
|
// Sync with the mutex to wait for potential changes on the view
|
|
onClick: () =>
|
|
this.model.mutex.exec(
|
|
() => this.props.openDialog({ resId }) // (canEdit is also considered in openDialog)
|
|
),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {Row} row
|
|
*/
|
|
getProgressBarProps(row) {
|
|
return {
|
|
progressBar: row.progressBar,
|
|
reactive: this.progressBarsReactive,
|
|
rowId: row.id,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {Row} row
|
|
*/
|
|
getRowCellColors(row) {
|
|
const { unavailabilities } = row;
|
|
const { cellPart } = this.model.metaData.scale;
|
|
// We assume that the unavailabilities have been normalized
|
|
// (i.e. are naturally ordered and are pairwise disjoint).
|
|
// A subCell is considered unavailable (and greyed) when totally covered by
|
|
// an unavailability.
|
|
let index = 0;
|
|
let j = 0;
|
|
/** @type {Record<string, string>} */
|
|
const cellColors = {};
|
|
const subSlotUnavailabilities = [];
|
|
for (const subColumn of this.subColumns) {
|
|
const { isToday, start, stop, columnId } = subColumn;
|
|
if (index < unavailabilities.length) {
|
|
let subSlotUnavailable = 0;
|
|
for (let i = index; i < unavailabilities.length; i++) {
|
|
const u = unavailabilities[i];
|
|
if (stop > u.stop) {
|
|
index++;
|
|
continue;
|
|
} else if (u.start <= start) {
|
|
subSlotUnavailable = 1;
|
|
}
|
|
break;
|
|
}
|
|
subSlotUnavailabilities.push(subSlotUnavailable);
|
|
if ((j + 1) % cellPart === 0) {
|
|
const style = getCellColor(cellPart, subSlotUnavailabilities, isToday);
|
|
subSlotUnavailabilities.splice(0, cellPart);
|
|
if (style) {
|
|
cellColors[columnId] = style;
|
|
}
|
|
}
|
|
j++;
|
|
}
|
|
}
|
|
return cellColors;
|
|
}
|
|
|
|
getFromData(groupedByField, resId, key, defaultVal) {
|
|
const values = this.model.data[key];
|
|
if (groupedByField) {
|
|
return values[groupedByField]?.[resId ?? false] || defaultVal;
|
|
}
|
|
return values.__default?.false || defaultVal;
|
|
}
|
|
|
|
/**
|
|
* @param {string} [groupedByField]
|
|
* @param {false|number} [resId]
|
|
* @returns {Object}
|
|
*/
|
|
getRowProgressBar(groupedByField, resId) {
|
|
return this.getFromData(groupedByField, resId, "progressBars", null);
|
|
}
|
|
|
|
/**
|
|
* @param {string} [groupedByField]
|
|
* @param {false|number} [resId]
|
|
* @returns {{ start: DateTime, stop: DateTime }[]}
|
|
*/
|
|
getRowUnavailabilities(groupedByField, resId) {
|
|
return this.getFromData(groupedByField, resId, "unavailabilities", []);
|
|
}
|
|
|
|
/**
|
|
* @param {"t0" | "t1" | "t2"} type
|
|
* @returns {number}
|
|
*/
|
|
getRowTypeHeight(type) {
|
|
return {
|
|
t0: 24,
|
|
t1: 36,
|
|
t2: 16,
|
|
}[type];
|
|
}
|
|
|
|
getRowTitleStyle(row) {
|
|
return `grid-column: ${row.groupLevel + 2} / -1`;
|
|
}
|
|
|
|
openPlanDialogCallback() {}
|
|
|
|
getSelectCreateDialogProps(params) {
|
|
const domain = this.getPlanDialogDomain();
|
|
const schedule = this.model.getDialogContext(params);
|
|
return {
|
|
title: _t("Plan"),
|
|
resModel: this.model.metaData.resModel,
|
|
context: schedule,
|
|
domain,
|
|
noCreate: !this.model.metaData.canCellCreate,
|
|
onSelected: (resIds) => {
|
|
if (resIds.length) {
|
|
this.model.reschedule(resIds, schedule, this.openPlanDialogCallback.bind(this));
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {Pill[]} pills
|
|
*/
|
|
getTotalRow(pills) {
|
|
const preRow = {
|
|
groupLevel: 0,
|
|
id: "[]",
|
|
rows: [],
|
|
name: _t("Total"),
|
|
recordIds: pills.map(({ record }) => record.id),
|
|
};
|
|
|
|
this.currentGridRow = 1;
|
|
const result = this.processRow(preRow, pills);
|
|
const [totalRow] = result.rows;
|
|
const allPills = this.rowPills[totalRow.id] || [];
|
|
const maxAggregateValue = Math.max(...allPills.map((p) => p.aggregateValue));
|
|
|
|
totalRow.factor = maxAggregateValue ? 90 / maxAggregateValue : 0;
|
|
|
|
return totalRow;
|
|
}
|
|
|
|
highlightPill(pillId, highlighted) {
|
|
const pill = this.pills[pillId];
|
|
if (!pill) {
|
|
return;
|
|
}
|
|
pill.highlighted = highlighted;
|
|
const pillWrapper = this.getPillWrapperEl(pillId);
|
|
pillWrapper?.classList.toggle("highlight", highlighted);
|
|
pillWrapper?.classList.toggle(
|
|
"o_connector_creator_highlight",
|
|
highlighted && this.connectorDragState.dragging
|
|
);
|
|
}
|
|
|
|
initializeConnectors() {
|
|
for (const connectorId in this.connectors) {
|
|
this.deleteConnector(connectorId);
|
|
}
|
|
}
|
|
|
|
isPillSmall(pill) {
|
|
return this.cellPartWidth * pill.grid.column[1] < pill.displayName.length * 10;
|
|
}
|
|
|
|
/**
|
|
* @param {Row} row
|
|
*/
|
|
isDisabled(row = null) {
|
|
return this.model.useSampleModel;
|
|
}
|
|
|
|
/**
|
|
* @param {Row} row
|
|
*/
|
|
isHoverable(row) {
|
|
return !this.model.useSampleModel;
|
|
}
|
|
|
|
/**
|
|
* @param {Group[]} groups
|
|
* @returns {Group[]}
|
|
*/
|
|
mergeGroups(groups) {
|
|
if (groups.length <= 1) {
|
|
return groups;
|
|
}
|
|
const index = Math.floor(groups.length / 2);
|
|
const left = this.mergeGroups(groups.slice(0, index));
|
|
const right = this.mergeGroups(groups.slice(index));
|
|
const group = right[0];
|
|
if (!group.break) {
|
|
const previousGroup = left.pop();
|
|
group.break = previousGroup.break;
|
|
group.grid.column[0] = previousGroup.grid.column[0];
|
|
group.aggregateValue = this.getAggregateValue(group, previousGroup);
|
|
}
|
|
return [...left, ...right];
|
|
}
|
|
|
|
onWillRender() {
|
|
if (this.noDisplayedConnectors && this.shouldRenderConnectors()) {
|
|
delete this.noDisplayedConnectors;
|
|
this.computeDerivedParams();
|
|
}
|
|
|
|
if (this.shouldComputeSomeWidths) {
|
|
this.computeSomeWidths();
|
|
}
|
|
|
|
if (this.shouldComputeSomeWidths || this.shouldComputeGridColumns) {
|
|
this.virtualGrid.setColumnsWidths(new Array(this.columnCount).fill(this.columnWidth));
|
|
this.computeVisibleColumns();
|
|
}
|
|
|
|
if (this.shouldComputeGridRows) {
|
|
this.virtualGrid.setRowsHeights(this.gridRows);
|
|
this.computeVisibleRows();
|
|
}
|
|
|
|
if (
|
|
this.shouldComputeSomeWidths ||
|
|
this.shouldComputeGridColumns ||
|
|
this.shouldComputeGridRows
|
|
) {
|
|
delete this.shouldComputeSomeWidths;
|
|
delete this.shouldComputeGridColumns;
|
|
delete this.shouldComputeGridRows;
|
|
this.computeVisiblePills();
|
|
if (this.shouldRenderConnectors()) {
|
|
this.computeVisibleConnectors();
|
|
} else {
|
|
this.noDisplayedConnectors = true;
|
|
}
|
|
}
|
|
|
|
delete this.shouldComputeSomeWidths;
|
|
delete this.shouldComputeGridColumns;
|
|
delete this.shouldComputeGridRows;
|
|
}
|
|
|
|
pushGridRows(gridRows) {
|
|
for (const key of ["t0", "t1", "t2"]) {
|
|
if (key in gridRows) {
|
|
const types = new Array(gridRows[key]).fill(this.getRowTypeHeight(key));
|
|
this.gridRows.push(...types);
|
|
}
|
|
}
|
|
}
|
|
|
|
processPillsAsRows(row, pills) {
|
|
const rows = [];
|
|
const parsedId = JSON.parse(row.id);
|
|
if (pills.length) {
|
|
for (const pill of pills) {
|
|
const { id: resId, display_name: name } = pill.record;
|
|
const subRow = {
|
|
id: JSON.stringify([...parsedId, { id: resId }]),
|
|
resId,
|
|
name,
|
|
groupLevel: row.groupLevel + 1,
|
|
recordIds: [resId],
|
|
fromServer: row.fromServer,
|
|
parentResId: row.resId ?? row.parentResId,
|
|
parentGroupedField: row.groupedByField || row.parentGroupedField,
|
|
};
|
|
const res = this.processRow(subRow, [pill], false);
|
|
rows.push(...res.rows);
|
|
}
|
|
} else {
|
|
const subRow = {
|
|
id: JSON.stringify([...parsedId, {}]),
|
|
resId: false,
|
|
name: "",
|
|
groupLevel: row.groupLevel + 1,
|
|
recordIds: [],
|
|
fromServer: row.fromServer,
|
|
parentResId: row.resId ?? row.parentResId,
|
|
parentGroupedField: row.groupedByField || row.parentGroupedField,
|
|
};
|
|
const res = this.processRow(subRow, [], false);
|
|
rows.push(...res.rows);
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* @param {Row} row
|
|
* @param {Pill[]} pills
|
|
* @param {boolean} [processAsGroup=false]
|
|
*/
|
|
processRow(row, pills, processAsGroup = true) {
|
|
const { dependencyField, displayUnavailability, fields } = this.model.metaData;
|
|
const { displayMode } = this.model.displayParams;
|
|
const {
|
|
consolidate,
|
|
fromServer,
|
|
groupedByField,
|
|
groupLevel,
|
|
id,
|
|
name,
|
|
parentResId,
|
|
parentGroupedField,
|
|
resId,
|
|
rows,
|
|
recordIds,
|
|
__extra__,
|
|
} = row;
|
|
|
|
// compute the subset pills at row level
|
|
const remainingPills = [];
|
|
let rowPills = [];
|
|
const groupPills = [];
|
|
const isMany2many = groupedByField && fields[groupedByField].type === "many2many";
|
|
for (const pill of pills) {
|
|
const { record } = pill;
|
|
const pushPill = recordIds.includes(record.id);
|
|
let keepPill = false;
|
|
if (pushPill && isMany2many) {
|
|
const value = record[groupedByField];
|
|
if (Array.isArray(value) && value.length > 1) {
|
|
keepPill = true;
|
|
}
|
|
}
|
|
if (pushPill) {
|
|
const rowPill = { ...pill };
|
|
rowPills.push(rowPill);
|
|
groupPills.push(pill);
|
|
}
|
|
if (!pushPill || keepPill) {
|
|
remainingPills.push(pill);
|
|
}
|
|
}
|
|
|
|
if (displayMode === "sparse" && __extra__) {
|
|
const rows = this.processPillsAsRows(row, groupPills);
|
|
return { rows, pillsToProcess: remainingPills };
|
|
}
|
|
|
|
const isGroup = displayMode === "sparse" ? processAsGroup : Boolean(rows);
|
|
|
|
const gridRowTypes = isGroup ? { t0: 1 } : { t1: 1 };
|
|
if (rowPills.length) {
|
|
if (isGroup) {
|
|
if (this.shouldComputeAggregateValues(row)) {
|
|
const groups = this.aggregatePills(rowPills, row);
|
|
const maxAggregateValue = Math.max(
|
|
...groups.map((group) => group.aggregateValue)
|
|
);
|
|
rowPills = groups.map((group) =>
|
|
this.getPillFromGroup(group, maxAggregateValue, consolidate)
|
|
);
|
|
} else {
|
|
rowPills = [];
|
|
}
|
|
} else {
|
|
const level = this.calculatePillsLevel(rowPills);
|
|
gridRowTypes.t1 = level;
|
|
if (!this.isTouchDevice) {
|
|
gridRowTypes.t2 = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
const progressBar = this.getRowProgressBar(groupedByField, resId);
|
|
if (progressBar && this.isTouchDevice && (!gridRowTypes.t1 || gridRowTypes.t1 === 1)) {
|
|
// In mobile: rows span over 2 rows to alllow progressbars to properly display
|
|
gridRowTypes.t1 = (gridRowTypes.t1 || 0) + 1;
|
|
}
|
|
if (row.id !== "[]") {
|
|
this.pushGridRows(gridRowTypes);
|
|
}
|
|
|
|
for (const rowPill of rowPills) {
|
|
rowPill.id = `__pill__${this.nextPillId++}`;
|
|
const pillFirstRow = this.currentGridRow + rowPill.level;
|
|
rowPill.grid = {
|
|
...rowPill.grid, // rowPill is a shallow copy of a prePill (possibly copied several times)
|
|
row: [pillFirstRow, pillFirstRow + 1],
|
|
};
|
|
if (!isGroup) {
|
|
const { record } = rowPill;
|
|
if (this.shouldRenderRecordConnectors(record)) {
|
|
if (!this.mappingRecordToPillsByRow[record.id]) {
|
|
this.mappingRecordToPillsByRow[record.id] = {
|
|
masterIds: record[dependencyField],
|
|
pills: {},
|
|
};
|
|
}
|
|
this.mappingRecordToPillsByRow[record.id].pills[id] = rowPill;
|
|
if (!this.mappingRowToPillsByRecord[id]) {
|
|
this.mappingRowToPillsByRecord[id] = {};
|
|
}
|
|
this.mappingRowToPillsByRecord[id][record.id] = rowPill;
|
|
}
|
|
}
|
|
rowPill.rowId = id;
|
|
this.pills[rowPill.id] = rowPill;
|
|
}
|
|
|
|
this.rowPills[id] = rowPills; // all row pills
|
|
|
|
const subRowsCount = Object.values(gridRowTypes).reduce((acc, val) => acc + val, 0);
|
|
/** @type {Row} */
|
|
const processedRow = {
|
|
cellColors: {},
|
|
fromServer,
|
|
groupedByField,
|
|
groupLevel,
|
|
id,
|
|
isGroup,
|
|
name,
|
|
progressBar,
|
|
resId,
|
|
grid: {
|
|
row: [this.currentGridRow, this.currentGridRow + subRowsCount],
|
|
},
|
|
};
|
|
if (displayUnavailability && !isGroup) {
|
|
processedRow.unavailabilities = this.getRowUnavailabilities(
|
|
parentGroupedField || groupedByField,
|
|
parentResId ?? resId
|
|
);
|
|
}
|
|
|
|
this.rowByIds[id] = processedRow;
|
|
|
|
this.currentGridRow += subRowsCount;
|
|
|
|
const field = this.model.metaData.thumbnails[groupedByField];
|
|
if (field) {
|
|
const model = this.model.metaData.fields[groupedByField].relation;
|
|
processedRow.thumbnailUrl = url("/web/image", {
|
|
model,
|
|
id: resId,
|
|
field,
|
|
});
|
|
}
|
|
|
|
const result = { rows: [processedRow], pillsToProcess: remainingPills };
|
|
|
|
if (!this.model.isClosed(id)) {
|
|
if (rows) {
|
|
let pillsToProcess = groupPills;
|
|
for (const subRow of rows) {
|
|
const res = this.processRow(subRow, pillsToProcess);
|
|
result.rows.push(...res.rows);
|
|
pillsToProcess = res.pillsToProcess;
|
|
}
|
|
} else if (displayMode === "sparse" && processAsGroup) {
|
|
const rows = this.processPillsAsRows(row, groupPills);
|
|
result.rows.push(...rows);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* @param {string} [groupedByField]
|
|
* @param {false|number} [resId]
|
|
* @returns {{ start: DateTime, stop: DateTime }[]}
|
|
*/
|
|
_getRowUnavailabilities(groupedByField, resId) {
|
|
const { unavailabilities } = this.model.data;
|
|
if (groupedByField) {
|
|
return unavailabilities[groupedByField]?.[resId ?? false] || [];
|
|
}
|
|
return unavailabilities.__default?.false || [];
|
|
}
|
|
|
|
/**
|
|
* @param {Object} params
|
|
* @param {Element} params.pill
|
|
* @param {number} params.diff
|
|
* @param {"start" | "end"} params.direction
|
|
*/
|
|
async resizePillDrop({ pill, diff, direction }) {
|
|
const { dateStartField, dateStopField, scale } = this.model.metaData;
|
|
const { cellTime, time } = scale;
|
|
const { record } = this.pills[pill.dataset.pillId];
|
|
const params = this.getScheduleParams(pill);
|
|
|
|
if (direction === "start") {
|
|
params.start = dateAddFixedOffset(record[dateStartField], { [time]: cellTime * diff });
|
|
} else {
|
|
params.stop = dateAddFixedOffset(record[dateStopField], { [time]: cellTime * diff });
|
|
}
|
|
const schedule = this.model.getSchedule(params);
|
|
|
|
await this.model.reschedule(record.id, schedule, this.openPlanDialogCallback);
|
|
}
|
|
|
|
/**
|
|
* @param {Partial<ConnectorProps>} params
|
|
* @param {PillId | null} [sourceId=null]
|
|
* @param {PillId | null} [targetId=null]
|
|
*/
|
|
setConnector(params, sourceId = null, targetId = null) {
|
|
const connectorParams = { ...params };
|
|
const connectorId = params.id || `__connector__${this.nextConnectorId++}`;
|
|
|
|
if (sourceId) {
|
|
connectorParams.sourcePoint = () => this.getPoint(sourceId, true);
|
|
}
|
|
|
|
if (targetId) {
|
|
connectorParams.targetPoint = () => this.getPoint(targetId, false);
|
|
}
|
|
|
|
if (this.connectors[connectorId]) {
|
|
Object.assign(this.connectors[connectorId], connectorParams);
|
|
} else {
|
|
this.connectors[connectorId] = {
|
|
id: connectorId,
|
|
highlighted: false,
|
|
displayButtons: false,
|
|
...connectorParams,
|
|
};
|
|
this.mappingConnectorToPills[connectorId] = {
|
|
sourcePillId: sourceId,
|
|
targetPillId: targetId,
|
|
};
|
|
}
|
|
|
|
if (sourceId) {
|
|
if (!this.mappingPillToConnectors[sourceId]) {
|
|
this.mappingPillToConnectors[sourceId] = [];
|
|
}
|
|
this.mappingPillToConnectors[sourceId].push(connectorId);
|
|
}
|
|
|
|
if (targetId) {
|
|
if (!this.mappingPillToConnectors[targetId]) {
|
|
this.mappingPillToConnectors[targetId] = [];
|
|
}
|
|
this.mappingPillToConnectors[targetId].push(connectorId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} [pillEl]
|
|
*/
|
|
setStickyPill(pillEl) {
|
|
this.stickyPillId = pillEl ? pillEl.dataset.pillId : null;
|
|
}
|
|
|
|
/**
|
|
* @param {Row} row
|
|
*/
|
|
shouldComputeAggregateValues(row) {
|
|
return true;
|
|
}
|
|
|
|
shouldMergeGroups() {
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Returns whether connectors should be rendered or not.
|
|
* The connectors won't be rendered on sampleData as we can't be sure that data are coherent.
|
|
* The connectors won't be rendered on mobile as the usability is not guarantied.
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
shouldRenderConnectors() {
|
|
return (
|
|
this.model.metaData.dependencyField && !this.model.useSampleModel && !this.env.isSmall
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns whether connectors should be rendered on particular records or not.
|
|
* This method is intended to be overridden in particular modules in order to set particular record's condition.
|
|
*
|
|
* @param {RelationalRecord} record
|
|
* @return {boolean}
|
|
*/
|
|
shouldRenderRecordConnectors(record) {
|
|
return this.shouldRenderConnectors();
|
|
}
|
|
|
|
/**
|
|
* @param {ConnectorId | null} connectorId
|
|
* @param {boolean} highlighted
|
|
*/
|
|
toggleConnectorHighlighting(connectorId, highlighted) {
|
|
const connector = this.connectors[connectorId];
|
|
if (!connector || (!connector.highlighted && !highlighted)) {
|
|
return;
|
|
}
|
|
|
|
connector.highlighted = highlighted;
|
|
connector.displayButtons = highlighted;
|
|
|
|
const { sourcePillId, targetPillId } = this.mappingConnectorToPills[connectorId];
|
|
|
|
this.highlightPill(sourcePillId, highlighted);
|
|
this.highlightPill(targetPillId, highlighted);
|
|
}
|
|
|
|
/**
|
|
* @param {PillId} pillId
|
|
* @param {boolean} highlighted
|
|
*/
|
|
togglePillHighlighting(pillId, highlighted) {
|
|
const pill = this.pills[pillId];
|
|
if (!pill || pill.highlighted === highlighted) {
|
|
return;
|
|
}
|
|
|
|
const { record } = pill;
|
|
const pillIdsToHighlight = new Set([pillId]);
|
|
|
|
if (record && this.shouldRenderRecordConnectors(record)) {
|
|
// Find other related pills
|
|
const { pills: relatedPills } = this.mappingRecordToPillsByRow[record.id];
|
|
for (const pill of Object.values(relatedPills)) {
|
|
pillIdsToHighlight.add(pill.id);
|
|
}
|
|
|
|
// Highlight related connectors
|
|
for (const [connectorId, connector] of Object.entries(this.connectors)) {
|
|
const ids = Object.values(this.getRecordIds(connectorId));
|
|
if (ids.includes(record.id)) {
|
|
connector.highlighted = highlighted;
|
|
connector.displayButtons = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Highlight pills from found IDs
|
|
for (const id of pillIdsToHighlight) {
|
|
this.highlightPill(id, highlighted);
|
|
}
|
|
}
|
|
|
|
//-------------------------------------------------------------------------
|
|
// Handlers
|
|
//-------------------------------------------------------------------------
|
|
|
|
onCellClicked(rowId, col) {
|
|
if (!this.preventClick) {
|
|
this.preventClick = true;
|
|
setTimeout(() => (this.preventClick = false), 1000);
|
|
const { canCellCreate, canPlan } = this.model.metaData;
|
|
if (canPlan) {
|
|
this.onPlan(rowId, col, col);
|
|
} else if (canCellCreate) {
|
|
this.onCreate(rowId, col, col);
|
|
}
|
|
}
|
|
}
|
|
|
|
onCreate(rowId, startCol, stopCol) {
|
|
const { start, stop } = this.getColumnStartStop(startCol, stopCol);
|
|
const context = this.model.getDialogContext({
|
|
rowId,
|
|
start,
|
|
stop,
|
|
withDefault: true,
|
|
});
|
|
this.props.create(context);
|
|
}
|
|
|
|
onInteractionChange() {
|
|
let { dragAction, mode } = this.interaction;
|
|
if (mode === "drag") {
|
|
mode = dragAction;
|
|
}
|
|
if (this.gridRef.el) {
|
|
for (const [action, className] of INTERACTION_CLASSNAMES) {
|
|
this.gridRef.el.classList.toggle(className, mode === action);
|
|
}
|
|
}
|
|
}
|
|
|
|
onPointerLeave() {
|
|
this.throttledComputeHoverParams.cancel();
|
|
|
|
if (!this.isDragging) {
|
|
const hoveredConnectorId = this.hovered.connector?.dataset.connectorId;
|
|
this.toggleConnectorHighlighting(hoveredConnectorId, false);
|
|
|
|
const hoveredPillId = this.hovered.pill?.dataset.pillId;
|
|
this.togglePillHighlighting(hoveredPillId, false);
|
|
}
|
|
|
|
this.hovered.connector = null;
|
|
this.hovered.pill = null;
|
|
this.hovered.hoverable = null;
|
|
|
|
this.computeDerivedParamsFromHover();
|
|
}
|
|
|
|
/**
|
|
* Updates all hovered elements, then calls "computeDerivedParamsFromHover".
|
|
*
|
|
* @see computeDerivedParamsFromHover
|
|
* @param {Event} ev
|
|
*/
|
|
computeHoverParams(ev) {
|
|
// Lazily compute elements from point as it is a costly operation
|
|
let els = null;
|
|
let position = {};
|
|
if (ev.type === "scroll") {
|
|
position = this.cursorPosition;
|
|
} else {
|
|
position.x = ev.clientX;
|
|
position.y = ev.clientY;
|
|
this.cursorPosition = position;
|
|
}
|
|
const pointedEls = () => els || (els = document.elementsFromPoint(position.x, position.y));
|
|
|
|
// To find hovered elements, also from pointed elements
|
|
const find = (selector) =>
|
|
ev.target.closest?.(selector) ||
|
|
pointedEls().find((el) => el.matches(selector)) ||
|
|
null;
|
|
|
|
this.hovered.connector = find(".o_gantt_connector");
|
|
this.hovered.hoverable = find(".o_gantt_hoverable");
|
|
this.hovered.pill = find(".o_gantt_pill_wrapper");
|
|
|
|
this.computeDerivedParamsFromHover();
|
|
}
|
|
|
|
/**
|
|
* @param {PointerEvent} ev
|
|
* @param {Pill} pill
|
|
*/
|
|
onPillClicked(ev, pill) {
|
|
if (this.popover.isOpen) {
|
|
return;
|
|
}
|
|
this.popover.target = ev.target.closest(".o_gantt_pill_wrapper");
|
|
this.popover.open(this.popover.target, this.getPopoverProps(pill));
|
|
}
|
|
|
|
onPlan(rowId, startCol, stopCol) {
|
|
const { start, stop } = this.getColumnStartStop(startCol, stopCol);
|
|
this.dialogService.add(
|
|
SelectCreateDialog,
|
|
this.getSelectCreateDialogProps({ rowId, start, stop, withDefault: true })
|
|
);
|
|
}
|
|
|
|
getRecordIds(connectorId) {
|
|
const { sourcePillId, targetPillId } = this.mappingConnectorToPills[connectorId];
|
|
return {
|
|
masterId: this.pills[sourcePillId]?.record.id,
|
|
slaveId: this.pills[targetPillId]?.record.id,
|
|
};
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {Object} params
|
|
* @param {ConnectorId} connectorId
|
|
*/
|
|
onRemoveButtonClick(connectorId) {
|
|
const { masterId, slaveId } = this.getRecordIds(connectorId);
|
|
this.model.removeDependency(masterId, slaveId);
|
|
}
|
|
rescheduleAccordingToDependencyCallback(result) {
|
|
if (result["type"] !== "warning" && "old_vals_per_pill_id" in result) {
|
|
this.model.toggleHighlightPlannedFilter(
|
|
Object.keys(result["old_vals_per_pill_id"]).map(Number)
|
|
);
|
|
}
|
|
this.notificationFn?.();
|
|
this.notificationFn = this.notificationService.add(
|
|
markup(
|
|
`<i class="fa btn-link fa-check"></i><span class="ms-1">${escape(
|
|
result["message"]
|
|
)}</span>`
|
|
),
|
|
{
|
|
type: result["type"],
|
|
sticky: true,
|
|
buttons:
|
|
result["type"] === "warning"
|
|
? []
|
|
: [
|
|
{
|
|
name: "Undo",
|
|
icon: "fa-undo",
|
|
onClick: async () => {
|
|
const ids = Object.keys(result["old_vals_per_pill_id"]).map(
|
|
Number
|
|
);
|
|
await this.orm.call(
|
|
this.model.metaData.resModel,
|
|
"action_rollback_scheduling",
|
|
[ids, result["old_vals_per_pill_id"]]
|
|
);
|
|
this.notificationFn();
|
|
await this.model.fetchData();
|
|
},
|
|
},
|
|
],
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {"forward" | "backward"} direction
|
|
* @param {ConnectorId} connectorId
|
|
*/
|
|
async onRescheduleButtonClick(direction, connectorId) {
|
|
const { masterId, slaveId } = this.getRecordIds(connectorId);
|
|
await this.model.rescheduleAccordingToDependency(
|
|
direction,
|
|
masterId,
|
|
slaveId,
|
|
this.rescheduleAccordingToDependencyCallback.bind(this)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {KeyboardEvent} ev
|
|
*/
|
|
onWindowKeyDown(ev) {
|
|
if (ev.key === "Control") {
|
|
this.prevDragAction =
|
|
this.interaction.dragAction === "copy" ? "reschedule" : this.interaction.dragAction;
|
|
this.interaction.dragAction = "copy";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {KeyboardEvent} ev
|
|
*/
|
|
onWindowKeyUp(ev) {
|
|
if (ev.key === "Control") {
|
|
this.interaction.dragAction = this.prevDragAction || "reschedule";
|
|
}
|
|
}
|
|
}
|