Initial commit: Project packages
|
After Width: | Height: | Size: 6 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70"><defs><path id="a" d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z"/><linearGradient id="c" x1="98.162%" x2="0%" y1="1.838%" y2="100%"><stop offset="0%" stop-color="#797DA5"/><stop offset="50.799%" stop-color="#6D7194"/><stop offset="100%" stop-color="#626584"/></linearGradient><path id="d" d="M55.253 36.993c-3.23 0-4.629 2.41-6.384 2.41-4.666 0-.419-13.45-.419-13.45s-15.27 6.106-15.27-.251c0-2.735 2.822-3.53 2.822-6.563 0-2.709-2.187-4.176-4.781-4.176-2.696 0-5.163 1.442-5.163 4.3 0 3.158 2.467 4.525 2.467 6.24 0 5.313-13.684 2.187-13.684 2.187v25.432s13.898 3.133 13.898-2.187c0-1.715-3.112-3.061-3.112-6.218 0-2.859 2.275-4.3 4.946-4.3 2.62 0 4.807 1.466 4.807 4.176 0 3.032-2.823 3.828-2.823 6.562 0 4.64 10.088 1.963 14.1 1.963 0 0-2.702-9.165 2.009-9.165 2.797 0 3.611 2.759 6.714 2.759 2.773 0 4.273-2.138 4.273-4.698 0-2.61-1.475-5.021-4.4-5.021z"/><path id="e" d="M55.253 34.993c-3.23 0-4.629 2.41-6.384 2.41-4.666 0 1.522-11.755-.419-13.45-1.94-1.695-15.27 6.106-15.27-.251 0-2.735 2.822-3.53 2.822-6.563 0-2.709-2.187-4.176-4.781-4.176-2.696 0-5.163 1.442-5.163 4.3 0 3.158 2.467 4.525 2.467 6.24 0 5.313-13.684 2.187-13.684 2.187v25.432s13.898 3.133 13.898-2.187c0-1.715-3.112-3.061-3.112-6.218 0-2.859 2.275-4.3 4.946-4.3 2.62 0 4.807 1.466 4.807 4.176 0 3.032-2.823 3.828-2.823 6.562 0 4.64 12.677 2.6 14.1 1.963 1.422-.636-2.702-9.165 2.009-9.165 2.797 0 3.611 2.759 6.714 2.759 2.773 0 4.273-2.138 4.273-4.698 0-2.61-1.475-5.021-4.4-5.021z"/></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><path fill="url(#c)" d="M0 0H70V70H0z"/><path fill="#FFF" fill-opacity=".383" d="M4 1h61c2.667 0 4.333.667 5 2V0H0v3c.667-1.333 2-2 4-2z"/><path fill="#393939" d="M4 70c-2 0-4-.148-4-4.15V39.197L27 15l11 24.84 6 .066 11.113-.066 4.21 2.112L42.511 70H4z" opacity=".324"/><path fill="#000" fill-opacity=".383" d="M4 69h61c2.667 0 4.333-1 5-3v4H0v-4c.667 2 2 3 4 3z"/><use fill="#000" fill-rule="nonzero" opacity=".3" xlink:href="#d"/><use fill="#FFF" fill-rule="nonzero" xlink:href="#e"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
|
@ -0,0 +1,44 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { ControlPanel } from "@web/search/control_panel/control_panel";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
const { onWillStart } = owl;
|
||||
|
||||
export class ProjectControlPanel extends ControlPanel {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService("orm");
|
||||
this.user = useService("user");
|
||||
const { active_id, show_project_update } = this.env.searchModel.globalContext;
|
||||
this.showProjectUpdate = this.env.config.viewType === "form" || show_project_update;
|
||||
this.projectId = this.showProjectUpdate ? active_id : false;
|
||||
|
||||
onWillStart(async () => {
|
||||
if (this.showProjectUpdate) {
|
||||
await this.loadData();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
const [data, isProjectUser] = await Promise.all([
|
||||
this.orm.call("project.project", "get_last_update_or_default", [this.projectId]),
|
||||
this.user.hasGroup("project.group_project_user"),
|
||||
]);
|
||||
this.data = data;
|
||||
this.isProjectUser = isProjectUser;
|
||||
}
|
||||
|
||||
async onStatusClick(ev) {
|
||||
ev.preventDefault();
|
||||
this.actionService.doAction("project.project_update_all_action", {
|
||||
additionalContext: {
|
||||
default_project_id: this.projectId,
|
||||
active_id: this.projectId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ProjectControlPanel.template = "project.ProjectControlPanel";
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="project.ProjectControlPanelContentBadge" owl="1">
|
||||
<t t-tag="isProjectUser ? 'button' : 'span'" class="badge border d-flex p-2 ms-2 bg-view" data-hotkey="y">
|
||||
<span t-attf-class="o_status_bubble o_color_bubble_{{data.color}}"/>
|
||||
<span t-att-class="'fw-normal ms-1' + (data.color === 0 ? ' text-muted' : '')" t-esc="data.status"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="project.ProjectControlPanelContent" owl="1">
|
||||
<t t-if="showProjectUpdate">
|
||||
<li t-if="isProjectUser" class="o_project_updates_breadcrumb ps-3" t-on-click="onStatusClick">
|
||||
<t t-call="project.ProjectControlPanelContentBadge"></t>
|
||||
</li>
|
||||
<li t-else="" class="o_project_updates_breadcrumb ps-3">
|
||||
<t t-call="project.ProjectControlPanelContentBadge"></t>
|
||||
</li>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="project.Breadcrumbs" t-inherit="web.Breadcrumbs" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//ol" position="inside">
|
||||
<t t-call="project.ProjectControlPanelContent"/>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="project.Breadcrumbs.Small" t-inherit="web.Breadcrumbs.Small" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//ol" position="inside">
|
||||
<t t-call="project.ProjectControlPanelContent"/>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="project.ProjectControlPanel.Regular" t-inherit="web.ControlPanel.Regular" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//t[@t-call='web.Breadcrumbs']" position="replace">
|
||||
<t t-call="project.Breadcrumbs"/>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="project.ProjectControlPanel.Small" t-inherit="web.ControlPanel.Small" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//t[@t-call='web.Breadcrumbs.Small']" position="replace">
|
||||
<t t-call="project.Breadcrumbs.Small"/>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="project.ProjectControlPanel" t-inherit="web.ControlPanel" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//t[@t-call='web.ControlPanel.Regular']" position="replace">
|
||||
<t t-call="project.ProjectControlPanel.Regular"/>
|
||||
</xpath>
|
||||
<xpath expr="//t[@t-call='web.ControlPanel.Small']" position="replace">
|
||||
<t t-call="project.ProjectControlPanel.Small"/>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
import { Many2OneField } from '@web/views/fields/many2one/many2one_field';
|
||||
|
||||
export class ProjectPrivateTaskMany2OneField extends Many2OneField { }
|
||||
ProjectPrivateTaskMany2OneField.template = 'project.ProjectPrivateTaskMany2OneField';
|
||||
|
||||
registry.category('fields').add('project_private_task', ProjectPrivateTaskMany2OneField);
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.project_private_task_many2one_field input::placeholder {
|
||||
color: $red;
|
||||
font-style: italic;
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<templates>
|
||||
|
||||
<t t-name="project.ProjectPrivateTaskMany2OneField" t-inherit="web.Many2OneField" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//t[@t-if='!props.canOpen']/span" position="attributes">
|
||||
<attribute name="t-if">props.value</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//t[@t-if='!props.canOpen']/span" position="after">
|
||||
<span t-else="" class="text-danger fst-italic text-muted"><i class="fa fa-lock"></i> Private</span>
|
||||
</xpath>
|
||||
<xpath expr="//t[@t-else='']/a" position="attributes">
|
||||
<attribute name="t-if">displayName</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//t[@t-else='']/a" position="after">
|
||||
<span t-else="" class="text-danger fst-italic text-muted"><i class="fa fa-lock"></i> Private</span>
|
||||
</xpath>
|
||||
<xpath expr="//div[hasclass('o_field_many2one_selection')]" position="attributes">
|
||||
<attribute name="class" separator=" " add="project_private_task_many2one_field"></attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { formatDate } from "@web/core/l10n/dates";
|
||||
import { useService } from '@web/core/utils/hooks';
|
||||
|
||||
const { Component, useState, onWillUpdateProps, status } = owl;
|
||||
const { DateTime } = luxon;
|
||||
|
||||
export class ProjectMilestone extends Component {
|
||||
setup() {
|
||||
this.orm = useService('orm');
|
||||
this.milestone = useState(this.props.milestone);
|
||||
this.state = useState({
|
||||
colorClass: this._getColorClass(),
|
||||
checkboxIcon: this._getCheckBoxIcon(),
|
||||
});
|
||||
onWillUpdateProps(this.onWillUpdateProps);
|
||||
}
|
||||
|
||||
get resModel() {
|
||||
return 'project.milestone';
|
||||
}
|
||||
|
||||
get deadline() {
|
||||
if (!this.milestone.deadline) return;
|
||||
return formatDate(DateTime.fromISO(this.milestone.deadline));
|
||||
}
|
||||
|
||||
_getColorClass() {
|
||||
return this.milestone.is_deadline_exceeded && !this.milestone.can_be_marked_as_done ? "text-danger" : this.milestone.can_be_marked_as_done ? "text-success" : "";
|
||||
}
|
||||
|
||||
_getCheckBoxIcon() {
|
||||
return this.milestone.is_reached ? "fa-check-square-o" : "fa-square-o";
|
||||
}
|
||||
|
||||
onWillUpdateProps(nextProps) {
|
||||
if (nextProps.milestone) {
|
||||
this.milestone = nextProps.milestone;
|
||||
this.state.colorClass = this._getColorClass();
|
||||
this.state.checkboxIcon = this._getCheckBoxIcon();
|
||||
}
|
||||
if (nextProps.context) {
|
||||
this.contextValue = nextProps.context;
|
||||
}
|
||||
}
|
||||
|
||||
async onDeleteMilestone() {
|
||||
await this.orm.call('project.milestone', 'unlink', [this.milestone.id]);
|
||||
await this.props.load();
|
||||
}
|
||||
|
||||
async onOpenMilestone() {
|
||||
if (!this.write_mutex) {
|
||||
this.write_mutex = true;
|
||||
this.props.open({
|
||||
resModel: this.resModel,
|
||||
resId: this.milestone.id,
|
||||
title: this.env._t("Milestone"),
|
||||
}, {
|
||||
onClose: async () => {
|
||||
if (status(this) === "mounted") {
|
||||
await this.props.load();
|
||||
this.write_mutex = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async toggleIsReached() {
|
||||
if (!this.write_mutex) {
|
||||
this.write_mutex = true;
|
||||
this.milestone = await this.orm.call(
|
||||
this.resModel,
|
||||
'toggle_is_reached',
|
||||
[[this.milestone.id], !this.milestone.is_reached],
|
||||
);
|
||||
this.state.colorClass = this._getColorClass();
|
||||
this.state.checkboxIcon = this._getCheckBoxIcon();
|
||||
this.write_mutex = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProjectMilestone.props = {
|
||||
context: Object,
|
||||
milestone: Object,
|
||||
open: Function,
|
||||
load: Function,
|
||||
};
|
||||
ProjectMilestone.template = 'project.ProjectMilestone';
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="project.ProjectMilestone" owl="1">
|
||||
<div class="list-group mb-2">
|
||||
<div class="o_rightpanel_milestone list-group-item list-group-item-action d-flex justify-content-evenly px-0 cursor-pointer" t-att-class="state.colorClass">
|
||||
<span t-on-click="toggleIsReached">
|
||||
<i class="fa position-absolute pt-1" t-att-class="state.checkboxIcon"/>
|
||||
</span>
|
||||
<div class="o_milestone_detail d-flex justify-content-between ps-3 pe-2 col-11" t-on-click="onOpenMilestone">
|
||||
<div class="text-truncate col-7" t-att-title="milestone.name">
|
||||
<t t-esc="milestone.name"/>
|
||||
</div>
|
||||
<span class="d-flex justify-content-center align-items-center">
|
||||
<t t-esc="deadline"/>
|
||||
</span>
|
||||
</div>
|
||||
<span class="d-flex align-items-center">
|
||||
<a t-on-click="onDeleteMilestone" title="Delete Milestone"><i class="fa fa-trash"/></a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/** @odoo-module */
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
export class ProjectProfitability extends Component {
|
||||
get revenues() {
|
||||
return this.props.data.revenues;
|
||||
}
|
||||
|
||||
get costs() {
|
||||
return this.props.data.costs;
|
||||
}
|
||||
|
||||
get margin() {
|
||||
const invoiced_billed = this.revenues.total.invoiced + this.costs.total.billed;
|
||||
const to_invoice_to_bill = this.revenues.total.to_invoice + this.costs.total.to_bill;
|
||||
return {
|
||||
invoiced_billed,
|
||||
to_invoice_to_bill,
|
||||
total: invoiced_billed + to_invoice_to_bill,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
ProjectProfitability.props = {
|
||||
data: Object,
|
||||
labels: Object,
|
||||
formatMonetary: Function,
|
||||
onClick: Function,
|
||||
};
|
||||
ProjectProfitability.template = 'project.ProjectProfitability';
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="project.ProjectProfitability" owl="1">
|
||||
<div class="o_rightpanel_subsection pb-3" t-if="revenues.data.length">
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
<thead class="align-middle">
|
||||
<tr>
|
||||
<th>Revenues</th>
|
||||
<th class="text-end">Invoiced</th>
|
||||
<th class="text-end">To Invoice</th>
|
||||
<th class="text-end">Expected</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="revenues.data" t-as="revenue" t-key="revenue.id" t-if="revenue.invoiced !== 0 || revenue.to_invoice !== 0">
|
||||
<t t-set="revenue_label" t-value="props.labels[revenue.id] or revenue.id"/>
|
||||
<td class="align-middle">
|
||||
<a t-if="revenue.action" href="#"
|
||||
t-on-click="() => this.props.onClick(revenue.action)"
|
||||
>
|
||||
<t t-esc="revenue_label"/>
|
||||
</a>
|
||||
<t t-esc="revenue_label" t-else=""/>
|
||||
</td>
|
||||
<td t-attf-class="text-end align-middle {{ revenue.invoiced === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(revenue.invoiced)"/></td>
|
||||
<td t-attf-class="text-end align-middle {{ revenue.to_invoice === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(revenue.to_invoice)"/></td>
|
||||
<td t-attf-class="text-end align-middle {{ revenue.invoiced + revenue.to_invoice === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(revenue.invoiced + revenue.to_invoice)"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="fw-bolder">
|
||||
<td>Total</td>
|
||||
<td t-attf-class="text-end {{ revenues.total.invoiced === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(revenues.total.invoiced)"/></td>
|
||||
<td t-attf-class="text-end {{ revenues.total.to_invoice === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(revenues.total.to_invoice)"/></td>
|
||||
<td t-attf-class="text-end {{ revenues.total.invoiced + revenues.total.to_invoice === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(revenues.total.invoiced + revenues.total.to_invoice)"/></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div class="o_rightpanel_subsection pb-3" t-if="costs.data.length">
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Costs</th>
|
||||
<th class="text-end">Billed</th>
|
||||
<th class="text-end">To Bill</th>
|
||||
<th class="text-end">Expected</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="costs.data" t-as="cost" t-key="cost.id" t-if="cost.billed !== 0 || cost.to_bill !== 0">
|
||||
<t t-set="cost_label" t-value="props.labels[cost.id] or cost.id"/>
|
||||
<td class="align-middle">
|
||||
<a t-if="cost.action" href="#"
|
||||
t-on-click="() => this.props.onClick(cost.action)"
|
||||
>
|
||||
<t t-esc="cost_label"/>
|
||||
</a>
|
||||
<t t-esc="cost_label" t-else=""/>
|
||||
</td>
|
||||
<td t-attf-class="text-end align-middle {{ cost.billed === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(cost.billed)"/></td>
|
||||
<td t-attf-class="text-end align-middle {{ cost.to_bill === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(cost.to_bill)"/></td>
|
||||
<td t-attf-class="text-end align-middle {{ cost.billed + cost.to_bill === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(cost.billed + cost.to_bill)"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="fw-bolder">
|
||||
<td>Total</td>
|
||||
<td t-attf-class="text-end {{ costs.total.billed === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(costs.total.billed)"/></td>
|
||||
<td t-attf-class="text-end {{ costs.total.to_bill === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(costs.total.to_bill)"/></td>
|
||||
<td t-attf-class="text-end {{ costs.total.billed + costs.total.to_bill === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(costs.total.billed + costs.total.to_bill)"/></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div class="o_rightpanel_subsection">
|
||||
<table class="w-100 table table-borderless mb-4">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Margin</th>
|
||||
<th class="text-end" t-att-class="margin.invoiced_billed < 0 ? 'text-danger' : 'text-success'"><t t-esc="props.formatMonetary(margin.invoiced_billed)"/></th>
|
||||
<th class="text-end" t-att-class="margin.to_invoice_to_bill < 0 ? 'text-danger' : 'text-success'"><t t-esc="props.formatMonetary(margin.to_invoice_to_bill)"/></th>
|
||||
<th class="text-end" t-att-class="margin.total < 0 ? 'text-danger' : 'text-success'"><t t-esc="props.formatMonetary(margin.total)"/></th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/** @odoo-module */
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
export class ProjectRightSidePanelSection extends Component { }
|
||||
|
||||
ProjectRightSidePanelSection.props = {
|
||||
name: { type: String, optional: true },
|
||||
header: { type: Boolean, optional: true },
|
||||
show: Boolean,
|
||||
showData: { type: Boolean, optional: true },
|
||||
slots: {
|
||||
type: Object,
|
||||
shape: {
|
||||
default: Object, // Content is not optional
|
||||
header: { type: Object, optional: true },
|
||||
title: { type: Object, optional: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
ProjectRightSidePanelSection.defaultProps = {
|
||||
header: true,
|
||||
showData: true,
|
||||
};
|
||||
|
||||
ProjectRightSidePanelSection.template = 'project.ProjectRightSidePanelSection';
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="project.ProjectRightSidePanelSection" owl="1">
|
||||
<div class="o_rightpanel_section py-0" t-att-name="props.name" t-if="props.show">
|
||||
<div class="o_rightpanel_header d-flex align-items-center justify-content-between py-4" t-if="props.header">
|
||||
<div class="o_rightpanel_title d-flex flex-row-reverse align-items-center" t-if="props.slots.title">
|
||||
<h3 class="m-0 lh-lg"><t t-slot="title"/></h3>
|
||||
</div>
|
||||
<t t-slot="header"/>
|
||||
</div>
|
||||
<div class="o_rightpanel_data fs-6" t-if="props.showData">
|
||||
<t t-slot="default"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { useService } from '@web/core/utils/hooks';
|
||||
import { formatFloat } from '@web/views/fields/formatters';
|
||||
import { session } from '@web/session';
|
||||
import { ViewButton } from '@web/views/view_button/view_button';
|
||||
import { FormViewDialog } from '@web/views/view_dialogs/form_view_dialog';
|
||||
|
||||
import { ProjectRightSidePanelSection } from './components/project_right_side_panel_section';
|
||||
import { ProjectMilestone } from './components/project_milestone';
|
||||
import { ProjectProfitability } from './components/project_profitability';
|
||||
|
||||
const { Component, onWillStart, useState } = owl;
|
||||
|
||||
export class ProjectRightSidePanel extends Component {
|
||||
setup() {
|
||||
this.orm = useService('orm');
|
||||
this.actionService = useService('action');
|
||||
this.dialog = useService('dialog');
|
||||
this.state = useState({
|
||||
data: {
|
||||
milestones: {
|
||||
data: [],
|
||||
},
|
||||
profitability_items: {
|
||||
costs: { data: [], total: { billed: 0.0, to_bill: 0.0 } },
|
||||
revenues: { data: [], total: { invoiced: 0.0, to_invoice: 0.0 } },
|
||||
},
|
||||
user: {},
|
||||
currency_id: false,
|
||||
}
|
||||
});
|
||||
|
||||
onWillStart(() => this.loadData());
|
||||
}
|
||||
|
||||
get context() {
|
||||
return this.props.context;
|
||||
}
|
||||
|
||||
get domain() {
|
||||
return this.props.domain;
|
||||
}
|
||||
|
||||
get projectId() {
|
||||
return this.context.active_id;
|
||||
}
|
||||
|
||||
get currencyId() {
|
||||
return this.state.data.currency_id;
|
||||
}
|
||||
|
||||
get sectionNames() {
|
||||
return {
|
||||
'milestones': this.env._t('Milestones'),
|
||||
'profitability': this.env._t('Profitability'),
|
||||
};
|
||||
}
|
||||
|
||||
get showProjectProfitability() {
|
||||
return !!this.state.data.profitability_items
|
||||
&& (
|
||||
this.state.data.profitability_items.revenues.data.length > 0
|
||||
|| this.state.data.profitability_items.costs.data.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
formatFloat(value) {
|
||||
return formatFloat(value, { digits: [false, 1] });
|
||||
}
|
||||
|
||||
formatMonetary(value, options = {}) {
|
||||
const valueFormatted = formatFloat(value, {
|
||||
...options,
|
||||
'digits': [false, 0],
|
||||
'noSymbol': true,
|
||||
});
|
||||
const currency = session.currencies[this.currencyId];
|
||||
if (!currency) {
|
||||
return valueFormatted;
|
||||
}
|
||||
if (currency.position === "after") {
|
||||
return `${valueFormatted}\u00A0${currency.symbol}`;
|
||||
} else {
|
||||
return `${currency.symbol}\u00A0${valueFormatted}`;
|
||||
}
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
if (!this.projectId) { // If this is called from notif, multiples updates but no specific project
|
||||
return {};
|
||||
}
|
||||
const data = await this.orm.call(
|
||||
'project.project',
|
||||
'get_panel_data',
|
||||
[[this.projectId]],
|
||||
{ context: this.context },
|
||||
);
|
||||
this.state.data = data;
|
||||
return data;
|
||||
}
|
||||
|
||||
async loadMilestones() {
|
||||
const milestones = await this.orm.call(
|
||||
'project.project',
|
||||
'get_milestones',
|
||||
[[this.projectId]],
|
||||
{ context: this.context },
|
||||
);
|
||||
this.state.data.milestones = milestones;
|
||||
return milestones;
|
||||
}
|
||||
|
||||
addMilestone() {
|
||||
const context = {
|
||||
...this.context,
|
||||
'default_project_id': this.projectId,
|
||||
};
|
||||
this.openFormViewDialog({
|
||||
context,
|
||||
title: this.env._t('New Milestone'),
|
||||
resModel: 'project.milestone',
|
||||
onRecordSaved: async () => {
|
||||
await this.loadMilestones();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async openFormViewDialog(params, options = {}) {
|
||||
this.dialog.add(FormViewDialog, params, options);
|
||||
}
|
||||
|
||||
async onProjectActionClick(params) {
|
||||
this.actionService.doActionButton({
|
||||
type: 'action',
|
||||
resId: this.projectId,
|
||||
context: this.context,
|
||||
resModel: 'project.project',
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
_getStatButtonClickParams(statButton) {
|
||||
return {
|
||||
type: statButton.action_type,
|
||||
name: statButton.action,
|
||||
context: statButton.additional_context || '{}',
|
||||
};
|
||||
}
|
||||
|
||||
_getStatButtonRecordParams() {
|
||||
return {
|
||||
resId: this.projectId,
|
||||
context: JSON.stringify(this.context),
|
||||
resModel: 'project.project',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
ProjectRightSidePanel.components = { ProjectRightSidePanelSection, ProjectMilestone, ViewButton, ProjectProfitability };
|
||||
ProjectRightSidePanel.template = 'project.ProjectRightSidePanel';
|
||||
ProjectRightSidePanel.props = {
|
||||
context: Object,
|
||||
domain: Array,
|
||||
};
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
$o-rightpanel-p: $o-horizontal-padding;
|
||||
$o-rightpanel-p-small: $o-horizontal-padding*0.5;
|
||||
$o-rightpanel-p-tiny: $o-rightpanel-p-small*0.5;
|
||||
|
||||
.o_controller_with_rightpanel .o_content {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
|
||||
.o_renderer {
|
||||
flex: 1 1 auto;
|
||||
max-height: 100%;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
|
||||
.o_kanban_record {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border-top: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_rightpanel {
|
||||
flex: 0 0 37%;
|
||||
padding: $o-rightpanel-p-small $o-rightpanel-p $o-rightpanel-p*2 $o-rightpanel-p;
|
||||
min-width: 400px;
|
||||
max-width: 1140px;
|
||||
|
||||
.o_rightpanel_section {
|
||||
&:nth-child(n+3) {
|
||||
border-top: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.o_form_view {
|
||||
.oe_button_box {
|
||||
margin: -1px (-$o-rightpanel-p) 0;
|
||||
|
||||
.oe_stat_button {
|
||||
flex-basis: 25%;
|
||||
|
||||
&:nth-child(4n):not(:last-child) {
|
||||
border-right-width: 0;
|
||||
}
|
||||
|
||||
.o_stat_text {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(lg) {
|
||||
.oe_stat_button {
|
||||
flex-basis: 33.33%;
|
||||
|
||||
&:nth-child(3n):not(:last-child) {
|
||||
border-right-width: 0px;
|
||||
}
|
||||
|
||||
&:nth-child(4n):not(:last-child){
|
||||
border-right-width: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
.oe_stat_button {
|
||||
flex-basis: 50%;
|
||||
|
||||
&:nth-child(2n) {
|
||||
border-right-width: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_rightpanel_header {
|
||||
@include media-breakpoint-down(md) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
tr th:not(:first-child) {
|
||||
width: 20%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="project.ProjectRightSidePanel" owl="1">
|
||||
<div t-if="projectId" class="o_rightpanel pt-0 bg-view border-start overflow-auto">
|
||||
<ProjectRightSidePanelSection
|
||||
name="'stat_buttons'"
|
||||
header="false"
|
||||
show="!!state.data.buttons"
|
||||
>
|
||||
<div class="o_form_view">
|
||||
<div class="oe_button_box o-form-buttonbox d-flex flex-wrap">
|
||||
<t t-foreach="state.data.buttons" t-as="button" t-key="button.action">
|
||||
<ViewButton
|
||||
t-if="button.show"
|
||||
defaultRank="'oe_stat_button'"
|
||||
className="'h-auto py-2 border border-start-0 border-top-0 text-start'"
|
||||
icon="`fa-${button.icon}`"
|
||||
title="button.text"
|
||||
clickParams="_getStatButtonClickParams(button)"
|
||||
record="_getStatButtonRecordParams()"
|
||||
>
|
||||
<t t-set-slot="contents">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_value text-start">
|
||||
<t t-esc="button.number"/>
|
||||
</span>
|
||||
<span class="o_stat_text">
|
||||
<t t-esc="button.text"/>
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
</ViewButton>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</ProjectRightSidePanelSection>
|
||||
<ProjectRightSidePanelSection
|
||||
name="'profitability'"
|
||||
show="showProjectProfitability"
|
||||
>
|
||||
<t t-set-slot="title" owl="1">
|
||||
Profitability
|
||||
</t>
|
||||
<ProjectProfitability
|
||||
data="state.data.profitability_items"
|
||||
labels="state.data.profitability_labels"
|
||||
formatMonetary="formatMonetary.bind(this)"
|
||||
onClick="(params) => this.onProjectActionClick(params)"
|
||||
/>
|
||||
</ProjectRightSidePanelSection>
|
||||
<ProjectRightSidePanelSection
|
||||
name="'milestones'"
|
||||
show="!!state.data.milestones && !!state.data.milestones.data"
|
||||
>
|
||||
<t t-set-slot="header" owl="1">
|
||||
<span class="btn btn-secondary">
|
||||
<div class="o_add_milestone">
|
||||
<a t-on-click="addMilestone">Add Milestone</a>
|
||||
</div>
|
||||
</span>
|
||||
</t>
|
||||
<t t-set-slot="title" owl="1">
|
||||
Milestones
|
||||
</t>
|
||||
<div t-foreach="state.data.milestones.data" t-as="milestone" t-key="milestone.id" class="o_rightpanel_data_row">
|
||||
<ProjectMilestone context="context" milestone="milestone" open.bind="openFormViewDialog" load.bind="loadMilestones"/>
|
||||
</div>
|
||||
<span t-if="state.data.milestones.data.length === 0" class="text-muted fst-italic">
|
||||
Track major progress points that must be reached to achieve success.
|
||||
</span>
|
||||
</ProjectRightSidePanelSection>
|
||||
</div>
|
||||
<!-- If this is called from notif, multiples updates but no specific project -->
|
||||
<div t-else=""/>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
import { StateSelectionField } from '@web/views/fields/state_selection/state_selection_field';
|
||||
|
||||
import { STATUS_COLORS, STATUS_COLOR_PREFIX } from '../../utils/project_utils';
|
||||
|
||||
export class ProjectStateSelectionField extends StateSelectionField {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.colorPrefix = STATUS_COLOR_PREFIX;
|
||||
this.colors = STATUS_COLORS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get showLabel() {
|
||||
return !this.props.hideLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get options() {
|
||||
return super.options.filter(o => o[0] !== 'to_define');
|
||||
}
|
||||
}
|
||||
|
||||
registry.category('fields').add('kanban.project_state_selection', ProjectStateSelectionField);
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
.o_field_status_with_color button.disabled {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.o_field_status_with_color {
|
||||
.o_status_text {
|
||||
max-width: 105px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { SelectionField } from '@web/views/fields/selection/selection_field';
|
||||
import { registry } from '@web/core/registry';
|
||||
|
||||
import { STATUS_COLORS, STATUS_COLOR_PREFIX } from '../../utils/project_utils';
|
||||
|
||||
export class ProjectStatusWithColorSelectionField extends SelectionField {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.colorPrefix = STATUS_COLOR_PREFIX;
|
||||
this.colors = STATUS_COLORS;
|
||||
}
|
||||
|
||||
get currentValue() {
|
||||
return this.props.value || this.options[0][0];
|
||||
}
|
||||
|
||||
statusColor(value) {
|
||||
return this.colors[value] ? this.colorPrefix + this.colors[value] : "";
|
||||
}
|
||||
}
|
||||
ProjectStatusWithColorSelectionField.template = 'project.ProjectStatusWithColorSelectionField';
|
||||
|
||||
registry.category('fields').add('status_with_color', ProjectStatusWithColorSelectionField);
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="project.ProjectStatusWithColorSelectionField" t-inherit="web.SelectionField" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//t[@t-if='props.readonly']/span" position="replace">
|
||||
<div class="d-flex align-items-center">
|
||||
<span t-attf-class="o_status me-2 {{ statusColor(currentValue) }}"/>
|
||||
<span class="o_status_text text-wrap text-truncate" t-out="string" t-att-raw-value="value"/>
|
||||
</div>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { ConfirmationDialog } from '@web/core/confirmation_dialog/confirmation_dialog';
|
||||
|
||||
export class ProjectStopRecurrenceConfirmationDialog extends ConfirmationDialog {
|
||||
_continueRecurrence() {
|
||||
if (this.props.continueRecurrence) {
|
||||
this.props.continueRecurrence();
|
||||
}
|
||||
this.props.close();
|
||||
}
|
||||
}
|
||||
ProjectStopRecurrenceConfirmationDialog.template = 'project.ProjectStopRecurrenceConfirmationDialog';
|
||||
ProjectStopRecurrenceConfirmationDialog.props.continueRecurrence = { type: Function, optional: true };
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="project.ProjectStopRecurrenceConfirmationDialog" t-inherit="web.ConfirmationDialog" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//button[@t-on-click='_confirm']" position="replace">
|
||||
<button class="btn btn-primary" t-on-click="_confirm">
|
||||
Stop Recurrence
|
||||
</button>
|
||||
</xpath>
|
||||
<xpath expr="//button[@t-on-click='_confirm']" position="after">
|
||||
<button t-if="props.continueRecurrence" class="btn btn-secondary" t-on-click="_continueRecurrence">
|
||||
Continue Recurrence
|
||||
</button>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
import { CharField } from '@web/views/fields/char/char_field';
|
||||
import { formatChar } from '@web/views/fields/formatters';
|
||||
|
||||
class ProjectTaskNameWithSubtaskCountCharField extends CharField {
|
||||
get formattedSubtaskCount() {
|
||||
return formatChar(this.props.record.data.allow_subtasks && this.props.record.data.child_text || '');
|
||||
}
|
||||
}
|
||||
|
||||
ProjectTaskNameWithSubtaskCountCharField.template = 'project.ProjectTaskNameWithSubtaskCountCharField';
|
||||
|
||||
registry.category('fields').add('name_with_subtask_count', ProjectTaskNameWithSubtaskCountCharField);
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="project.ProjectTaskNameWithSubtaskCountCharField" t-inherit="web.CharField" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//span[@t-esc='formattedValue']" position="after">
|
||||
<span
|
||||
class="text-muted ms-2"
|
||||
t-out="formattedSubtaskCount"
|
||||
style="font-weight: normal;"
|
||||
/>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
.o_kanban_project_tasks .oe_kanban_align.badge {
|
||||
background: inherit;
|
||||
color: inherit;
|
||||
border: 1px solid var(--success);
|
||||
}
|
||||
|
||||
.o_kanban_project_tasks .o_field_one2many_sub_task {
|
||||
margin-top:2px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.o_form_project_project .o_setting_box {
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
BIN
odoo-bringout-oca-ocb-project/project/static/src/img/bird.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 2 KiB |
|
After Width: | Height: | Size: 8 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
|
@ -0,0 +1,30 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import time from 'web.time';
|
||||
import publicWidget from 'web.public.widget';
|
||||
|
||||
publicWidget.registry.ProjectRatingImage = publicWidget.Widget.extend({
|
||||
selector: '.o_portal_project_rating .o_rating_image',
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
this.$el.popover({
|
||||
placement: 'bottom',
|
||||
trigger: 'hover',
|
||||
html: true,
|
||||
content: function () {
|
||||
var $elem = $(this);
|
||||
var id = $elem.data('id');
|
||||
var ratingDate = $elem.data('rating-date');
|
||||
var baseDate = time.auto_str_to_date(ratingDate);
|
||||
var duration = moment(baseDate).fromNow();
|
||||
var $rating = $('#rating_' + id);
|
||||
$rating.find('.rating_timeduration').text(duration);
|
||||
return $rating.html();
|
||||
},
|
||||
});
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import ActivityView from '@mail/js/views/activity/activity_view';
|
||||
import { ProjectControlPanel } from '@project/js/project_control_panel';
|
||||
import viewRegistry from 'web.view_registry';
|
||||
|
||||
const ProjectActivityView = ActivityView.extend({
|
||||
config: Object.assign({}, ActivityView.prototype.config, {
|
||||
ControlPanel: ProjectControlPanel,
|
||||
}),
|
||||
});
|
||||
|
||||
viewRegistry.add('project_activity', ProjectActivityView);
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import ControlPanel from 'web.ControlPanel';
|
||||
import session from 'web.session';
|
||||
|
||||
const { onWillStart, onWillUpdateProps } = owl;
|
||||
|
||||
export class ProjectControlPanel extends ControlPanel {
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.show_project_update = this.props.view.type === "form" || this.props.action.context.show_project_update;
|
||||
this.project_id = this.show_project_update ? this.props.action.context.active_id : false;
|
||||
|
||||
onWillStart(() => this._loadWidgetData());
|
||||
onWillUpdateProps(() => this._loadWidgetData());
|
||||
}
|
||||
|
||||
async _loadWidgetData() {
|
||||
if (this.show_project_update) {
|
||||
this.data = await this.rpc({
|
||||
model: 'project.project',
|
||||
method: 'get_last_update_or_default',
|
||||
args: [this.project_id],
|
||||
});
|
||||
this.is_project_user = await session.user_has_group('project.group_project_user');
|
||||
}
|
||||
}
|
||||
|
||||
async onStatusClick(ev) {
|
||||
ev.preventDefault();
|
||||
await this.trigger('do-action', {
|
||||
action: "project.project_update_all_action",
|
||||
options: {
|
||||
additional_context: {
|
||||
default_project_id: this.project_id,
|
||||
active_id: this.project_id
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { ProjectControlPanel } from "@project/components/project_control_panel/project_control_panel";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { graphView } from "@web/views/graph/graph_view";
|
||||
|
||||
const viewRegistry = registry.category("views");
|
||||
|
||||
export const projectGraphView = {...graphView, ControlPanel: ProjectControlPanel};
|
||||
|
||||
viewRegistry.add("project_graph", projectGraphView);
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { ProjectControlPanel } from "@project/components/project_control_panel/project_control_panel";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { pivotView } from "@web/views/pivot/pivot_view";
|
||||
|
||||
const projectPivotView = {...pivotView, ControlPanel: ProjectControlPanel};
|
||||
|
||||
registry.category("views").add("project_pivot", projectPivotView);
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { GraphArchParser } from "@web/views/graph/graph_arch_parser";
|
||||
import { graphView } from "@web/views/graph/graph_view";
|
||||
|
||||
const viewRegistry = registry.category("views");
|
||||
|
||||
const MEASURE_STRINGS = {
|
||||
parent_res_id: _lt("Project"),
|
||||
rating: _lt("Rating Value (/5)"),
|
||||
res_id: _lt("Task"),
|
||||
};
|
||||
|
||||
class ProjectRatingArchParser extends GraphArchParser {
|
||||
parse() {
|
||||
const archInfo = super.parse(...arguments);
|
||||
for (const [key, val] of Object.entries(MEASURE_STRINGS)) {
|
||||
archInfo.fieldAttrs[key] = {
|
||||
...archInfo.fieldAttrs[key],
|
||||
string: val.toString(),
|
||||
};
|
||||
}
|
||||
return archInfo;
|
||||
}
|
||||
}
|
||||
|
||||
// Would it be not better achiedved by using a proper arch directly?
|
||||
const projectRatingGraphView = {
|
||||
...graphView,
|
||||
ArchParser: ProjectRatingArchParser,
|
||||
};
|
||||
|
||||
viewRegistry.add("project_rating_graph", projectRatingGraphView);
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { PivotArchParser } from "@web/views/pivot/pivot_arch_parser";
|
||||
import { pivotView } from "@web/views/pivot/pivot_view";
|
||||
|
||||
const viewRegistry = registry.category("views");
|
||||
|
||||
const MEASURE_STRINGS = {
|
||||
parent_res_id: _lt("Project"),
|
||||
rating: _lt("Rating Value (/5)"),
|
||||
res_id: _lt("Task"),
|
||||
};
|
||||
|
||||
class ProjectRatingArchParser extends PivotArchParser {
|
||||
parse() {
|
||||
const archInfo = super.parse(...arguments);
|
||||
for (const [key, val] of Object.entries(MEASURE_STRINGS)) {
|
||||
archInfo.fieldAttrs[key] = {
|
||||
...archInfo.fieldAttrs[key],
|
||||
string: val.toString(),
|
||||
};
|
||||
}
|
||||
return archInfo;
|
||||
}
|
||||
}
|
||||
|
||||
// Would it be not better achiedved by using a proper arch directly?
|
||||
|
||||
const projectRatingPivotView = {
|
||||
...pivotView,
|
||||
ArchParser: ProjectRatingArchParser,
|
||||
};
|
||||
|
||||
viewRegistry.add("project_rating_pivot", projectRatingPivotView);
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { _lt } from 'web.core';
|
||||
import kanbanExamplesRegistry from 'web.kanban_examples_registry';
|
||||
import { registry } from "@web/core/registry";
|
||||
import { renderToMarkup } from '@web/core/utils/render';
|
||||
|
||||
const { markup } = owl;
|
||||
const greenBullet = markup(`<span class="o_status d-inline-block o_status_green"></span>`);
|
||||
const redBullet = markup(`<span class="o_status d-inline-block o_status_red"></span>`);
|
||||
const star = markup(`<a style="color: gold;" class="fa fa-star"></a>`);
|
||||
const clock = markup(`<a class="fa fa-clock-o"></a>`);
|
||||
|
||||
const exampleData = {
|
||||
ghostColumns: [_lt('New'), _lt('Assigned'), _lt('In Progress'), _lt('Done')],
|
||||
applyExamplesText: _lt("Use This For My Project"),
|
||||
allowedGroupBys: ['stage_id'],
|
||||
examples:[{
|
||||
name: _lt('Software Development'),
|
||||
columns: [_lt('Backlog'), _lt('Specifications'), _lt('Development'), _lt('Tests'), _lt('Delivered')],
|
||||
get description() {
|
||||
return renderToMarkup("project.example.generic");
|
||||
},
|
||||
bullets: [greenBullet, redBullet, star],
|
||||
}, {
|
||||
name: _lt('Agile Scrum'),
|
||||
columns: [_lt('Backlog'), _lt('Sprint Backlog'), _lt('Sprint in Progress'), _lt('Sprint Complete'), _lt('Old Completed Sprint')],
|
||||
get description() {
|
||||
return renderToMarkup("project.example.agilescrum");
|
||||
},
|
||||
bullets: [greenBullet, redBullet],
|
||||
}, {
|
||||
name: _lt('Digital Marketing'),
|
||||
columns: [_lt('Ideas'), _lt('Researching'), _lt('Writing'), _lt('Editing'), _lt('Done')],
|
||||
get description() {
|
||||
return renderToMarkup("project.example.digitalmarketing");
|
||||
},
|
||||
bullets: [greenBullet, redBullet],
|
||||
}, {
|
||||
name: _lt('Customer Feedback'),
|
||||
columns: [_lt('New'), _lt('In development'), _lt('Done'), _lt('Refused')],
|
||||
get description() {
|
||||
return renderToMarkup("project.example.customerfeedback");
|
||||
},
|
||||
bullets: [greenBullet, redBullet],
|
||||
}, {
|
||||
name: _lt('Consulting'),
|
||||
columns: [_lt('New Projects'), _lt('Resources Allocation'), _lt('In Progress'), _lt('Done')],
|
||||
get description() {
|
||||
return renderToMarkup("project.example.consulting");
|
||||
},
|
||||
bullets: [greenBullet, redBullet],
|
||||
}, {
|
||||
name: _lt('Research Project'),
|
||||
columns: [_lt('Brainstorm'), _lt('Research'), _lt('Draft'), _lt('Final Document')],
|
||||
get description() {
|
||||
return renderToMarkup("project.example.researchproject");
|
||||
},
|
||||
bullets: [greenBullet, redBullet],
|
||||
}, {
|
||||
name: _lt('Website Redesign'),
|
||||
columns: [_lt('Page Ideas'), _lt('Copywriting'), _lt('Design'), _lt('Live')],
|
||||
get description() {
|
||||
return renderToMarkup("project.example.researchproject");
|
||||
},
|
||||
}, {
|
||||
name: _lt('T-shirt Printing'),
|
||||
columns: [_lt('New Orders'), _lt('Logo Design'), _lt('To Print'), _lt('Done')],
|
||||
get description() {
|
||||
return renderToMarkup("project.example.tshirtprinting");
|
||||
},
|
||||
bullets: [star],
|
||||
}, {
|
||||
name: _lt('Design'),
|
||||
columns: [_lt('New Request'), _lt('Design'), _lt('Client Review'), _lt('Handoff')],
|
||||
get description() {
|
||||
return renderToMarkup("project.example.generic");
|
||||
},
|
||||
bullets: [greenBullet, redBullet, star, clock],
|
||||
}, {
|
||||
name: _lt('Publishing'),
|
||||
columns: [_lt('Ideas'), _lt('Writing'), _lt('Editing'), _lt('Published')],
|
||||
get description() {
|
||||
return renderToMarkup("project.example.generic");
|
||||
},
|
||||
bullets: [greenBullet, redBullet, star, clock],
|
||||
}, {
|
||||
name: _lt('Manufacturing'),
|
||||
columns: [_lt('New Orders'), _lt('Material Sourcing'), _lt('Manufacturing'), _lt('Assembling'), _lt('Delivered')],
|
||||
get description() {
|
||||
return renderToMarkup("project.example.generic");
|
||||
},
|
||||
bullets: [greenBullet, redBullet, star, clock],
|
||||
}, {
|
||||
name: _lt('Podcast and Video Production'),
|
||||
columns: [_lt('Research'), _lt('Script'), _lt('Recording'), _lt('Mixing'), _lt('Published')],
|
||||
get description() {
|
||||
return renderToMarkup("project.example.generic");
|
||||
},
|
||||
bullets: [greenBullet, redBullet, star, clock],
|
||||
}],
|
||||
};
|
||||
|
||||
kanbanExamplesRegistry.add('project', exampleData);
|
||||
registry.category("kanban_examples").add('project', exampleData);
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
odoo.define('project.tour', function(require) {
|
||||
"use strict";
|
||||
|
||||
const {_t} = require('web.core');
|
||||
const {Markup} = require('web.utils');
|
||||
var tour = require('web_tour.tour');
|
||||
|
||||
tour.register('project_tour', {
|
||||
sequence: 110,
|
||||
url: "/web",
|
||||
rainbowManMessage: _t("Congratulations, you are now a master of project management."),
|
||||
}, [tour.stepUtils.showAppsMenuItem(), {
|
||||
trigger: '.o_app[data-menu-xmlid="project.menu_main_pm"]',
|
||||
content: Markup(_t('Want a better way to <b>manage your projects</b>? <i>It starts here.</i>')),
|
||||
position: 'right',
|
||||
edition: 'community',
|
||||
}, {
|
||||
trigger: '.o_app[data-menu-xmlid="project.menu_main_pm"]',
|
||||
content: Markup(_t('Want a better way to <b>manage your projects</b>? <i>It starts here.</i>')),
|
||||
position: 'bottom',
|
||||
edition: 'enterprise',
|
||||
}, {
|
||||
trigger: '.o-kanban-button-new',
|
||||
extra_trigger: '.o_project_kanban',
|
||||
content: Markup(_t('Let\'s create your first <b>project</b>.')),
|
||||
position: 'bottom',
|
||||
width: 200,
|
||||
}, {
|
||||
trigger: '.o_project_name input',
|
||||
content: Markup(_t('Choose a <b>name</b> for your project. <i>It can be anything you want: the name of a customer,\
|
||||
of a product, of a team, of a construction site, etc.</i>')),
|
||||
position: 'right',
|
||||
}, {
|
||||
trigger: '.o_open_tasks',
|
||||
content: Markup(_t('Let\'s create your first <b>project</b>.')),
|
||||
position: 'top',
|
||||
run: function (actions) {
|
||||
actions.auto('.modal:visible .btn.btn-primary');
|
||||
},
|
||||
}, {
|
||||
trigger: ".o_kanban_project_tasks .o_column_quick_create .input-group .o_input",
|
||||
content: Markup(_t("Add columns to organize your tasks into <b>stages</b> <i>e.g. New - In Progress - Done</i>.")),
|
||||
position: 'bottom',
|
||||
run: "text Test",
|
||||
}, {
|
||||
trigger: ".o_kanban_project_tasks .o_column_quick_create .o_kanban_add",
|
||||
content: Markup(_t('Let\'s create your first <b>stage</b>.')),
|
||||
position: 'right',
|
||||
}, {
|
||||
trigger: ".o_kanban_project_tasks .o_column_quick_create .input-group .o_input",
|
||||
extra_trigger: '.o_kanban_group',
|
||||
content: Markup(_t("Add columns to organize your tasks into <b>stages</b> <i>e.g. New - In Progress - Done</i>.")),
|
||||
position: 'bottom',
|
||||
run: "text Test",
|
||||
}, {
|
||||
trigger: ".o_kanban_project_tasks .o_column_quick_create .o_kanban_add",
|
||||
content: Markup(_t('Let\'s create your second <b>stage</b>.')),
|
||||
position: 'right',
|
||||
}, {
|
||||
trigger: '.o-kanban-button-new',
|
||||
extra_trigger: '.o_kanban_group:eq(1)',
|
||||
content: Markup(_t("Let's create your first <b>task</b>.")),
|
||||
position: 'bottom',
|
||||
width: 200,
|
||||
}, {
|
||||
trigger: '.o_kanban_quick_create div.o_field_char[name=name] input',
|
||||
extra_trigger: '.o_kanban_project_tasks',
|
||||
content: Markup(_t('Choose a task <b>name</b> <i>(e.g. Website Design, Purchase Goods...)</i>')),
|
||||
position: 'right',
|
||||
}, {
|
||||
trigger: '.o_kanban_quick_create .o_kanban_add',
|
||||
extra_trigger: '.o_kanban_project_tasks',
|
||||
content: _t("Add your task once it is ready."),
|
||||
position: "bottom",
|
||||
}, {
|
||||
trigger: ".o_kanban_record .oe_kanban_content",
|
||||
extra_trigger: '.o_kanban_project_tasks',
|
||||
content: Markup(_t("<b>Drag & drop</b> the card to change your task from stage.")),
|
||||
position: "bottom",
|
||||
run: "drag_and_drop_native .o_kanban_group:eq(1) ",
|
||||
}, {
|
||||
trigger: ".o_kanban_record:first",
|
||||
extra_trigger: '.o_kanban_project_tasks',
|
||||
content: _t("Let's start working on your task."),
|
||||
position: "bottom",
|
||||
}, {
|
||||
trigger: ".o_ChatterTopbar_buttonSendMessage",
|
||||
extra_trigger: '.o_form_project_tasks',
|
||||
content: Markup(_t("Use the chatter to <b>send emails</b> and communicate efficiently with your customers. \
|
||||
Add new people to the followers' list to make them aware of the main changes about this task.")),
|
||||
width: 350,
|
||||
position: "bottom",
|
||||
}, {
|
||||
trigger: ".o_ChatterTopbar_buttonLogNote",
|
||||
extra_trigger: '.o_form_project_tasks',
|
||||
content: Markup(_t("<b>Log notes</b> for internal communications <i>(the people following this task won't be notified \
|
||||
of the note you are logging unless you specifically tag them)</i>. Use @ <b>mentions</b> to ping a colleague \
|
||||
or # <b>mentions</b> to reach an entire team.")),
|
||||
width: 350,
|
||||
position: "bottom"
|
||||
}, {
|
||||
trigger: ".o_ChatterTopbar_buttonScheduleActivity",
|
||||
extra_trigger: '.o_form_project_tasks',
|
||||
content: Markup(_t("Create <b>activities</b> to set yourself to-dos or to schedule meetings.")),
|
||||
}, {
|
||||
trigger: ".modal-dialog .btn-primary",
|
||||
extra_trigger: '.o_form_project_tasks',
|
||||
content: _t("Schedule your activity once it is ready."),
|
||||
position: "bottom",
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: ".o_field_widget[name='user_ids']",
|
||||
extra_trigger: '.o_form_project_tasks',
|
||||
content: _t("Assign a responsible to your task"),
|
||||
position: "right",
|
||||
run() {
|
||||
document.querySelector('.o_field_widget[name="user_ids"] input').click();
|
||||
}
|
||||
}, {
|
||||
trigger: ".ui-autocomplete > li > a:not(:has(i.fa))",
|
||||
auto: true,
|
||||
mobile: false,
|
||||
}, {
|
||||
trigger: "div[role='article']",
|
||||
mobile: true,
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: ".o_form_button_save",
|
||||
extra_trigger: '.o_form_project_tasks.o_form_dirty',
|
||||
content: Markup(_t("You have unsaved changes - no worries! Odoo will automatically save it as you navigate.<br/> You can discard these changes from here or manually save your task.<br/>Let's save it manually.")),
|
||||
position: "bottom",
|
||||
}, {
|
||||
trigger: ".breadcrumb .o_back_button",
|
||||
extra_trigger: '.o_form_project_tasks',
|
||||
content: Markup(_t("Let's go back to the <b>kanban view</b> to have an overview of your next tasks.")),
|
||||
position: "right",
|
||||
run: 'click',
|
||||
}, {
|
||||
trigger: '.o_kanban_renderer',
|
||||
// last step to confirm we've come back before considering the tour successful
|
||||
auto: true
|
||||
}]);
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import ListController from 'web.ListController';
|
||||
import ListRenderer from 'web.ListRenderer';
|
||||
import ListView from 'web.ListView';
|
||||
import viewRegistry from 'web.view_registry';
|
||||
import ProjectRightSidePanel from '@project/js/right_panel/project_right_panel';
|
||||
import {
|
||||
RightPanelControllerMixin,
|
||||
RightPanelRendererMixin,
|
||||
RightPanelViewMixin,
|
||||
} from '@project/js/right_panel/project_right_panel_mixin';
|
||||
|
||||
const ProjectUpdateListRenderer = ListRenderer.extend(RightPanelRendererMixin);
|
||||
|
||||
const ProjectUpdateListController = ListController.extend(RightPanelControllerMixin);
|
||||
|
||||
export const ProjectUpdateListView = ListView.extend(RightPanelViewMixin).extend({
|
||||
config: Object.assign({}, ListView.prototype.config, {
|
||||
Controller: ProjectUpdateListController,
|
||||
Renderer: ProjectUpdateListRenderer,
|
||||
RightSidePanel: ProjectRightSidePanel,
|
||||
}),
|
||||
});
|
||||
|
||||
viewRegistry.add('project_update_list', ProjectUpdateListView);
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import fieldRegistry from 'web.field_registry';
|
||||
import { FieldChar } from 'web.basic_fields';
|
||||
|
||||
export const FieldNameWithSubTaskCount = FieldChar.extend({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
if (this.viewType === 'kanban') {
|
||||
// remove click event handler
|
||||
this.events = { ...this.events };
|
||||
delete this.events.click;
|
||||
}
|
||||
},
|
||||
|
||||
_render: function () {
|
||||
let result = this._super.apply(this, arguments);
|
||||
if (this.recordData.allow_subtasks && this.recordData.child_text) {
|
||||
this.$el.append($('<span>')
|
||||
.addClass("text-muted ms-2")
|
||||
.text(this.recordData.child_text)
|
||||
.css('font-weight', 'normal'));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
fieldRegistry.add('name_with_subtask_count', FieldNameWithSubTaskCount);
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/** @odoo-module alias=project.project_private_task **/
|
||||
"use strict";
|
||||
|
||||
import field_registry from 'web.field_registry';
|
||||
import { FieldMany2One } from 'web.relational_fields';
|
||||
import core from 'web.core';
|
||||
|
||||
const QWeb = core.qweb;
|
||||
|
||||
const ProjectPrivateTask = FieldMany2One.extend({
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_renderReadonly: function() {
|
||||
this._super.apply(this, arguments);
|
||||
if (!this.m2o_value) {
|
||||
this.$el.empty();
|
||||
this.$el.append(QWeb.render('project.task.PrivateProjectName'));
|
||||
this.$el.addClass('pe-none');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
field_registry.add('project_private_task', ProjectPrivateTask);
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { qweb } from 'web.core';
|
||||
import fieldRegistry from 'web.field_registry';
|
||||
import { FieldSelection } from 'web.relational_fields';
|
||||
|
||||
/**
|
||||
* options :
|
||||
* `color_field` : The field that must be use to color the bubble. It must be in the view. (from 0 to 11). Default : grey.
|
||||
*/
|
||||
export const StatusWithColor = FieldSelection.extend({
|
||||
_template: 'project.statusWithColor',
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.color = this.recordData[this.nodeOptions.color_field];
|
||||
this.hideLabel = this.nodeOptions.hide_label;
|
||||
if (this.nodeOptions.no_quick_edit) {
|
||||
this._canQuickEdit = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_renderReadonly() {
|
||||
this._super.apply(this, arguments);
|
||||
if (this.value) {
|
||||
this.$el.addClass('o_status_with_color');
|
||||
if (this.hideLabel) {
|
||||
this.$el.attr('title', this.$el.text());
|
||||
this.$el.empty();
|
||||
}
|
||||
this.$el.prepend(qweb.render(this._template, {
|
||||
color: this.color,
|
||||
}));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
fieldRegistry.add('status_with_color', StatusWithColor);
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
.o_FormRenderer_chatterContainer {
|
||||
display: flex;
|
||||
background-color: $white;
|
||||
border-color: $border-color;
|
||||
|
||||
.o_portal_chatter {
|
||||
width: 100%;
|
||||
|
||||
.o_portal_chatter_header {
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
div.o_portal_chatter_composer,
|
||||
div.o_portal_chatter_messages {
|
||||
div.d-flex {
|
||||
gap: 10px;
|
||||
|
||||
.o_portal_chatter_attachments {
|
||||
margin-bottom: 1rem;
|
||||
.o_portal_chatter_attachment {
|
||||
> button {
|
||||
@include o-position-absolute($top: 0, $right: 0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover > button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_portal_chatter_avatar {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
.o_portal_message_internal_off {
|
||||
.btn-danger {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.o_portal_message_internal_on {
|
||||
.btn-success {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.o-aside {
|
||||
flex-direction: column;
|
||||
|
||||
.o_portal_chatter {
|
||||
.o_portal_chatter_header {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.o_portal_chatter_composer, .o_portal_chatter_messages {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/** @odoo-module */
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
export class ChatterAttachmentsViewer extends Component {}
|
||||
|
||||
ChatterAttachmentsViewer.template = 'project.ChatterAttachmentsViewer';
|
||||
ChatterAttachmentsViewer.props = {
|
||||
attachments: Array,
|
||||
canDelete: { type: Boolean, optional: true },
|
||||
delete: { type: Function, optional: true },
|
||||
};
|
||||
ChatterAttachmentsViewer.defaultProps = {
|
||||
delete: async () => {},
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="project.ChatterAttachmentsViewer" owl="1">
|
||||
<div class="o_portal_chatter_attachments mt-3">
|
||||
<div t-if="props.attachments.length" class="row">
|
||||
<div t-foreach="props.attachments" t-as="attachment" t-key="attachment.id" class="col-lg-3 col-md-4 col-sm-6">
|
||||
<div class="o_portal_chatter_attachment mb-2 position-relative text-center">
|
||||
<button
|
||||
t-if="props.canDelete and attachment.state == 'pending'"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
title="Delete"
|
||||
t-on-click="() => props.delete(attachment)"
|
||||
>
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
<a t-attf-href="/web/content/#{attachment.id}?download=true&access_token=#{attachment.access_token}" target="_blank">
|
||||
<div class='oe_attachment_embedded o_image' t-att-title="attachment.name" t-att-data-mimetype="attachment.mimetype"/>
|
||||
<div class='o_portal_chatter_attachment_name'>
|
||||
<t t-out='attachment.filename'/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { useService } from '@web/core/utils/hooks';
|
||||
import { TextField } from '@web/views/fields/text/text_field';
|
||||
import { PortalAttachDocument } from '../portal_attach_document/portal_attach_document';
|
||||
import { ChatterAttachmentsViewer } from './chatter_attachments_viewer';
|
||||
|
||||
const { Component, useState, onWillUpdateProps } = owl;
|
||||
|
||||
export class ChatterComposer extends Component {
|
||||
setup() {
|
||||
this.rpc = useService('rpc');
|
||||
this.state = useState({
|
||||
displayError: false,
|
||||
attachments: this.props.attachments.map(file => file.state === 'done'),
|
||||
message: '',
|
||||
loading: false,
|
||||
});
|
||||
|
||||
onWillUpdateProps(this.onWillUpdateProps);
|
||||
}
|
||||
|
||||
onWillUpdateProps(nextProps) {
|
||||
this.clearErrors();
|
||||
this.state.message = '';
|
||||
this.state.attachments = nextProps.attachments.map(file => file.state === 'done');
|
||||
}
|
||||
|
||||
get discussionUrl() {
|
||||
return `${window.location.href.split('#')[0]}#discussion`;
|
||||
}
|
||||
|
||||
update(change) {
|
||||
this.clearErrors();
|
||||
this.state.message = change;
|
||||
}
|
||||
|
||||
prepareMessageData() {
|
||||
const attachment_ids = [];
|
||||
const attachment_tokens = [];
|
||||
for (const attachment of this.state.attachments) {
|
||||
attachment_ids.push(attachment.id);
|
||||
attachment_tokens.push(attachment.access_token);
|
||||
}
|
||||
return {
|
||||
message: this.state.message,
|
||||
attachment_ids,
|
||||
attachment_tokens,
|
||||
res_model: this.props.resModel,
|
||||
res_id: this.props.resId,
|
||||
project_sharing_id: this.props.projectSharingId,
|
||||
};
|
||||
}
|
||||
|
||||
async sendMessage() {
|
||||
this.clearErrors();
|
||||
if (!this.state.message && !this.state.attachments.length) {
|
||||
this.state.displayError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await this.rpc(
|
||||
"/mail/chatter_post",
|
||||
this.prepareMessageData(),
|
||||
);
|
||||
this.props.postProcessMessageSent();
|
||||
this.state.message = "";
|
||||
this.state.attachments = [];
|
||||
}
|
||||
|
||||
clearErrors() {
|
||||
this.state.displayError = false;
|
||||
}
|
||||
|
||||
async beforeUploadFile() {
|
||||
this.state.loading = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
onFileUpload(files) {
|
||||
this.state.loading = false;
|
||||
this.clearErrors();
|
||||
for (const file of files) {
|
||||
file.state = 'pending';
|
||||
this.state.attachments.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAttachment(attachment) {
|
||||
this.clearErrors();
|
||||
try {
|
||||
await this.rpc(
|
||||
'/portal/attachment/remove',
|
||||
{
|
||||
attachment_id: attachment.id,
|
||||
access_token: attachment.access_token,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this.state.displayError = true;
|
||||
}
|
||||
this.state.attachments = this.state.attachments.filter(a => a.id !== attachment.id);
|
||||
}
|
||||
}
|
||||
|
||||
ChatterComposer.components = {
|
||||
ChatterAttachmentsViewer,
|
||||
PortalAttachDocument,
|
||||
TextField,
|
||||
};
|
||||
|
||||
ChatterComposer.props = {
|
||||
resModel: String,
|
||||
projectSharingId: Number,
|
||||
resId: { type: Number, optional: true },
|
||||
allowComposer: { type: Boolean, optional: true },
|
||||
displayComposer: { type: Boolean, optional: true },
|
||||
token: { type: String, optional: true },
|
||||
messageCount: { type: Number, optional: true },
|
||||
isUserPublic: { type: Boolean, optional: true },
|
||||
partnerId: { type: Number, optional: true },
|
||||
postProcessMessageSent: { type: Function, optional: true },
|
||||
attachments: { type: Array, optional: true },
|
||||
};
|
||||
ChatterComposer.defaultProps = {
|
||||
allowComposer: true,
|
||||
displayComposer: false,
|
||||
isUserPublic: true,
|
||||
token: '',
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
ChatterComposer.template = 'project.ChatterComposer';
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<!-- Widget PortalComposer (standalone)
|
||||
|
||||
required many options: token, res_model, res_id, ...
|
||||
-->
|
||||
<t t-name="project.ChatterComposer" owl="1">
|
||||
<div t-if="props.allowComposer" class="o_portal_chatter_composer">
|
||||
<t t-if="props.displayComposer">
|
||||
<div t-if="state.displayError" class="alert alert-danger mb8 o_portal_chatter_composer_error" role="alert">
|
||||
Oops! Something went wrong. Try to reload the page and log in.
|
||||
</div>
|
||||
|
||||
<div class="d-flex">
|
||||
<img t-if="!props.isUserPublic or props.token"
|
||||
alt="Avatar"
|
||||
class="o_portal_chatter_avatar o_object_fit_cover align-self-start"
|
||||
t-attf-src="/web/image/res.partner/{{ props.partnerId }}/avatar_128"
|
||||
/>
|
||||
<div class="flex-grow-1">
|
||||
<div class="o_portal_chatter_composer_input">
|
||||
<div class="o_portal_chatter_composer_body mb32">
|
||||
<TextField
|
||||
rowCount="4"
|
||||
placeholder="'Write a message...'"
|
||||
value="state.message"
|
||||
update.bind="update"
|
||||
/>
|
||||
<ChatterAttachmentsViewer
|
||||
attachments="state.attachments"
|
||||
canDelete="true"
|
||||
delete.bind="deleteAttachment"
|
||||
/>
|
||||
<div class="mt8">
|
||||
<button name="send_message" t-on-click="sendMessage" class="btn btn-primary me-1" type="submit" t-att-disabled="state.loading">
|
||||
Send
|
||||
</button>
|
||||
<PortalAttachDocument
|
||||
resModel="props.resModel"
|
||||
resId="props.resId"
|
||||
token="props.token"
|
||||
multiUpload="true"
|
||||
onUpload.bind="onFileUpload"
|
||||
beforeOpen.bind="beforeUploadFile"
|
||||
>
|
||||
<i class="fa fa-paperclip"/>
|
||||
</PortalAttachDocument>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<h4>Leave a comment</h4>
|
||||
<p>You must be <a t-attf-href="/web/login?redirect={{ discussionUrl }}">logged in</a> to post a comment.</p>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { formatDateTime, parseDateTime } from "@web/core/l10n/dates";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { sprintf } from '@web/core/utils/strings';
|
||||
import { ChatterComposer } from "./chatter_composer";
|
||||
import { ChatterMessageCounter } from "./chatter_message_counter";
|
||||
import { ChatterMessages } from "./chatter_messages";
|
||||
import { ChatterPager } from "./chatter_pager";
|
||||
|
||||
const { Component, markup, onWillStart, useState, onWillUpdateProps } = owl;
|
||||
|
||||
export class ChatterContainer extends Component {
|
||||
setup() {
|
||||
this.rpc = useService('rpc');
|
||||
this.state = useState({
|
||||
currentPage: this.props.pagerStart,
|
||||
messages: [],
|
||||
options: this.defaultOptions,
|
||||
});
|
||||
|
||||
onWillStart(this.onWillStart);
|
||||
onWillUpdateProps(this.onWillUpdateProps);
|
||||
}
|
||||
|
||||
get defaultOptions() {
|
||||
return {
|
||||
message_count: 0,
|
||||
is_user_public: true,
|
||||
is_user_employee: false,
|
||||
is_user_published: false,
|
||||
display_composer: Boolean(this.props.resId),
|
||||
partner_id: null,
|
||||
pager_scope: 4,
|
||||
pager_step: 10,
|
||||
};
|
||||
}
|
||||
|
||||
get options() {
|
||||
return this.state.options;
|
||||
}
|
||||
|
||||
set options(options) {
|
||||
this.state.options = {
|
||||
...this.defaultOptions,
|
||||
...options,
|
||||
display_composer: !!options.display_composer,
|
||||
access_token: typeof options.display_composer === 'string' ? options.display_composer : '',
|
||||
};
|
||||
}
|
||||
|
||||
get composerProps() {
|
||||
return {
|
||||
allowComposer: Boolean(this.props.resId),
|
||||
displayComposer: this.state.options.display_composer,
|
||||
partnerId: this.state.options.partner_id || undefined,
|
||||
token: this.state.options.access_token,
|
||||
resModel: this.props.resModel,
|
||||
resId: this.props.resId,
|
||||
projectSharingId: this.props.projectSharingId,
|
||||
postProcessMessageSent: async () => {
|
||||
this.state.currentPage = 1;
|
||||
await this.fetchMessages();
|
||||
},
|
||||
attachments: this.state.options.default_attachment_ids,
|
||||
};
|
||||
}
|
||||
|
||||
onWillStart() {
|
||||
this.initChatter(this.messagesParams(this.props));
|
||||
}
|
||||
|
||||
onWillUpdateProps(nextProps) {
|
||||
this.initChatter(this.messagesParams(nextProps));
|
||||
}
|
||||
|
||||
async onChangePage(page) {
|
||||
this.state.currentPage = page;
|
||||
await this.fetchMessages();
|
||||
}
|
||||
|
||||
async initChatter(params) {
|
||||
if (params.res_id && params.res_model) {
|
||||
const chatterData = await this.rpc(
|
||||
'/mail/chatter_init',
|
||||
params,
|
||||
);
|
||||
this.state.messages = this.preprocessMessages(chatterData.messages);
|
||||
this.options = chatterData.options;
|
||||
} else {
|
||||
this.state.messages = [];
|
||||
this.options = {};
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMessages() {
|
||||
const result = await this.rpc(
|
||||
'/mail/chatter_fetch',
|
||||
this.messagesParams(this.props),
|
||||
);
|
||||
this.state.messages = this.preprocessMessages(result.messages);
|
||||
this.state.options.message_count = result.message_count;
|
||||
return result;
|
||||
}
|
||||
|
||||
messagesParams(props) {
|
||||
const params = {
|
||||
res_model: props.resModel,
|
||||
res_id: props.resId,
|
||||
limit: this.state.options.pager_step,
|
||||
offset: (this.state.currentPage - 1) * this.state.options.pager_step,
|
||||
allow_composer: Boolean(props.resId),
|
||||
project_sharing_id: props.projectSharingId,
|
||||
};
|
||||
if (props.token) {
|
||||
params.token = props.token;
|
||||
}
|
||||
if (props.domain) {
|
||||
params.domain = props.domain;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
preprocessMessages(messages) {
|
||||
return messages.map(m => ({
|
||||
...m,
|
||||
author_avatar_url: sprintf('/web/image/mail.message/%s/author_avatar/50x50', m.id),
|
||||
published_date_str: sprintf(
|
||||
this.env._t('Published on %s'),
|
||||
formatDateTime(
|
||||
parseDateTime(
|
||||
m.date,
|
||||
{ format: 'MM-dd-yyy HH:mm:ss' },
|
||||
),
|
||||
)
|
||||
),
|
||||
body: markup(m.body),
|
||||
}));
|
||||
}
|
||||
|
||||
updateMessage(message_id, changes) {
|
||||
Object.assign(
|
||||
this.state.messages.find(m => m.id === message_id),
|
||||
changes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ChatterContainer.components = {
|
||||
ChatterComposer,
|
||||
ChatterMessageCounter,
|
||||
ChatterMessages,
|
||||
ChatterPager,
|
||||
};
|
||||
|
||||
ChatterContainer.props = {
|
||||
token: { type: String, optional: true },
|
||||
resModel: String,
|
||||
resId: { type: Number, optional: true },
|
||||
pid: { type: String, optional: true },
|
||||
hash: { type: String, optional: true },
|
||||
pagerStart: { type: Number, optional: true },
|
||||
twoColumns: { type: Boolean, optional: true },
|
||||
projectSharingId: Number,
|
||||
};
|
||||
ChatterContainer.defaultProps = {
|
||||
token: '',
|
||||
pid: '',
|
||||
hash: '',
|
||||
pagerStart: 1,
|
||||
};
|
||||
ChatterContainer.template = 'project.ChatterContainer';
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="project.ChatterContainer" owl="1">
|
||||
<div t-attf-class="o_portal_chatter p-0 container {{props.twoColumns ? 'row' : ''}}">
|
||||
<div t-attf-class="{{props.twoColumns ? 'col-lg-5' : props.resId ? 'border-bottom' : ''}}">
|
||||
<div class="o_portal_chatter_header">
|
||||
<ChatterMessageCounter count="state.options.message_count"/>
|
||||
</div>
|
||||
<hr/>
|
||||
<ChatterComposer t-props="composerProps"/>
|
||||
</div>
|
||||
<div t-attf-class="{{props.twoColumns ? 'offset-lg-1 col-lg-6' : 'pt-4'}}">
|
||||
<ChatterMessages messages="props.resId ? state.messages : []" isUserEmployee="state.options.is_user_employee" update.bind="updateMessage" />
|
||||
<div class="o_portal_chatter_footer">
|
||||
<ChatterPager
|
||||
page="this.state.currentPage || 1"
|
||||
messageCount="this.state.options.message_count"
|
||||
pagerScope="this.state.options.pager_scope"
|
||||
pagerStep="this.state.options.pager_step"
|
||||
changePage.bind="onChangePage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
/** @odoo-module */
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
export class ChatterMessageCounter extends Component { }
|
||||
|
||||
ChatterMessageCounter.props = {
|
||||
count: Number,
|
||||
};
|
||||
ChatterMessageCounter.template = 'project.ChatterMessageCounter';
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="project.ChatterMessageCounter" owl="1">
|
||||
<div class="o_message_counter">
|
||||
<t t-if="props.count">
|
||||
<span class="fa fa-comments" />
|
||||
<span class="o_message_count"> <t t-esc="props.count"/> </span>
|
||||
comments
|
||||
</t>
|
||||
<t t-else="">
|
||||
There are no comments for now.
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
import { ChatterAttachmentsViewer } from "./chatter_attachments_viewer";
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
export class ChatterMessages extends Component {
|
||||
setup() {
|
||||
this.rpc = useService('rpc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the visibility of the message.
|
||||
*
|
||||
* @param {Object} message message to change the visibility
|
||||
*/
|
||||
async toggleMessageVisibility(message) {
|
||||
const result = await this.rpc(
|
||||
'/mail/update_is_internal',
|
||||
{ message_id: message.id, is_internal: !message.is_internal },
|
||||
);
|
||||
this.props.update(message.id, { is_internal: result });
|
||||
}
|
||||
}
|
||||
|
||||
ChatterMessages.template = 'project.ChatterMessages';
|
||||
ChatterMessages.props = {
|
||||
messages: Array,
|
||||
isUserEmployee: { type: Boolean, optional: true },
|
||||
update: { type: Function, optional: true },
|
||||
};
|
||||
ChatterMessages.defaultProps = {
|
||||
update: (message_id, changes) => {},
|
||||
};
|
||||
ChatterMessages.components = { ChatterAttachmentsViewer };
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="project.ChatterMessages" owl="1">
|
||||
<div class="o_portal_chatter_messages">
|
||||
<t t-foreach="props.messages" t-as="message" t-key="message.id">
|
||||
<div class="d-flex o_portal_chatter_message">
|
||||
<img class="o_portal_chatter_avatar" t-att-src="message.author_avatar_url" alt="avatar"/>
|
||||
<div class="flex-grow-1">
|
||||
<t t-if="props.isUserEmployee">
|
||||
<div t-if="message.is_message_subtype_note" class="float-end">
|
||||
<button class="btn btn-secondary" title="Internal notes are only displayed to internal users." disabled="true">Internal Note</button>
|
||||
</div>
|
||||
<div t-else=""
|
||||
t-attf-class="float-end {{message.is_internal ? 'o_portal_message_internal_on' : 'o_portal_message_internal_off'}}"
|
||||
t-on-click="() => this.toggleMessageVisibility(message)"
|
||||
>
|
||||
<button class="btn btn-danger"
|
||||
title="Currently restricted to internal employees, click to make it available to everyone viewing this document."
|
||||
>
|
||||
Employees Only
|
||||
</button>
|
||||
<button class="btn btn-success"
|
||||
title="Currently available to everyone viewing this document, click to restrict to internal employees."
|
||||
>
|
||||
Visible
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<div class="o_portal_chatter_message_title">
|
||||
<h5 class='mb-1'><t t-out="message.author_id[1]"/></h5>
|
||||
<p class="o_portal_chatter_puslished_date"><t t-out="message.published_date_str"/></p>
|
||||
</div>
|
||||
<t t-out="message.body"/>
|
||||
<div class="o_portal_chatter_attachments">
|
||||
<ChatterAttachmentsViewer attachments="message.attachment_ids"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
/** @odoo-module */
|
||||
|
||||
const { Component, useState, onWillUpdateProps } = owl;
|
||||
|
||||
export class ChatterPager extends Component {
|
||||
setup() {
|
||||
this.state = useState({
|
||||
disabledButtons: false,
|
||||
pageCount: 1,
|
||||
pageStart: 1,
|
||||
pageEnd: 1,
|
||||
pagePrevious: 1,
|
||||
pageNext: 1,
|
||||
pages: [1],
|
||||
offset: 0,
|
||||
});
|
||||
this.computePagerState(this.props);
|
||||
|
||||
onWillUpdateProps(this.onWillUpdateProps);
|
||||
}
|
||||
|
||||
computePagerState(props) {
|
||||
let page = props.page || 1;
|
||||
let scope = props.pagerScope;
|
||||
|
||||
const step = props.pagerStep;
|
||||
|
||||
// Compute Pager
|
||||
this.state.messageCount = Math.ceil(parseFloat(props.messageCount) / step);
|
||||
|
||||
page = Math.max(1, Math.min(page, this.state.messageCount));
|
||||
|
||||
const pageStart = Math.max(page - parseInt(Math.floor(scope / 2)), 1);
|
||||
this.state.pageEnd = Math.min(pageStart + scope, this.state.messageCount);
|
||||
this.state.pageStart = Math.max(this.state.pageEnd - scope, 1);
|
||||
|
||||
this.state.pages = Array.from(
|
||||
{length: this.state.pageEnd - this.state.pageStart + 1},
|
||||
(_, i) => i + this.state.pageStart,
|
||||
);
|
||||
this.state.pagePrevious = Math.max(this.state.pageStart, page - 1);
|
||||
this.state.pageNext = Math.min(this.state.pageEnd, page + 1);
|
||||
}
|
||||
|
||||
onWillUpdateProps(nextProps) {
|
||||
this.computePagerState(nextProps);
|
||||
}
|
||||
|
||||
async onPageChanged(page) {
|
||||
this.state.disabledButtons = true;
|
||||
await this.props.changePage(page);
|
||||
this.state.disabledButtons = false;
|
||||
}
|
||||
}
|
||||
|
||||
ChatterPager.props = {
|
||||
pagerScope: Number,
|
||||
pagerStep: Number,
|
||||
page: Number,
|
||||
messageCount: Number,
|
||||
changePage: Function,
|
||||
};
|
||||
|
||||
ChatterPager.template = 'project.ChatterPager';
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="project.ChatterPager" owl="1">
|
||||
<div class="d-flex justify-content-center">
|
||||
<ul class="pagination mb-0 pb-4" t-if="state.pages.length > 1">
|
||||
<li t-if="props.page != props.page_previous" t-att-data-page="state.pagePrevious" class="page-item o_portal_chatter_pager_btn">
|
||||
<a t-on-click="() => this.onPageChanged(state.pagePrevious)" class="page-link"><i class="fa fa-chevron-left" role="img" aria-label="Previous" title="Previous"/></a>
|
||||
</li>
|
||||
<t t-foreach="state.pages" t-as="page" t-key="page_index">
|
||||
<li t-att-data-page="page" t-attf-class="page-item #{page == props.page ? 'o_portal_chatter_pager_btn active' : 'o_portal_chatter_pager_btn'}">
|
||||
<a t-on-click="() => this.onPageChanged(page)" t-att-disabled="page == props.page" class="page-link"><t t-esc="page"/></a>
|
||||
</li>
|
||||
</t>
|
||||
<li t-if="props.page != state.pageNext" t-att-data-page="state.pageNext" class="page-item o_portal_chatter_pager_btn">
|
||||
<a t-on-click="() => this.onPageChanged(state.pageNext)" class="page-link"><i class="fa fa-chevron-right" role="img" aria-label="Next" title="Next"/></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import FavoriteMenuLegacy from 'web.FavoriteMenu';
|
||||
import CustomFavoriteItemLegacy from 'web.CustomFavoriteItem';
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
|
||||
/**
|
||||
* Remove all components contained in the favorite menu registry except the CustomFavoriteItem
|
||||
* component for only the project sharing feature.
|
||||
*/
|
||||
export function prepareFavoriteMenuRegister() {
|
||||
let customFavoriteItemKey = 'favorite-generator-menu';
|
||||
const keys = FavoriteMenuLegacy.registry.keys().filter(key => key !== customFavoriteItemKey);
|
||||
FavoriteMenuLegacy.registry = Object.assign(FavoriteMenuLegacy.registry, {
|
||||
map: {},
|
||||
_scoreMapping: {},
|
||||
_sortedKeys: null,
|
||||
});
|
||||
FavoriteMenuLegacy.registry.add(customFavoriteItemKey, CustomFavoriteItemLegacy, 0);
|
||||
// notify the listeners, we keep only one key in this registry.
|
||||
for (const key of keys) {
|
||||
for (const callback of FavoriteMenuLegacy.registry.listeners) {
|
||||
callback(key, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
customFavoriteItemKey = 'custom-favorite-item';
|
||||
const favoriteMenuRegistry = registry.category("favoriteMenu");
|
||||
for (const [key] of favoriteMenuRegistry.getEntries()) {
|
||||
if (key !== customFavoriteItemKey) {
|
||||
favoriteMenuRegistry.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { PortalFileInput } from '../portal_file_input/portal_file_input';
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
export class PortalAttachDocument extends Component {}
|
||||
|
||||
PortalAttachDocument.template = 'project.PortalAttachDocument';
|
||||
PortalAttachDocument.components = { PortalFileInput };
|
||||
PortalAttachDocument.props = {
|
||||
highlight: { type: Boolean, optional: true },
|
||||
onUpload: { type: Function, optional: true },
|
||||
beforeOpen: { type: Function, optional: true },
|
||||
slots: {
|
||||
type: Object,
|
||||
shape: {
|
||||
default: Object,
|
||||
},
|
||||
},
|
||||
resId: { type: Number, optional: true },
|
||||
resModel: { type: String, optional: true },
|
||||
multiUpload: { type: Boolean, optional: true },
|
||||
hidden: { type: Boolean, optional: true },
|
||||
acceptedFileExtensions: { type: String, optional: true },
|
||||
token: { type: String, optional: true },
|
||||
};
|
||||
PortalAttachDocument.defaultProps = {
|
||||
acceptedFileExtensions: "*",
|
||||
onUpload: () => {},
|
||||
route: "/portal/attachment/add",
|
||||
beforeOpen: async () => true,
|
||||
};
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="project.PortalAttachDocument" owl="1">
|
||||
<button t-attf-class="btn o_attachment_button #{props.highlight ? 'btn-primary' : 'btn-secondary'}">
|
||||
<PortalFileInput
|
||||
onUpload="props.onUpload"
|
||||
beforeOpen="props.beforeOpen"
|
||||
multiUpload="props.multiUpload"
|
||||
resModel="props.resModel"
|
||||
resId="props.resId"
|
||||
route="props.route"
|
||||
accessToken="props.token"
|
||||
>
|
||||
<t t-set-slot="default">
|
||||
<i class="fa fa-paperclip"/>
|
||||
</t>
|
||||
</PortalFileInput>
|
||||
</button>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { FileInput } from '@web/core/file_input/file_input';
|
||||
|
||||
export class PortalFileInput extends FileInput {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get httpParams() {
|
||||
const {
|
||||
model: res_model,
|
||||
id: res_id,
|
||||
...otherParams
|
||||
} = super.httpParams;
|
||||
return {
|
||||
res_model,
|
||||
res_id,
|
||||
access_token: this.props.accessToken,
|
||||
...otherParams,
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFiles(params) {
|
||||
const { ufile: files, ...otherParams } = params;
|
||||
const filesData = await Promise.all(
|
||||
files.map(
|
||||
(file) =>
|
||||
super.uploadFiles({
|
||||
file,
|
||||
name: file.name,
|
||||
...otherParams,
|
||||
})
|
||||
)
|
||||
);
|
||||
return filesData;
|
||||
}
|
||||
}
|
||||
|
||||
PortalFileInput.props = {
|
||||
...FileInput.props,
|
||||
accessToken: { type: String, optional: true },
|
||||
};
|
||||
PortalFileInput.defaultProps = {
|
||||
...FileInput.defaultProps,
|
||||
accessToken: '',
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/** @odoo-module **/
|
||||
import { startWebClient } from '@web/start';
|
||||
import { ProjectSharingWebClient } from './project_sharing';
|
||||
import { prepareFavoriteMenuRegister } from './components/favorite_menu_registry';
|
||||
|
||||
prepareFavoriteMenuRegister();
|
||||
startWebClient(ProjectSharingWebClient);
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { useBus, useService } from '@web/core/utils/hooks';
|
||||
import { ActionContainer } from '@web/webclient/actions/action_container';
|
||||
import { MainComponentsContainer } from "@web/core/main_components_container";
|
||||
import { useOwnDebugContext } from "@web/core/debug/debug_context";
|
||||
import { session } from '@web/session';
|
||||
|
||||
const { Component, useEffect, useExternalListener, useState } = owl;
|
||||
|
||||
export class ProjectSharingWebClient extends Component {
|
||||
setup() {
|
||||
window.parent.document.body.style.margin = "0"; // remove the margin in the parent body
|
||||
this.actionService = useService('action');
|
||||
this.user = useService("user");
|
||||
useService("legacy_service_provider");
|
||||
useOwnDebugContext({ categories: ["default"] });
|
||||
this.state = useState({
|
||||
fullscreen: false,
|
||||
});
|
||||
useBus(this.env.bus, "ACTION_MANAGER:UI-UPDATED", (mode) => {
|
||||
if (mode !== "new") {
|
||||
this.state.fullscreen = mode === "fullscreen";
|
||||
}
|
||||
});
|
||||
useEffect(
|
||||
() => {
|
||||
this._showView();
|
||||
},
|
||||
() => []
|
||||
);
|
||||
useExternalListener(window, "click", this.onGlobalClick, { capture: true });
|
||||
}
|
||||
|
||||
async _showView() {
|
||||
const { action_name, project_id, open_task_action } = session;
|
||||
await this.actionService.doAction(
|
||||
action_name,
|
||||
{
|
||||
clearBreadcrumbs: true,
|
||||
additionalContext: {
|
||||
active_id: project_id,
|
||||
}
|
||||
}
|
||||
);
|
||||
if (open_task_action) {
|
||||
await this.actionService.doAction(open_task_action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onGlobalClick(ev) {
|
||||
// When a ctrl-click occurs inside an <a href/> element
|
||||
// we let the browser do the default behavior and
|
||||
// we do not want any other listener to execute.
|
||||
if (
|
||||
ev.ctrlKey &&
|
||||
((ev.target instanceof HTMLAnchorElement && ev.target.href) ||
|
||||
(ev.target instanceof HTMLElement && ev.target.closest("a[href]:not([href=''])")))
|
||||
) {
|
||||
ev.stopImmediatePropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProjectSharingWebClient.components = { ActionContainer, MainComponentsContainer };
|
||||
ProjectSharingWebClient.template = 'project.ProjectSharingWebClient';
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="project.ProjectSharingWebClient" owl="1">
|
||||
<ActionContainer />
|
||||
<MainComponentsContainer/>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-inherit="web.CustomFavoriteItem" t-inherit-mode="extension">
|
||||
<xpath expr="//CheckBox[@value='state.isShared']" position="replace"/>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { append, createElement, setAttributes } from "@web/core/utils/xml";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { SIZES } from "@web/core/ui/ui_service";
|
||||
import { getModifier, ViewCompiler } from "@web/views/view_compiler";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { FormCompiler } from "@web/views/form/form_compiler";
|
||||
|
||||
/**
|
||||
* Compiler the portal chatter in project sharing.
|
||||
*
|
||||
* @param {HTMLElement} node
|
||||
* @param {Object} params
|
||||
* @returns
|
||||
*/
|
||||
function compileChatter(node, params) {
|
||||
const chatterContainerXml = createElement('ChatterContainer');
|
||||
const parentURLQuery = new URLSearchParams(window.parent.location.search);
|
||||
setAttributes(chatterContainerXml, {
|
||||
token: `'${parentURLQuery.get('access_token')}'` || '',
|
||||
resModel: params.resModel,
|
||||
resId: params.resId,
|
||||
projectSharingId: params.projectSharingId,
|
||||
});
|
||||
const chatterContainerHookXml = createElement('div');
|
||||
chatterContainerHookXml.classList.add('o_FormRenderer_chatterContainer');
|
||||
append(chatterContainerHookXml, chatterContainerXml);
|
||||
return chatterContainerHookXml;
|
||||
}
|
||||
|
||||
export class ProjectSharingChatterCompiler extends ViewCompiler {
|
||||
setup() {
|
||||
this.compilers.push({ selector: "t", fn: this.compileT });
|
||||
this.compilers.push({ selector: 'div.oe_chatter', fn: this.compileChatter });
|
||||
}
|
||||
|
||||
compile(node, params) {
|
||||
const res = super.compile(node, params).children[0];
|
||||
const chatterContainerHookXml = res.querySelector(".o_FormRenderer_chatterContainer");
|
||||
if (chatterContainerHookXml) {
|
||||
setAttributes(chatterContainerHookXml, {
|
||||
"t-if": `uiService.size >= ${SIZES.XXL}`,
|
||||
});
|
||||
chatterContainerHookXml.classList.add('overflow-x-hidden', 'overflow-y-auto', 'o-aside', 'h-100');
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
compileT(node, params) {
|
||||
const compiledRoot = createElement("t");
|
||||
for (const child of node.childNodes) {
|
||||
const invisible = getModifier(child, "invisible");
|
||||
let compiledChild = this.compileNode(child, params, false);
|
||||
compiledChild = this.applyInvisible(invisible, compiledChild, {
|
||||
...params,
|
||||
recordExpr: "model.root",
|
||||
});
|
||||
append(compiledRoot, compiledChild);
|
||||
}
|
||||
return compiledRoot;
|
||||
}
|
||||
|
||||
compileChatter(node) {
|
||||
return compileChatter(node, {
|
||||
resId: 'model.root.resId or undefined',
|
||||
resModel: 'model.root.resModel',
|
||||
projectSharingId: 'model.root.context.active_id_chatter',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("form_compilers").add("portal_chatter_compiler", {
|
||||
selector: "div.oe_chatter",
|
||||
fn: (node) =>
|
||||
compileChatter(node, {
|
||||
resId: "props.record.resId or undefined",
|
||||
resModel: "props.record.resModel",
|
||||
projectSharingId: "props.record.context.active_id_chatter",
|
||||
}),
|
||||
});
|
||||
|
||||
patch(FormCompiler.prototype, 'project_sharing_chatter', {
|
||||
compile(node, params) {
|
||||
const res = this._super(node, params);
|
||||
const chatterContainerHookXml = res.querySelector('.o_FormRenderer_chatterContainer');
|
||||
if (!chatterContainerHookXml) {
|
||||
return res; // no chatter, keep the result as it is
|
||||
}
|
||||
if (chatterContainerHookXml.parentNode.classList.contains('o_form_sheet')) {
|
||||
return res; // if chatter is inside sheet, keep it there
|
||||
}
|
||||
const formSheetBgXml = res.querySelector('.o_form_sheet_bg');
|
||||
const parentXml = formSheetBgXml && formSheetBgXml.parentNode;
|
||||
if (!parentXml) {
|
||||
return res; // miss-config: a sheet-bg is required for the rest
|
||||
}
|
||||
// after sheet bg (standard position, below form)
|
||||
setAttributes(chatterContainerHookXml, {
|
||||
't-if': `uiService.size < ${SIZES.XXL}`,
|
||||
});
|
||||
append(parentXml, chatterContainerHookXml);
|
||||
return res;
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { useService } from '@web/core/utils/hooks';
|
||||
import { createElement } from "@web/core/utils/xml";
|
||||
import { FormController } from '@web/views/form/form_controller';
|
||||
import { useViewCompiler } from '@web/views/view_compiler';
|
||||
import { ProjectSharingChatterCompiler } from './project_sharing_form_compiler';
|
||||
import { ChatterContainer } from '../../components/chatter/chatter_container';
|
||||
|
||||
export class ProjectSharingFormController extends FormController {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.uiService = useService('ui');
|
||||
const { arch, xmlDoc } = this.archInfo;
|
||||
const template = createElement('t');
|
||||
const xmlDocChatter = xmlDoc.querySelector("div.oe_chatter");
|
||||
if (xmlDocChatter && xmlDocChatter.parentNode.nodeName === "form") {
|
||||
template.appendChild(xmlDocChatter.cloneNode(true));
|
||||
}
|
||||
const mailTemplates = useViewCompiler(ProjectSharingChatterCompiler, arch, { Mail: template }, {});
|
||||
this.mailTemplate = mailTemplates.Mail;
|
||||
}
|
||||
|
||||
getActionMenuItems() {
|
||||
return {};
|
||||
}
|
||||
|
||||
get translateAlert() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
ProjectSharingFormController.components = {
|
||||
...FormController.components,
|
||||
ChatterContainer,
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-inherit="web.FormView" t-inherit-mode="extension">
|
||||
<xpath expr="//div[hasclass('o_form_view_container')]" position="after">
|
||||
<t t-if="mailTemplate">
|
||||
<t t-call="{{ mailTemplate }}" />
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { ChatterContainer } from '../../components/chatter/chatter_container';
|
||||
import { FormRenderer } from '@web/views/form/form_renderer';
|
||||
|
||||
export class ProjectSharingFormRenderer extends FormRenderer { }
|
||||
ProjectSharingFormRenderer.components = {
|
||||
...FormRenderer.components,
|
||||
ChatterContainer,
|
||||
};
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { formView } from '@web/views/form/form_view';
|
||||
import { ProjectSharingFormController } from './project_sharing_form_controller';
|
||||
import { ProjectSharingFormRenderer } from './project_sharing_form_renderer';
|
||||
|
||||
formView.Controller = ProjectSharingFormController;
|
||||
formView.Renderer = ProjectSharingFormRenderer;
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { kanbanView } from "@web/views/kanban/kanban_view";
|
||||
import { KanbanDynamicGroupList, KanbanModel } from "@web/views/kanban/kanban_model";
|
||||
|
||||
export class ProjectSharingTaskKanbanDynamicGroupList extends KanbanDynamicGroupList {
|
||||
get context() {
|
||||
return {
|
||||
...super.context,
|
||||
project_kanban: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ProjectSharingTaskKanbanModel extends KanbanModel {}
|
||||
|
||||
ProjectSharingTaskKanbanModel.DynamicGroupList = ProjectSharingTaskKanbanDynamicGroupList;
|
||||
|
||||
kanbanView.Model = ProjectSharingTaskKanbanModel;
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||
import { evalDomain } from "@web/views/utils";
|
||||
|
||||
const { onWillUpdateProps } = owl;
|
||||
|
||||
export class ProjectSharingListRenderer extends ListRenderer {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.setColumns(this.allColumns);
|
||||
onWillUpdateProps((nextProps) => {
|
||||
this.setColumns(nextProps.archInfo.columns);
|
||||
});
|
||||
}
|
||||
|
||||
setColumns(columns) {
|
||||
if (this.props.list.records.length) {
|
||||
const allColumns = [];
|
||||
const firstRecord = this.props.list.records[0];
|
||||
for (const column of columns) {
|
||||
if (
|
||||
column.modifiers.column_invisible &&
|
||||
column.modifiers.column_invisible instanceof Array
|
||||
) {
|
||||
const result = evalDomain(column.modifiers.column_invisible, firstRecord.evalContext);
|
||||
if (result) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
allColumns.push(column);
|
||||
}
|
||||
this.allColumns = allColumns;
|
||||
} else {
|
||||
this.allColumns = columns;
|
||||
}
|
||||
this.state.columns = this.allColumns.filter(
|
||||
(col) => !col.optional || this.optionalActiveFields[col.name]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { listView } from "@web/views/list/list_view";
|
||||
|
||||
import { ProjectSharingListRenderer } from "./list_renderer";
|
||||
|
||||
const props = listView.props;
|
||||
listView.props = function (genericProps, view) {
|
||||
const result = props(genericProps, view);
|
||||
return {
|
||||
...result,
|
||||
allowSelectors: false,
|
||||
};
|
||||
};
|
||||
listView.Renderer = ProjectSharingListRenderer;
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
.o_portal_project_rating {
|
||||
.thumbnail{
|
||||
height: 240px;
|
||||
}
|
||||
.o_top_partner_rating_image {
|
||||
height: 15px;
|
||||
}
|
||||
.o_top_partner_image {
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
}
|
||||
.o_top_partner_feedback{
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.o_vertical_separator {
|
||||
border-left: 1px solid #eeeeee
|
||||
}
|
||||
.o_rating_progress {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.o_rating_count {
|
||||
display: inline-block;
|
||||
min-width: 22px
|
||||
}
|
||||
.o_smiley_no_padding_left {
|
||||
padding-left: 0;
|
||||
}
|
||||
.o_smiley_no_padding_right {
|
||||
padding-right: 0;
|
||||
}
|
||||
.o_lighter_smileys {
|
||||
opacity: 0.4
|
||||
}
|
||||
}
|
||||
|
||||
.o_priority_star {
|
||||
display: inline-block;
|
||||
|
||||
&.fa-star-o {
|
||||
color: $o-main-color-muted;
|
||||
}
|
||||
&.fa-star {
|
||||
color: $o-main-favorite-color;
|
||||
}
|
||||
}
|
||||
|
||||
.o_status {
|
||||
display: block;
|
||||
background-color: map-get($grays, '200');
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
box-shadow: inset 0 0 0 1px rgba($black, .2);
|
||||
|
||||
.dropdown-item > & {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
.o_kanban_renderer.o_kanban_dashboard.o_project_kanban {
|
||||
|
||||
.o_project_kanban_main {
|
||||
max-width: 300px;
|
||||
|
||||
// Prevents the kanban settings menu from overflowing and creating a scrollbar.
|
||||
.o_kanban_card_manage_pane .o_kanban_card_manage_section a {
|
||||
@extend .text-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.o_project_kanban_boxes {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
justify-content: space-between;
|
||||
|
||||
.o_project_kanban_box {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
padding: 0 0 0 0;
|
||||
margin: 0 5px;
|
||||
|
||||
&:first-child {
|
||||
margin: 0 5px 0 0;
|
||||
padding-left: 12px
|
||||
}
|
||||
|
||||
.o_value {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
> div {
|
||||
font-weight: 500;
|
||||
|
||||
button.o_needaction {
|
||||
font-size: small;
|
||||
font-weight: 400;
|
||||
margin-left: 4px;
|
||||
@include o-hover-opacity(0.5, 1);
|
||||
|
||||
&:before {
|
||||
content: "/ ";
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: "\f086";
|
||||
font: normal normal normal 14px/1 FontAwesome;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
.o_view_nocontent {
|
||||
top: 15%;
|
||||
}
|
||||
}
|
||||
|
||||
.o_kanban_record {
|
||||
div[name="is_favorite"] .fa-star-o {
|
||||
visibility: hidden;
|
||||
}
|
||||
&:hover div[name="is_favorite"] .fa-star-o {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_dow_widget {
|
||||
th {
|
||||
padding: 10px 10px 0 10px;
|
||||
}
|
||||
|
||||
.o_dow_days {
|
||||
td {
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
.o_form_project_tasks div.o_field_many2many[name=depend_on_ids] tr.o_selected_row td.o_list_button {
|
||||
background-color: $table-border-color !important;
|
||||
}
|
||||
|
||||
.o_form_project_project, .o_form_project_tasks {
|
||||
.note-editable {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.o_form_view.o_form_project_tasks .o_notebook > .tab-content > .tab-pane > :first-child.o_field_html .o_readonly {
|
||||
padding: $o-horizontal-padding $o-horizontal-padding;
|
||||
}
|
||||
|
||||
.o_project_update_description {
|
||||
.note-editable, .o_wysiwyg_resizer {
|
||||
border: 0;
|
||||
}
|
||||
&.o_field_html .o_readonly {
|
||||
padding: $o-horizontal-padding $o-horizontal-padding;
|
||||
}
|
||||
}
|
||||
|
||||
.o_form_view .btn.oe_stat_button.o_project_not_clickable {
|
||||
&:hover, &:focus {
|
||||
background-color: transparent;
|
||||
opacity: 0.8;
|
||||
color: $o-main-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.oe_title .o_favorite i.fa {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.o_data_cell .o_favorite i.fa {
|
||||
font-size: 1.35em;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.o_project_sharing_container > main {
|
||||
display: flex;
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
.o_status_bubble {
|
||||
@extend .o_status;
|
||||
|
||||
@for $size from 2 through length($o-colors) {
|
||||
// Note: the first color is supposed to be invisible so it's ignored
|
||||
&.o_color_bubble_#{$size - 1} {
|
||||
background-color: nth($o-colors, $size);
|
||||
}
|
||||
}
|
||||
|
||||
&.o_color_bubble_20 {
|
||||
background-color: $o-success;
|
||||
}
|
||||
&.o_color_bubble_21 {
|
||||
background-color: $o-info;
|
||||
}
|
||||
&.o_color_bubble_22 {
|
||||
background-color: $o-warning;
|
||||
}
|
||||
&.o_color_bubble_23 {
|
||||
background-color: $o-danger;
|
||||
}
|
||||
}
|
||||
|
||||
.o_status_with_color {
|
||||
span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
&.o_field_widget {
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_project_update_description a[type="object"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.o_kanban_project_tasks .o_field_many2manytags, .o_kanban_tags{
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.o_project_m2m_avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: -1px;
|
||||
display: inline-block;
|
||||
|
||||
img {
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
object-fit: cover;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
import { ProjectStopRecurrenceConfirmationDialog } from '../components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog';
|
||||
|
||||
class ProjectTaskRecurrence {
|
||||
constructor(env, dialog, orm) {
|
||||
this.env = env;
|
||||
this.dialog = dialog;
|
||||
this.orm = orm;
|
||||
this.resModel = 'project.task';
|
||||
}
|
||||
|
||||
async stopRecurrence(tasks, callback = () => {}) {
|
||||
const taskIdsWithRecurrence = [];
|
||||
const tasksPerRecurrence = {};
|
||||
const recurrenceIds = [];
|
||||
for (const task of tasks) {
|
||||
if (task.data.recurrence_id) {
|
||||
taskIdsWithRecurrence.push(task.resId);
|
||||
const recurrenceId = task.data.recurrence_id && task.data.recurrence_id[0];
|
||||
if (!(recurrenceId in tasksPerRecurrence)) {
|
||||
tasksPerRecurrence[recurrenceId] = [task.resId];
|
||||
recurrenceIds.push(recurrenceId);
|
||||
} else {
|
||||
tasksPerRecurrence[recurrenceId].push(task.resId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!recurrenceIds.length) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
let allowContinue = false;
|
||||
if (recurrenceIds.length === 1) {
|
||||
const count = await this.orm.searchCount(
|
||||
this.resModel,
|
||||
[['recurrence_id', '=', recurrenceIds[0]]],
|
||||
);
|
||||
allowContinue = count != 1;
|
||||
} else {
|
||||
const taskReadGroup = await this.orm.readGroup(
|
||||
this.resModel,
|
||||
[['recurrence_id', 'in', recurrenceIds]],
|
||||
['recurrence_id'],
|
||||
['recurrence_id'],
|
||||
);
|
||||
allowContinue = true;
|
||||
for (const res of taskReadGroup) {
|
||||
const taskCount = tasksPerRecurrence[res.recurrence_id[0]].length;
|
||||
if (taskCount === res.recurrence_id_count) {
|
||||
allowContinue = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let dialogBody;
|
||||
if (tasks.length > 1) {
|
||||
dialogBody = allowContinue
|
||||
? this.env._t('It seems that some tasks are part of a recurrence.')
|
||||
: this.env._t('It seems that some tasks are part of a recurrence. At least one of them must be kept as a model to create the next occurences.');
|
||||
} else {
|
||||
dialogBody = allowContinue
|
||||
? this.env._t('It seems that this task is part of a recurrence.')
|
||||
: this.env._t('It seems that this task is recurrent. Would you like to stop its recurrence?');
|
||||
}
|
||||
|
||||
const dialogProps = {
|
||||
body: dialogBody,
|
||||
confirm: async () => {
|
||||
await this.orm.call(
|
||||
this.resModel,
|
||||
'action_stop_recurrence',
|
||||
[taskIdsWithRecurrence],
|
||||
);
|
||||
callback();
|
||||
},
|
||||
cancel: () => {},
|
||||
};
|
||||
if (allowContinue) {
|
||||
dialogProps.continueRecurrence = async () => {
|
||||
await this.orm.call(
|
||||
this.resModel,
|
||||
'action_continue_recurrence',
|
||||
[taskIdsWithRecurrence],
|
||||
);
|
||||
callback();
|
||||
};
|
||||
}
|
||||
this.dialog.add(ProjectStopRecurrenceConfirmationDialog, dialogProps);
|
||||
}
|
||||
}
|
||||
|
||||
export const taskRecurrenceService = {
|
||||
dependencies: ['dialog', 'orm'],
|
||||
async: [
|
||||
'stopRecurrence',
|
||||
],
|
||||
start(env, { dialog, orm }) {
|
||||
return new ProjectTaskRecurrence(env, dialog, orm);
|
||||
}
|
||||
};
|
||||
|
||||
registry.category('services').add('project_task_recurrence', taskRecurrenceService);
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
/** @odoo-module */
|
||||
|
||||
/**
|
||||
* List of colors according to the selection value, see `project_update.py`
|
||||
*/
|
||||
export const STATUS_COLORS = {
|
||||
'on_track': 10,
|
||||
'at_risk': 2,
|
||||
'off_track': 1,
|
||||
'on_hold': 4,
|
||||
};
|
||||
|
||||
export const STATUS_COLOR_PREFIX = 'o_status_bubble mx-0 o_color_bubble_';
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { GraphModel } from "@web/views/graph/graph_model";
|
||||
|
||||
export class BurndownChartModel extends GraphModel {
|
||||
/**
|
||||
* @protected
|
||||
* @override
|
||||
*/
|
||||
async _loadDataPoints(metaData) {
|
||||
metaData.measures.__count.string = this.env._t('# of Tasks');
|
||||
return super._loadDataPoints(metaData);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { SearchModel } from "@web/search/search_model";
|
||||
|
||||
|
||||
export class BurndownChartSearchModel extends SearchModel {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
setup(services) {
|
||||
this.notificationService = useService("notification");
|
||||
super.setup(...arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async load(config) {
|
||||
await super.load(...arguments);
|
||||
// Store date and stage_id searchItemId in the SearchModel for reuse in other functions.
|
||||
for (const searchItem of Object.values(this.searchItems)) {
|
||||
if (['dateGroupBy', 'groupBy'].includes(searchItem.type)) {
|
||||
if (this.stageIdSearchItemId && this.dateSearchItemId) {
|
||||
return;
|
||||
}
|
||||
switch (searchItem.fieldName) {
|
||||
case 'date':
|
||||
this.dateSearchItemId = searchItem.id;
|
||||
break;
|
||||
case 'stage_id':
|
||||
this.stageIdSearchItemId = searchItem.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
deactivateGroup(groupId) {
|
||||
// Prevent removing Date & Stage group by from the search
|
||||
if (this.searchItems[this.stageIdSearchItemId].groupId == groupId && this.searchItems[this.dateSearchItemId].groupId) {
|
||||
this._addGroupByNotification(this.env._t("Date and Stage"));
|
||||
return;
|
||||
}
|
||||
super.deactivateGroup(groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
toggleDateGroupBy(searchItemId, intervalId) {
|
||||
// Ensure that there is always one and only one date group by selected.
|
||||
if (searchItemId === this.dateSearchItemId) {
|
||||
let filtered_query = [];
|
||||
let triggerNotification = false;
|
||||
for (const queryElem of this.query) {
|
||||
if (queryElem.searchItemId !== searchItemId) {
|
||||
filtered_query.push(queryElem);
|
||||
} else if (queryElem.intervalId === intervalId) {
|
||||
triggerNotification = true;
|
||||
}
|
||||
}
|
||||
if (filtered_query.length !== this.query.length) {
|
||||
this.query = filtered_query;
|
||||
if (triggerNotification) {
|
||||
this._addGroupByNotification(this.env._t("Date"));
|
||||
}
|
||||
}
|
||||
}
|
||||
super.toggleDateGroupBy(...arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
toggleSearchItem(searchItemId) {
|
||||
// Ensure that stage_id is always selected.
|
||||
if (searchItemId === this.stageIdSearchItemId
|
||||
&& this.query.some(queryElem => queryElem.searchItemId === searchItemId)) {
|
||||
this._addGroupByNotification(this.env._t("Stage"));
|
||||
return;
|
||||
}
|
||||
super.toggleSearchItem(...arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a notification relative to the group by constraint of the Burndown Chart.
|
||||
* @param fieldName The field name(s) the notification has to be related to.
|
||||
* @private
|
||||
*/
|
||||
_addGroupByNotification(fieldName) {
|
||||
const notif = this.env._t("The Burndown Chart must be grouped by");
|
||||
this.notificationService.add(
|
||||
`${notif} ${fieldName}`,
|
||||
{ type: "danger" }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async _notify() {
|
||||
// Ensure that we always group by date firstly and by stage_id secondly
|
||||
let stageIdIndex = -1;
|
||||
let dateIndex = -1;
|
||||
for (const [index, queryElem] of this.query.entries()) {
|
||||
if (stageIdIndex !== -1 && dateIndex !== -1) {
|
||||
break;
|
||||
}
|
||||
switch (queryElem.searchItemId) {
|
||||
case this.dateSearchItemId:
|
||||
dateIndex = index;
|
||||
break;
|
||||
case this.stageIdSearchItemId:
|
||||
stageIdIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (stageIdIndex > 0) {
|
||||
if (stageIdIndex > dateIndex) {
|
||||
dateIndex += 1;
|
||||
}
|
||||
this.query.splice(0, 0, this.query.splice(stageIdIndex, 1)[0]);
|
||||
}
|
||||
if (dateIndex > 0) {
|
||||
this.query.splice(0, 0, this.query.splice(dateIndex, 1)[0]);
|
||||
}
|
||||
await super._notify(...arguments);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { BurndownChartModel } from "./burndown_chart_model";
|
||||
import { graphView } from "@web/views/graph/graph_view";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { BurndownChartSearchModel } from "./burndown_chart_search_model";
|
||||
|
||||
const viewRegistry = registry.category("views");
|
||||
|
||||
const burndownChartGraphView = {
|
||||
...graphView,
|
||||
buttonTemplate: "project.BurndownChartView.Buttons",
|
||||
hideCustomGroupBy: true,
|
||||
Model: BurndownChartModel,
|
||||
searchMenuTypes: graphView.searchMenuTypes.filter(menuType => menuType !== "comparison"),
|
||||
SearchModel: BurndownChartSearchModel,
|
||||
};
|
||||
|
||||
viewRegistry.add("burndown_chart", burndownChartGraphView);
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="project.BurndownChartView.Buttons" t-inherit="web.GraphView.Buttons" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//button[@data-mode='pie']" position="replace">
|
||||
</xpath>
|
||||
<xpath expr="//div[@role='toolbar'][@name='toggleOrderToolbar']" position="replace">
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { useService } from '@web/core/utils/hooks';
|
||||
import { FormRenderer } from '@web/views/form/form_renderer';
|
||||
|
||||
const { useRef, useEffect } = owl;
|
||||
|
||||
export class FormRendererWithHtmlExpander extends FormRenderer {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.ui = useService('ui');
|
||||
const ref = useRef('compiled_view_root');
|
||||
useEffect(
|
||||
(el, size) => {
|
||||
if (el && size === 6) {
|
||||
const descriptionField = el.querySelector(this.htmlFieldQuerySelector);
|
||||
if (descriptionField) {
|
||||
const editor = descriptionField.querySelector('.note-editable');
|
||||
const elementToResize = editor || descriptionField;
|
||||
const { bottom, height } = elementToResize.getBoundingClientRect();
|
||||
const minHeight = document.documentElement.clientHeight - bottom - height;
|
||||
elementToResize.style.minHeight = `${minHeight}px`;
|
||||
}
|
||||
}
|
||||
},
|
||||
() => [ref.el, this.ui.size, this.props.record.mode],
|
||||
);
|
||||
}
|
||||
|
||||
get htmlFieldQuerySelector() {
|
||||
return '.oe_form_field.oe_form_field_html';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { formView } from '@web/views/form/form_view';
|
||||
import { FormRendererWithHtmlExpander } from './form_renderer_with_html_expander';
|
||||
|
||||
export const formViewWithHtmlExpander = {
|
||||
...formView,
|
||||
Renderer: FormRendererWithHtmlExpander,
|
||||
};
|
||||
|
||||
registry.category('views').add('form_description_expander', formViewWithHtmlExpander);
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { CalendarController } from "@web/views/calendar/calendar_controller";
|
||||
|
||||
export class ProjectCalendarController extends CalendarController {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.displayName += this.env._t(" - Tasks by Deadline");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { calendarView } from "@web/views/calendar/calendar_view";
|
||||
import { ProjectCalendarController } from "@project/views/project_calendar/project_calendar_controller";
|
||||
import { ProjectControlPanel } from "@project/components/project_control_panel/project_control_panel";
|
||||
|
||||
export const projectCalendarView = {
|
||||
...calendarView,
|
||||
Controller: ProjectCalendarController,
|
||||
ControlPanel: ProjectControlPanel,
|
||||
};
|
||||
registry.category("views").add("project_calendar", projectCalendarView);
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { FormRendererWithHtmlExpander } from "../form_with_html_expander/form_renderer_with_html_expander";
|
||||
|
||||
export class ProjectFormRenderer extends FormRendererWithHtmlExpander {
|
||||
get htmlFieldQuerySelector() {
|
||||
return '.o_field_html[name="description"]';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { formViewWithHtmlExpander } from '../form_with_html_expander/form_view_with_html_expander';
|
||||
import { ProjectFormRenderer } from "./project_form_renderer";
|
||||
|
||||
export const projectFormView = {
|
||||
...formViewWithHtmlExpander,
|
||||
Renderer: ProjectFormRenderer,
|
||||
};
|
||||
|
||||
registry.category("views").add("project_form", projectFormView);
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { useService } from '@web/core/utils/hooks';
|
||||
import { FormController } from '@web/views/form/form_controller';
|
||||
|
||||
export class ProjectTaskFormController extends FormController {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.taskRecurrence = useService('project_task_recurrence');
|
||||
}
|
||||
|
||||
getActionMenuItems() {
|
||||
if (!(this.archiveEnabled && this.model.root.isActive) || !this.model.root.data.recurrence_id) {
|
||||
return super.getActionMenuItems();
|
||||
}
|
||||
this.archiveEnabled = false;
|
||||
const actionMenuItems = super.getActionMenuItems();
|
||||
this.archiveEnabled = true;
|
||||
if (actionMenuItems) {
|
||||
actionMenuItems.other.unshift({
|
||||
description: this.env._t('Archive'),
|
||||
callback: () => this.taskRecurrence.stopRecurrence(
|
||||
[this.model.root],
|
||||
() => this.model.root.archive(),
|
||||
),
|
||||
});
|
||||
}
|
||||
return actionMenuItems;
|
||||
}
|
||||
|
||||
deleteRecord() {
|
||||
if (!this.model.root.data.recurrence_id) {
|
||||
return super.deleteRecord();
|
||||
}
|
||||
this.taskRecurrence.stopRecurrence(
|
||||
[this.model.root],
|
||||
() => {
|
||||
this.model.root.delete();
|
||||
if (!this.model.root.resId) {
|
||||
this.env.config.historyBack();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { FormRendererWithHtmlExpander } from "../form_with_html_expander/form_renderer_with_html_expander";
|
||||
|
||||
export class ProjectTaskFormRenderer extends FormRendererWithHtmlExpander {
|
||||
get htmlFieldQuerySelector() {
|
||||
return '.o_field_html[name="description"]';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { formViewWithHtmlExpander } from '../form_with_html_expander/form_view_with_html_expander';
|
||||
import { ProjectTaskFormController } from './project_task_form_controller';
|
||||
import { ProjectTaskFormRenderer } from "./project_task_form_renderer";
|
||||
|
||||
export const projectTaskFormView = {
|
||||
...formViewWithHtmlExpander,
|
||||
Controller: ProjectTaskFormController,
|
||||
Renderer: ProjectTaskFormRenderer,
|
||||
};
|
||||
|
||||
registry.category("views").add("project_task_form", projectTaskFormView);
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
.o_form_project_tasks {
|
||||
.o_widget_web_ribbon:not(.o_invisible_modifier) + div div[name="kanban_state"] > div {
|
||||
margin-right: 150px;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
div[name="kanban_state"] span.o_status {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.note-editable {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.o_wysiwyg_resizer {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.o_form_project_recurrence_message *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.o_form_editable .oe_title {
|
||||
max-width: initial;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { KanbanDynamicGroupList } from "@web/views/kanban/kanban_model";
|
||||
import { Domain } from '@web/core/domain';
|
||||
import { session } from '@web/session';
|
||||
|
||||
export class ProjectTaskKanbanDynamicGroupList extends KanbanDynamicGroupList {
|
||||
get context() {
|
||||
const context = super.context;
|
||||
context.project_kanban = true;
|
||||
if (context.createPersonalStageGroup) {
|
||||
context.default_user_id = context.uid;
|
||||
delete context.createPersonalStageGroup;
|
||||
delete context.default_project_id;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
get isGroupedByStage() {
|
||||
return !!this.groupByField && this.groupByField.name === 'stage_id';
|
||||
}
|
||||
|
||||
get isGroupedByPersonalStages() {
|
||||
return !!this.groupByField && this.groupByField.name === 'personal_stage_type_ids';
|
||||
}
|
||||
|
||||
async _loadGroups() {
|
||||
if (!this.isGroupedByPersonalStages) {
|
||||
return super._loadGroups(...arguments);
|
||||
}
|
||||
const previousDomain = this.domain;
|
||||
this.domain = Domain.and([[['user_ids', 'in', session.uid]], previousDomain]).toList({});
|
||||
const result = await super._loadGroups(...arguments);
|
||||
this.domain = previousDomain;
|
||||
return result;
|
||||
}
|
||||
|
||||
async createGroup() {
|
||||
if (this.isGroupedByPersonalStages) {
|
||||
this.defaultContext = Object.assign({}, this.defaultContext || {}, {
|
||||
createPersonalStageGroup: true,
|
||||
});
|
||||
}
|
||||
const result = await super.createGroup(...arguments);
|
||||
if (this.isGroupedByPersonalStages) {
|
||||
delete this.defaultContext.createPersonalStageGroup;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||