19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:56 +01:00
parent a2f74aefd8
commit 4a4d12c333
844 changed files with 212348 additions and 270090 deletions

View file

@ -0,0 +1,148 @@
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { registry } from "@web/core/registry";
import { _t } from "@web/core/l10n/translation";
export function showTemplateUndoNotification(
env,
{
model,
recordId,
message,
undoMethod = "action_undo_convert_to_template",
actionType = "success",
undoCallback,
}
) {
const undoNotification = env.services.notification.add(_t(message), {
type: actionType,
buttons: [
{
name: _t("Undo"),
icon: "fa-undo",
onClick: async () => {
const res = await env.services.orm.call(model, undoMethod, [recordId]);
if (undoCallback) {
await env.services.orm.call(model, undoCallback.method, undoCallback.args);
}
if (res && undoMethod !== "unlink") {
env.services.action.doAction(res);
} else if (undoMethod === "unlink") {
// Taking out the controller to be restored after unlinking the record
const restoreController =
env.services.action.currentController.config.breadcrumbs?.at(-2);
restoreController?.onSelected();
}
undoNotification();
},
},
],
});
}
export function showTemplateUndoConfirmationDialog(
env,
{
model,
recordId,
bodyMessage,
confirmLabel,
undoMethod = "action_undo_convert_to_template",
confirmationCallback,
}
) {
env.services.dialog.add(ConfirmationDialog, {
body: bodyMessage,
confirmLabel: confirmLabel,
confirm: async () => {
const action = await env.services.orm.call(model, undoMethod, [recordId]);
await env.services.action.doAction(action);
if (confirmationCallback) {
await env.services.orm.call(
model,
confirmationCallback.method,
confirmationCallback.args
);
}
},
cancelLabel: _t("Discard"),
cancel: () => {},
});
}
export async function showTemplateFormView(
env,
{ model, recordId, method = "action_create_template_from_project" }
) {
const action = await env.services.orm.call(model, method, [recordId]);
await env.services.action.doAction({
type: "ir.actions.act_window",
res_model: model,
views: [[false, "form"]],
res_id: action.params.project_id,
});
await env.services.action.doAction(action);
}
// Task → Template Notification
registry.category("actions").add("project_show_template_notification", (env, action) => {
const params = action.params || {};
showTemplateUndoNotification(env, {
model: "project.task",
recordId: params.task_id,
message: _t("Task converted to template"),
});
return params.next;
});
// Task → Template Undo Confirmation Dialog
registry
.category("actions")
.add("project_show_template_undo_confirmation_dialog", (env, action) => {
const params = action.params || {};
showTemplateUndoConfirmationDialog(env, {
model: "project.task",
recordId: params.task_id,
bodyMessage: _t(
"This task is currently a template. Would you like to convert it back into a regular task?"
),
confirmLabel: _t("Convert to Task"),
});
return params.next;
});
// Project → Template Create Redirection
registry.category("actions").add("project_to_template_redirection_action", (env, action) => {
const params = action.params || {};
return showTemplateFormView(env, {
model: "project.project",
recordId: params.project_id,
});
});
// Project → Template Notification
registry.category("actions").add("project_template_show_notification", (env, action) => {
const params = action.params || {};
showTemplateUndoNotification(env, {
model: "project.project",
recordId: params.project_id,
message: params.message || _t("Project converted to template."),
undoMethod: params.undo_method,
undoCallback: params.callback_data || null,
});
return params.next;
});
// Project → Template Undo Confirmation Dialog
registry
.category("actions")
.add("project_template_show_undo_confirmation_dialog", (env, action) => {
const params = action.params || {};
showTemplateUndoConfirmationDialog(env, {
model: "project.project",
recordId: params.project_id,
bodyMessage: params.message,
confirmLabel: _t("Revert to Project"),
confirmationCallback: params.callback_data || null,
});
return params.next;
});

View file

@ -0,0 +1,35 @@
import { useState, onRendered } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { BooleanToggleField, booleanToggleField } from "@web/views/fields/boolean_toggle/boolean_toggle_field";
export class TaskCheckMark extends BooleanToggleField {
static template = "project.TaskCheckMark";
setup() {
super.setup();
this.reached = useState({
isReached: false,
});
onRendered(() => {
this.reached.isReached = this.props.record.data[this.props.name];
});
}
async onChange(ev) {
const { record, name } = this.props;
const value = !record.data[name];
const recordUpdate = record.update.bind(record);
if (['kanban', 'list'].includes(this.env.config.viewType)) {
await recordUpdate({ [name]: value }, { save: true });
} else {
await recordUpdate({ [name]: value });
}
}
}
export const taskCheckMark = {
...booleanToggleField,
component: TaskCheckMark,
}
registry.category("fields").add("task_done_checkmark", taskCheckMark);

View file

@ -0,0 +1,26 @@
a.o_todo_done_button {
margin: 0px 5px;
@include o-hover-text-color($gray-400, $o-success);
&:hover:before {
content: "\f058";
}
&.done_button_enabled {
@include o-hover-text-color($o-success, $gray-400);
&:hover:before {
content: "\f05d";
}
}
}
a.o_todo_done_button_mobile {
margin: 0px 5px;
color: $gray-400;
&.done_button_enabled {
color: $o-success;
}
}
.o_task_kanban_card_body {
padding-left: 9px;
}

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="project.TaskCheckMark" t-inherit="web.BooleanToggleField" t-inherit-mode="primary">
<xpath expr="//CheckBox" position="replace">
<a title="Mark as done"
t-on-click.stop="onChange"
t-attf-class="o_todo_done_button ps-0 fa fa-lg fa-check-circle{{!reached.isReached ? '-o' : ' done_button_enabled'}}"/>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,55 @@
import { useState } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { TaskListRenderer } from "../task_list_renderer";
export class NotebookTaskListRenderer extends TaskListRenderer {
static rowsTemplate = "project.NotebookTaskListRenderer.Rows";
setup() {
super.setup();
this.hideState = useState({
hide: localStorage.getItem(this._getStorageKey) === 'true',
});
}
/**
* @private
* @returns {string}
*/
get _getStorageKey() {
return `hide_closed_${this.constructor.name}`;
}
get hideClosed() {
return this.hideState.hide;
}
get closedX2MCount() {
return this.props.list.context.closed_X2M_count;
}
get openLabel() {
return typeof this.closedX2MCount === "undefined" ? _t("Show closed tasks") : _t("%s closed tasks", this.closedX2MCount);
}
get closeLabel() {
return _t("Hide closed tasks");
}
get toggleListHideLabel() {
return this.hideClosed ? this.openLabel : this.closeLabel;
}
get ShowX2MRecords() {
// If there isn't a closed_X2M_count defined in the context of the x2m task in the view we are always displaying the Toggle button
// In case there is no computed field to calculate the number of closed X2M tasks in the backend
return this.closedX2MCount > 0 || typeof this.closedX2MCount === "undefined";
}
toggleHideClosed() {
this.hideState.hide = !this.hideState.hide;
localStorage.setItem(this._getStorageKey, this.hideState.hide);
document.activeElement.blur();
}
}

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="project.NotebookTaskListRenderer.Rows" t-inherit="web.ListRenderer.Rows" t-inherit-mode="primary">
<xpath expr="//t[@t-foreach='list.records']" position="replace">
<t t-foreach="list.records" t-as="record" t-key="record.id">
<t t-if="!['1_done','1_canceled'].includes(record.data.state) or !hideState.hide">
<t t-call="{{ constructor.recordRowTemplate }}"/>
</t>
</t>
</xpath>
<xpath expr="//t[@t-foreach='controls']" position="after">
<a t-if="ShowX2MRecords" role="button" class="ml16 o_toggle_closed_task_button" href="#" t-on-click="() => this.toggleHideClosed()">
<t t-esc="toggleListHideLabel"/>
</a>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,26 @@
import { registry } from "@web/core/registry";
import { X2ManyField, x2ManyField } from '@web/views/fields/x2many/x2many_field';
import { NotebookTaskListRenderer } from './notebook_task_list_renderer';
export class NotebookTaskOne2ManyField extends X2ManyField {
static components = {
...X2ManyField.components,
ListRenderer: NotebookTaskListRenderer,
};
get rendererProps() {
const rendererProps = super.rendererProps;
if (this.props.viewMode === "kanban") {
rendererProps.openRecord = this.switchToForm.bind(this);
}
return rendererProps;
}
}
export const notebookTaskOne2ManyField = {
...x2ManyField,
component: NotebookTaskOne2ManyField,
}
registry.category("fields").add("notebook_task_one2many", notebookTaskOne2ManyField);

View file

@ -1,44 +0,0 @@
/** @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

@ -1,55 +0,0 @@
<?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,14 @@
import { registry } from "@web/core/registry";
import { booleanFavoriteField } from "@web/views/fields/boolean_favorite/boolean_favorite_field";
export const projectIsFavoriteField = {
...booleanFavoriteField,
extractProps: (fieldsInfo, dynamicInfo) => {
return {
...booleanFavoriteField.extractProps(fieldsInfo, dynamicInfo),
readonly: Boolean(fieldsInfo.attrs.readonly),
};
},
};
registry.category("fields").add("project_is_favorite", projectIsFavoriteField);

View file

@ -0,0 +1,26 @@
import { Component } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { computeM2OProps, Many2One } from "@web/views/fields/many2one/many2one";
import { buildM2OFieldDescription, Many2OneField } from "@web/views/fields/many2one/many2one_field";
export class ProjectMany2OneField extends Component {
static template = "project.ProjectMany2OneField";
static components = { Many2One };
static props = { ...Many2OneField.props };
get m2oProps() {
const props = computeM2OProps(this.props);
const { record } = this.props;
props.cssClass = "w-100";
if (!record.data.project_id && !record._isRequired("project_id")) {
props.placeholder = _t("Private");
props.cssClass += " private_placeholder";
}
return props;
}
}
registry.category("fields").add("project", {
...buildM2OFieldDescription(ProjectMany2OneField),
});

View file

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

View file

@ -0,0 +1,13 @@
<templates>
<t t-name="project.ProjectMany2OneField">
<div class="d-flex align-items-center gap-1">
<t t-set="isPrivateTask" t-value="props.readonly and !props.record.data.parent_id and !props.record.data.project_id"/>
<Many2One t-if="!isPrivateTask" t-props="m2oProps"/>
<span class="text-danger fst-italic text-muted"
t-out="this.props.record.fields[this.props.name].falsy_value_label"
t-if="isPrivateTask"/>
</div>
</t>
</templates>

View file

@ -1,9 +0,0 @@
/** @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

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

View file

@ -1,21 +0,0 @@
<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

@ -1,14 +1,19 @@
/** @odoo-module */
import { formatDate } from "@web/core/l10n/dates";
import { useService } from '@web/core/utils/hooks';
import { Component, useState, onWillUpdateProps } from "@odoo/owl";
const { Component, useState, onWillUpdateProps, status } = owl;
const { DateTime } = luxon;
export class ProjectMilestone extends Component {
static props = {
context: Object,
milestone: Object,
};
static template = "project.ProjectMilestone";
setup() {
this.orm = useService('orm');
this.dialog = useService("dialog");
this.milestone = useState(this.props.milestone);
this.state = useState({
colorClass: this._getColorClass(),
@ -45,29 +50,6 @@ export class ProjectMilestone extends Component {
}
}
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;
@ -82,11 +64,3 @@ export class ProjectMilestone extends Component {
}
}
}
ProjectMilestone.props = {
context: Object,
milestone: Object,
open: Function,
load: Function,
};
ProjectMilestone.template = 'project.ProjectMilestone';

View file

@ -1,23 +1,20 @@
<?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-name="project.ProjectMilestone">
<div class="list-group">
<div class="o_rightpanel_milestone list-group-item list-group-item-action d-flex gap-2 border-0 cursor-pointer" t-att-class="state.colorClass" t-on-click="toggleIsReached">
<t t-if="milestone.is_reached" t-set="title">Mark as incomplete</t>
<t t-else="" t-set="title">Mark as reached</t>
<i class="fa fa-fw mt-1" t-att-class="state.checkboxIcon" t-att-title="title"/>
<div class="o_milestone_detail d-flex justify-content-between flex-grow-1 gap-2">
<div 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>

View file

@ -1,8 +1,16 @@
/** @odoo-module */
const { Component } = owl;
import { Component } from "@odoo/owl";
export class ProjectProfitability extends Component {
static props = {
data: Object,
labels: Object,
formatMonetary: Function,
onProjectActionClick: Function,
onClick: Function,
};
static template = "project.ProjectProfitability";
get revenues() {
return this.props.data.revenues;
}
@ -21,11 +29,3 @@ export class ProjectProfitability extends Component {
};
}
}
ProjectProfitability.props = {
data: Object,
labels: Object,
formatMonetary: Function,
onClick: Function,
};
ProjectProfitability.template = 'project.ProjectProfitability';

