合并企业版代码(未测试,先提交到测试分支)

This commit is contained in:
qihao.gong@jikimo.com
2023-04-14 17:42:23 +08:00
parent 7a7b3d7126
commit d28525526a
1300 changed files with 513579 additions and 5426 deletions

View File

@@ -0,0 +1,336 @@
/** @odoo-module **/
import { deepMerge } from "./connector_utils";
const { Component, onWillUpdateProps } = owl;
class Connector extends Component {
// -----------------------------------------------------------------------------
// Life cycle hooks
// -----------------------------------------------------------------------------
/**
* @override
*/
setup() {
this._refreshPropertiesFromProps(this.props);
onWillUpdateProps(this.onWillUpdateProps);
}
/**
*
* @override
* @param nextProps
* @returns {Promise<void>}
*/
async onWillUpdateProps(nextProps) {
this._refreshPropertiesFromProps(nextProps);
}
// -----------------------------------------------------------------------------
// Private
// -----------------------------------------------------------------------------
/**
* Refreshes the connector properties from the props.
*
* @param {Object} props
* @private
*/
_refreshPropertiesFromProps(props) {
const defaultStyleProps = {
drawHead: true,
outlineStroke: {
color: 'rgba(255,255,255,0.5)',
hoveredColor: 'rgba(255,255,255,0.9)',
width: 2,
},
slackness: 0.5,
stroke: {
color: 'rgba(0,0,0,0.5)',
hoveredColor: 'rgba(0,0,0,0.9)',
width: 2,
},
};
this.hoverEaseWidth = props.hoverEaseWidth ? props.hoverEaseWidth : 1;
this.style = deepMerge(defaultStyleProps, props.style);
const pathInfo = this._getPathInfo(props.source, props.target, this.style.slackness);
this.path = `M ${pathInfo.singlePath.source.left} ${pathInfo.singlePath.source.top} \
C ${pathInfo.singlePath.sourceControlPoint.left} ${pathInfo.singlePath.sourceControlPoint.top} \
${pathInfo.singlePath.targetControlPoint.left} ${pathInfo.singlePath.targetControlPoint.top} \
${pathInfo.singlePath.target.left} ${pathInfo.singlePath.target.top}`;
this.removeButtonPosition = pathInfo.doublePath.startingPath.target;
}
/**
* Returns the parameters of both the single Bezier curve as well as is decomposition into two beziers curves
* (which allows to get the middle position of the single Bezier curve) for the provided source, target and
* slackness (0 being a straight line).
*
* @param {{ left: number, top: number }} source
* @param {{ left: number, top: number }} target
* @param {number} slackness [0, 1]
* @returns {{
* singlePath: {
* source: {
* top: number,
* left: number
* },
* sourceControlPoint: {
* top: number,
* left: number
* },
* target: {
* top: number,
* left: number
* },
* targetControlPoint: {
* top: number,
* left: number
* }
* },
* doublePath: {
* endingPath: {
* source: {
* top: number,
* left: number
* },
* sourceControlPoint: {
* top: number,
* left: number
* },
* target: {
* top: number,
* left: number
* },
* targetControlPoint: {
* top: number,
* left: number
* }
* },
* startingPath: {
* source: {
* top: number,
* left: number
* },
* sourceControlPoint: {
* top: number,
* left: number
* },
* target: {
* top: number,
* left: number
* },
* targetControlPoint: {
* top: number,
* left: number
* }
* }
* }
* }}
* @private
*/
_getPathInfo(source, target, slackness) {
const b = { left: 0, top: 0 };
const c = { left: 0, top: 0 };
// If the source is on the left of the target, we need to invert the control points.
const directionFactor = source.left < target.left ? 1 : -1;
// What follows can be seen as magic numbers. And those are indeed such numbers as they have been determined
// by observing their shape while creating short and long connectors. These seems to allow keeping the same
// kind of shape amongst short and long connectors.
const xDelta = 100 + directionFactor * (target.left - source.left) * slackness / 10;
const yDelta = Math.abs(source.top - target.top) < 16 && source.left > target.left ? 15 + 0.001 * (source.left - target.left) * slackness : 0;
b.left = source.left + xDelta;
b.top = source.top + yDelta;
// Prevent having the air pin effect when in creation and having target on the left of the source
if (!this.props.inCreation || directionFactor > 0) {
c.left = target.left - xDelta;
} else {
c.left = target.left + xDelta;
}
c.top = target.top + yDelta;
const cuttingDistance = 0.5;
const e = Connector._getLinearInterpolation(source, b, cuttingDistance);
const f = Connector._getLinearInterpolation(b, c, cuttingDistance);
const g = Connector._getLinearInterpolation(c, target, cuttingDistance);
const h = Connector._getLinearInterpolation(e, f, cuttingDistance);
const i = Connector._getLinearInterpolation(f, g, cuttingDistance);
const j = Connector._getLinearInterpolation(h, i, cuttingDistance);
return {
singlePath: {
source: source,
sourceControlPoint: b,
target: target,
targetControlPoint: c,
},
doublePath: {
endingPath: {
source: j,
sourceControlPoint: i,
target: target,
targetControlPoint: g,
},
startingPath: {
source: source,
sourceControlPoint: e,
target: j,
targetControlPoint: h,
},
},
};
}
// -----------------------------------------------------------------------------
// Handlers
// -----------------------------------------------------------------------------
/**
* Handler for connector_stroke_buttons remove click event.
*
* @param {OwlEvent} ev
*/
_onRemoveButtonClick(ev) {
const payload = {
data: deepMerge(this.props.data),
id: this.props.id,
};
this.props.onRemoveButtonClick(payload);
}
/**
* Handler for connector_stroke_buttons reschedule sooner click event.
*
* @param {OwlEvent} ev
*/
_onRescheduleSoonerClick(ev) {
const payload = {
data: deepMerge(this.props.data),
id: this.props.id,
};
this.props.onRescheduleSoonerButtonClick(payload);
}
/**
* Handler for connector_stroke_buttons reschedule later click event.
*
* @param {OwlEvent} ev
*/
_onRescheduleLaterClick(ev) {
const payload = {
data: deepMerge(this.props.data),
id: this.props.id,
};
this.props.onRescheduleLaterButtonClick(payload);
}
}
const endProps = {
shape: {
left: Number,
top: Number,
},
type: Object,
};
const strokeStyleProps = {
optional: true,
shape: {
color: {
optional: true,
type: String,
},
hoveredColor: {
optional: true,
type: String,
},
width: {
optional: true,
type: Number,
},
},
type: Object,
};
Object.assign(Connector, {
props: {
canBeRemoved: {
optional: true,
type: Boolean
},
data: {
optional: true,
type: Object,
},
hoverEaseWidth: {
optional: true,
type: Number,
},
hovered: {
optional: true,
type: Boolean
},
inCreation: {
optional: true,
type: Boolean,
},
id: { type: String | Number },
source: endProps,
style: {
optional: true,
shape: {
drawHead: {
optional: true,
type: Boolean,
},
outlineStroke: strokeStyleProps,
slackness: {
optional: true,
type: Number,
validate: slackness => (0 <= slackness && slackness <= 1),
},
stroke: strokeStyleProps,
},
type: Object,
},
target: endProps,
onRemoveButtonClick: { type: Function, optional: true },
onRescheduleSoonerButtonClick: { type: Function, optional: true },
onRescheduleLaterButtonClick: { type: Function, optional: true },
},
defaultProps: {
onRemoveButtonClick: () => {},
onRescheduleSoonerButtonClick: () => {},
onRescheduleLaterButtonClick: () => {},
},
template: 'connector',
// -----------------------------------------------------------------------------
// Private Static
// -----------------------------------------------------------------------------
/**
* Returns the linear interpolation for a point to be found somewhere between a startingPoint and a endingPoint.
*
* @param {{top: number, left: number}} startingPoint
* @param {{top: number, left: number}} endingPoint
* @param {number} interpolationPercentage [0, 1] the distance (from 0 startingPoint to 1 endingPoint)
* the point has to be computed at.
* @returns {{top: number, left: number}}
* @private
*/
_getLinearInterpolation(startingPoint, endingPoint, interpolationPercentage) {
if (interpolationPercentage < 0 || 1 > interpolationPercentage) {
// Ensures interpolationPercentage is within expected boundaries.
interpolationPercentage = Math.min(Math.max (0, interpolationPercentage), 1);
}
const remaining = 1 - interpolationPercentage;
return {
left: startingPoint.left * remaining + endingPoint.left * interpolationPercentage,
top: startingPoint.top * remaining + endingPoint.top * interpolationPercentage
};
},
});
export default Connector;

View File

