Initial commit: Project packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:52 +02:00
commit 89613c97b0
753 changed files with 496325 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

View file

@ -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

View file

@ -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";

View file

@ -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>

View file

@ -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);

View file

@ -0,0 +1,4 @@
.project_private_task_many2one_field input::placeholder {
color: $red;
font-style: italic;
}

View file

@ -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>

View file

@ -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';

View file

@ -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>

View file

@ -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';

View file

@ -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 &lt; 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 &lt; 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 &lt; 0 ? 'text-danger' : 'text-success'"><t t-esc="props.formatMonetary(margin.total)"/></th>
</tr>
</thead>
</table>
</div>
</t>
</templates>

View file

@ -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';

View file

@ -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>

View file

@ -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,
};

View file

@ -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%;
}
}
}

View file

@ -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 &amp;&amp; !!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>

View file

@ -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);

View file

@ -0,0 +1,9 @@
.o_field_status_with_color button.disabled {
color: black;
}
.o_field_status_with_color {
.o_status_text {
max-width: 105px;
}
}

View file

@ -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);

View file

@ -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>

View file

@ -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 };

View file

@ -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>

View file

@ -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);

View file

@ -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>

View file

@ -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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View file

@ -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);
},
});

View file

@ -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);

View file

@ -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
}
}
});
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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 &amp; 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
}]);
});

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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;
}
}
}
}

View file

@ -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 () => {},
};

View file

@ -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&amp;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>

View file

@ -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';

View file

@ -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>

View file

@ -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';

View file

@ -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>

View file

@ -0,0 +1,10 @@
/** @odoo-module */
const { Component } = owl;
export class ChatterMessageCounter extends Component { }
ChatterMessageCounter.props = {
count: Number,
};
ChatterMessageCounter.template = 'project.ChatterMessageCounter';

View file

@ -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>

View file

@ -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 };

View file

@ -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>

View file

@ -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';

View file

@ -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 &gt; 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>

View file

@ -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);
}
}
}

View file

@ -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,
};

View file

@ -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>

View file

@ -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: '',
};

View file

@ -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);

View file

@ -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';

View file

@ -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>

View file

@ -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>

View file

@ -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;
}
});

View file

@ -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,
}

View file

@ -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>

View file

@ -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,
};

View file

@ -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;

View file

@ -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;

View file

@ -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]
);
}
}

View file

@ -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;

View file

@ -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%);
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}

View file

@ -0,0 +1,3 @@
.o_project_sharing_container > main {
display: flex;
}

View file

@ -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;
}
}

View file

@ -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);

View file

@ -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_';

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);

View file

@ -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>

View file

@ -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';
}
}

View file

@ -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);

View file

@ -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");
}
}

View file

@ -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);

View file

@ -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"]';
}
}

View file

@ -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);

View file

@ -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();
}
}
);
}
}

View file

@ -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"]';
}
}

View file

@ -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);

View file

@ -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;
}
}

View file

@ -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;
}
}

Some files were not shown because too many files have changed in this diff Show more