View file

@ -1,89 +1,109 @@
<?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>
<t t-name="project.ProjectProfitability">
<div class="w-100">
<div class="o_rightpanel_subsection pb-3" t-if="revenues.data.length">
<table class="o_rightpanel_data_table table table-sm table-borderless table-hover mb-0 border-top">
<thead class="bg-100">
<tr>
<th>Revenues</th>
<th class="text-end">Expected</th>
<th class="text-end">To Invoice</th>
<th class="text-end">Invoiced</th>
</tr>
</thead>
<tbody>
<t t-foreach="revenues.data" t-as="revenue" t-key="revenue.id" t-if="revenue.invoiced !== 0 || revenue.to_invoice !== 0">
<tr class="revenue_section">
<t t-set="revenue_label" t-value="props.labels[revenue.id] or revenue.id"/>
<td>
<a class="revenue_section" 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 {{ revenue.invoiced + revenue.to_invoice === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(revenue.invoiced + revenue.to_invoice)"/></td>
<td t-attf-class="text-end {{ revenue.to_invoice === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(revenue.to_invoice)"/></td>
<td t-attf-class="text-end {{ revenue.invoiced === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(revenue.invoiced)"/></td>
</tr>
</t>
</tbody>
<tfoot>
<tr class="fw-bolder">
<td>Total Revenues</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>
<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 === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(revenues.total.invoiced)"/></td>
</tr>
</tfoot>
</table>
</div>
<div class="o_rightpanel_subsection pb-3" t-if="costs.data.length">
<table class="o_rightpanel_data_table table table-sm table-borderless table-hover mb-0 border-top">
<thead class="bg-100">
<tr>
<th>Costs</th>
<th class="text-end">Expected</th>
<th class="text-end">To Bill</th>
<th class="text-end">Billed</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>
<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 {{ cost.billed + cost.to_bill === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(cost.billed + cost.to_bill)"/></td>
<td t-attf-class="text-end {{ cost.to_bill === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(cost.to_bill)"/></td>
<td t-attf-class="text-end {{ cost.billed === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(cost.billed)"/></td>
</tr>
</tbody>
<tfoot>
<tr class="fw-bolder">
<td>Total Costs</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>
<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 === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(costs.total.billed)"/></td>
</tr>
</tfoot>
</table>
</div>
<div class="o_rightpanel_subsection" t-if="revenues.data.length &amp;&amp; costs.data.length">
<table class="o_rightpanel_data_table table table-sm table-borderless table-secondary w-100 mb-0 border-top">
<thead>
<tr class="align-top">
<th>Total</th>
<th class="text-end" t-att-class="margin.total &lt; 0 ? 'text-danger' : 'text-success'">
<t t-set="total_revenue" t-value="revenues.total.to_invoice + revenues.total.invoiced"/>
<t t-out="props.formatMonetary(margin.total)"/>
<small class="d-block" t-if="total_revenue != 0">
<t t-out="margin.total > 0 ? '+' : ''"/><t t-out="(margin.total / total_revenue * 100).toFixed()"/>%
</small>
</th>
<th class="text-end" t-att-class="margin.to_invoice_to_bill &lt; 0 ? 'text-danger' : 'text-success'">
<t t-out="props.formatMonetary(margin.to_invoice_to_bill)"/><br/>
<small class="d-block" t-if="revenues.total.to_invoice != 0">
<t t-out="margin.to_invoice_to_bill > 0 ? '+' : ''"/><t t-out="(margin.to_invoice_to_bill / revenues.total.to_invoice * 100).toFixed()"/>%
</small>
</th>
<th class="text-end" t-att-class="margin.invoiced_billed &lt; 0 ? 'text-danger' : 'text-success'">
<t t-out="props.formatMonetary(margin.invoiced_billed)"/><br/>
<small class="d-block" t-if="revenues.total.invoiced != 0">
<t t-out="margin.invoiced_billed > 0 ? '+' : ''"/><t t-out="(margin.invoiced_billed / revenues.total.invoiced * 100).toFixed()"/>%
</small>
</th>
</tr>
</thead>
</table>
</div>
</div>
</t>
</templates>

View file

@ -1,26 +1,26 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
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 },
export class ProjectRightSidePanelSection extends Component {
static 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,
};
dataClassName: { type: Object, optional: true },
headerClassName: { type: String, optional: true },
};
static defaultProps = {
header: true,
showData: true,
};
ProjectRightSidePanelSection.template = 'project.ProjectRightSidePanelSection';
static template = "project.ProjectRightSidePanelSection";
}

View file

@ -1,15 +1,15 @@
<?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">
<t t-name="project.ProjectRightSidePanelSection">
<div class="o_rightpanel_section" t-att-name="props.name" t-if="props.show">
<div class="d-flex align-items-center justify-content-between gap-1 pe-3" t-att-class="props.headerClassName" t-if="props.header">
<div class="o_rightpanel_title flex-grow-1 ps-3 py-3 py-md-2" t-if="props.slots.title" t-attf-class="{{ env.isSmall ? 'd-flex align-items-center' : '' }}">
<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">
<div class="o_rightpanel_data" t-if="props.showData" t-att-class="props.dataClassName">
<t t-slot="default"/>
</div>
</div>

View file

@ -0,0 +1,9 @@
.o_rightpanel {
.table {
--#{$prefix}table-bg: transparent;
}
.o_rightpanel_subtable {
--#{$prefix}border-color: #{$gray-400};
}
}

View file

@ -1,22 +1,34 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { formatFloat } from '@web/views/fields/formatters';
import { session } from '@web/session';
import { _t } from "@web/core/l10n/translation";
import { useBus, useService } from "@web/core/utils/hooks";
import { formatFloat } from "@web/views/fields/formatters";
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;
import { getCurrency } from '@web/core/currency';
import { Component, onWillStart, useState } from "@odoo/owl";
import { SIZES } from "@web/core/ui/ui_service";
export class ProjectRightSidePanel extends Component {
static components = {
ProjectRightSidePanelSection,
ProjectMilestone,
ViewButton,
ProjectProfitability,
};
static template = "project.ProjectRightSidePanel";
static props = {
context: Object,
domain: Array,
};
setup() {
this.orm = useService('orm');
this.actionService = useService('action');
this.dialog = useService('dialog');
this.uiService = useService("ui");
useBus(this.uiService.bus, "resize", this.updateGridTemplateColumns)
this.state = useState({
data: {
milestones: {
@ -28,12 +40,34 @@ export class ProjectRightSidePanel extends Component {
},
user: {},
currency_id: false,
}
},
gridTemplateColumns: this._getGridTemplateColumns(),
});
onWillStart(() => this.loadData());
}
_getGridTemplateColumns() {
switch (this.uiService.size) {
case SIZES.XS:
return 2;
case SIZES.SM:
return 3;
case SIZES.XXL:
return 5;
default:
return 4;
}
}
updateGridTemplateColumns() {
this.state.gridTemplateColumns = this._getGridTemplateColumns();
}
get panelVisible() {
return this.state.data.show_milestones || this.state.data.show_project_profitability_helper;
}
get context() {
return this.props.context;
}
@ -52,17 +86,14 @@ export class ProjectRightSidePanel extends Component {
get sectionNames() {
return {
'milestones': this.env._t('Milestones'),
'profitability': this.env._t('Profitability'),
'milestones': _t('Milestones'),
'profitability': _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
);
const { costs, revenues } = this.state.data.profitability_items;
return costs.data.length || revenues.data.length;
}
formatFloat(value) {
@ -75,7 +106,7 @@ export class ProjectRightSidePanel extends Component {
'digits': [false, 0],
'noSymbol': true,
});
const currency = session.currencies[this.currencyId];
const currency = getCurrency(this.currencyId);
if (!currency) {
return valueFormatted;
}
@ -100,34 +131,22 @@ export class ProjectRightSidePanel extends Component {
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 viewTasks() {
this.actionService.doActionButton({
type: "object",
resId: this.projectId,
name: "action_view_tasks_from_project_milestone",
resModel: "project.project",
});
}
async openFormViewDialog(params, options = {}) {
this.dialog.add(FormViewDialog, params, options);
async viewMilestones() {
this.actionService.doActionButton({
type: "object",
resId: this.projectId,
name: "action_get_list_view",
resModel: "project.project",
});
}
async onProjectActionClick(params) {
@ -151,15 +170,8 @@ export class ProjectRightSidePanel extends Component {
_getStatButtonRecordParams() {
return {
resId: this.projectId,
context: JSON.stringify(this.context),
context: this.context,
resModel: 'project.project',
};
}
}
ProjectRightSidePanel.components = { ProjectRightSidePanelSection, ProjectMilestone, ViewButton, ProjectProfitability };
ProjectRightSidePanel.template = 'project.ProjectRightSidePanel';
ProjectRightSidePanel.props = {
context: Object,
domain: Array,
};

View file

@ -1,20 +1,26 @@
$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;
@include media-breakpoint-down(lg) {
flex-direction: column;
overflow: initial;
}
.o_renderer {
flex: 1 1 auto;
max-height: 100%;
position: relative;
padding: 0;
.o_kanban_record {
@include media-breakpoint-down(lg) {
flex-shrink: 0;
max-height: inherit;
}
&.o_kanban_ungrouped .o_kanban_record {
width: 100%;
margin: 0;
border-top: 0;
@ -24,61 +30,21 @@ $o-rightpanel-p-tiny: $o-rightpanel-p-small*0.5;
}
.o_rightpanel {
flex: 0 0 37%;
padding: $o-rightpanel-p-small $o-rightpanel-p $o-rightpanel-p*2 $o-rightpanel-p;
flex: 0 0 50%;
min-width: 400px;
max-width: 1140px;
.o_rightpanel_section {
&:nth-child(n+3) {
border-top: 1px solid $border-color;
}
@include media-breakpoint-down(lg) {
flex-basis: auto;
min-width: auto;
max-width: none;
border: 0;
}
.o_rightpanel_section {
.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;
box-shadow: inset 0 -1px 0 $border-color;
}
}
@ -86,4 +52,18 @@ $o-rightpanel-p-tiny: $o-rightpanel-p-small*0.5;
width: 20%;
}
}
.o_rightpanel_data_table {
--#{$prefix}border-style: dashed;
th, td {
&:first-child {
padding-left: map-get($spacers, 3);
}
&:last-child {
padding-right: map-get($spacers, 3);
}
}
}
}

View file

@ -1,20 +1,24 @@
<?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">
<t t-name="project.ProjectRightSidePanel">
<div t-if="panelVisible" class="o_rightpanel h-auto h-lg-100 w-100 w-lg-auto border-start border-bottom bg-view 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">
<div
class="oe_button_box o-form-buttonbox d-print-none d-grid"
t-att-class="{'border-top': env.isSmall}"
t-attf-style="grid-template-columns: repeat({{ state.gridTemplateColumns }}, 1fr);"
>
<t t-foreach="state.data.buttons" t-as="button" t-key="button_index">
<ViewButton
t-if="button.show"
defaultRank="'oe_stat_button'"
className="'h-auto py-2 border border-start-0 border-top-0 text-start'"
className="'h-auto ms-0 border-0 border-bottom border-end rounded-0 py-2 px-3 bg-view'"
icon="`fa-${button.icon}`"
title="button.text"
clickParams="_getStatButtonClickParams(button)"
@ -22,10 +26,10 @@
>
<t t-set-slot="contents">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value text-start">
<span class="o_stat_value">
<t t-esc="button.number"/>
</span>
<span class="o_stat_text">
<span class="o_stat_text text-break text-wrap">
<t t-esc="button.text"/>
</span>
</div>
@ -35,40 +39,67 @@
</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">
<t t-set-slot="header">
<t t-if="state.data.milestones.data.length !== 0">
<span class="btn btn-secondary">
<div class="o_view_tasks">
<a t-on-click="viewTasks">
Tasks
</a>
</div>
</span>
</t>
<span class="btn btn-secondary">
<div class="o_add_milestone">
<a t-on-click="addMilestone">Add Milestone</a>
<a t-on-click="viewMilestones">
<t t-if="state.data.milestones.data.length === 0">
Add Milestones
</t>
<t t-else="">
Edit Milestones
</t>
</a>
</div>
</span>
</t>
<t t-set-slot="title" owl="1">
<t t-set-slot="title">
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"/>
<ProjectMilestone context="context" milestone="milestone"/>
</div>
<div t-if="state.data.milestones.data.length === 0" class="px-3 pb-3">
<span class="text-muted fst-italic">
Track major progress points that must be reached to achieve success.
</span>
</div>
</ProjectRightSidePanelSection>
<ProjectRightSidePanelSection
name="'profitability'"
show="state.data.show_project_profitability_helper"
headerClassName="'border-top pt-0 pt-md-3'"
dataClassName="{ 'd-flex overflow-x-auto': env.isSmall }"
>
<t t-set-slot="title">
Profitability
</t>
<ProjectProfitability
t-if="showProjectProfitability"
data="state.data.profitability_items"
labels="state.data.profitability_labels"
formatMonetary="formatMonetary.bind(this)"
onProjectActionClick="onProjectActionClick.bind(this)"
onClick="(params) => this.onProjectActionClick(params)"
/>
<div t-elif="state.data.show_project_profitability_helper" class="px-3 pb-3">
<span class="text-muted fst-italic">
Track project costs, revenues, and margin by setting the analytic account associated with the project on relevant documents.
</span>
</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 -->

View file

@ -1,7 +1,8 @@
/** @odoo-module */
import { registry } from '@web/core/registry';
import { StateSelectionField } from '@web/views/fields/state_selection/state_selection_field';
import {
StateSelectionField,
stateSelectionField,
} from "@web/views/fields/state_selection/state_selection_field";
import { STATUS_COLORS, STATUS_COLOR_PREFIX } from '../../utils/project_utils';
@ -12,13 +13,6 @@ export class ProjectStateSelectionField extends StateSelectionField {
this.colors = STATUS_COLORS;
}
/**
* @override
*/
get showLabel() {
return !this.props.hideLabel;
}
/**
* @override
*/
@ -27,4 +21,9 @@ export class ProjectStateSelectionField extends StateSelectionField {
}
}
registry.category('fields').add('kanban.project_state_selection', ProjectStateSelectionField);
export const projectStateSelectionField = {
...stateSelectionField,
component: ProjectStateSelectionField,
};
registry.category("fields").add("project_state_selection", projectStateSelectionField);

View file

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

View file

@ -1,11 +1,17 @@
/** @odoo-module */
import { SelectionField } from '@web/views/fields/selection/selection_field';
import { SelectionField, 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 {
static props = {
...SelectionField.props,
statusLabel: { type: String, optional: true },
hideStatusName: { type: Boolean, optional: true },
};
static template = "project.ProjectStatusWithColorSelectionField";
setup() {
super.setup();
this.colorPrefix = STATUS_COLOR_PREFIX;
@ -13,13 +19,23 @@ export class ProjectStatusWithColorSelectionField extends SelectionField {
}
get currentValue() {
return this.props.value || this.options[0][0];
return this.props.record.data[this.props.name] || 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);
export const projectStatusWithColorSelectionField = {
...selectionField,
component: ProjectStatusWithColorSelectionField,
extractProps: (fieldInfo, dynamicInfo) => {
const props = selectionField.extractProps(fieldInfo, dynamicInfo);
props.statusLabel = fieldInfo.attrs.status_label;
props.hideStatusName = Boolean(fieldInfo.attrs.hideStatusName);
return props;
},
};
registry.category("fields").add("status_with_color", projectStatusWithColorSelectionField);

View file

@ -1,11 +1,17 @@
<?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">
<t t-name="project.ProjectStatusWithColorSelectionField" t-inherit="web.SelectionField" t-inherit-mode="primary">
<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"/>
<span t-attf-class="o_status {{ statusColor(currentValue) }} d-inline-block"/>
<div class="ps-2">
<div class="o_stat_text" t-if="this.props.statusLabel" t-out="this.props.statusLabel"/>
<div class="o_stat_value" t-out="string" t-if="!this.props.hideStatusName" t-att-raw-value="value"/>
<div class="o_stat_value" t-else="">
<span><t t-out="this.props.record.data['update_count']"/> Status</span>
</div>
</div>
</div>
</xpath>
</t>

View file

@ -1,14 +0,0 @@
/** @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

@ -1,17 +0,0 @@
<?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

@ -1,15 +1,16 @@
/** @odoo-module */
import { registry } from '@web/core/registry';
import { CharField } from '@web/views/fields/char/char_field';
import { formatChar } from '@web/views/fields/formatters';
import { CharField, charField } from '@web/views/fields/char/char_field';
class ProjectTaskNameWithSubtaskCountCharField extends CharField {
get formattedSubtaskCount() {
return formatChar(this.props.record.data.allow_subtasks && this.props.record.data.child_text || '');
}
export class ProjectTaskNameWithSubtaskCountCharField extends CharField {
static template = "project.ProjectTaskNameWithSubtaskCountCharField";
}
ProjectTaskNameWithSubtaskCountCharField.template = 'project.ProjectTaskNameWithSubtaskCountCharField';
registry.category('fields').add('name_with_subtask_count', ProjectTaskNameWithSubtaskCountCharField);
export const projectTaskNameWithSubtaskCountCharField = {
...charField,
component: ProjectTaskNameWithSubtaskCountCharField,
fieldDependencies: [
{ name: "subtask_count", type: "integer" },
{ name: "closed_subtask_count", type: "integer" },
],
}
registry.category("fields").add("name_with_subtask_count", projectTaskNameWithSubtaskCountCharField);

View file

@ -1,13 +1,13 @@
<?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">
<t t-name="project.ProjectTaskNameWithSubtaskCountCharField" t-inherit="web.CharField" t-inherit-mode="primary">
<xpath expr="//span[@t-esc='formattedValue']" position="after">
<span
class="text-muted ms-2"
t-out="formattedSubtaskCount"
style="font-weight: normal;"
/>
<span class="text-muted ms-2 fw-normal">
<t t-if="props.record.data.subtask_count">
(<t t-out="props.record.data.closed_subtask_count"/>/<t t-out="props.record.data.subtask_count"/> sub-tasks)
</t>
</span>
</xpath>
</t>

View file

@ -0,0 +1,29 @@
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { PriorityField, priorityField } from "@web/views/fields/priority/priority_field";
export class PrioritySwitchField extends PriorityField {
get commands() {
return this.options.map(([id, name]) => [
_t("Set priority as %s", name),
() => this.updateRecord(id),
{
category: "smart_action",
hotkey: "alt+r",
isAvailable: () => this.props.record.data[this.props.name] !== id,
},
]);
}
}
export const prioritySwitchField = {
...priorityField,
component: PrioritySwitchField,
extractProps({ viewType }) {
const props = priorityField.extractProps(...arguments);
props.withCommand = viewType === "form";
return props;
},
};
registry.category("fields").add("priority_switch", prioritySwitchField);

View file

@ -0,0 +1,60 @@
import { Component } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { omit } from "@web/core/utils/objects";
import { computeM2OProps, Many2One } from "@web/views/fields/many2one/many2one";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { ProjectTaskStateSelection } from "../project_task_state_selection";
export class TaskStageWithStateSelection extends Component {
static template = "project.TaskStageWithStateSelection";
static props = {
...standardFieldProps,
stateReadonly: { type: Boolean, optional: true },
viewType: { type: String },
};
static components = {
ProjectTaskStateSelection,
Many2One,
};
get stageProps() {
return computeM2OProps(this.props);
}
get stateProps() {
return {
...omit(this.props, "stateReadonly", "viewType"),
name: "state",
readonly: this.props.stateReadonly,
viewType: this.props.viewType,
showLabel: false,
};
}
}
export const taskStageWithStateSelection = {
component: TaskStageWithStateSelection,
supportedOptions: [
{
label: _t("State readonly"),
name: "state_readonly",
type: "boolean",
default: true,
},
],
fieldDependencies: [{ name: "state", type: "selection" }],
supportedTypes: ["many2one"],
extractProps({ options, viewType }) {
return {
stateReadonly: "state_readonly" in options ? options.state_readonly : true,
viewType: viewType,
};
},
};
registry.category("fields").add("task_stage_with_state_selection", taskStageWithStateSelection);

View file

@ -0,0 +1,10 @@
<templates>
<t t-name="project.TaskStageWithStateSelection">
<div class="d-flex gap-1">
<Many2One t-props="stageProps"/>
<ProjectTaskStateSelection t-props="stateProps"/>
</div>
</t>
</templates>

View file

@ -0,0 +1,190 @@
import { _t } from "@web/core/l10n/translation";
import {
StateSelectionField,
stateSelectionField,
} from "@web/views/fields/state_selection/state_selection_field";
import { useCommand } from "@web/core/commands/command_hook";
import { formatSelection } from "@web/views/fields/formatters";
import { registry } from "@web/core/registry";
import { useState } from "@odoo/owl";
export class ProjectTaskStateSelection extends StateSelectionField {
static template = "project.ProjectTaskStateSelection";
static props = {
...stateSelectionField.component.props,
isToggleMode: { type: Boolean, optional: true },
viewType: { type: String },
};
setup() {
this.state = useState({
isStateButtonHighlighted: false,
});
this.icons = {
"01_in_progress": "o_status",
"03_approved": "o_status o_status_green",
"02_changes_requested": "fa fa-lg fa-exclamation-circle",
"1_done": "fa fa-lg fa-check-circle",
"1_canceled": "fa fa-lg fa-times-circle",
"04_waiting_normal": "fa fa-lg fa-hourglass-o",
};
this.colorIcons = {
"01_in_progress": "",
"03_approved": "text-success",
"02_changes_requested": "o_status_changes_requested",
"1_done": "text-success",
"1_canceled": "text-danger",
"04_waiting_normal": "btn-outline-info",
};
this.colorButton = {
"01_in_progress": "btn-outline-secondary",
"03_approved": "btn-outline-success",
"02_changes_requested": "btn-outline-warning",
"1_done": "btn-outline-success",
"1_canceled": "btn-outline-danger",
"04_waiting_normal": "btn-outline-info",
};
if (this.props.viewType != 'form') {
super.setup();
} else {
const commandName = _t("Set state as...");
useCommand(
commandName,
() => {
return {
placeholder: commandName,
providers: [
{
provide: () =>
this.options.map(subarr => ({
name: subarr[1],
action: () => {
this.updateRecord(subarr[0]);
},
})),
},
],
};
},
{
category: "smart_action",
hotkey: "alt+f",
isAvailable: () => !this.props.readonly && !this.props.isDisabled,
}
);
}
}
get options() {
const labels = new Map(super.options);
const states = ["1_canceled", "1_done"];
const currentState = this.props.record.data[this.props.name];
if (currentState != "04_waiting_normal") {
states.unshift("01_in_progress", "02_changes_requested", "03_approved");
}
return states.map((state) => [state, labels.get(state)]);
}
get availableOptions() {
// overrided because we need the currentOption in the dropdown as well
return this.options;
}
get label() {
const waitOption = super.options.findLast(([state, _]) => state === "04_waiting_normal");
const fullSelection = [...this.options, waitOption];
return formatSelection(this.currentValue, {
selection: fullSelection,
});
}
stateIcon(value) {
return this.icons[value] || "";
}
/**
* @override
*/
statusColor(value) {
return this.colorIcons[value] || "";
}
/**
* determine if a single click will trigger the toggleState() method
* which will switch the state from in progress to done.
* Either the isToggleMode is active on the record OR the task is_private
*/
get isToggleMode() {
return this.props.isToggleMode || !this.props.record.data.project_id;
}
isView(viewNames) {
return viewNames.includes(this.props.viewType);
}
async toggleState() {
const toggleVal = this.currentValue == "1_done" ? "01_in_progress" : "1_done";
await this.updateRecord(toggleVal);
}
getDropdownPosition() {
if (this.isView(['activity', 'kanban', 'list', 'calendar']) || this.env.isSmall) {
return '';
}
return 'bottom-end';
}
getTogglerClass(currentValue) {
if (this.isView(['activity', 'kanban', 'list', 'calendar']) || this.env.isSmall) {
return 'btn btn-link d-flex p-0';
}
return 'o_state_button btn rounded-pill ' + this.colorButton[currentValue];
}
async updateRecord(value) {
const result = await super.updateRecord(value);
this.state.isStateButtonHighlighted = false;
if (result) {
return result;
}
}
/**
* @param {MouseEvent} ev
*/
onMouseEnterStateButton(ev) {
if (!this.env.isSmall) {
this.state.isStateButtonHighlighted = true;
}
}
/**
* @param {MouseEvent} ev
*/
onMouseLeaveStateButton(ev) {
this.state.isStateButtonHighlighted = false;
}
}
export const projectTaskStateSelection = {
...stateSelectionField,
component: ProjectTaskStateSelection,
fieldDependencies: [{ name: "project_id", type: "many2one" }],
supportedOptions: [
...stateSelectionField.supportedOptions, {
label: _t("Is toggle mode"),
name: "is_toggle_mode",
type: "boolean"
}
],
extractProps({ options, viewType }) {
const props = stateSelectionField.extractProps(...arguments);
props.isToggleMode = Boolean(options.is_toggle_mode);
props.viewType = viewType;
return props;
},
}
registry.category("fields").add("project_task_state_selection", projectTaskStateSelection);

View file

@ -0,0 +1,67 @@
.o_field_project_task_state_selection, .o_field_task_stage_with_state_selection {
.o_status {
width: $o-bubble-color-size-xl;
height: $o-bubble-color-size-xl;
text-align: center;
margin-top: -0.5px;
}
.fa-lg {
font-size: 1.75em;
margin-top: -2.5px;
max-width: 20px;
max-height: 20px;
}
.fa-hourglass-o {
font-size: 1.4em !important;
margin-top: 0.5px !important;
}
.o_task_state_list_view {
height: $o-line-size;
.fa-lg {
font-size: 1.315em;
vertical-align: -6%;
}
.o_status {
width: $o-bubble-color-size;
height: $o-bubble-color-size;
}
.fa-hourglass-o {
font-size: 1.15em !important;
padding-left: 1px !important;
}
}
.o_status_changes_requested {
color: $warning;
}
}
.project_task_state_selection_menu {
.fa {
margin-top: -1.5px;
font-size: 1.315em;
vertical-align: -6%;
transform: translateX(-50%);
}
.o_status {
margin-top: 1px;
width: 14.65px;
height: 14.65px;
text-align: center;
}
.o_status_changes_requested {
color: $warning;
}
}
.o_field_task_stage_with_state_selection {
.fa-lg {
font-size: 1.57em;
}
}

View file

@ -0,0 +1,98 @@
<templates>
<t t-name="project.ProjectTaskStateSelection" t-inherit="web.StateSelectionField" t-inherit-mode="primary">
<!-- Readonly button -->
<xpath expr="//t[@t-if='props.readonly']/button" position="attributes">
<attribute name="tabindex">-1</attribute>
</xpath>
<xpath expr="//t[@t-if='props.readonly']/button/span[1]" position="attributes">
<attribute name="t-attf-class">{{ stateIcon(currentValue) }} {{ statusColor(currentValue) }}</attribute>
</xpath>
<xpath expr="//t[@t-if='props.readonly']/button" position="attributes">
<attribute name="t-att-title">label</attribute>
</xpath>
<!-- Waiting state button -->
<xpath expr="//t[@t-if='props.readonly']" position="after">
<t t-elif="currentValue == '04_waiting_normal' and isView(['activity', 'kanban', 'list', 'calendar'])">
<button class="d-flex align-items-center btn fw-normal p-0 justify-content-center " title="This task is blocked by another unfinished task" t-att-class="{'o_task_state_list_view': isView(['list'])}">
<i class="fa fa-lg fa-hourglass-o text-info"></i>
</button>
</t>
</xpath>
<!-- The toggle mark as done button -->
<xpath expr="//t[@t-if='props.readonly']" position="after">
<t t-elif="isToggleMode and currentValue == '01_in_progress'">
<button t-if="isView(['activity', 'kanban', 'list', 'calendar']) or this.env.isSmall"
class="d-flex align-items-center btn fw-normal p-0"
tabindex="-1"
t-att-class="{'o_task_state_list_view': isView(['list'])}"
t-on-click.stop="toggleState"
>
<i t-attf-class="{{ stateIcon(currentValue) }} {{ statusColor(currentValue) }} {{ ['1_done', '1_canceled'].includes(currentValue) and isView(['activity', 'kanban']) ? 'opacity-50' : '' }}"></i>
</button>
<button t-else="" class="o_state_button btn oe_highlight rounded-pill" style="white-space: nowrap;" tabindex="-1"
t-attf-class="#{currentValue == '1_done' ? 'btn-success' : 'btn-outline-secondary'}"
t-att-class="{'bg-view border' : state.isStateButtonHighlighted}"
t-on-click="toggleState" t-on-mouseenter="onMouseEnterStateButton"
t-on-mouseleave="onMouseLeaveStateButton">
<div class="d-flex align-items-center">
<span class="o_status_label">
<t t-if="state.isStateButtonHighlighted">
<span class="text-success oe_highlight">
<i class="fa fa-fw fa-check"/>
Mark as done
</span>
</t>
<t t-else="currentValue == '01_in_progress'">
In Progress
</t>
</span>
</div>
</button>
</t>
</xpath>
<!-- Normal dropdown mode toggle button (displayed on the card/record by default) -->
<xpath expr="//Dropdown/button/div" position="replace">
<div t-if="isView(['activity', 'kanban', 'list', 'calendar']) or this.env.isSmall" class="d-flex align-items-center" t-att-class="{'o_task_state_list_view': isView(['list'])}" t-att-title="label">
<i t-if="currentValue == '04_waiting_normal'" t-attf-class="{{ stateIcon(currentValue) }} {{ statusColor(currentValue) }}" style="color: #4A4F59;"/>
<i t-else="" t-attf-class="{{ stateIcon(currentValue) }} {{ statusColor(currentValue) }} {{ ['1_done', '1_canceled'].includes(currentValue) and isView(['activity', 'kanban']) ? 'opacity-50' : '' }}"/>
</div>
<div t-else="" class="d-flex align-items-center">
<t t-if="currentValue == '04_waiting_normal'">
<i class="fa fa-fw fa-hourglass-o"/>
<span class="o_status_label" title="This task is blocked by another unfinished task">
Waiting
</span>
</t>
<t t-elif="currentValue != '1_done' and currentValue != '1_canceled'">
<span t-attf-class="o_status_label" t-out="label"/>
</t>
<t t-else="">
<span class="o_status_label" t-out="label"/>
</t>
</div>
</xpath>
<!-- Tooltip for the dropdown toggler -->
<xpath expr="//Dropdown" position="attributes">
<!-- <attribute name="class">toggle_dropdown</attribute> -->
<attribute name="position">`${ getDropdownPosition() }`</attribute>
<attribute name="menuClass" separator=" + " add="' project_task_state_selection_menu'"></attribute>
</xpath>
<xpath expr="//Dropdown/button" position="attributes">
<attribute name="tooltip">''</attribute>
<attribute name="class" remove="p-0" add="shadow-none" separator=" "/>
<attribute name="t-att-class">getTogglerClass(currentValue)</attribute>
</xpath>
<!-- Dropdown divider -->
<xpath expr="//CheckboxItem" position="before">
<div t-if="option[0] == '1_canceled' and (currentValue != '04_waiting_normal' or this.env.isSmall)" role="separator" class="dropdown-divider"/>
</xpath>
<!-- Approval mode dropdown button (class)-->
<xpath expr="//CheckboxItem/span[1]" position="attributes">
<attribute name="t-attf-class" separator=" " add="{{ stateIcon(option[0]) }}" remove="o_status"></attribute>
</xpath>
<xpath expr="//CheckboxItem/span[2]" position="attributes">
<attribute name="t-attf-class">{{ statusColor(option[0]) }}</attribute>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,67 @@
import { Component, useState, useRef } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { useAutofocus } from "@web/core/utils/hooks";
export class SubtaskCreate extends Component {
static template = "project.SubtaskCreate";
static props = {
name: String,
isReadonly: { type: Boolean, optional: true },
onSubtaskCreateNameChanged: { type: Function },
onBlur: { type: Function },
};
setup() {
this.placeholder = _t("Write a task name");
this.state = useState({
inputSize: 1,
name: this.props.name,
isFieldInvalid: false,
});
this.input = useRef("subtaskCreateInput");
useAutofocus({ refName: "subtaskCreateInput" });
}
/**
* @private
* @param {InputEvent} ev
*/
_onFocus(ev) {
ev.target.value = this.placeholder;
ev.target.select();
}
/**
* @private
* @param {InputEvent} ev
*/
_onInput(ev) {
const value = ev.target.value;
this.state.name = value;
this.state.isFieldInvalid = false;
}
_onClick() {
this.input.el.focus();
}
/**
* @private
* @param {InputEvent} ev
*/
_onNameChanged(ev) {
const value = ev.target.value.trim();
if (value !== "") {
this.props.onSubtaskCreateNameChanged(value);
ev.target.blur();
}
}
_onSaveClick() {
if (this.input.el.value.trim() === "") {
this.props.onSubtaskCreateNameChanged(this.input.el.value.trim());
this.state.isFieldInvalid = true;
this.state.name = "";
}
}
}

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<div t-name="project.SubtaskCreate" class="subtask_create_input d-flex" owl="1">
<input type="text" title="Rename"
class='o_input border-0 border-bottom py-1'
t-att-class="{'o_field_invalid border-danger': state.isFieldInvalid}"
t-ref='subtaskCreateInput'
t-att-value="state.name"
t-att-placeholder="placeholder"
t-on-input="_onInput"
t-on-click="_onClick"
t-on-change="_onNameChanged"
t-att-disabled="props.isReadonly"/>
<button t-on-click="_onSaveClick" class="ms-auto fw-bold btn btn-link py-0 px-2">
SAVE
</button>
</div>
</templates>

View file

@ -0,0 +1,116 @@
import { Component, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { _t } from "@web/core/l10n/translation";
import { Field, getPropertyFieldInfo } from "@web/views/fields/field";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
import { SubtaskCreate } from "./subtask_kanban_create/subtask_kanban_create";
export class SubtaskKanbanList extends Component {
static components = {
Field,
SubtaskCreate,
};
static props = {
...standardWidgetProps,
isReadonly: {
type: Boolean,
optional: true,
},
};
static template = "project.SubtaskKanbanList";
setup() {
this.actionService = useService("action");
this.orm = useService("orm");
this.notification = useService("notification");
this.subtaskCreate = useState({
open: false,
name: "",
});
this.state = useState({
subtasks: [],
isLoad: true,
prevSubtaskCount: 0,
});
}
get list() {
return this.props.record.data.child_ids;
}
get closedList() {
const currentCount = this.list.records.length;
if (this.state.isLoad || currentCount !== this.state.prevSubtaskCount) {
this.state.prevSubtaskCount = currentCount;
this.state.isLoad = false;
this.state.subtasks = this.list.records
.filter((subtask) => !["1_done", "1_canceled"].includes(subtask.data.state));
}
return this.state.subtasks;
}
get fieldInfo() {
return {
state: {
...getPropertyFieldInfo({
name: "state",
type: "selection",
widget: "project_task_state_selection",
}),
viewType: "kanban",
},
};
}
async goToSubtask(subtask_id) {
return this.actionService.doAction({
type: "ir.actions.act_window",
res_model: this.list.resModel,
res_id: subtask_id,
views: [[false, "form"]],
target: "current",
context: {
active_id: subtask_id,
},
});
}
async onSubTaskCreated(ev) {
this.subtaskCreate.open = true;
}
async _onBlur() {
this.subtaskCreate.open = false;
}
async _onSubtaskCreateNameChanged(name) {
if (name.trim() === "") {
this.notification.add(_t("Invalid Display Name"), {
type: "danger",
});
} else {
const sequences = this.list.records.map(r => r.data.sequence);
const nextSequence = (sequences.length ? Math.max(...sequences) : 0) + 1;
await this.orm.create("project.task", [{
display_name: name,
parent_id: this.props.record.resId,
project_id: this.props.record.data.project_id.id,
user_ids: this.props.record.data.user_ids.resIds,
sequence: nextSequence,
}]);
this.subtaskCreate.open = false;
this.subtaskCreate.name = "";
this.props.record.load();
}
}
}
const subtaskKanbanList = {
component: SubtaskKanbanList,
};
registry.category("view_widgets").add("subtask_kanban_list", subtaskKanbanList);

View file

@ -0,0 +1,34 @@
.subtask_list {
margin-top: 6px;
.subtask_list_row {
display: grid;
grid-template-columns: auto auto 22px;
padding: 4px 0px;
.subtask_name_col {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
align-self: center;
color: inherit;
}
}
.subtask_list_row {
@include media-breakpoint-up(sm) {
.o_field_widget.o_field_many2many_tags_avatar .o_quick_assign {
visibility: hidden !important;
}
}
}
.subtask_list_row:hover {
background-color: $table-hover-bg;
@include media-breakpoint-up(sm) {
.o_field_widget.o_field_many2many_tags_avatar .o_quick_assign {
visibility: visible !important;
}
}
}
}

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="project.SubtaskKanbanList">
<t t-if="props.record.displaySubtasks">
<div class="subtask_list">
<div t-foreach="closedList" t-as="record" t-key="record.id" class="subtask_list_row">
<a t-attf-class="subtask_name_col {{ ['1_done', '1_canceled'].includes(record.data.state) ? 'text-muted opacity-50' : '' }}"
t-att-title="record.data.display_name"
t-esc="record.data.display_name"
t-on-click.prevent="() => this.goToSubtask(record.resId)"/>
<Field
class="`o_field_many2many_avatar_user subtask_user_widget_col d-inline-flex align-items-center justify-content-end me-1 ${['1_done', '1_canceled'].includes(record.data.state) ? 'opacity-50' : ''}`"
name="'user_ids'"
record="record"
readonly="false"
isEditable="true"
type="'kanban.many2many_avatar_user'"/>
<Field name="'state'"
class="`subtask_state_widget_col d-flex justify-content-center align-items-center`"
record="record"
type="'project_task_state_selection'"
fieldInfo="fieldInfo.state"/>
</div>
<div class="subtask_create" t-on-click.stop="(ev) => this.onSubTaskCreated(ev)"
t-on-keydown="(ev) => ev.code === 'Escape' ? this._onBlur(ev) : ()=>{}">
<t t-if="subtaskCreate.open">
<SubtaskCreate
name="subtaskCreate.name"
isReadonly="props.isReadonly"
onSubtaskCreateNameChanged.bind="_onSubtaskCreateNameChanged"
onBlur.bind="_onBlur" />
</t>
<t t-else="">
<i class="fa fa-plus my-2"/> Add Sub-task
</t>
</div>
</div>
</t>
</t>
</templates>

View file

@ -0,0 +1,15 @@
import { _t } from "@web/core/l10n/translation";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { NotebookTaskListRenderer } from '../notebook_task_one2many_field/notebook_task_list_renderer';
export class SubtaskListRenderer extends NotebookTaskListRenderer {
async onDeleteRecord(record) {
return new Promise((resolve) => {
this.dialog.add(ConfirmationDialog, {
body: _t("Are you sure you want to delete this record?"),
confirm: () => super.onDeleteRecord(record).then(resolve),
cancel: resolve,
});
});
}
}

View file

@ -0,0 +1,38 @@
import { registry } from "@web/core/registry";
import { pick } from "@web/core/utils/objects";
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
import { SubtaskListRenderer } from "./subtask_list_renderer";
export class SubtaskOne2ManyField extends X2ManyField {
static components = {
...X2ManyField.components,
ListRenderer: SubtaskListRenderer,
};
getFormActionContext() {
const defaultValueKeys = Object.keys(this.props.context).filter((key) => key.startsWith('default_'));
return pick(
this.props.context,
"active_test",
"propagate_not_active",
...defaultValueKeys,
);
}
get rendererProps() {
const rendererProps = super.rendererProps;
if (this.props.viewMode === "kanban") {
rendererProps.openRecord = this.switchToForm.bind(this);
}
return rendererProps;
}
}
export const subtaskOne2ManyField = {
...x2ManyField,
component: SubtaskOne2ManyField,
additionalClasses: ["o_field_one2many"],
};
registry.category("fields").add("subtasks_one2many", subtaskOne2ManyField);

View file

@ -0,0 +1,22 @@
import { useService } from "@web/core/utils/hooks";
import { ListRenderer } from "@web/views/list/list_renderer";
import { useEffect } from "@odoo/owl";
export class TaskListRenderer extends ListRenderer {
setup() {
super.setup();
this.dialog = useService("dialog");
useEffect(
(editedRecord) => this.focusName(editedRecord),
() => [this.editedRecord]
);
}
focusName(editedRecord) {
if (editedRecord?.isNew && !editedRecord.dirty) {
const col = this.columns.find((c) => c.name === "name");
this.focusCell(col);
}
}
}

View file

@ -0,0 +1,5 @@
declare module "models" {
export interface Thread {
collaborator_ids: ResPartner[];
}
}

View file

@ -0,0 +1,34 @@
import { FollowerList } from "@mail/core/web/follower_list";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { patch } from "@web/core/utils/patch";
const followerListPatch = {
setup() {
super.setup();
this.dialogService = useService("dialog");
},
/**
* @param {MouseEvent} ev
* @param {import("models").Follower} follower
*/
async onClickRemove(ev, follower) {
if (follower.partner_id.in(follower.thread.collaborator_ids)) {
this.dialogService.add(ConfirmationDialog, {
title: _t("Remove Collaborator"),
body: _t(
"This follower is currently a project collaborator. Removing them will revoke their portal access to the project. Are you sure you want to proceed?"
),
confirmLabel: _t("Remove Collaborator"),
cancelLabel: _t("Discard"),
confirm: () => super.onClickRemove(ev, follower),
cancel: () => {},
});
} else {
super.onClickRemove(ev, follower);
}
},
};
patch(FollowerList.prototype, followerListPatch);

View file

@ -0,0 +1,13 @@
import { Thread } from "@mail/core/common/thread_model";
import { fields } from "@mail/model/misc";
import { patch } from "@web/core/utils/patch";
/** @type {import("models").Thread} */
const threadPatch = {
setup() {
super.setup();
this.collaborator_ids = fields.Many("res.partner");
},
};
patch(Thread.prototype, threadPatch);

View file

@ -1,14 +1,12 @@
.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;
padding-left: 1.5rem;
}
.o_many2many_avatar_user_no_wrap div {
flex-wrap: nowrap !important;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View file

@ -0,0 +1,22 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.4073 61.7256L17.3805 60.5477C17.1171 60.3946 16.9537 60.0693 16.9524 59.6071L18.9792 60.7849C18.9805 61.2472 19.1439 61.5725 19.4073 61.7256Z" fill="#FBDBD0"/>
<path d="M46.3291 1.72739L48.3559 2.90524C48.0893 2.75029 47.7202 2.77217 47.3134 3.00702L45.2866 1.82917C45.6934 1.59432 46.0625 1.57246 46.3291 1.72739Z" fill="#FBDBD0"/>
<path d="M18.9792 60.7849L16.9524 59.6071L16.8399 19.9392L18.8666 21.1171L18.9792 60.7849Z" fill="#FBDBD0"/>
<path d="M20.3289 18.5865L18.3021 17.4087L45.2866 1.82917L47.3134 3.00702L20.3289 18.5865Z" fill="#FBDBD0"/>
<path d="M47.3134 3.00702C48.1213 2.5406 48.7807 2.91429 48.7833 3.84469L48.8958 43.5126C48.8984 44.443 48.2433 45.5777 47.4354 46.0442L20.4509 61.6237C19.6412 62.0912 18.9818 61.7153 18.9792 60.7849L18.8666 21.1171C18.864 20.1867 19.5192 19.054 20.3289 18.5865L47.3134 3.00702Z" fill="white"/>
<path d="M18.8666 21.1171L16.8399 19.9392C16.8372 19.0088 17.4924 17.8762 18.3021 17.4087L20.3289 18.5865C19.5192 19.054 18.864 20.1867 18.8666 21.1171Z" fill="#FBDBD0"/>
<path d="M36.3632 5.83742L34.3364 4.65957C34.7715 4.40832 35.1659 4.38505 35.4507 4.55056L37.4775 5.72841C37.1927 5.5629 36.7983 5.58617 36.3632 5.83742Z" fill="#C1DBF6"/>
<path d="M29.687 13.4999L27.6602 12.3221L27.6545 10.3199L29.6814 11.4977L29.687 13.4999Z" fill="#C1DBF6"/>
<path d="M31.2424 8.79392L29.2156 7.61607L34.3364 4.65957L36.3632 5.83742L31.2424 8.79392Z" fill="#C1DBF6"/>
<path d="M29.6814 11.4977L27.6545 10.3199C27.6517 9.32618 28.3508 8.11533 29.2156 7.61607L31.2424 8.79392C30.3776 9.29321 29.6785 10.5041 29.6814 11.4977Z" fill="#C1DBF6"/>
<path d="M36.3631 5.83743C37.2279 5.33814 37.9315 5.73914 37.9343 6.73284L37.94 8.73501L38.9774 8.13608C39.8385 7.63892 40.5403 8.03885 40.5431 9.02838L40.5474 10.5528C40.5479 10.7191 40.4594 10.8729 40.3154 10.9561L27.7929 18.1859C27.484 18.3642 27.0978 18.142 27.0968 17.7853L27.094 16.7932C27.0912 15.8037 27.7884 14.596 28.6496 14.0988L29.6869 13.4999L29.6813 11.4977C29.6785 10.504 30.3775 9.29318 31.2423 8.79391L36.3631 5.83743Z" fill="#C1DBF6"/>
<path d="M45.951 1.62559C46.0851 1.62559 46.2039 1.65594 46.3011 1.71332C46.3011 1.71332 48.3529 2.90383 48.353 2.90383C48.6175 3.05594 48.782 3.38106 48.7833 3.84456L48.8433 25.0004L48.8958 24.6125L52.895 18.7457C52.895 18.7457 52.9862 18.7477 53.1284 18.7611L53.1295 18.7594C53.1295 18.7594 53.2675 18.3456 54.0125 18.2489C54.0682 18.2418 54.1199 18.2384 54.1679 18.2384C54.3157 18.2384 54.4288 18.2699 54.5156 18.317L55.0473 17.4625C55.0473 17.4625 55.2359 17.4086 55.483 17.4086C55.7037 17.4086 55.9711 17.4516 56.1924 17.6143C56.7571 18.0295 56.6615 18.4974 56.6615 18.4974L55.4805 20.1077C55.6295 20.3841 55.6267 20.5668 55.6267 20.5668L48.8575 30.0141L48.8958 43.5125C48.8985 44.4429 48.2433 45.5777 47.4354 46.0441L20.4509 61.6236C20.215 61.7599 19.9921 61.8241 19.7943 61.8241C19.6486 61.8241 19.5166 61.7892 19.4033 61.7222C19.403 61.7222 17.3804 60.5477 17.3804 60.5477C17.117 60.3946 16.9536 60.0692 16.9523 59.6071L16.8399 19.9391C16.8372 19.0087 17.4924 17.8761 18.3021 17.4086L27.6593 12.0063L27.6545 10.3199C27.6517 9.32614 28.3508 8.11533 29.2155 7.6161L34.3363 4.65962C34.5887 4.51386 34.8273 4.44479 35.0388 4.44479C35.192 4.44479 35.331 4.48106 35.4506 4.5506L37.4774 5.72838C37.4767 5.72792 37.4758 5.72772 37.475 5.72726C37.6248 5.81363 37.7434 5.95338 37.823 6.13821L45.2866 1.82916C45.5278 1.68998 45.7556 1.62548 45.951 1.62559ZM45.9511 0.711304C45.5869 0.71119 45.1991 0.824035 44.8295 1.03732L38.0028 4.97862C37.9904 4.97069 37.978 4.96278 37.9653 4.95519C37.9559 4.94927 37.9465 4.94346 37.9368 4.93789L35.91 3.7601C35.6517 3.61 35.3504 3.53064 35.0389 3.53053C34.6569 3.53053 34.2666 3.64404 33.879 3.86792L28.7584 6.82429C27.6041 7.49069 26.7364 8.9946 26.7402 10.3225L26.7435 11.4793L17.8449 16.6168C16.7471 17.2507 15.922 18.6801 15.9255 19.9417L16.038 59.6097C16.0402 60.3832 16.362 61.0132 16.9209 61.3381L17.9326 61.9256C18.4528 62.2278 18.752 62.4015 18.943 62.5014L18.9382 62.5094C19.1918 62.6593 19.4879 62.7385 19.7943 62.7385C20.1621 62.7385 20.5368 62.6298 20.9081 62.4154L47.8925 46.836C48.9893 46.2028 49.8136 44.7728 49.8101 43.51L49.7726 30.3067L56.3699 21.0993C56.4784 20.9479 56.538 20.7668 56.5408 20.5806C56.5418 20.519 56.5373 20.4065 56.5051 20.2567L57.3987 19.0381C57.4767 18.9317 57.5308 18.8097 57.5572 18.6804C57.6553 18.2002 57.4986 17.4399 56.7339 16.8778C56.3928 16.627 55.9602 16.4944 55.483 16.4944C55.1259 16.4944 54.8482 16.5686 54.7961 16.5835C54.578 16.6457 54.3908 16.7869 54.2709 16.9796L54.0545 17.3274C54.0021 17.3303 53.9488 17.3354 53.8952 17.3423C53.3139 17.4176 52.9317 17.6321 52.6837 17.8561C52.4646 17.9082 52.2694 18.0401 52.1394 18.2308L49.7483 21.7386L49.6975 3.84205C49.6954 3.0666 49.372 2.43624 48.8105 2.11236C48.7637 2.08522 46.7598 0.922504 46.7598 0.922504C46.5281 0.785567 46.2463 0.711304 45.9511 0.711304Z" fill="#374874"/>
<path d="M55.0474 17.4626C55.0474 17.4626 55.7234 17.2694 56.1925 17.6144C56.7572 18.0296 56.6616 18.4974 56.6616 18.4974L55.2957 20.3599L53.8884 19.3252L55.0474 17.4626Z" fill="white"/>
<path d="M37.8288 40.8481L52.8949 18.7457C52.8949 18.7457 53.3762 18.7524 53.9082 18.9064C54.1838 18.9861 54.4731 19.1054 54.7161 19.2838C55.6391 19.9614 55.6267 20.5669 55.6267 20.5669L40.4778 42.5038L37.8288 43.9938L37.3735 43.6489L37.8288 40.8481Z" fill="#C1DBF6"/>
<path d="M50.2599 24.9606L54.799 18.7457C54.799 18.7457 54.7576 18.1525 54.0126 18.249C53.2676 18.3456 53.1296 18.7595 53.1296 18.7595L49.0733 24.4851C49.0733 24.4851 48.5353 25.2567 50.2599 24.9606Z" fill="white"/>
<path d="M37.4151 43.4211L37.2359 44.2192C37.2184 44.2974 37.3051 44.3571 37.3719 44.3127L38.029 43.8764C38.029 43.8764 37.8873 43.636 37.8152 43.5798C37.7303 43.5137 37.4151 43.4211 37.4151 43.4211Z" fill="#ECECEC"/>
<path d="M33.5678 20.2363C33.7408 20.1365 33.9139 20.1156 34.042 20.1983C34.2881 20.3569 34.2763 20.8357 34.0159 21.2682L26.7734 33.302C26.6488 33.5097 26.4894 33.669 26.3298 33.7611C26.1635 33.8571 25.9971 33.88 25.87 33.8084L23.4514 32.4301C23.198 32.284 23.1967 31.8129 23.4484 31.3754C23.5749 31.157 23.7407 30.9886 23.9067 30.8928C24.0727 30.7969 24.2389 30.7736 24.3663 30.8454L24.5389 30.9439L26.3385 31.9709L31.7319 23.0094L33.1255 20.6938C33.2496 20.4871 33.4087 20.3282 33.5678 20.2363Z" fill="#374874"/>
<path d="M34.9714 16.5324C35.1509 16.4288 35.2973 16.5106 35.2979 16.7168L35.3387 31.0812C35.3392 31.2857 35.1938 31.5377 35.0143 31.6413L22.5149 38.8578C22.3354 38.9614 22.1905 38.8772 22.1899 38.6727L22.1492 24.3082C22.1486 24.102 22.2926 23.8525 22.4721 23.7489L34.9714 16.5324ZM35.0132 31.2691L34.9725 16.9047L22.4731 24.1212L22.5139 38.4856L35.0132 31.2691Z" fill="#374874"/>
<path d="M35.3679 33.4351C35.5474 33.3314 35.6923 33.4141 35.6929 33.6203L35.7337 47.9846C35.7342 48.1893 35.5903 48.4403 35.4108 48.544L22.91 55.7613C22.7319 55.8641 22.5855 55.7807 22.5849 55.5761L22.5442 41.2118C22.5436 41.0055 22.6891 40.7552 22.8671 40.6525L35.3679 33.4351ZM35.4097 48.1717L35.369 33.8074L22.8682 41.0247L22.9089 55.389L35.4097 48.1717Z" fill="#374874"/>
<path d="M33.9633 37.1396C34.1361 37.0398 34.3088 37.0192 34.4369 37.1018C34.683 37.2604 34.6712 37.7392 34.4108 38.1717L27.1683 50.2055C27.0437 50.4131 26.8843 50.5724 26.7247 50.6645C26.5584 50.7605 26.392 50.7835 26.2649 50.712L23.8477 49.3327C23.5929 49.1876 23.5916 48.7163 23.8447 48.278C23.9705 48.0601 24.1363 47.8917 24.3023 47.7959C24.4684 47.7 24.6345 47.6767 24.7612 47.7488L24.9338 47.8474L26.7334 48.8745L32.1268 39.9128L33.5204 37.5974C33.6452 37.3901 33.8043 37.2314 33.9633 37.1396Z" fill="#374874"/>
</svg>

After

Width:  |  Height:  |  Size: 7.5 KiB

View file

@ -0,0 +1,26 @@
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
import { parseDate } from "@web/core/l10n/dates";
export class ProjectRatingImage extends Interaction {
static selector = ".o_portal_project_rating .o_rating_image";
start() {
window.Popover.getOrCreateInstance(this.el, {
placement: "bottom",
trigger: "hover",
html: true,
content: () => {
const duration = parseDate(this.el.dataset.ratingDate).toRelative();
const ratingEl = document.querySelector("#rating_" + this.el.dataset.id);
ratingEl.querySelector(".rating_timeduration").textContent = duration;
return ratingEl.outerHTML;
},
});
}
}
registry
.category("public.interactions")
.add("project.project_rating_image", ProjectRatingImage);

View file

@ -1,30 +0,0 @@
/** @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

@ -1,13 +0,0 @@
/** @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

@ -1,42 +0,0 @@
/** @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

@ -1,11 +0,0 @@
/** @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

@ -1,9 +0,0 @@
/** @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

@ -1,35 +0,0 @@
/** @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

@ -1,36 +0,0 @@
/** @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

@ -1,105 +0,0 @@
/** @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

@ -1,144 +1,262 @@
odoo.define('project.tour', function(require) {
"use strict";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_utils";
const {_t} = require('web.core');
const {Markup} = require('web.utils');
var tour = require('web_tour.tour');
import { markup } from "@odoo/owl";
tour.register('project_tour', {
sequence: 110,
url: "/web",
rainbowManMessage: _t("Congratulations, you are now a master of project management."),
}, [tour.stepUtils.showAppsMenuItem(), {
registry.category("web_tour.tours").add('project_tour', {
url: "/odoo",
steps: () => [stepUtils.showAppsMenuItem(), {
isActive: ["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: 'right',
edition: 'community',
content: markup(_t('Want a better way to <b>manage your projects</b>? <i>It starts here.</i>')),
tooltipPosition: 'right',
run: "click",
}, {
isActive: ["enterprise"],
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',
}, {
content: markup(_t('Want a better way to <b>manage your projects</b>? <i>It starts here.</i>')),
tooltipPosition: 'bottom',
run: "click",
},
{
trigger: ".o_project_kanban",
},
{
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,
content: markup(_t('Let\'s create your first <b>project</b>.')),
tooltipPosition: 'bottom',
run: "click",
}, {
isActive: ['.o-kanban-button-new.dropdown'], // if the project template dropdown is active
trigger: 'button.o-dropdown-item:contains("New Project")',
content: markup(_t('Let\'s create a regular <b>project</b>.')),
tooltipPosition: 'right',
run: "click",
}, {
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',
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>')),
tooltipPosition: 'right',
run: "edit Test",
}, {
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');
},
content: markup(_t('Let\'s create your first <b>project</b>.')),
tooltipPosition: 'top',
run: "click .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_header input",
content: markup(_t("Add columns to organize your tasks into <b>stages</b> <i>e.g. New - In Progress - Done</i>.")),
tooltipPosition: 'bottom',
run: "edit 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",
content: markup(_t('Let\'s create your first <b>stage</b>.')),
tooltipPosition: 'right',
run: "click",
},
{
trigger: ".o_kanban_group",
},
{
trigger: ".o_kanban_project_tasks .o_column_quick_create .o_kanban_header input",
content: markup(_t("Add columns to organize your tasks into <b>stages</b> <i>e.g. New - In Progress - Done</i>.")),
tooltipPosition: 'bottom',
run: "edit 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',
}, {
content: markup(_t('Let\'s create your second <b>stage</b>.')),
tooltipPosition: 'right',
run: "click",
},
{
trigger: ".o_kanban_group:eq(1)",
},
{
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',
}, {
content: markup(_t("Let's create your first <b>task</b>.")),
tooltipPosition: 'bottom',
run: "click",
},
{
trigger: ".o_kanban_project_tasks",
},
{
trigger: '.o_kanban_quick_create div.o_field_char[name=display_name] input',
content: markup(_t('Choose a task <b>name</b> <i>(e.g. Website Design, Purchase Goods...)</i>')),
tooltipPosition: 'right',
run: "edit Test",
},
{
trigger: ".o_kanban_project_tasks",
},
{
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) ",
}, {
tooltipPosition: "bottom",
run: "click",
},
{
trigger: ".o_kanban_project_tasks",
},
{
trigger: ".o_kanban_record",
content: markup(_t("<b>Drag &amp; drop</b> the card to change your task from stage.")),
tooltipPosition: "bottom",
run: "drag_and_drop(.o_kanban_group:eq(1))",
},
{
trigger: ".o_kanban_project_tasks",
},
{
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.")),
}, {
tooltipPosition: "bottom",
run: "click",
},
{
trigger: ".o_form_project_tasks",
},
{
trigger: ".o-mail-Chatter-topbar button.o-mail-Chatter-sendMessage",
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.")),
tooltipPosition: "bottom",
run: "click",
},
{
trigger: ".o_form_project_tasks",
},
{
trigger: "button.o-mail-Chatter-logNote",
content: markup(_t("<b>Log internal notes</b> and use @<b>mentions</b> to notify your colleagues.")),
tooltipPosition: "bottom",
run: "click",
},
{
trigger: ".o_form_project_tasks",
},
{
trigger: ".o-mail-Chatter-topbar button.o-mail-Chatter-activity",
content: markup(_t("Create <b>activities</b> to set yourself to-dos or to schedule meetings.")),
tooltipPosition: "bottom",
run: "click",
},
{
trigger: ".o_form_project_tasks",
},
{
trigger: ".modal-dialog .btn-primary",
extra_trigger: '.o_form_project_tasks',
content: _t("Schedule your activity once it is ready."),
position: "bottom",
tooltipPosition: "bottom",
run: "click",
}, {
trigger: ".o_field_widget[name='user_ids']",
extra_trigger: '.o_form_project_tasks',
},
{
trigger: ".o_form_project_tasks",
},
{
isActive: ["auto"],
trigger: ".o_field_widget[name='user_ids'] input",
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,
tooltipPosition: "right",
run: "edit Admin",
},
{
isActive: ["manual"],
trigger: ".o_field_widget[name='user_ids']",
content: _t("Assign a responsible to your task"),
tooltipPosition: "right",
run: "click",
},
{
isActive: ["desktop", "auto"],
trigger: "a.dropdown-item[id*='user_ids'] span",
content: _t("Select an assignee from the menu"),
run: "click",
},
{
isActive: ["mobile"],
trigger: "div.o_kanban_renderer > article.o_kanban_record",
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",
isActive: ["auto"],
trigger: 'a[name="sub_tasks_page"]',
content: _t('Open sub-tasks notebook section'),
run: 'click',
}, {
isActive: ["auto"],
trigger: '.o_field_subtasks_one2many .o_list_renderer a[role="button"]',
content: _t('Add a sub-task'),
run: 'click',
}, {
isActive: ["auto"],
trigger: '.o_field_subtasks_one2many div[name="name"] input',
content: markup(_t('Give the sub-task a <b>name</b>')),
run: "edit New Sub-task",
},
{
trigger: ".o_form_project_tasks .o_form_dirty",
},
{
isActive: ["auto"],
trigger: ".o_form_button_save",
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.")),
tooltipPosition: "bottom",
run: "click",
},
{
trigger: ".o_form_project_tasks",
},
{
trigger: ".o_breadcrumb .o_back_button",
content: markup(_t("Let's go back to the <b>kanban view</b> to have an overview of your next tasks.")),
tooltipPosition: "right",
run: 'click',
}, {
isActive: ["auto"],
trigger: ".o_kanban_record .o_widget_subtask_counter .subtask_list_button",
content: _t("You can open sub-tasks from the kanban card!"),
run: "click",
},
{
trigger: ".o_widget_subtask_kanban_list .subtask_list",
},
{
isActive: ["auto"],
trigger: ".o_kanban_record .o_widget_subtask_kanban_list .subtask_create",
content: _t("Create a new sub-task"),
run: "click",
},
{
trigger: ".subtask_create_input",
},
{
isActive: ["auto"],
trigger: ".o_kanban_record .o_widget_subtask_kanban_list .subtask_create_input input",
content: markup(_t("Give the sub-task a <b>name</b>")),
run: "edit Newer Sub-task && click body",
}, {
isActive: ["auto"],
trigger: ".o_kanban_record .o_widget_subtask_kanban_list .subtask_list_row:contains(newer sub-task) .o_field_project_task_state_selection button",
content: _t("You can change the sub-task state here!"),
run: "click",
},
{
trigger: ".project_task_state_selection_menu.dropdown-menu",
},
{
isActive: ["auto"],
trigger: ".project_task_state_selection_menu.dropdown-menu span.text-danger",
content: markup(_t("Mark the task as <b>Cancelled</b>")),
run: "click",
}, {
trigger: ".o-overlay-container:not(:visible):not(:has(.project_task_state_selection_menu))",
}, {
isActive: ["auto"],
trigger: ".o_kanban_record .o_widget_subtask_counter .subtask_list_button:contains('1/2')",
content: _t("Close the sub-tasks list"),
run: "click",
}, {
isActive: ["auto"],
trigger: '.o_kanban_renderer',
// last step to confirm we've come back before considering the tour successful
auto: true
}]);
});
run: "click",
}]});

View file

@ -1,26 +0,0 @@
/** @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

@ -1,31 +0,0 @@
/** @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

@ -1,25 +0,0 @@
/** @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

@ -1,44 +0,0 @@
/** @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,39 @@
import { Chatter } from "@mail/chatter/web_portal/chatter";
import { useSubEnv } from "@odoo/owl";
import { patch } from "@web/core/utils/patch";
import { useService } from "@web/core/utils/hooks";
patch(Chatter.prototype, {
setup() {
super.setup(...arguments);
Object.assign(this.state, {
isFollower: this.props.isFollower,
});
this.orm = useService("orm");
useSubEnv({
// 'inFrontendPortalChatter' is specific to the frontend portal chatters
// and should not be set to 'true' in the project sharing chatter environment.
projectSharingId: this.props.projectSharingId,
});
},
async toggleIsFollower() {
this.state.isFollower = await this.orm.call(
this.props.threadModel,
"project_sharing_toggle_is_follower",
[this.props.threadId]
);
},
onPostCallback() {
super.onPostCallback();
this.state.isFollower = true;
},
});
Chatter.props = [
...Chatter.props,
"token",
"projectSharingId",
"isFollower",
"displayFollowButton",
];

View file

@ -0,0 +1,12 @@
import { ComposerAction } from "@mail/core/common/composer_actions";
import { patch } from "@web/core/utils/patch";
patch(ComposerAction.prototype, {
_condition({ owner }) {
if (this.id === "open-full-composer" && owner.env.projectSharingId) {
return false;
}
return super._condition(...arguments);
},
});

View file

@ -0,0 +1,41 @@
import { Composer } from "@mail/core/common/composer";
import { patch } from "@web/core/utils/patch";
import { onWillStart } from "@odoo/owl";
patch(Composer.prototype, {
setup() {
super.setup();
onWillStart(() => {
if (!this.thread.id) {
this.state.active = false;
}
});
},
get extraData() {
const extraData = super.extraData;
if (this.env.projectSharingId) {
extraData.project_sharing_id = this.env.projectSharingId;
}
return extraData;
},
get isSendButtonDisabled() {
if (this.thread && !this.thread.id) {
return true;
}
return super.isSendButtonDisabled;
},
get allowUpload() {
if (this.thread && !this.thread.id) {
return false;
}
return super.allowUpload;
},
get shouldHideFromMessageListOnDelete() {
return true;
}
});

View file

@ -0,0 +1,9 @@
import { Message } from "@mail/core/common/message";
import { patch } from "@web/core/utils/patch";
patch(Message.prototype, {
get shouldHideFromMessageListOnDelete() {
return true;
},
});

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="portal.Chatter" t-inherit-mode="extension">
<xpath expr="//div[hasclass('o-mail-Chatter-top')]" position="before">
<div class="d-flex justify-content-end">
<button t-if="props.displayFollowButton and props.threadId" class="btn btn-link w-auto" t-on-click="toggleIsFollower">
<t t-if="state.isFollower">
Unfollow
</t>
<t t-else="">
Follow
</t>
</button>
</div>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,21 @@
import { SuggestionService } from "@mail/core/common/suggestion_service";
import { patch } from "@web/core/utils/patch";
patch(SuggestionService.prototype, {
async fetchPartnersRoles(term, thread, { abortSignal } = {}) {
if (thread.model === "project.task") {
this.store.insert(
await this.makeOrmCall(
"project.task",
"get_mention_suggestions",
[thread.id],
{ search: term },
{ abortSignal }
)
);
return;
}
return super.fetchPartnersRoles(...arguments);
},
});

View file

@ -1,69 +0,0 @@
.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

@ -1,15 +0,0 @@
/** @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

@ -1,28 +0,0 @@
<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

@ -1,134 +0,0 @@
/** @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

@ -1,61 +0,0 @@
<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

@ -1,172 +0,0 @@
/** @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

@ -1,27 +0,0 @@
<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

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

View file

@ -1,16 +0,0 @@
<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

@ -1,37 +0,0 @@
/** @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

@ -1,43 +0,0 @@
<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

@ -1,64 +0,0 @@
/** @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

@ -1,21 +0,0 @@
<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,10 @@
import { ListRenderer } from "@web/views/list/list_renderer";
export class DependOnIdsListRenderer extends ListRenderer {
get nbHiddenRecords() {
const { context, records } = this.props.list;
return context.depend_on_count - records.length;
}
}
DependOnIdsListRenderer.rowsTemplate = "project.DependOnIdsListRowsRenderer";

View file

@ -0,0 +1,13 @@
<templates>
<t t-name="project.DependOnIdsListRowsRenderer" t-inherit="web.ListRenderer.Rows" t-inherit-mode="primary" owl="1">
<xpath expr="//t[@t-foreach='list.records']" position="after">
<tr class="o_data_row">
<td t-if="nbHiddenRecords" t-att-colspan="nbCols" style="text-align:center;">
<i class="text-muted">
This task is currently blocked by <t t-out="nbHiddenRecords"/> (other) tasks to which you do not have access.
</i>
</td>
</tr>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,18 @@
import { registry } from "@web/core/registry";
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
import { DependOnIdsListRenderer } from "./depend_on_ids_list_renderer";
export class DependOnIdsOne2ManyField extends X2ManyField {
static components = {
...X2ManyField.components,
ListRenderer: DependOnIdsListRenderer,
};
}
export const dependOnIdsOne2ManyField = {
...x2ManyField,
component: DependOnIdsOne2ManyField,
};
registry.category("fields").add("depend_on_ids_one2many", dependOnIdsOne2ManyField);

View file

@ -1,35 +0,0 @@
/** @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

@ -1,33 +0,0 @@
/** @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

@ -1,21 +0,0 @@
<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

@ -1,46 +0,0 @@
/** @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,45 @@
import { ImageCropPlugin } from "@html_editor/main/media/image_crop_plugin";
import { ImageSavePlugin } from "@html_editor/main/media/image_save_plugin";
import { MediaPlugin } from "@html_editor/main/media/media_plugin";
import { MAIN_PLUGINS } from "@html_editor/plugin_sets";
export class ProjectSharingMediaPlugin extends MediaPlugin {
resources = {
...this.resources,
toolbar_items: this.resources.toolbar_items.filter(
item => item.id !== "replace_image"
),
}
}
export class ProjectSharingImageSavePlugin extends ImageSavePlugin {
async createAttachment({ el, imageData, resId }) {
const response = JSON.parse(
await this.services.http.post(
"/project_sharing/attachment/add_image",
{
name: el.dataset.fileName || "",
data: imageData,
res_id: resId,
access_token: "",
csrf_token: odoo.csrf_token,
},
"text"
)
);
if (response.error) {
this.services.notification.add(response.error, { type: "danger" });
el.remove();
}
const attachment = response;
attachment.image_src = "/web/image/" + attachment.id + "-" + attachment.name;
return attachment;
}
}
MAIN_PLUGINS.splice(MAIN_PLUGINS.indexOf(MediaPlugin), 1);
MAIN_PLUGINS.push(ProjectSharingMediaPlugin);
MAIN_PLUGINS.splice(MAIN_PLUGINS.indexOf(ImageSavePlugin), 1);
MAIN_PLUGINS.push(ProjectSharingImageSavePlugin);
MAIN_PLUGINS.splice(MAIN_PLUGINS.indexOf(ImageCropPlugin), 1);

View file

@ -1,7 +1,4 @@
/** @odoo-module **/
import { startWebClient } from '@web/start';
import { ProjectSharingWebClient } from './project_sharing';
import { prepareFavoriteMenuRegister } from './components/favorite_menu_registry';
prepareFavoriteMenuRegister();
startWebClient(ProjectSharingWebClient);

View file

@ -1,19 +1,17 @@
/** @odoo-module **/
import { useBus, useService } from '@web/core/utils/hooks';
import { ActionContainer } from '@web/webclient/actions/action_container';
import { browser } from "@web/core/browser/browser";
import { useBus, useService } from "@web/core/utils/hooks";
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;
import { ActionContainer } from "@web/webclient/actions/action_container";
import { Component, onMounted, useExternalListener, useState } from "@odoo/owl";
export class ProjectSharingWebClient extends Component {
static props = {};
static components = { ActionContainer, MainComponentsContainer };
static template = "project.ProjectSharingWebClient";
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");
this.actionService = useService("action");
useOwnDebugContext({ categories: ["default"] });
this.state = useState({
fullscreen: false,
@ -23,28 +21,31 @@ export class ProjectSharingWebClient extends Component {
this.state.fullscreen = mode === "fullscreen";
}
});
useEffect(
() => {
this._showView();
},
() => []
);
onMounted(() => {
this.loadRouterState();
// the chat window and dialog services listen to 'web_client_ready' event in
// order to initialize themselves:
this.env.bus.trigger("WEB_CLIENT_READY");
});
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,
async loadRouterState() {
// ** url-retrocompatibility **
const stateLoaded = await this.actionService.loadState();
// Scroll to anchor after the state is loaded
if (stateLoaded) {
if (browser.location.hash !== "") {
try {
const el = document.querySelector(browser.location.hash);
if (el !== null) {
el.scrollIntoView(true);
}
} catch {
// do nothing if the hash is not a correct selector.
}
}
);
if (open_task_action) {
await this.actionService.doAction(open_task_action);
}
}
@ -56,7 +57,8 @@ export class ProjectSharingWebClient extends Component {
// we let the browser do the default behavior and
// we do not want any other listener to execute.
if (
ev.ctrlKey &&
(ev.ctrlKey || ev.metaKey) &&
!ev.target.isContentEditable &&
((ev.target instanceof HTMLAnchorElement && ev.target.href) ||
(ev.target instanceof HTMLElement && ev.target.closest("a[href]:not([href=''])")))
) {
@ -65,6 +67,3 @@ export class ProjectSharingWebClient extends Component {
}
}
}
ProjectSharingWebClient.components = { ActionContainer, MainComponentsContainer };
ProjectSharingWebClient.template = 'project.ProjectSharingWebClient';

View file

@ -0,0 +1,4 @@
.o_project_sharing .o_control_panel {
// to be able to vertically center the content of the control panel since there is no menu header displayed in project sharing
padding-top: 16px !important;
}

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<t t-name="project.ProjectSharingWebClient" owl="1">
<t t-name="project.ProjectSharingWebClient">
<ActionContainer />
<MainComponentsContainer/>
</t>

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