@@ -0,0 +1,414 @@
/** @odoo-module **/
import Connector from "./connector";
import { deepMerge } from "./connector_utils";
import { LegacyComponent } from "@web/legacy/legacy_component";
const { onMounted, onWillUnmount, onWillUpdateProps } = owl;
class ConnectorContainer extends LegacyComponent {
// -----------------------------------------------------------------------------
// Life cycle hooks
// -----------------------------------------------------------------------------
/**
* @override
*/
setup() {
this._createsParentListenerHandlers();
/**
* Keeps track of the mouse events related info.
* @type {{ isParentDragging: boolean, hoveredConnector: HTMLElement }}
*/
this.mouseEventsInfo = { };
// Connector component used in order to manage connector creation.
this.newConnector = {
id: "newConnectorId",
canBeRemoved: false,
};
// Apply default styling (if any) to newConnector.
if ('defaultStyle' in this.props) {
this.newConnector = deepMerge(this.newConnector, { style: this.props.defaultStyle });
}
if ('newConnectorStyle' in this.props) {
this.newConnector = deepMerge(this.newConnector, { style: this.props.newConnectorStyle });
}
this._refreshPropertiesFromProps(this.props);
onMounted(this.onMounted);
onWillUnmount(this.onWillUnmount);
onWillUpdateProps(this.onWillUpdateProps);
}
/**
* @override
*/
onMounted() {
if (this.parentElement && this.parentElement !== this.el.parentElement) {
this._removeParentListeners();
}
this.parentElement = this.el.parentElement;
this._addParentListeners();
}
/**
* @override
*/
onWillUnmount() {
this._removeParentListeners();
}
/**
*
* @override
* @param nextProps
* @returns {Promise<void>}
*/
async onWillUpdateProps(nextProps) {
this._refreshPropertiesFromProps(nextProps);
}
// -----------------------------------------------------------------------------
// Private
// -----------------------------------------------------------------------------
/**
* Adds the ConnectorContainer's parent required EventListeners.
*
* @private
*/
_addParentListeners() {
this.parentElement.addEventListener('mousedown', this._onParentMouseDownHandler);
this.parentElement.addEventListener('mousemove', this._throttledOnParentMouseOverHandler);
this.parentElement.addEventListener('mouseup', this._onParentMouseUpHandler);
this.parentElement.addEventListener('mouseleave', this._onParentMouseUpHandler);
}
/**
* Creates the handlers used in _addParentListeners and _removeParentListeners calls.
*
* @private
*/
_createsParentListenerHandlers() {
this._throttledOnParentMouseOverHandler = _.throttle((ev) => this._onParentMouseOver(ev), 50);
this._onParentMouseDownHandler = (ev) => this._onParentMouseDown(ev);
this._onParentMouseUpHandler = (ev) => this._onParentMouseUp(ev);
}
/**
* Gets the element offset against the connectorContainer's parent.
*
* @param {HTMLElement} element the element the offset position has to be calculated for.
*/
_getElementPosition(element) {
// This will not work for css translated elements and this is acceptable at this time.
// If needed in the future, consider using getBoundingClientRect() if getComputedStyle() returns a style
// having a transform (and place this also in the for loop as we would need to do it if the element or any of
// its parent is using css transform).
let left = element.offsetLeft || 0;
let top = element.offsetTop || 0;
for (let el = element.offsetParent; el != null && el !== this.el.parentElement; el = el.offsetParent) {
left += el.offsetLeft || 0;
top += el.offsetTop || 0;
}
return {
left: left,
top: top,
};
}
/**
* Refreshes the connector properties from the props.
*
* @param {Object} props
* @private
*/
_refreshPropertiesFromProps(props) {
this.connectors = deepMerge(props.connectors);
if (this.props.defaultStyle) {
Object.keys(this.connectors)
.forEach((key) => {
this.connectors[key].style = deepMerge(this.props.defaultStyle, this.connectors[key].style);
if (!('hoverEaseWidth' in this.connectors[key]) && 'hoverEaseWidth' in props) {
this.connectors[key].hoverEaseWidth = props.hoverEaseWidth;
}
});
}
const isHoveredConnectorSet = 'hoveredConnector' in this.mouseEventsInfo;
const isHoveredConnectorPartOfProps = isHoveredConnectorSet && this.mouseEventsInfo.hoveredConnector.dataset.id in this.connectors;
if (isHoveredConnectorSet && !isHoveredConnectorPartOfProps) {
// Ensures to reset the mouseEventsInfo in case the hoveredConnector is set but is no more part
// of the new props.
delete this.mouseEventsInfo.hoveredConnector;
}
}
/**
* Removes the ConnectorContainer's parent required EventListeners.
*
*/
_removeParentListeners() {
this.parentElement.removeEventListener('mousedown', this._onParentMouseDownHandler);
this.parentElement.removeEventListener('mousemove', this._throttledOnParentMouseOverHandler);
this.parentElement.removeEventListener('mouseup', this._onParentMouseUpHandler);
this.parentElement.removeEventListener('mouseleave', this._onParentMouseUpHandler);
}
/**
* Updates the hover state of the connector and render
*
* @param {string} id the id of the connector which hover state has to be updated.
* @param {boolean} hovered the hover state to be set.
* @private
*/
_updateConnectorHoverState(id, hovered) {
this.connectors[id].hovered = hovered && !(this.props.preventHoverEffect || this.mouseEventsInfo.isParentDragging);
if (hovered) {
// When a connector is hover we need to ensure it is rendered as last element as svg z-index works
// that way and unfortunately no css can be used to modify it.
const hoverConnector = this.connectors[id];
delete this.connectors[id];
this.connectors[id] = hoverConnector;
}
this.render();
}
// -----------------------------------------------------------------------------
// Public
// -----------------------------------------------------------------------------
/**
* Gets the top, right, bottom and left anchors positions for the provided element with respect to the
* ConnectorContainer's parent.
*
* @param {HTMLElement} element the element the anchors positions have to be calculated for.
* @param {HTMLElement} container the container the anchors positions will be calculated with respect to. In order
* to have a valid result, the container should be an element with position attribute
* set to relative.
* @returns {{
* top: {top: number, left: number},
* left: {top: number, left: number},
* bottom: {top: number, left: number},
* right: {top: number, left: number}
* }}
*/
getAnchorsPositions(element) {
const elementPosition = this._getElementPosition(element);
return {
top: {
top: elementPosition.top,
left: elementPosition.left + element.offsetWidth / 2,
},
right: {
top: elementPosition.top + element.offsetHeight / 2,
left: elementPosition.left + element.offsetWidth,
},
bottom: {
top: elementPosition.top + element.offsetHeight,
left: elementPosition.left + element.offsetWidth / 2,
},
left: {
top: elementPosition.top + element.offsetHeight / 2,
left: elementPosition.left,
},
};
}
// -----------------------------------------------------------------------------
// Handlers
// -----------------------------------------------------------------------------
/**
* Handler for the ConnectorContainer's parent mousedown event. This handle is responsible of managing the start of a possible
* connector creation (depending on whether the event target matches the sourceQuerySelector).
*
* @param {MouseEvent} ev
* @private
*/
_onParentMouseDown(ev) {
const connector_source = ev.target.closest(this.props.sourceQuerySelector);
if (connector_source) {
ev.stopPropagation();
ev.preventDefault();
this.mouseEventsInfo.isParentDragging = true;
const anchors = this.getAnchorsPositions(ev.target);
this.newConnector = deepMerge(
this.newConnector,
{
data: {
sourceElement: connector_source,
},
inCreation: true,
source: {
top: anchors.right.top,
left: anchors.right.left,
},
target: {
top: anchors.right.top + ev.offsetY,
left: anchors.right.left + ev.offsetX,
},
});
this.props.onCreationStart(deepMerge({ }, this.newConnector));
this.render();
}
}
/**
* Handler for the ConnectorContainer's parent mouseover event. This handle is responsible of the update of the newConnector
* component props if a connector creation has started.
*
* @param {MouseEvent} ev
* @private
*/
_onParentMouseOver(ev) {
if (this.mouseEventsInfo.isParentDragging === true) {
ev.stopPropagation();
ev.preventDefault();
const position = this._getElementPosition(ev.target);
this.newConnector = deepMerge(
this.newConnector,
{
target: {
top: position.top + ev.offsetY,
left: position.left + ev.offsetX,
},
});
this.render();
}
}
/**
* Handler for the ConnectorContainer's parent mouseup event. This handle is responsible of triggering either the
* connector-creation-done or connector-creation-abort (depending on whether the event target matches the
* targetQuerySelector) if a connector creation has started.
*
* @param {MouseEvent} ev
* @private
*/
_onParentMouseUp(ev) {
if (this.mouseEventsInfo.isParentDragging === true) {
ev.stopPropagation();
ev.preventDefault();
const connector_target = ev.target.closest(this.props.targetQuerySelector || this.props.sourceQuerySelector);
if (connector_target) {
this.newConnector = deepMerge(
this.newConnector,
{
data: {
targetElement: connector_target,
},
});
this.props.onCreationDone(deepMerge({ }, this.newConnector));
} else {
this.props.onCreationAbort(deepMerge({ }, this.newConnector));
}
this.mouseEventsInfo.isParentDragging = false;
delete this.newConnector.source;
delete this.newConnector.target;
this.render();
}
}
/**
* Handler for the connector_manager svg mouseout event. Its purpose is to handle the hover state of the connectors.
* It has been implemented here in order to manage it globally instead of in each connector (and thus limiting
* the number of listeners).
*
* @param {OwlEvent} ev
* @private
*/
_onMouseOut(ev) {
ev.stopPropagation();
if (!('hoveredConnector' in this.mouseEventsInfo)) {
// If hoverConnector is not set this means were not in a connector. So ignore it.
return;
}
let relatedTarget = ev.relatedTarget;
while (relatedTarget) {
// Go up the parent chain
if (relatedTarget === this.mouseEventsInfo.hoveredConnector) {
// Check that we are still inside hoveredConnector.
// If so it means it is a transition between child elements so ignore it.
return;
}
relatedTarget = relatedTarget.parentElement;
}
this._updateConnectorHoverState(this.mouseEventsInfo.hoveredConnector.dataset.id, false);
this.props.onMouseOut(this.connectors[this.mouseEventsInfo.hoveredConnector.dataset.id]);
delete this.mouseEventsInfo.hoveredConnector;
}
/**
* Handler for the connector_manager svg mouseover event. Its purpose is to handle the hover state of the connectors.
* It has been implemented here in order to manage it globally instead of in each connector (and thus limiting
* the number of listeners).
*
* @param {OwlEvent} ev
* @private
*/
_onMouseOver(ev) {
ev.stopPropagation();
if ('hoveredConnector' in this.mouseEventsInfo) {
// As mouseout is call prior to mouseover, if hoveredConnector is set this means
// that we haven't left it. So it's a mouseover inside it.
return;
}
let target = ev.target.closest('.o_connector');
if (!target) {
// We are not into a connector si ignore.
return;
}
if (!(target.dataset.id in this.connectors) || Object.is(this.connectors[target.dataset.id], this.newConnector)) {
// We ensure that the connector to hover is not this.newConnector
return;
}
this.mouseEventsInfo.hoveredConnector = target;
this._updateConnectorHoverState(target.dataset.id, true);
this.props.onMouseOver(this.connectors[target.dataset.id]);
}
}
Object.assign(ConnectorContainer, {
components: { Connector },
props: {
connectors: { type: Object },
defaultStyle: Connector.props.style,
hoverEaseWidth: {
optional: true,
type: Number,
},
newConnectorStyle: Connector.props.style,
preventHoverEffect: {
optional: true,
type: Boolean
},
sourceQuerySelector: { type: String },
targetQuerySelector: {
optional: true,
type: String,
},
onRemoveButtonClick: { type: Function, optional: true },
onRescheduleSoonerButtonClick: { type: Function, optional: true },
onRescheduleLaterButtonClick: { type: Function, optional: true },
onCreationAbort: { type: Function, optional: true },
onCreationDone: { type: Function, optional: true },
onCreationStart: { type: Function, optional: true },
onMouseOut: { type: Function, optional: true },
onMouseOver: { type: Function, optional: true },
},
defaultProps: {
onRemoveButtonClick: () => {},
onRescheduleSoonerButtonClick: () => {},
onRescheduleLaterButtonClick: () => {},
onCreationAbort: () => {},
onCreationDone: () => {},
onCreationStart: () => {},
onMouseOut: () => {},
onMouseOver: () => {},
},
template: 'connector_container',
});
export default ConnectorContainer;

View File

@@ -0,0 +1,127 @@
/** @odoo-module **/
// -----------------------------------------------------------------------------
// Private
// -----------------------------------------------------------------------------
/**
* Clones the value according to its type and returns it.
*
* @param value
* @return {Object|Array|*} The cloned value.
* @private
*/
function _clone(value) {
if (_isPlainObject(value) || Array.isArray(value)) {
return deepMerge(_getEmptyTarget(value), value);
} else {
return value;
}
}
/**
* Gets an empty value according to value's type.
*
* @param {Object | Array} value
* @return {Object | Array} an empty Array or Object.
* @private
*/
function _getEmptyTarget(value) {
return Array.isArray(value) ? [] : { };
}
/**
* Returns whether the provided argument is a plain object or not.
*
* @param {*} value
* @returns {boolean} true if the provided argument is a plain object, false if not.
*/
function _isPlainObject(value) {
if (typeof value == 'object' && value !== null) {
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
return false;
}
/**
* Deep merges target and source arrays and returns the result.
*
* @param {Array} target
* @param {Array} source
* @return {Array} the result of the merge.
* @private
*/
function _deepMergeArray(target, source) {
return target.concat(source)
.map((entry) => _clone(entry));
}
/**
* Deep merges target and source Objects and returns the result
*
* @param {Object} target
* @param {Object} source
* @return {Object} the result of the merge.
*/
function _mergeObject(target, source) {
const destination = { };
if (_isPlainObject(target)) {
Object.keys(target).forEach((key) => {
destination[key] = _clone(target[key]);
});
}
Object.keys(source).forEach((key) => {
if ((_isPlainObject(target) && key in target) && _isPlainObject(source[key])) {
destination[key] = deepMerge(target[key], source[key]);
} else {
destination[key] = _clone(source[key]);
}
});
return destination;
}
// -----------------------------------------------------------------------------
// Public
// -----------------------------------------------------------------------------
/**
* Deep merges target and source and returns the result.
* This implementation has been added since vanilla JS is now preferred.
* A deep copy is made of all the plain objects and arrays.
* For the other type of objects (like HTMLElement, etc.), the reference is passed.
*
* @param {Object | Array} target
* @param {Object | Array} [source] if source is undefined, target wil be set to an empty value of source type.
* @returns {Object | Array} the result of the merge.
*/
export function deepMerge(target, source) {
if (typeof source === 'undefined') {
source = _getEmptyTarget(source);
}
const isSourceAnArray = Array.isArray(source);
const isTargetAnArray = Array.isArray(target);
if (isSourceAnArray !== isTargetAnArray) {
return _clone(source);
} else if (isSourceAnArray) {
return _deepMergeArray(target, source);
} else {
return _mergeObject(target, source);
}
}
/**
* Deep merges all the entries from the passed array and returns the result.
*
* @param {Array} array the elements to be merged together.
* @return {Object | Array} the result of the merge.
*/
export function deepMergeAll(array) {
if (!Array.isArray(array)) {
throw new Error('deepmergeAll argument must be an Array.');
}
return array.reduce((accumulator, current) => deepMerge(accumulator, current), { });
}

View File

@@ -0,0 +1,684 @@
/** @odoo-module alias=web_gantt.GanttController */
import AbstractController from 'web.AbstractController';
import core from 'web.core';
import config from 'web.config';
import { confirm as confirmDialog } from 'web.Dialog';
import { Domain } from '@web/core/domain';
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
const QWeb = core.qweb;
const _t = core._t;
const { Component } = owl;
export function removeDomainLeaf(domain, keysToRemove) {
function processLeaf(elements, idx, operatorCtx, newDomain) {
const leaf = elements[idx];
if (leaf.type === 10) {
if (keysToRemove.includes(leaf.value[0].value)) {
if (operatorCtx === '&') {
newDomain.ast.value.push(...Domain.TRUE.ast.value);
} else if (operatorCtx === '|') {
newDomain.ast.value.push(...Domain.FALSE.ast.value);
}
} else {
newDomain.ast.value.push(leaf);
}
return 1;
} else if (leaf.type === 1) {
// Special case to avoid OR ('|') that can never resolve to true
if (leaf.value === '|' && elements[idx + 1].type === 10 && elements[idx + 2].type === 10
&& keysToRemove.includes(elements[idx + 1].value[0].value)
&& keysToRemove.includes(elements[idx + 2].value[0].value)
) {
newDomain.ast.value.push(...Domain.TRUE.ast.value);
return 3;
}
newDomain.ast.value.push(leaf);
if (leaf.value === '!') {
return 1 + processLeaf(elements, idx + 1, '&', newDomain);
}
const firstLeafSkip = processLeaf(elements, idx + 1, leaf.value, newDomain);
const secondLeafSkip = processLeaf(elements, idx + 1 + firstLeafSkip, leaf.value, newDomain);
return 1 + firstLeafSkip + secondLeafSkip;
}
return 0;
}
domain = new Domain(domain);
if (domain.ast.value.length === 0) {
return domain;
}
const newDomain = new Domain([]);
processLeaf(domain.ast.value, 0, '&', newDomain);
return newDomain;
}
export default AbstractController.extend({
events: _.extend({}, AbstractController.prototype.events, {
'click .o_gantt_button_add': '_onAddClicked',
'click .o_gantt_button_scale': '_onScaleClicked',
'click .o_gantt_button_prev': '_onPrevPeriodClicked',
'click .o_gantt_button_next': '_onNextPeriodClicked',
'click .o_gantt_button_today': '_onTodayClicked',
'click .o_gantt_button_expand_rows': '_onExpandClicked',
'click .o_gantt_button_collapse_rows': '_onCollapseClicked',
}),
custom_events: _.extend({}, AbstractController.prototype.custom_events, {
add_button_clicked: '_onCellAddClicked',
collapse_row: '_onCollapseRow',
expand_row: '_onExpandRow',
on_connector_end_drag: '_onConnectorEndDrag',
on_connector_highlight: '_onConnectorHighlight',
on_connector_start_drag: '_onConnectorStartDrag',
on_create_connector: '_onCreateConnector',
on_pill_highlight: '_onPillHighlight',
on_remove_connector: '_onRemoveConnector',
on_reschedule_according_to_dependency: '_onRescheduleAccordingToDependency',
pill_clicked: '_onPillClicked',
pill_resized: '_onPillResized',
pill_dropped: '_onPillDropped',
plan_button_clicked: '_onCellPlanClicked',
updating_pill_started: '_onPillUpdatingStarted',
updating_pill_stopped: '_onPillUpdatingStopped',
}),
buttonTemplateName: 'GanttView.buttons',
/**
* @override
* @param {Widget} parent
* @param {GanttModel} model
* @param {GanttRenderer} renderer
* @param {Object} params
* @param {Object} params.context
* @param {Array[]} params.dialogViews
* @param {Object} params.SCALES
* @param {boolean} params.collapseFirstLevel
*/
init(parent, model, renderer, params) {
this._super.apply(this, arguments);
this.model = model;
this.context = params.context;
this.dialogViews = params.dialogViews;
this.SCALES = params.SCALES;
this.allowedScales = params.allowedScales;
this.collapseFirstLevel = params.collapseFirstLevel;
this.createAction = params.createAction;
this.actionDomain = params.actionDomain;
this._draggingConnector = false;
this.isRTL = _t.database.parameters.direction === "rtl";
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
* @param {jQuery} [$node] to which the buttons will be appended
*/
renderButtons($node) {
this.$buttons = this._renderButtonsQWeb();
if ($node) {
this.$buttons.appendTo($node);
}
},
_renderButtonsQWeb() {
return $(QWeb.render(this.buttonTemplateName, this._renderButtonQWebParameter()));
},
_renderButtonQWebParameter() {
const state = this.model.get();
const nbGroups = state.groupedBy.length;
const minNbGroups = this.collapseFirstLevel ? 0 : 1;
const displayExpandCollapseButtons = nbGroups > minNbGroups;
return {
groupedBy: state.groupedBy,
widget: this,
SCALES: this.SCALES,
activateScale: state.scale,
allowedScales: this.allowedScales,
displayExpandCollapseButtons: displayExpandCollapseButtons,
isMobile: config.device.isMobile,
};
},
/**
* @override
*/
updateButtons() {
if (!this.$buttons) {
return;
}
this.$buttons.html(this._renderButtonsQWeb());
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
* @param {integer} id
* @param {Object} schedule
*/
_copy(id, schedule) {
return this._executeAsyncOperation(
this.model.copy.bind(this.model),
[id, schedule]
);
},
/**
* @private
* @param {function} operation
* @param {Array} args
*/
_executeAsyncOperation(operation, args) {
const prom = new Promise((resolve, reject) => {
const asyncOp = operation(...args);
asyncOp.then(resolve).guardedCatch(resolve);
this.dp.add(asyncOp).guardedCatch(reject);
});
return prom.then(this.reload.bind(this, {}));
},
/**
* @private
* @param {OdooEvent} event
*/
_getDialogContext(date, rowId) {
const state = this.model.get();
const context = {};
context[state.dateStartField] = date.clone();
context[state.dateStopField] = date.clone().endOf(this.SCALES[state.scale].interval);
if (rowId) {
// Default values of the group this cell belongs in
// We can read them from any pill in this group row
for (const fieldName of state.groupedBy) {
const groupValue = Object.assign({}, ...JSON.parse(rowId));
let value = groupValue[fieldName];
if (Array.isArray(value)) {
const { type: fieldType } = state.fields[fieldName];
if (fieldType === "many2many") {
value = [value[0]];
} else if (fieldType === "many2one") {
value = value[0];
}
}
if (value !== undefined) {
context[fieldName] = value;
}
}
}
// moment context dates needs to be converted in server time in view
// dialog (for default values)
for (const k in context) {
const type = state.fields[k].type;
if (context[k] && (type === 'datetime' || type === 'date')) {
context[k] = this.model.convertToServerTime(context[k]);
}
}
return context;
},
/**
* Opens dialog to add/edit/view a record
*
* @private
* @param {Object} props FormViewDialog props
* @param {Object} options
*/
_openDialog(props, options = {}) {
const title = props.title || (props.resId ? _t("Open") : _t("Create"));
const onClose = options.onClose || (() => {});
options = {
...options,
onClose: async () => {
onClose();
await this.reload();
},
};
let removeRecord;
if (this.is_action_enabled('delete') && props.resId) {
removeRecord = this._onDialogRemove.bind(this, props.resId)
}
Component.env.services.dialog.add(FormViewDialog, {
title,
resModel: this.modelName,
viewId: this.dialogViews[0][0],
resId: props.resId,
mode: this.is_action_enabled('edit') ? "edit" : "readonly",
context: _.extend({}, this.context, props.context),
removeRecord
}, options);
},
/**
* Handler called when clicking the
* delete button in the edit/view dialog.
* Reload the view and close the dialog
*
* @returns {function}
*/
_onDialogRemove(resID) {
const confirm = new Promise((resolve) => {
confirmDialog(this, _t('Are you sure to delete this record?'), {
confirm_callback: () => {
resolve(true);
},
cancel_callback: () => {
resolve(false);
},
});
});
return confirm.then((confirmed) => {
if ((!confirmed)) {
return Promise.resolve();
}// else
return this._rpc({
model: this.modelName,
method: 'unlink',
args: [[resID,],],
}).then(() => {
return this.reload();
})
});
},
/**
* Get domain of records for plan dialog in the gantt view.
*
* @private
* @param {Object} state
* @returns {Array[]}
*/
_getPlanDialogDomain(state) {
const newDomain = removeDomainLeaf(
this.actionDomain,
[state.dateStartField, state.dateStopField]
);
return Domain.and([
newDomain,
['|', [state.dateStartField, '=', false], [state.dateStopField, '=', false]],
]).toList({});
},
/**
* Opens dialog to plan records.
*
* @private
* @param {Object} context
*/
_openPlanDialog(context) {
const state = this.model.get();
Component.env.services.dialog.add(SelectCreateDialog, {
title: _t("Plan"),
resModel: this.modelName,
domain: this._getPlanDialogDomain(state),
views: this.dialogViews,
context: Object.assign({}, this.context, context),
onSelected: (resIds) => {
if (resIds.length) {
// Here, the dates are already in server time so we set the
// isUTC parameter of reschedule to true to avoid conversion
this._reschedule(resIds, context, true, this.openPlanDialogCallback);
}
},
});
},
/**
* upon clicking on the create button, determines if a dialog with a formview should be opened
* or if a wizard should be openned, then opens it
*
* @param {object} context
*/
_onCreate(context) {
if (this.createAction) {
const fullContext = Object.assign({}, this.context, context);
this.do_action(this.createAction, {
additional_context: fullContext,
on_close: this.reload.bind(this, {})
});
} else {
this._openDialog({ context });
}
},
/**
* Reschedule records and reload.
*
* Use a DropPrevious to prevent unnecessary reload and rendering.
*
* Note that when the rpc fails, we have to reload and re-render as some
* records might be outdated, causing the rpc failure).
*
* @private
* @param {integer[]|integer} ids
* @param {Object} schedule
* @param {boolean} isUTC
* @returns {Promise} resolved when the record has been reloaded, rejected
* if the request has been dropped by DropPrevious
*/
_reschedule(ids, schedule, isUTC, callback) {
return this._executeAsyncOperation(
this.model.reschedule.bind(this.model),
[ids, schedule, isUTC, callback]
);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Opens a dialog to create a new record.
*
* @private
* @param {OdooEvent} ev
*/
_onCellAddClicked(ev) {
ev.stopPropagation();
const context = this._getDialogContext(ev.data.date, ev.data.rowId);
for (const k in context) {
context[_.str.sprintf('default_%s', k)] = context[k];
}
this._onCreate(context);
},
/**
* @private
* @param {MouseEvent} ev
*/
_onAddClicked(ev) {
ev.preventDefault();
const context = {};
const state = this.model.get();
context[state.dateStartField] = this.model.convertToServerTime(state.focusDate.clone().startOf(state.scale));
context[state.dateStopField] = this.model.convertToServerTime(state.focusDate.clone().endOf(state.scale));
for (const k in context) {
context[_.str.sprintf('default_%s', k)] = context[k];
}
this._onCreate(context);
},
/**
* @private
* @param {MouseEvent} ev
*/
_onCollapseClicked(ev) {
ev.preventDefault();
this.model.collapseRows();
this.update({}, { reload: false });
},
/**
* @private
* @param {OdooEvent} ev
* @param {string} ev.data.rowId
*/
_onCollapseRow(ev) {
ev.stopPropagation();
this.model.collapseRow(ev.data.rowId);
this.renderer.updateRow(this.model.get(ev.data.rowId));
},
/**
* Handler for renderer on-connector-end-drag event.
*
* @param {OdooEvent} ev
* @private
*/
_onConnectorEndDrag(ev) {
ev.stopPropagation();
this._draggingConnector = false;
this.renderer.set_connector_creation_mode(this._draggingConnector);
},
/**
* Handler for renderer on-connector-highlight event.
*
* @param {OdooEvent} ev
* @private
*/
_onConnectorHighlight(ev) {
ev.stopPropagation();
if (!this._updating && !this._draggingConnector) {
this.renderer.toggleConnectorHighlighting(ev.data.connector, ev.data.highlighted);
}
},
/**
* Handler for renderer on-connector-start-drag event.
*
* @param {OdooEvent} ev
* @private
*/
_onConnectorStartDrag(ev) {
ev.stopPropagation();
this._draggingConnector = true;
this.renderer.set_connector_creation_mode(this._draggingConnector);
},
/**
* Handler for renderer on-create-connector event.
*
* @param {OdooEvent} ev
* @returns {Promise<*>}
* @private
*/
async _onCreateConnector(ev) {
ev.stopPropagation();
await this.model.createDependency(ev.data.masterId, ev.data.slaveId);
await this.reload();
},
/**
* @private
* @param {MouseEvent} ev
*/
_onExpandClicked(ev) {
ev.preventDefault();
this.model.expandRows();
this.update({}, { reload: false });
},
/**
* @private
* @param {OdooEvent} ev
* @param {string} ev.data.rowId
*/
_onExpandRow(ev) {
ev.stopPropagation();
this.model.expandRow(ev.data.rowId);
this.renderer.updateRow(this.model.get(ev.data.rowId));
},
/**
* @private
* @param {MouseEvent} ev
*/
_onNextPeriodClicked(ev) {
ev.preventDefault();
const state = this.model.get();
this.update({ date: state.focusDate.add(1, state.scale) });
},
/**
* Opens dialog when clicked on pill to view record.
*
* @private
* @param {OdooEvent} ev
* @param {jQuery} ev.data.target
*/
async _onPillClicked(ev) {
if (!this._updating) {
ev.data.target.addClass('o_gantt_pill_editing');
// Sync with the mutex to wait for potential changes on the view
await this.model.mutex.getUnlockedDef();
const props = { resId: ev.data.target.data('id') };
const options = { onClose: () => ev.data.target.removeClass('o_gantt_pill_editing') };
this._openDialog(props, options);
}
},
/**
* Saves pill information when dragged.
*
* @private
* @param {OdooEvent} ev
* @param {Object} ev.data
* @param {integer} [ev.data.diff]
* @param {integer} [ev.data.groupLevel]
* @param {string} [ev.data.pillId]
* @param {string} [ev.data.newRowId]
* @param {string} [ev.data.oldRowId]
* @param {'copy'|'reschedule'} [ev.data.action]
*/
_onPillDropped(ev) {
ev.stopPropagation();
const state = this.model.get();
const schedule = {};
let diff = ev.data.diff;
diff = this.isRTL ? -diff : diff;
if (diff) {
const pill = _.findWhere(state.records, { id: ev.data.pillId });
schedule[state.dateStartField] = this.model.dateAdd(pill[state.dateStartField], diff, this.SCALES[state.scale].time);
schedule[state.dateStopField] = this.model.dateAdd(pill[state.dateStopField], diff, this.SCALES[state.scale].time);
} else if (ev.data.action === 'copy') {
// When we copy the info on dates is sometimes mandatory (e.g. working on hr.leave, see copy_data)
const pill = _.findWhere(state.records, { id: ev.data.pillId });
schedule[state.dateStartField] = pill[state.dateStartField].clone();
schedule[state.dateStopField] = pill[state.dateStopField].clone();
}
if (ev.data.newRowId && ev.data.newRowId !== ev.data.oldRowId) {
const groupValue = Object.assign({}, ...JSON.parse(ev.data.newRowId));
// if the pill is dragged in a top level group, we only want to
// write on fields linked to this top level group
const fieldsToWrite = state.groupedBy.slice(0, ev.data.groupLevel + 1);
for (const fieldName of fieldsToWrite) {
// TODO: maybe not write if the value hasn't changed?
let valueToWrite = groupValue[fieldName];
if (Array.isArray(valueToWrite)) {
const { type: fieldType } = state.fields[fieldName];
if (fieldType === "many2many") {
valueToWrite = [valueToWrite[0]];
} else if (fieldType === "many2one") {
valueToWrite = valueToWrite[0];
}
}
schedule[fieldName] = valueToWrite;
}
}
if (ev.data.action === 'copy') {
this._copy(ev.data.pillId, schedule);
} else {
this._reschedule(ev.data.pillId, schedule);
}
},
/**
* Handler for renderer on-connector-end-drag event.
*
* @param {OdooEvent} ev
* @private
*/
async _onPillHighlight(ev) {
ev.stopPropagation();
if (!this._updating || !ev.data.highlighted) {
await this.renderer.togglePillHighlighting(ev.data.element, ev.data.highlighted);
}
},
/**
* Save pill information when resized
*
* @private
* @param {OdooEvent} ev
*/
_onPillResized(ev) {
ev.stopPropagation();
const schedule = {};
schedule[ev.data.field] = ev.data.date;
this._reschedule(ev.data.id, schedule);
},
/**
* @private
* @param {OdooEvent} ev
*/
_onPillUpdatingStarted(ev) {
ev.stopPropagation();
this._updating = true;
this.renderer.togglePreventConnectorsHoverEffect(true);
},
/**
* @private
* @param {OdooEvent} ev
*/
_onPillUpdatingStopped(ev) {
ev.stopPropagation();
this._updating = false;
this.renderer.togglePreventConnectorsHoverEffect(false);
},
/**
* Opens a dialog to plan records.
*
* @private
* @param {OdooEvent} ev
*/
_onCellPlanClicked(ev) {
ev.stopPropagation();
const context = this._getDialogContext(ev.data.date, ev.data.rowId);
this._openPlanDialog(context);
},
/**
* @private
* @param {MouseEvent} ev
*/
_onPrevPeriodClicked(ev) {
ev.preventDefault();
const state = this.model.get();
this.update({ date: state.focusDate.subtract(1, state.scale) });
},
/**
* Handler for renderer on-remove-connector event.
*
* @param {OdooEvent} ev
* @private
*/
async _onRemoveConnector(ev) {
ev.stopPropagation();
await this.model.removeDependency(ev.data.masterId, ev.data.slaveId);
await this.reload();
},
/**
* Handler for renderer on-reschedule-according-to-dependency event.
*
* @param {OdooEvent} ev
* @private
*/
async _onRescheduleAccordingToDependency(ev) {
ev.stopPropagation();
const result = await this.model.rescheduleAccordingToDependency(
ev.data.direction,
ev.data.masterId,
ev.data.slaveId);
if (result === false) {
return
} else {
await this.reload();
if (result.type == 'ir.actions.client') {
this.do_action(result);
}
}
},
/**
* @private
* @param {MouseEvent} ev
*/
_onScaleClicked(ev) {
ev.preventDefault();
const $button = $(ev.currentTarget);
if ($button.hasClass('active')) {
return;
}
this.update({ scale: $button.data('value') });
},
/**
* @private
* @param {MouseEvent} ev
*/
_onTodayClicked(ev) {
ev.preventDefault();
this.update({ date: moment() });
},
});

View File

@@ -0,0 +1,793 @@
/** @odoo-module alias=web_gantt.GanttModel */
import AbstractModel from 'web.AbstractModel';
import { x2ManyCommands } from '@web/core/orm_service';
import concurrency from 'web.concurrency';
import core from 'web.core';
import fieldUtils from 'web.field_utils';
import { findWhere, groupBy } from 'web.utils';
import session from 'web.session';
const _t = core._t;
export default AbstractModel.extend({
/**
* @override
*/
init(parent, params = {}) {
this._super.apply(this, arguments);
this.dp = new concurrency.DropPrevious();
this.mutex = new concurrency.Mutex();
this.dependencyField = params.dependencyField;
this.dependencyInvertedField = params.dependencyInvertedField;
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Collapses the given row.
*
* @param {string} rowId
*/
collapseRow(rowId) {
this.allRows[rowId].isOpen = false;
},
/**
* Collapses all rows (first level only).
*/
collapseRows() {
this.ganttData.rows.forEach((group) => {
group.isOpen = false;
});
},
/**
* Convert date to server timezone
*
* @param {Moment} date
* @returns {string} date in server format
*/
convertToServerTime(date) {
const result = date.clone();
if (!result.isUTC()) {
result.subtract(session.getTZOffset(date), 'minutes');
}
return result.locale('en').format('YYYY-MM-DD HH:mm:ss');
},
/**
* Adds a dependency between masterId and slaveId (slaveId depends
* on masterId).
*
* @param masterId
* @param slaveId
* @returns {Promise<*>}
*/
async createDependency(masterId, slaveId) {
return this.mutex.exec(() => {
const writeCommand = {};
writeCommand[this.dependencyField] = [x2ManyCommands.linkTo(masterId)];
return this._rpc({
model: this.modelName,
method: 'write',
args: [[slaveId], writeCommand],
});
});
},
/**
* Add or subtract value to a moment.
* If we are changing by a whole day or more, adjust the time if needed to keep
* the same local time, if the UTC offset has changed between the 2 dates
* (usually, because of daylight savings)
*
* @param {Moment} date
* @param {integer} offset
* @param {string} unit
*/
dateAdd(date, offset, unit) {
const result = date.clone().add(offset, unit);
if(Math.abs(result.diff(date, 'hours')) >= 24) {
const tzOffsetDiff = result.clone().local().utcOffset() - date.clone().local().utcOffset();
if(tzOffsetDiff !== 0) {
result.subtract(tzOffsetDiff, 'minutes');
}
}
return result;
},
/**
* @override
* @param {string} [rowId]
* @returns {Object} the whole gantt data if no rowId given, the given row's
* description otherwise
*/
__get(rowId) {
if (rowId) {
return this.allRows[rowId];
} else {
return Object.assign({ isSample: this.isSampleModel }, this.ganttData);
}
},
/**
* Expands the given row.
*
* @param {string} rowId
*/
expandRow(rowId) {
this.allRows[rowId].isOpen = true;
},
/**
* Expands all rows.
*/
expandRows() {
Object.keys(this.allRows).forEach((rowId) => {
const row = this.allRows[rowId];
if (row.isGroup) {
this.allRows[rowId].isOpen = true;
}
});
},
/**
* @override
* @param {Object} params
* @param {Object} params.context
* @param {Object} params.colorField
* @param {string} params.dateStartField
* @param {string} params.dateStopField
* @param {string[]} params.decorationFields
* @param {string} params.defaultGroupBy
* @param {string} params.permanentGroupBy
* @param {boolean} params.displayUnavailability
* @param {Array[]} params.domain
* @param {Object} params.fields
* @param {boolean} params.dynamicRange
* @param {string[]} params.groupedBy
* @param {Moment} params.initialDate
* @param {string} params.modelName
* @param {string} params.scale
* @returns {Promise<any>}
*/
async __load(params) {
await this._super(...arguments);
this.modelName = params.modelName;
this.fields = params.fields;
this.domain = params.domain;
this.context = params.context;
this.decorationFields = params.decorationFields;
this.colorField = params.colorField;
this.progressField = params.progressField;
this.consolidationParams = params.consolidationParams;
this.collapseFirstLevel = params.collapseFirstLevel;
this.displayUnavailability = params.displayUnavailability;
this.SCALES = params.SCALES;
this.progressBarFields = params.progressBarFields ? params.progressBarFields.split(",") : false;
this.defaultGroupBy = params.defaultGroupBy ? params.defaultGroupBy.split(',') : [];
this.permanentGroupBy = params.permanentGroupBy
let groupedBy = params.groupedBy;
if (!groupedBy || !groupedBy.length) {
groupedBy = this.defaultGroupBy;
}
if (this.permanentGroupBy && !groupedBy.includes(this.permanentGroupBy)) {
groupedBy.push(this.permanentGroupBy)
}
groupedBy = this._filterDateInGroupedBy(groupedBy);
this.ganttData = {
dateStartField: params.dateStartField,
dateStopField: params.dateStopField,
groupedBy,
fields: params.fields,
dynamicRange: params.dynamicRange,
};
this._setRange(params.initialDate, params.scale);
return this._fetchData().then(() => {
// The 'load' function returns a promise which resolves with the
// handle to pass to the 'get' function to access the data. In this
// case, we don't want to pass any argument to 'get' (see its API).
return Promise.resolve();
});
},
/**
* @param {any} handle
* @param {Object} params
* @param {Array[]} params.domain
* @param {string[]} params.groupBy
* @param {string} params.scale
* @param {Moment} params.date
* @returns {Promise<any>}
*/
async __reload(handle, params) {
await this._super(...arguments);
if ('scale' in params) {
this._setRange(this.ganttData.focusDate, params.scale);
}
if ('date' in params) {
this._setRange(params.date, this.ganttData.scale);
}
if ('domain' in params) {
this.domain = params.domain;
}
if ('groupBy' in params) {
if (params.groupBy && params.groupBy.length) {
this.ganttData.groupedBy = this._filterDateInGroupedBy(params.groupBy);
if(this.ganttData.groupedBy.length !== params.groupBy.length){
this.displayNotification({ message: _t('Grouping by date is not supported'), type: 'danger' });
}
if (this.permanentGroupBy && !this.ganttData.groupedBy.includes(this.permanentGroupBy)) {
this.ganttData.groupedBy.push(this.permanentGroupBy)
}
} else {
this.ganttData.groupedBy = this.defaultGroupBy;
}
}
return this._fetchData().then(() => {
// The 'reload' function returns a promise which resolves with the
// handle to pass to the 'get' function to access the data. In this
// case, we don't want to pass any argument to 'get' (see its API).
return Promise.resolve();
});
},
/**
* Create a copy of a task with defaults determined by schedule.
*
* @param {integer} id
* @param {Object} schedule
* @returns {Promise}
*/
copy(id, schedule) {
const defaults = this.rescheduleData(schedule);
return this.mutex.exec(() => {
return this._rpc({
model: this.modelName,
method: 'copy',
args: [id, defaults],
context: this.context,
});
});
},
/**
* Removes the dependency between masterId and slaveId (slaveId is no
* more dependent on masterId).
*
* @param masterId
* @param slaveId
* @returns {Promise<*>}
*/
async removeDependency(masterId, slaveId) {
return this.mutex.exec(() => {
const writeCommand = {};
writeCommand[this.dependencyField] = [x2ManyCommands.forget(masterId)];
return this._rpc({
model: this.modelName,
method: 'write',
args: [[slaveId], writeCommand],
});
});
},
/**
* Reschedule a task to the given schedule.
*
* @param {integer} id
* @param {Object} schedule
* @param {boolean} isUTC
* @returns {Promise}
*/
reschedule(ids, schedule, isUTC, callback) {
if (!_.isArray(ids)) {
ids = [ids];
}
const data = this.rescheduleData(schedule, isUTC);
return this.mutex.exec(() => {
return this._rpc({
model: this.modelName,
method: 'write',
args: [ids, data],
context: this.context,
}).then((result) => {
if (callback) {
callback(result);
}
});
});
},
/**
* Reschedule masterId or slaveId according to the direction
*
* @param direction
* @param masterId
* @param slaveId
* @returns {Promise<*>}
*/
async rescheduleAccordingToDependency(direction, masterId, slaveId) {
return this.mutex.exec(() => {
return this._rpc({
model: this.modelName,
method: 'web_gantt_reschedule',
args: [
direction,
masterId,
slaveId,
this.dependencyField,
this.dependencyInvertedField,
this.ganttData.dateStartField,
this.ganttData.dateStopField
],
});
});
},
/**
* @param {Object} schedule
* @param {boolean} isUTC
*/
rescheduleData(schedule, isUTC) {
const allowedFields = [
this.ganttData.dateStartField,
this.ganttData.dateStopField,
...this.ganttData.groupedBy
];
const data = _.pick(schedule, allowedFields);
let type;
for (let k in data) {
type = this.fields[k].type;
if (data[k] && (type === 'datetime' || type === 'date') && !isUTC) {
data[k] = this.convertToServerTime(data[k]);
}
};
return data
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Fetches records to display (and groups if necessary).
*
* @private
* @returns {Deferred}
*/
_fetchData() {
const domain = this._getDomain();
const context = Object.assign({}, this.context, { group_by: this.ganttData.groupedBy });
let groupsDef;
if (this.ganttData.groupedBy.length) {
groupsDef = this._rpc({
model: this.modelName,
method: 'read_group',
fields: this._getFields(),
domain: domain,
context: context,
groupBy: this.ganttData.groupedBy,
orderBy: this.ganttData.groupedBy.map((f) => { return {name: f}; }),
lazy: this.ganttData.groupedBy.length === 1,
});
}
const dataDef = this._rpc({
route: '/web/dataset/search_read',
model: this.modelName,
fields: this._getFields(),
context: context,
orderBy: [{name: this.ganttData.dateStartField}],
domain: domain,
});
return this.dp.add(Promise.all([groupsDef, dataDef])).then((results) => {
const groups = results[0] || [];
groups.forEach((g) => (g.fromServer = true));
const searchReadResult = results[1];
const oldRows = this.allRows;
this.allRows = {};
this.ganttData.records = this._parseServerData(searchReadResult.records);
this.ganttData.rows = this._generateRows({
groupedBy: this.ganttData.groupedBy,
groups: groups,
oldRows: oldRows,
parentPath: [],
records: this.ganttData.records,
});
const proms = [];
if (this.displayUnavailability && !this.isSampleModel) {
proms.push(this._fetchUnavailability());
}
if (this.progressBarFields && !this.isSampleModel) {
proms.push(this._fetchProgressBarData());
}
return Promise.all(proms);
});
},
/**
* Compute rows for unavailability rpc call.
*
* @private
* @param {Object} rows in the format of ganttData.rows
* @returns {Object} simplified rows only containing useful attributes
*/
_computeUnavailabilityRows(rows) {
return _.map(rows, (r) => {
if (r) {
return {
groupedBy: r.groupedBy,
records: r.records,
name: r.name,
resId: r.resId,
rows: this._computeUnavailabilityRows(r.rows)
}
} else {
return r;
}
});
},
/**
* Fetches gantt unavailability.
*
* @private
* @returns {Deferred}
*/
_fetchUnavailability() {
return this._rpc({
model: this.modelName,
method: 'gantt_unavailability',
args: [
this.convertToServerTime(this.ganttData.startDate),
this.convertToServerTime(this.ganttData.stopDate),
this.ganttData.scale,
this.ganttData.groupedBy,
this._computeUnavailabilityRows(this.ganttData.rows),
],
context: this.context,
}).then((enrichedRows) => {
// Update ganttData.rows with the new unavailabilities data
this._updateUnavailabilityRows(this.ganttData.rows, enrichedRows);
});
},
/**
* Update rows with unavailabilities from enriched rows.
*
* @private
* @param {Object} original rows in the format of ganttData.rows
* @param {Object} enriched rows as returned by the gantt_unavailability rpc call
* @returns {Object} original rows enriched with the unavailabilities data
*/
_updateUnavailabilityRows(original, enriched) {
_.zip(original, enriched).forEach((rowPair) => {
const o = rowPair[0];
const e = rowPair[1];
o.unavailabilities = _.map(e.unavailabilities, (u) => {
// These are new data from the server, they haven't been parsed yet
u.start = this._parseServerValue({ type: 'datetime' }, u.start);
u.stop = this._parseServerValue({ type: 'datetime' }, u.stop);
return u;
});
if (o.rows && e.rows) {
this._updateUnavailabilityRows(o.rows, e.rows);
}
});
},
/**
* Process groups and records to generate a recursive structure according
* to groupedBy fields. Note that there might be empty groups (filled by
* read_goup with group_expand) that also need to be processed.
*
* @private
* @param {Object} params
* @param {Object[]} params.groups
* @param {Object[]} params.records
* @param {string[]} params.groupedBy
* @param {Object} params.oldRows previous version of this.allRows (prior to
* this reload), used to keep collapsed rows collapsed
* @param {Object[]} params.parentPath used to determine the ancestors of a
* row through their groupedBy field and value.
* The stringification of this must give a unique identifier to the parent row.
* @returns {Object[]}
*/
_generateRows(params) {
const { groupedBy, groups, oldRows, parentPath, records } = params;
const groupLevel = this.ganttData.groupedBy.length - groupedBy.length;
if (!groupedBy.length || !groups.length) {
const row = {
groupLevel,
id: JSON.stringify([...parentPath, {}]),
isGroup: false,
name: "",
records,
};
this.allRows[row.id] = row;
return [row];
}
const rows = [];
// Some groups might be empty (thanks to expand_groups), so we can't
// simply group the data, we need to keep all returned groups
const groupedByField = groupedBy[0];
const currentLevelGroups = groupBy(groups, group => {
if (group[groupedByField] === undefined) {
// Here we change undefined value to false as:
// 1/ we want to group together:
// - groups having an undefined value for groupedByField
// - groups having false value for groupedByField
// 2/ we want to be sure that stringification keeps
// the groupedByField because of:
// JSON.stringify({ key: undefined }) === "{}"
// (see id construction below)
group[groupedByField] = false;
}
return group[groupedByField];
});
const isM2MGrouped = this.ganttData.fields[groupedByField].type === "many2many";
let groupedRecords;
if (isM2MGrouped) {
groupedRecords = {};
for (const [key, currentGroup] of Object.entries(currentLevelGroups)) {
groupedRecords[key] = [];
const value = currentGroup[0][groupedByField];
for (const r of records || []) {
if (
!value && r[groupedByField].length === 0 ||
value && r[groupedByField].includes(value[0])
) {
groupedRecords[key].push(r)
}
}
}
} else {
groupedRecords = groupBy(records || [], groupedByField);
}
for (const key in currentLevelGroups) {
const subGroups = currentLevelGroups[key];
const groupRecords = groupedRecords[key] || [];
// For empty groups (or when groupedByField is a m2m), we can't look at the record to get the
// formatted value of the field, we have to trust expand_groups.
let value;
if (groupRecords && groupRecords.length && !isM2MGrouped) {
value = groupRecords[0][groupedByField];
} else {
value = subGroups[0][groupedByField];
}
const part = {};
part[groupedByField] = value;
const path = [...parentPath, part];
const id = JSON.stringify(path);
const resId = Array.isArray(value) ? value[0] : value;
const minNbGroups = this.collapseFirstLevel ? 0 : 1;
const isGroup = groupedBy.length > minNbGroups;
const fromServer = subGroups.some((g) => g.fromServer);
const row = {
name: this._getRowName(groupedByField, value),
groupedBy,
groupedByField,
groupLevel,
id,
resId,
isGroup,
fromServer,
isOpen: !findWhere(oldRows, { id: JSON.stringify(parentPath), isOpen: false }),
records: groupRecords,
};
if (isGroup) {
row.rows = this._generateRows({
...params,
groupedBy: groupedBy.slice(1),
groups: subGroups,
oldRows,
parentPath: path,
records: groupRecords,
});
row.childrenRowIds = [];
row.rows.forEach((subRow) => {
row.childrenRowIds.push(subRow.id);
row.childrenRowIds = row.childrenRowIds.concat(subRow.childrenRowIds || []);
});
}
rows.push(row);
this.allRows[row.id] = row;
}
return rows;
},
/**
* Get domain of records to display in the gantt view.
*
* @private
* @returns {Array[]}
*/
_getDomain() {
const domain = [
[this.ganttData.dateStartField, '<=', this.convertToServerTime(this.ganttData.stopDate)],
[this.ganttData.dateStopField, '>=', this.convertToServerTime(this.ganttData.startDate)],
];
return this.domain.concat(domain);
},
/**
* Get all the fields needed.
*
* @private
* @returns {string[]}
*/
_getFields() {
let fields = ['display_name', this.ganttData.dateStartField, this.ganttData.dateStopField];
fields = fields.concat(this.ganttData.groupedBy, this.decorationFields);
if (this.progressField) {
fields.push(this.progressField);
}
if (this.colorField) {
fields.push(this.colorField);
}
if (this.consolidationParams.field) {
fields.push(this.consolidationParams.field);
}
if (this.consolidationParams.excludeField) {
fields.push(this.consolidationParams.excludeField);
}
return _.uniq(fields);
},
/**
* Format field value to display purpose.
*
* @private
* @param {any} value
* @param {Object} field
* @returns {string} formatted field value
*/
_getFieldFormattedValue(value, field) {
let options = {};
if (field.type === 'boolean') {
options = {forceString: true};
}
let label;
if (field.type === "many2many") {
label = Array.isArray(value) ? value[1] : value;
} else {
label = fieldUtils.format[field.type](value, field, options);
}
return label || _.str.sprintf(_t('Undefined %s'), field.string);
},
/**
* @param {string} groupedByField
* @param {*} value
* @returns {string}
*/
_getRowName(groupedByField, value) {
const field = this.fields[groupedByField];
return this._getFieldFormattedValue(value, field);
},
/**
* @override
*/
_isEmpty() {
return !this.ganttData.records.length;
},
/**
* Parse in place the server values (and in particular, convert datetime
* field values to moment in UTC).
*
* @private
* @param {Object} data the server data to parse
* @returns {Promise<any>}
*/
_parseServerData(data) {
data.forEach((record) => {
Object.keys(record).forEach((fieldName) => {
record[fieldName] = this._parseServerValue(this.fields[fieldName], record[fieldName]);
});
});
return data;
},
/**
* Set date range to render gantt
*
* @private
* @param {Moment} focusDate current activated date
* @param {string} scale current activated scale
*/
_setRange(focusDate, scale) {
this.ganttData.scale = scale;
this.ganttData.focusDate = focusDate;
if (this.ganttData.dynamicRange) {
this.ganttData.startDate = focusDate.clone().startOf(this.SCALES[scale].interval);
this.ganttData.stopDate = this.ganttData.startDate.clone().add(1, scale);
} else {
this.ganttData.startDate = focusDate.clone().startOf(scale);
this.ganttData.stopDate = focusDate.clone().endOf(scale);
}
},
/**
* Remove date in groupedBy field
*/
_filterDateInGroupedBy(groupedBy) {
return groupedBy.filter(
groupedByField => {
const fieldName = groupedByField.split(':')[0];
return fieldName in this.fields && this.fields[fieldName].type.indexOf('date') === -1;
}
);
},
//----------------------
// Gantt Progress Bars
//----------------------
/**
* Get progress bars info in order to display progress bar in gantt title column
*
* @private
*/
_fetchProgressBarData() {
const progressBarFields = this.progressBarFields.filter(field => this.ganttData.groupedBy.includes(field));
if (this.isSampleModel || !progressBarFields.length) {
return;
}
const resIds = {};
let hasResIds = false;
for (const field of progressBarFields) {
resIds[field] = this._getProgressBarResIds(field, this.ganttData.rows);
hasResIds = hasResIds || resIds[field].length;
}
if (!hasResIds) {
return;
}
return this._rpc({
model: this.modelName,
method: 'gantt_progress_bar',
args: [
progressBarFields,
resIds,
this.convertToServerTime(this.ganttData.startDate),
this.convertToServerTime(this.ganttData.endDate || this.ganttData.startDate.clone().add(1, this.ganttData.scale)),
],
}).then((progressBarInfo) => {
for (const field of progressBarFields) {
this._addProgressBarInfo(field, this.ganttData.rows, progressBarInfo[field]);
}
});
},
/**
* Recursive function to get resIds of groups where the progress bar will be added.
*
* @private
*/
_getProgressBarResIds(field, rows) {
const resIds = [];
for (const row of rows) {
if (row.groupedByField === field) {
if (row.resId !== false) {
resIds.push(row.resId);
}
} else {
resIds.push(...this._getProgressBarResIds(field, row.rows || []));
}
}
return [...new Set(resIds)];
},
/**
* Recursive function to add progressBar info to rows grouped by the field.
*
* @private
*/
_addProgressBarInfo(field, rows, progressBarInfo) {
for (const row of rows) {
if (row.groupedByField === field) {
row.progressBar = progressBarInfo[row.resId];
if (row.progressBar) {
row.progressBar.value_formatted = fieldUtils.format.float(row.progressBar.value, {'digits': [false, 0]});
row.progressBar.max_value_formatted = fieldUtils.format.float(row.progressBar.max_value, {'digits': [false, 0]});
row.progressBar.ratio = row.progressBar.max_value ? row.progressBar.value / row.progressBar.max_value * 100 : 0;
row.progressBar.warning = progressBarInfo.warning;
}
} else {
this._addProgressBarInfo(field, row.rows, progressBarInfo);
}
}
},
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,189 @@
/** @odoo-module alias=web_gantt.GanttView */
import AbstractView from 'web.AbstractView';
import core from 'web.core';
import GanttModel from 'web_gantt.GanttModel';
import GanttRenderer from 'web_gantt.GanttRenderer';
import GanttController from 'web_gantt.GanttController';
import pyUtils from 'web.py_utils';
import view_registry from 'web.view_registry';
const _t = core._t;
const _lt = core._lt;
const GanttView = AbstractView.extend({
display_name: _lt('Gantt'),
icon: 'fa fa-tasks',
config: _.extend({}, AbstractView.prototype.config, {
Model: GanttModel,
Controller: GanttController,
Renderer: GanttRenderer,
}),
jsLibs: [
'/web/static/lib/nearest/jquery.nearest.js',
],
viewType: 'gantt',
/**
* @override
*/
init(viewInfo, params) {
this._super.apply(this, arguments);
const { domain } = params.action || {};
this.controllerParams.actionDomain = domain || [];
this.SCALES = {
day: { string: _t('Day'), hotkey: 'e', cellPrecisions: { full: 60, half: 30, quarter: 15 }, defaultPrecision: 'full', time: 'minutes', interval: 'hour' },
week: { string: _t('Week'), hotkey: 'p', cellPrecisions: { full: 24, half: 12 }, defaultPrecision: 'half', time: 'hours', interval: 'day' },
month: { string: _t('Month'), hotkey: 'm', cellPrecisions: { full: 24, half: 12 }, defaultPrecision: 'half', time: 'hours', interval: 'day' },
year: { string: _t('Year'), hotkey: 'y', cellPrecisions: { full: 1 }, defaultPrecision: 'full', time: 'months', interval: 'month' },
};
const arch = this.arch;
// Decoration fields
const decorationFields = [];
_.each(arch.children, (child) => {
if (child.tag === 'field') {
decorationFields.push(child.attrs.name);
}
});
let collapseFirstLevel = !!arch.attrs.collapse_first_level;
// Unavailability
const displayUnavailability = !!arch.attrs.display_unavailability;
// Colors
const colorField = arch.attrs.color;
// Cell precision
// precision = {'day': 'hour:half', 'week': 'day:half', 'month': 'day', 'year': 'month:quarter'}
const precisionAttrs = arch.attrs.precision ? pyUtils.py_eval(arch.attrs.precision) : {};
const cellPrecisions = {};
_.each(this.SCALES, (vals, key) => {
if (precisionAttrs[key]) {
const precision = precisionAttrs[key].split(':'); // hour:half
// Note that precision[0] (which is the cell interval) is not
// taken into account right now because it is no customizable.
if (precision[1] && _.contains(_.keys(vals.cellPrecisions), precision[1])) {
cellPrecisions[key] = precision[1];
}
}
cellPrecisions[key] = cellPrecisions[key] || vals.defaultPrecision;
});
let consolidationMaxField;
let consolidationMaxValue;
const consolidationMax = arch.attrs.consolidation_max ? pyUtils.py_eval(arch.attrs.consolidation_max) : {};
if (Object.keys(consolidationMax).length > 0) {
consolidationMaxField = Object.keys(consolidationMax)[0];
consolidationMaxValue = consolidationMax[consolidationMaxField];
// We need to display the aggregates even if there is only one groupby
collapseFirstLevel = !!consolidationMaxField || collapseFirstLevel;
}
const consolidationParams = {
field: arch.attrs.consolidation,
maxField: consolidationMaxField,
maxValue: consolidationMaxValue,
excludeField: arch.attrs.consolidation_exclude,
};
// form view which is opened by gantt
let formViewId = arch.attrs.form_view_id ? parseInt(arch.attrs.form_view_id, 10) : false;
if (params.action && !formViewId) { // fallback on form view action, or 'false'
const result = _.findWhere(params.action.views, { type: 'form' });
formViewId = result ? result.viewID : false;
}
const dialogViews = [[formViewId, 'form']];
let allowedScales;
if (arch.attrs.scales) {
const possibleScales = Object.keys(this.SCALES);
allowedScales = _.reduce(arch.attrs.scales.split(','), (allowedScales, scale) => {
if (possibleScales.indexOf(scale) >= 0) {
allowedScales.push(scale.trim());
}
return allowedScales;
}, []);
} else {
allowedScales = Object.keys(this.SCALES);
}
const scale = params.context.default_scale || arch.attrs.default_scale || 'month';
const initialDate = moment(params.context.initialDate || params.initialDate || arch.attrs.initial_date || new Date());
const offset = arch.attrs.offset;
if (offset && scale) {
initialDate.add(offset, scale);
}
// thumbnails for groups (display a thumbnail next to the group name)
const thumbnails = this.arch.attrs.thumbnails ? pyUtils.py_eval(this.arch.attrs.thumbnails) : {};
// plan option
const canPlan = this.arch.attrs.plan ? !!JSON.parse(this.arch.attrs.plan) : true;
// cell create option
const canCellCreate = this.arch.attrs.cell_create ? !!JSON.parse(this.arch.attrs.cell_create) : true;
// Dependencies
const dependencyField = !!this.arch.attrs.dependency_field && this.arch.attrs.dependency_field;
const dependencyInvertedField = !!this.arch.attrs.dependency_inverted_field && this.arch.attrs.dependency_inverted_field;
if (dependencyField) {
decorationFields.push(dependencyField);
}
this.controllerParams.context = params.context || {};
this.controllerParams.dialogViews = dialogViews;
this.controllerParams.SCALES = this.SCALES;
this.controllerParams.allowedScales = allowedScales;
this.controllerParams.collapseFirstLevel = collapseFirstLevel;
this.controllerParams.createAction = arch.attrs.on_create || null;
this.loadParams.initialDate = initialDate;
this.loadParams.collapseFirstLevel = collapseFirstLevel;
this.loadParams.colorField = colorField;
this.loadParams.dateStartField = arch.attrs.date_start;
this.loadParams.dateStopField = arch.attrs.date_stop;
this.loadParams.progressField = arch.attrs.progress;
this.loadParams.decorationFields = decorationFields;
this.loadParams.defaultGroupBy = this.arch.attrs.default_group_by;
this.loadParams.permanentGroupBy = this.arch.attrs.permanent_group_by;
this.loadParams.dynamicRange = this.arch.attrs.dynamic_range;
this.loadParams.displayUnavailability = displayUnavailability;
this.loadParams.fields = this.fields;
this.loadParams.scale = scale;
this.loadParams.SCALES = this.SCALES;
this.loadParams.consolidationParams = consolidationParams;
this.loadParams.progressBarFields = arch.attrs.progress_bar;
this.modelParams.dependencyField = dependencyField;
this.modelParams.dependencyInvertedField = dependencyInvertedField;
this.rendererParams.canCreate = this.controllerParams.activeActions.create;
this.rendererParams.canCellCreate = canCellCreate;
this.rendererParams.canEdit = this.controllerParams.activeActions.edit;
this.rendererParams.canPlan = canPlan && this.rendererParams.canEdit;
this.rendererParams.fieldsInfo = viewInfo.fields;
this.rendererParams.SCALES = this.SCALES;
this.rendererParams.cellPrecisions = cellPrecisions;
this.rendererParams.totalRow = arch.attrs.total_row || false;
this.rendererParams.string = arch.attrs.string || _t('Gantt View');
this.rendererParams.popoverTemplate = _.findWhere(arch.children, {tag: 'templates'});
this.rendererParams.colorField = colorField;
this.rendererParams.disableDragdrop = arch.attrs.disable_drag_drop ? !!JSON.parse(arch.attrs.disable_drag_drop) : false;
this.rendererParams.progressField = arch.attrs.progress;
this.rendererParams.displayUnavailability = displayUnavailability;
this.rendererParams.collapseFirstLevel = collapseFirstLevel;
this.rendererParams.consolidationParams = consolidationParams;
this.rendererParams.thumbnails = thumbnails;
this.rendererParams.progressBarFields = arch.attrs.progress_bar;
this.rendererParams.pillLabel = !!arch.attrs.pill_label;
this.rendererParams.dependencyEnabled = !!this.modelParams.dependencyField
this.rendererParams.dependencyField = this.modelParams.dependencyField
},
});
view_registry.add('gantt', GanttView);
export default GanttView;

View File

@@ -0,0 +1,756 @@
// Define the necessary conditions to provide visual feedback on hover,
// focus, drag, clone and resize.
@mixin o-gantt-hover() {
&:hover, &:focus, &.ui-draggable-dragging, &.ui-resizable-resize {
// Avoid visual feedback if 'o_gantt_view' has class 'o_grabbing' or 'o_copying'.
@at-root #{selector-replace(&, ".o_gantt_view", ".o_gantt_view:not(.o_grabbing):not(.o_copying):not(.o_no_dragging)")} {
@content;
}
}
}
// Generate background and text for each color.
@mixin o-gantt-hoverable-colors($color) {
$color-subdle: mix($color, white, 60%);
color: color-contrast($color-subdle);
background-color: $color-subdle;
cursor: pointer;
@include o-gantt-hover() {
background-color: $color;
color: color-contrast($color);
}
}
// Generate stripes decorations for each color.
@mixin gantt-gradient-decorations($color) {
$color-subdle: mix($color, white, 60%);
background-image: repeating-linear-gradient(-45deg, $color-subdle 0 10px, lighten($color-subdle, 6%) 10px 20px);
@include o-gantt-hover() {
background-image: repeating-linear-gradient(-45deg, $color 0 10px, lighten($color, 6%) 10px 20px);
}
}
@mixin gantt-ribbon-decoration($color) {
content: '';
width: 20px;
height: 16px;
@include o-position-absolute(-11px, $left: -13px);
box-shadow: 1px 1px 0 white;
background: $color;
transform: rotate(45deg);
}
@mixin gant-today-cell() {
&.o_gantt_today {
border-color: mix($gantt-highlight-today-border, $gantt-border-color, 25%);
border-left-color: $gantt-highlight-today-border;
background-color: $gantt-highlight-today-bg;
+ .o_gantt_header_cell, + .o_gantt_cell {
border-left-color: $gantt-highlight-today-border;
}
&.o_gantt_unavailability {
background: mix($gantt-highlight-today-bg, $gantt-unavailability-bg);
}
}
}
.o_gantt_view {
// Allows to use color variables in js
--Gant__Day-background-color: #{$o-view-background-color};
--Gant__DayOff-background-color: #e9ecef;
--Gant__DayOffToday-background-color: #fffaeb;
box-shadow: 0 5px 20px -15px rgba(black, .3);
user-select: none;
@include media-breakpoint-down(md) {
.o_gantt_view_container {
width: max-content;
}
}
#o_gantt_containment {
@include o-position-absolute(0, 0, 1px, percentage(2 / $grid-columns));
}
// =============== Cursors while dragging ==============
// =======================================================
&.o_grabbing, &.o_grabbing .o_gantt_pill {
cursor: move!important;
}
&.o_copying, &.o_copying .o_gantt_pill {
cursor: copy!important;
}
&.o_no_dragging {
.o_gantt_cell_buttons, .ui-resizable-handle {
visibility: hidden;
}
&, .o_gantt_pill {
cursor: not-allowed!important;
}
}
&.o_grabbing, &.o_copying {
.o_gantt_cell_buttons,
.ui-draggable-dragging:before,
.ui-draggable-dragging .ui-resizable-handle {
visibility: hidden;
}
}
.o_dragged_pill {
opacity: .5;
}
.ui-draggable-dragging {
opacity: .8;
transform: rotate(-3deg);
box-shadow: 0 5px 25px -10px black;
transition: transform 0.6s, box-shadow 0.3s;
}
// =============== Header ==============
// =======================================
.o_gantt_header_container {
top: 0;
z-index: 10; // header should overlap the pills
.o_gantt_row_sidebar {
box-shadow: inset 0 -1px 0 $gantt-border-color;
line-height: 4.8rem;
}
.o_gantt_header_slots {
box-shadow: inset 1px 0 0 $gantt-border-color;
}
.o_gantt_header_scale {
border-top: 1px solid $gantt-border-color;
border-bottom: 1px solid $gantt-border-color;
}
.o_gantt_header_cell {
@include gant-today-cell();
border-left: 1px solid transparent;
color: $headings-color;
@include media-breakpoint-down(md) {
min-width: 0;
}
}
}
.o_gantt_row {
&:hover{
.o_gantt_group_hours {
display: initial;
}
}
}
// === All sidebar headers (Regular, Groups and Total) ====
// ========================================================
.o_gantt_row_sidebar {
color: $headings-color;
font-weight: bold;
.o_gantt_row_title {
line-height: $gantt-pill-height + 4px;
position: relative;
}
.o_gantt_progressbar, .o_gantt_text_hoverable {
right: 0;
height: 100%
}
@include media-breakpoint-down(sm) {
.o_gantt_progressbar, .o_gantt_text_mobile {
top: 50%;
height: 50%;
}
}
.o_gantt_group_hours {
display: none;
@include media-breakpoint-down(sm) {
display: block;
}
}
}
// All rows (Regular, Group Header and Total)
// ==========================================
.o_gantt_row, .o_gantt_total_row_container {
.o_gantt_pill {
z-index: 1; // pill should overlap the grid
height: $gantt-pill-height;
p {
// Prevent displaying pill's description when size is smaller than 50px
max-width: calc((100% - 50px) * 9999);
margin: 0 4px;
}
}
}
// ===== "Regular" & "Group Header" rows =====
// ===========================================
.o_gantt_row_container {
.o_gantt_row {
border-bottom: 1px solid $gantt-border-color;
background: #FFFFFF;
&:first-child {
> .o_gantt_slots_container, > .o_gantt_row_sidebar {
box-shadow: inset 0 4px 5px -3px rgba(black, .1);
}
}
.o_gantt_slots_container .o_gantt_cell.ui-drag-hover {
background: rgba(0, 160, 157, 0.3) !important;
.o_gantt_pill {
background: rgba(0, 160, 157, 0.3);
}
}
}
.o_gantt_row_thumbnail_wrapper {
.o_gantt_row_thumbnail {
width: auto;
max-height: $gantt-pill-height - 10px;
}
}
.o_gantt_cell {
@include gant-today-cell();
border-left: 1px solid $gantt-border-color;
}
}
// ============= "Regular" rows ==============
// ===========================================
.o_gantt_row_nogroup {
.o_gantt_cell {
min-height: $gantt-pill-height + 4px;
}
.o_gantt_pill {
@include o-gantt-hoverable-colors(nth($o-colors-complete, 1));
overflow: hidden;
user-select: none;
box-sizing: content-box;
&.ui-resizable-resizing, &.ui-draggable-dragging {
z-index: 2; // other pills show not hide these ones
}
&.decoration-info {
@include gantt-gradient-decorations(nth($o-colors-complete, 1));
}
.ui-resizable-e, .ui-resizable-w {
width: 10px;
}
&:hover {
.ui-resizable-e, .ui-resizable-w {
background-color: rgba(230,230,230, .5);
&:hover {
background-color: rgba(230,230,230, .8);
}
}
}
&.ui-resizable-resizing {
.ui-resizable-e, .ui-resizable-w {
background-color: rgba(black, .5);
}
}
// used for `color` attribute on <gantt>
@for $index from 2 through length($o-colors-complete) - 1 {
// @for $index from 3 through length($o-colors) {
&.o_gantt_color_#{$index - 1} {
$gantt-color: nth($o-colors-complete, $index);
@include o-gantt-hoverable-colors($gantt-color);
&.decoration-info {
@include gantt-gradient-decorations($gantt-color);
}
}
}
@each $color, $value in $theme-colors {
&.decoration-#{$color}:before {
@include gantt-ribbon-decoration($value);
}
}
}
.o_gantt_cell.o_gantt_unavailability {
background: linear-gradient(
$gantt-unavailability-bg,
$gantt-unavailability-bg
) no-repeat;
&.o_gantt_unavailable_first_half {
background-size: 50%;
}
&.o_gantt_unavailable_second_half {
background-position: right;
background-size: 50%;
}
}
.o_gantt_cell.o_gantt_unavailable_second_half.o_gantt_today {
background: linear-gradient(
to right,
$gantt-highlight-today-bg 50%,
$gantt-unavailability-bg 50%
);
background-size: 100%;
}
.o_gantt_cell_buttons {
@include o-position-absolute(0, 0, $left: 0);
display: none;
z-index: 4;
color: $body-color;
.o_gantt_cell_add {
cursor: cell;
}
.o_gantt_cell_plan {
cursor: zoom-in;
}
.o_gantt_cell_add, .o_gantt_cell_plan {
background: $gray-100;
width: 30px;
line-height: 16px;
box-shadow: 0 1px 2px rgba(black, .2);
cursor: pointer;
&:first-child {
border-bottom-left-radius: 4px;
}
&:last-child {
border-bottom-right-radius: 4px;
}
}
}
.o_gantt_pill_wrapper {
line-height: $gantt-pill-height;
margin: 0 2px;
&.o_gantt_pill_wrapper_continuous_left {
padding-left: 0;
}
&.o_gantt_pill_wrapper_continuous_right {
padding-right: 0;
}
.o_gantt_pill_resize_badge {
@include o-position-absolute($bottom: -18px);
box-shadow: 0 1px 2px 0 rgba(black, .28);
background-color: #FFFFFF;
}
&.o_gantt_consolidated_wrapper {
.o_gantt_consolidated_pill {
@include o-position-absolute(0, 0, 0, 0);
height: auto;
}
.o_gantt_consolidated_pill_title {
z-index: 2;
color: white;
}
}
}
// ==== "Regular" row - When it's an opened group's children
&.open .o_gantt_row_sidebar {
font-weight: normal;
}
// ==== "Regular" row - "Hover" State
.o_gantt_cell.o_gantt_hoverable.o_hovered {
.o_gantt_cell_buttons {
display: flex;
}
&.o_gantt_unavailability {
&.o_gantt_unavailable_first_half {
background: linear-gradient(
to right,
rgba($gantt-unavailability-bg, .7) 50%,
$gantt-highlight-hover-row 50%
);
background-size: 100%;
}
&.o_gantt_unavailable_second_half {
background: linear-gradient(
to right,
$gantt-highlight-hover-row 50%,
rgba($gantt-unavailability-bg, .7) 50%
);
background-size: 100%;
}
&.o_gantt_unavailable_full {
background: linear-gradient(
to right,
rgba($gantt-unavailability-bg, .7) 50%,
rgba($gantt-unavailability-bg, .7) 50%
);
background-size: 100%;
}
}
}
.o_gantt_row_sidebar .o_gantt_group_hours {
line-height: $gantt-pill-height + 4px;
}
}
// ==== "Group Header" rows (closed) =====
// =======================================
.o_gantt_row_group {
cursor: pointer;
&, &.open:hover {
.o_gantt_row_sidebar, .o_gantt_slots_container, .o_gantt_text_hoverable.o_gantt_group_none {
background-image: linear-gradient(darken($gantt-row-open-bg, 5%), $gantt-row-open-bg);
}
}
&:hover, &.open {
.o_gantt_row_sidebar, .o_gantt_slots_container, .o_gantt_text_hoverable.o_gantt_group_none {
background-image: linear-gradient($gantt-row-open-bg, darken($gantt-row-open-bg, 5%));
}
}
.o_gantt_row_sidebar, .o_gantt_row_title, .o_gantt_cell {
min-height: $gantt-pill-consolidated-height;
line-height: $gantt-pill-consolidated-height;
}
.o_gantt_row_sidebar .o_gantt_text_hoverable {
&.o_gantt_group_success {
background-image: linear-gradient(#add4bd, #b8dec3);
}
&.o_gantt_group_danger {
background-image: linear-gradient(#e3b2bd, #eebcc3);
}
}
.o_gantt_row_thumbnail_wrapper .o_gantt_row_thumbnail {
max-width: 17px;
}
.o_gantt_cell {
border-color: mix($gantt-row-open-bg, $gantt-border-color, 30%);
&.o_gantt_today {
background-color: mix($gantt-row-open-bg, $gantt-highlight-today-bg);
}
}
.o_gantt_pill {
border-color: $primary;
}
.o_gantt_pill_wrapper.o_gantt_consolidated_wrapper {
margin-top: 0;
line-height: $gantt-pill-consolidated-height;
.o_gantt_consolidated_pill {
@include o-position-absolute($gantt-pill-consolidated-height * .5 - 1px, 0, auto, 0);
background-color: $primary;
height: 2px;
&:before, &:after {
border-top: 4px solid transparent;
border-bottom: 5px solid transparent;
content: '';
}
&:before {
@include o-position-absolute($top: -3px, $left: 0);
border-left: 5px solid;
border-left-color: inherit;
}
&:after {
@include o-position-absolute($top: -3px, $right: 0);
border-right: 5px solid;
border-right-color: inherit;
}
}
}
// === "Group Header" rows (open) ======
// =======================================
&.open .o_gantt_cell {
&, &.o_gantt_today, &.o_gantt_today + .o_gantt_cell {
border-color: transparent;
background-color: transparent;
}
.o_gantt_pill_wrapper.o_gantt_consolidated_wrapper .o_gantt_consolidated_pill {
&:before, &:after {
top: 2px;
border: 2px solid transparent;
border-top-color: inherit;
}
&:before {
border-left-color: inherit;
}
&:after {
border-right-color: inherit;
}
}
}
}
// === "Group Header" & "TOTAL" rows ========
// ===========================================
.o_gantt_row_group, .o_gantt_total {
.o_gantt_consolidated_pill_title {
z-index: 2;
background-color: $o-view-background-color;
color: $body-color;
}
}
// ============= "TOTAL" row =================
// ===========================================
.o_gantt_total {
z-index: 2;
}
.o_gantt_total_row_container .o_gantt_row {
border-bottom: 1px solid $gantt-border-color;
.o_gantt_cell {
@include gant-today-cell();
border-left: 1px solid rgba($gantt-border-color, .25);
&:first-child {
border-left: 1px solid rgba($gantt-border-color, 1);
}
}
.o_gantt_cell, .o_gantt_row_title, .o_gantt_pill_wrapper {
min-height: $gantt-pill-height * 1.6;
line-height: $gantt-pill-height * 1.6;
}
.o_gantt_consolidated_pill_title {
bottom: 2px;
line-height: 1.5;
}
.o_gantt_pill {
@include o-position-absolute(auto, 0, 0, 0);
background-color: rgba($o-brand-odoo, .5);
}
.o_gantt_pill_wrapper:hover {
overflow: visible;
.o_gantt_pill {
background-color: rgba($o-brand-odoo, .8);
}
&:before {
@include o-position-absolute(auto, -1px, 0, -1px);
border: 1px solid $o-brand-odoo;
border-width: 0 1px;
background: rgba($o-brand-odoo, .1);
height: 100vh;
content: '';
pointer-events: none;
z-index: 1;
}
}
.o_gantt_cell:last-child .o_gantt_pill_wrapper:hover:before {
border-right: 0px;
right: 0;
}
}
.o_view_nocontent {
z-index: 11; // as we have z-index: 10 on header container so z-index: header + 1;
}
// Suggest the browsers to print background graphics (IE users will still
// need to go to their settings in order to print them)
-webkit-print-color-adjust: exact; /* Chrome, Safari */
color-adjust: exact; /*Firefox*/
}
.o_gantt_slots_container {
.o_gantt_cell {
.o_gantt_pill_wrapper {
// used for `color` attribute on <gantt>
@for $index from 1 through length($o-colors-complete) - 1 {
// @for $index from 3 through length($o-colors) {
.o_gantt_pill {
&.highlight {
z-index: 2;
}
&.o_gantt_color_#{$index - 1} {
$color: nth($o-colors-complete, $index);
&.highlight {
background-color: $color;
color: color-contrast($color);
}
.o_gantt_progress {
opacity: 0.2;
background-color: darken(nth($o-colors-complete, $index), 30%);
}
}
}
.o_connector_creator_wrapper {
&.o_gantt_color_#{$index - 1} {
$color: nth($o-colors-complete, $index);
.o_connector_creator_bullet {
background-color: $color;
color: color-contrast($color);
@include o-grab-cursor;
}
.o_connector_creator_top {
border-top: solid 1px $color;
}
.o_connector_creator_right {
border-left: solid 1px $color;
}
.o_connector_creator_bottom {
border-bottom: solid 1px $color;
}
.o_connector_creator_left {
border-right: solid 1px $color;
}
}
}
}
}
}
}
.o_connector_container {
pointer-events: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.o_connector {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
&.o_connector_hovered {
z-index: 2;
}
.o_connector_stroke_button {
> rect {
cursor: pointer;
stroke: #091124;
stroke-width: 24px;
fill: white;
}
&.o_connector_stroke_reschedule_button {
line {
stroke: #00a09d;
}
&:hover{
> rect {
fill: #00a09d;
}
line {
stroke: white;
}
}
}
&.o_connector_stroke_remove_button {
g {
rect {
fill: #dd3c4f;
}
}
&:hover{
> rect {
fill: #dd3c4f;
}
g {
rect {
fill: white;
}
}
}
}
}
}
.o_connector_creator_wrapper {
z-index: 3;
position: absolute;
height: $o-connector-wrapper-height;
width: 100%
}
.o_connector_creator_wrapper_top {
top: -1 * $o-connector-wrapper-height;
}
.o_connector_creator_wrapper_bottom {
bottom: -1 * $o-connector-wrapper-height;
}
.o_connector_creator {
position: absolute;
height: $o-connector-creator-size;
width: $o-connector-creator-size;
}
.o_connector_creator_bullet {
height: $o-connector-creator-bullet-diameter;
width: $o-connector-creator-bullet-diameter;
position: absolute;
border-radius: $o-connector-creator-bullet-diameter / 2;
}
.o_connector_creator_top {
bottom: 0;
.o_connector_creator_bullet {
top: -0.5 * $o-connector-creator-bullet-diameter;
}
}
.o_connector_creator_right {
right: $o-connector-creator-size;
.o_connector_creator_bullet {
right: -0.5 * $o-connector-creator-bullet-diameter;
}
}
.o_connector_creator_bottom {
top: 0;
.o_connector_creator_bullet {
bottom: -0.5 * $o-connector-creator-bullet-diameter;
}
}
.o_connector_creator_left {
left: $o-connector-creator-size;
.o_connector_creator_bullet {
left: -0.5 * $o-connector-creator-bullet-diameter;
}
}

View File

@@ -0,0 +1,15 @@
// = Gantt View Variables
// ============================================================================
$gantt-border-color: $o-gray-300 !default;
$gantt-pill-height: 31px !default;
$gantt-highlight-today-border: #dca665 !default;
$gantt-highlight-today-bg: #fffaeb !default;
$gantt-highlight-hover-row: rgba($o-brand-primary, .1) !default;
$gantt-row-open-bg: $o-gray-100 !default;
$gantt-pill-consolidated-height: 24px !default;
$gantt-unavailability-bg: $o-gray-200 !default;
$o-connector-creator-bullet-radius: 3px !default;
$o-connector-creator-size: 8px !default;
$o-connector-creator-bullet-diameter: 2 * $o-connector-creator-bullet-radius !default;
$o-connector-wrapper-height: $o-connector-creator-size + $o-connector-creator-bullet-radius !default;

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="connector_stroke_head" owl="1">
<path d="M2,2 L10,6 L2,10 L6,6 L2,2"
class="o_connector_stroke_head"
t-att="{
'fill': color,
'stroke': color,
}"
t-if="width"
version="1.1"
xmlns="http://www.w3.org/2000/svg"/>
</t>
<t t-name="connector_stroke" owl="1">
<path fill="none"
pointer-events="stroke"
t-att="{
'd': path,
'stroke': color,
'stroke-width': width,
'class': 'o_connector_stroke' + (classNameModifier ? classNameModifier : ''),
'marker-end': markerEnd ? 'url(#' + markerEnd + ')' : false,
'pointer-events': props.inCreation ? 'none' : 'stroke',
}"
t-if="width"/>
</t>
<t t-name="connector" owl="1">
<svg class="o_connector"
pointer-events="none"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
t-att-class="props.hovered ? 'o_connector_hovered' : ''"
t-att-data-id="props.id">
<t t-set="strokeColor"
t-value="props.hovered and !!style.stroke.hoveredColor ? style.stroke.hoveredColor : style.stroke.color"/>
<t t-set="outlineStrokeColor"
t-value="props.hovered and !!style.outlineStroke.hoveredColor ? style.outlineStroke.hoveredColor : style.outlineStroke.color"/>
<defs t-if="style.drawHead">
<marker markerHeight="6"
markerWidth="6"
markerUnits="strokeWidth"
orient="auto"
refX="9"
refY="6"
stroke-linejoin="round"
t-attf-id="o_connector_arrow_head_{{props.id}}"
viewBox="0 0 12 12">
<t t-call="connector_stroke_head">
<t t-set="color" t-value="strokeColor"/>
<t t-set="width" t-value="style.stroke.width"/>
</t>
</marker>
</defs>
<t t-if="hoverEaseWidth > 0" t-call="connector_stroke">
<t t-set="color" t-value="transparent"/>
<t t-set="classNameModifier" t-value="'_hover_ease'"/>
<t t-set="width"
t-value="style.stroke.width + hoverEaseWidth + style.outlineStroke.width"/>
</t>
<t t-if="style.outlineStroke.width > 0" t-call="connector_stroke">
<t t-set="color" t-value="outlineStrokeColor"/>
<t t-set="classNameModifier" t-value="'_outline'"/>
<t t-set="width" t-value="style.stroke.width + style.outlineStroke.width"/>
</t>
<t t-call="connector_stroke">
<t t-set="color" t-value="strokeColor"/>
<t t-set="markerEnd" t-value="'o_connector_arrow_head_' + props.id"/>
<t t-set="width" t-value="style.stroke.width"/>
</t>
<svg class="o_connector_stroke_buttons"
pointer-events="all"
t-att="{
'height': 16,
'width': 48,
'x': removeButtonPosition.left - 24,
'y': removeButtonPosition.top - 8
}"
t-if="props.canBeRemoved and props.hovered"
version="1.1"
viewBox="0 0 1536 512"
xmlns="http://www.w3.org/2000/svg">
<rect fill="transparent" x="0" y="0" width="1536" height="512"/>
<g t-on-click.stop="_onRescheduleSoonerClick" class="o_connector_stroke_button o_connector_stroke_reschedule_button">
<rect fill="white" x="20" y="20" width="472" height="472" rx="236" ry="236"/>
<g pointer-events="none">
<line x1="192" y1="256" x2="320" y2="128" stroke-width="56"/>
<line x1="192" y1="256" x2="320" y2="384" stroke-width="56"/>
</g>
</g>
<g t-on-click.stop="_onRemoveButtonClick" class="o_connector_stroke_button o_connector_stroke_remove_button">
<rect fill="white" x="532" y="20" width="472" height="472" class="o_connector_stroke_button" rx="236" ry="236"/>
<g transform="rotate(45,768,256)" pointer-events="none">
<rect x="740" y="100" fill="rgb(221, 60, 79)" width="56" height="312"/>
<rect x="612" y="228" fill="rgb(221, 60, 79)" width="312" height="56"/>
</g>
</g>
<g t-on-click.stop="_onRescheduleLaterClick" class="o_connector_stroke_button o_connector_stroke_reschedule_button">
<rect fill="white" x="1044" y="20" width="472" height="472" rx="236" ry="236"/>
<g pointer-events="none">
<line x1="1216" y1="128" x2="1344" y2="256" stroke-width="56"/>
<line x1="1216" y1="384" x2="1344" y2="256" stroke-width="56"/>
</g>
</g>
</svg>
</svg>
</t>
</templates>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="connector_container" owl="1">
<div class="o_connector_container"
t-on-mouseover.stop="_onMouseOver"
t-on-mouseout.stop="_onMouseOut">
<t t-foreach="connectors"
t-as="connector"
t-key="connector_value.id">
<Connector t-props="connector_value"
onRemoveButtonClick="props.onRemoveButtonClick"
onRescheduleSoonerButtonClick="props.onRescheduleSoonerButtonClick"
onRescheduleLaterButtonClick="props.onRescheduleLaterButtonClick"
/>
</t>
<Connector t-props="newConnector" t-if="!!newConnector.source &amp;&amp; !!newConnector.target"/>
</div>
</t>
</templates>

View File

@@ -0,0 +1,260 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<div t-name="GanttView.buttons">
<button t-if="widget.is_action_enabled('create')" class="o_gantt_button_add btn btn-primary me-3" title="Add record" data-hotkey="r">
Add
</button>
<div class="d-inline-block me-3">
<button class="o_gantt_button_prev btn btn-primary" title="Previous">
<span class="fa fa-arrow-left"/>
</button>
<button class="o_gantt_button_today btn btn-primary" data-hotkey="t">
Today
</button>
<button class="o_gantt_button_next btn btn-primary" title="Next">
<span class="fa fa-arrow-right"/>
</button>
</div>
<div class="btn-group o_gantt_range">
<t t-call="GanttView.RangeButtons"/>
</div>
<div t-attf-class="btn-group #{displayExpandCollapseButtons ? '' : 'd-none'}">
<button class="o_gantt_button_expand_rows btn btn-secondary" title="Expand rows">
<i class="fa fa-expand"/>
</button>
<button class="o_gantt_button_collapse_rows btn btn-secondary" title="Collapse rows">
<i class="fa fa-compress"/>
</button>
</div>
</div>
<t t-name="GanttView.RangeButtons">
<t t-if="isMobile">
<t t-call="GanttView.RangeButtons.Mobile"/>
</t>
<t t-else="">
<t t-call="GanttView.RangeButtons.Loop"/>
</t>
</t>
<t t-name="GanttView.RangeButtons.Mobile">
<a class="btn btn-link dropdown-toggle" href="#" data-bs-toggle="dropdown" aria-expanded="false">
<t t-esc="SCALES[activateScale].string"/>
</a>
<ul class="dropdown-menu" role="menu">
<t t-call="GanttView.RangeButtons.Loop"/>
</ul>
</t>
<t t-name="GanttView.RangeButtons.Loop">
<t t-foreach="allowedScales" t-as="scale">
<button t-attf-class="o_gantt_button_scale btn btn-secondary #{scale == activateScale ? 'active' : ''} #{isMobile ? 'dropdown-item' : ''}" type="button" t-att-data-value="scale" t-att-data-hotkey="SCALES[scale].hotkey" >
<t t-esc="SCALES[scale].string"/>
</button>
</t>
</t>
<div t-name="GanttView" class="o_gantt_view">
<div class="o_gantt_view_container container-fluid p-0">
<div class="row g-0 o_gantt_header_container position-sticky bg-view">
<div class="col-md-2 col-3 position-relative o_gantt_row_sidebar text-center" t-if="widget.state.groupedBy.length">
<span class="fw-bold" t-esc="widget.string"/>
</div>
<div class="col position-relative o_gantt_header_slots">
<div class="row g-0">
<div class="col text-center p-2 fw-bold">
<t t-esc="widget._getFocusDateFormat()"/>
</div>
</div>
<div class="row g-0 o_gantt_header_scale">
<t t-set="formats" t-value="{'week': 'dddd, Do', 'month': 'DD', 'year': isMobile ? 'MMM' : 'MMMM'}"/>
<t t-foreach="widget.viewInfo.slots" t-as="slot">
<t t-set="isToday" t-value="slot.isSame(new Date(), 'day') &amp;&amp; widget.state.scale !== 'day'"/>
<div t-attf-class="col position-relative o_gantt_header_cell text-center p-2 #{isToday? 'o_gantt_today' : ''} ">
<t t-if="widget.state.scale in formats" t-esc="slot.format(formats[widget.state.scale])"/>
<small t-else="">
<b t-esc="slot.format('k')"/>
<span class="d-block d-xl-inline-block" t-esc="slot.format('a')"/>
</small>
</div>
</t>
</div>
</div>
</div>
<div class="row g-0">
<div class="col position-relative o_gantt_row_container">
</div>
</div>
<div class="row g-0 o_gantt_total position-relative" t-if="widget.totalRow">
<div class="col position-relative o_gantt_total_row_container">
</div>
</div>
</div>
</div>
<div t-name="GanttView.Connector" t-attf-class="invisible o_connector_creator_wrapper o_connector_creator_wrapper_{{vertical_alignment}} {{!widget.options.isGroup ? pill.decorations.join(' ') : ''}} {{'_color' in pill ? 'o_gantt_color_' + pill._color : ''}}">
<div t-attf-class="o_connector_creator o_connector_creator_{{vertical_alignment}} o_connector_creator_{{horizontal_alignment}}">
<div t-att-data-id="pill.id" class="o_connector_creator_bullet">
</div>
</div>
</div>
<t t-name="GanttView.PillsConnectors">
<t t-call="GanttView.Connector" t-if="widget.dependencyEnabled">
<t t-set="vertical_alignment" t-value="'top'"></t>
<t t-set="horizontal_alignment" t-value="'left'"></t>
</t>
<t t-out="0"/>
<t t-call="GanttView.Connector" t-if="widget.dependencyEnabled">
<t t-set="vertical_alignment" t-value="'bottom'"></t>
<t t-set="horizontal_alignment" t-value="'right'"></t>
</t>
</t>
<div t-name="GanttView.Row" t-attf-class="row g-0 o_gantt_row bg-view #{widget.isTotal ? 'o_gantt_row_total' : widget.isGroup ? 'o_gantt_row_group' : 'o_gantt_row_nogroup'} #{widget.isOpen ? 'open' : ''}" t-att-data-from-server="widget.fromServer" t-att-data-row-id="widget.rowId">
<div t-if="!widget.options.hideSidebar" t-attf-class="col-md-2 col-3 o_gantt_row_sidebar position-relative #{!widget.name ? 'o_gantt_row_sidebar_empty' : '' }" t-attf-style="padding-left: #{widget.leftPadding}px;">
<t t-if="widget.progressBar">
<t t-if="widget.progressBar.max_value > 0">
<span t-attf-class="position-absolute o_gantt_progressbar bg-opacity-25 {{ widget.progressBar.ratio > 100 ? 'o_gantt_group_danger text-bg-danger' : 'o_gantt_group_success text-bg-success' }}"
t-att-style="'width: ' + Math.min(widget.progressBar.ratio, 100) + '%;'">
&amp;nbsp;
</span>
<span t-attf-class="position-absolute o_gantt_text_hoverable z-index-1 {{widget.progressBar.ratio > 0 ? (widget.progressBar.ratio > 100 ? 'o_gantt_group_danger text-bg-danger' : 'o_gantt_group_success text-bg-success') : 'o_gantt_group_none' }} {{ widget.isMobile ? 'o_gantt_text_mobile': 'o_gantt_text_hoverable'}}">
<span class="o_gantt_group_hours px-1" t-att-style="widget.cellHeight ? 'line-height: ' + (widget.isMobile ? widget.cellHeight / 2 : widget.cellHeight) + 'px;' : ''">
<t t-esc="widget.progressBar.value_formatted"/> / <t t-esc="widget.progressBar.max_value_formatted"/>
</span>
</span>
</t>
<t t-else="">
<span name="progress_bar_no_max_value" class="position-absolute o_gantt_text_hoverable o_gantt_group_none">
<span name="progress_bar_no_max_value_group" class="o_gantt_group_hours px-1"
t-attf-title="#{widget.progressBar.warning} #{widget.progressBar.value_formatted}."
t-att-style="widget.cellHeight ? 'line-height: ' + (widget.isMobile ? widget.cellHeight / 2 : widget.cellHeight) + 'px;' : ''">
<i class="fa fa-exclamation-triangle"></i>
</span>
</span>
</t>
</t>
<div t-attf-class="o_gantt_row_title text-truncate pe-1 #{widget.isTotal ? 'text-end pe-3 h4 my-0 fw-bold' : ''}" t-att-title="widget.name or ''"
t-att-style="widget.cellHeight ? 'line-height: ' + widget.cellHeight + 'px;' : ''">
<i t-if="!widget.isTotal &amp; widget.isGroup"
t-attf-class="fa small #{widget.isOpen ? 'fa-minus' : 'fa-plus'}"/>
<div t-if="widget.thumbnailUrl and widget.resId"
t-attf-class="o_gantt_row_thumbnail_wrapper d-inline #{!widget.options.isGroup ? 'me-1' : ''}">
<img t-att-src="widget.thumbnailUrl" class="rounded-circle o_gantt_row_thumbnail"/>
</div>
<t t-esc="widget.name"/>
</div>
</div>
<div class="o_gantt_slots_container col position-relative">
<div class="row g-0">
<div t-foreach="widget.slots" t-as="slot"
t-attf-class="col position-relative o_gantt_cell #{slot.isToday ? 'o_gantt_today' : ''} #{slot.hasButtons ? 'o_gantt_hoverable' : ''}"
t-att-data-date="slot.start.format('YYYY-MM-DD HH:mm:ss')"
t-attf-style="height: #{widget.cellHeight}px;#{slot.style ? ' ' + slot.style : ''}">
<!-- plan and add buttons -->
<div t-if="slot.hasButtons" class="o_gantt_cell_buttons justify-content-center">
<div class="position-absolute d-flex">
<i t-if="widget.options.canCreate and widget.options.canCellCreate" title="Create" t-attf-class="o_gantt_cell_add fa fa-plus d-flex justify-content-center #{widget.options.canPlan ? 'pe-1' : ''}"/>
<i t-if="widget.options.canPlan" title="Plan existing" class="o_gantt_cell_plan fa fa-search-plus d-flex justify-content-center"/>
</div>
</div>
<!-- pills -->
<t t-foreach="slot.pills" t-as="pill">
<div t-if="widget.isTotal"
class="o_gantt_pill_wrapper position-absolute d-flex justify-content-center"
t-attf-style="width: #{pill.width}; #{widget.isRTL ? 'right' : 'left'}: #{pill.leftMargin}%;">
<t t-call="GanttView.PillsConnectors">
<div t-att-data-id="pill.id"
t-attf-class="o_gantt_pill o_gantt_consolidated_pill"
t-att-title="pill.display_name"
t-att-style="'height:' + pill.totalHeight + '%;'"/>
</t>
<span class="o_gantt_consolidated_pill_title position-absolute text-truncate px-1" t-esc="pill.display_name"/>
</div>
<div t-elif="pill.consolidated"
t-attf-class="o_gantt_pill_wrapper position-absolute o_gantt_consolidated_wrapper #{widget.options.isGroup ? 'o_gantt_pill_wrapper_group' : ''}"
t-attf-style="width: #{pill.width}; #{widget.isRTL ? 'right' : 'left'}: #{pill.leftMargin}%;">
<t t-call="GanttView.PillsConnectors">
<div
t-att-data-id="pill.id"
t-attf-class="o_gantt_pill o_gantt_consolidated_pill #{pill.status? 'bg-' + pill.status + ' border-' + pill.status : ''} #{!widget.options.isGroup ? pill.decorations.join(' ') : ''}"
t-att-title="pill.display_name"
>
<span
t-if="widget.progressField"
t-attf-class="position-absolute o_gantt_progress"
t-attf-data-progress="#{pill._progress}%;"
t-attf-style="width:#{pill._progress}%;"
>
&amp;nbsp;
</span>
</div>
</t>
<span class="o_gantt_consolidated_pill_title position-relative text-truncate px-1" t-esc="pill.display_name"/>
</div>
<div t-else=""
t-attf-class="o_gantt_pill_wrapper position-absolute #{widget.options.isGroup ? 'o_gantt_pill_wrapper_group' : ''}"
t-attf-style="width: #{pill.width}; margin-top: #{pill.topPadding}px; #{widget.isRTL ? 'right' : 'left'}: #{pill.leftMargin}%;">
<t t-call="GanttView.PillsConnectors">
<div
t-att-data-id="pill.id"
t-attf-class="o_gantt_pill position-relative #{!widget.options.isGroup ? pill.decorations.join(' ') : ''} #{'_color' in pill ? 'o_gantt_color_' + pill._color : ''}"
t-attf-style="#{widget.options.isGroup ? pill.style : ''}"
t-att-title="pill.display_name"
>
<span
t-if="widget.progressField"
t-attf-class="position-absolute o_gantt_progress"
t-attf-data-progress="#{pill._progress}%;"
t-attf-style="width:#{pill._progress}%;"
>
&amp;nbsp;
</span>
<!-- README: be careful when modifying the DOM inside the pill ; @_onMouseMove is strongly dependant of it -->
<p class="text-truncate position-relative mb-0 o_gantt_pill_title" t-esc="pill.label ? pill.label : pill.display_name"/>
</div>
</t>
</div>
</t>
</div>
</div>
</div>
</div>
<t t-name="GanttView.ResizeBadge">
<span t-if="diff === 0" class="o_gantt_pill_resize_badge badge rounded-pill" t-attf-style="#{direction}: 0px;">
<t t-esc="_.str.sprintf('%s %s', diff, time)"/>
</span>
<span t-elif="diff &gt; 0" class="o_gantt_pill_resize_badge badge rounded-pill text-success" t-attf-style="#{direction}: 0px;">
<t t-esc="_.str.sprintf('+%s %s', diff, time)"/>
</span>
<span t-else="diff &lt; 0" class="o_gantt_pill_resize_badge badge rounded-pill text-danger" t-attf-style="#{direction}: 0px;">
<t t-esc="_.str.sprintf('%s %s', diff, time)"/>
</span>
</t>
<!-- Default popover template used if none is defined in the Gantt view arch -->
<div t-name="gantt-popover">
<ul class="p-0 mb-0 list-unstyled d-flex flex-row">
<li class="d-flex flex-column pe-2">
<strong>Name:</strong>
<strong>Start:</strong>
<strong>Stop:</strong>
</li>
<li class="d-flex flex-column">
<span t-esc="display_name"/>
<span t-esc="userTimezoneStartDate.format('DD MMM, LT')"/>
<span t-esc="userTimezoneStopDate.format('DD MMM, LT')"/>
</li>
</ul>
</div>
</templates>