diff --git a/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.js b/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.js
new file mode 100644
index 00000000000..c7e1c6fb1cf
--- /dev/null
+++ b/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.js
@@ -0,0 +1,60 @@
+/** @odoo-module **/
+
+import { _lt } from "@web/core/l10n/translation";
+import { registry } from "@web/core/registry";
+import { formatDuration } from "@web/views/fields/formatters";
+import {
+ preloadStatusBar,
+ statusBarField,
+ StatusBarField,
+} from "@web/views/fields/statusbar/statusbar_field";
+
+export class StatusBarDurationField extends StatusBarField {
+ static template = "mail.StatusBarDurationField";
+
+ computeItems(grouped = true) {
+ const items = super.computeItems(grouped);
+ if (!grouped) {
+ return items;
+ }
+ if (this.props.record.data.duration_tracking) {
+ for (const property in items) {
+ this.setStatusbarDuration(items[property]);
+ }
+ }
+ return items;
+ }
+
+ setStatusbarDuration(stages) {
+ const durationTracking = this.props.record.data.duration_tracking;
+ if (!Object.keys(durationTracking).length) {
+ return;
+ }
+ for (const stage of stages) {
+ if (stage.id in durationTracking && durationTracking[stage.id] > 0) {
+ stage.shortTimeInStage = formatDuration(durationTracking[stage.id], false);
+ stage.fullTimeInStage = formatDuration(durationTracking[stage.id], true);
+ } else {
+ stage.shortTimeInStage = false;
+ }
+ }
+ }
+}
+
+export const statusBarDurationField = {
+ ...statusBarField,
+ component: StatusBarDurationField,
+ displayName: _lt("Status with time"),
+ supportedTypes: ["many2one"],
+ fieldDependencies: [{ name: "duration_tracking", type: "JSON" }],
+};
+
+registry.category("fields").add("statusbar_duration", statusBarDurationField);
+
+registry.category("preloadedData").add("statusbar_duration", {
+ loadOnTypes: ["many2one"],
+ extraMemoizationKey: (record, fieldName) => {
+ return record.data[fieldName];
+ },
+ preload: preloadStatusBar,
+});
diff --git a/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.scss b/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.scss
new file mode 100644
index 00000000000..fb1a9a1ac4d
--- /dev/null
+++ b/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.scss
@@ -0,0 +1,3 @@
+.o_field_statusbar_duration {
+ @extend .o_field_statusbar;
+}
diff --git a/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.xml b/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.xml
new file mode 100644
index 00000000000..22e4d984fb6
--- /dev/null
+++ b/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/mail/static/tests/views/fields/statusbar_duration_field_tests.js b/addons/mail/static/tests/views/fields/statusbar_duration_field_tests.js
new file mode 100644
index 00000000000..e0876d4a418
--- /dev/null
+++ b/addons/mail/static/tests/views/fields/statusbar_duration_field_tests.js
@@ -0,0 +1,100 @@
+/** @odoo-module **/
+
+import { getFixture } from "@web/../tests/helpers/utils";
+import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
+
+let serverData;
+let target;
+
+QUnit.module("Fields", (hooks) => {
+ hooks.beforeEach(() => {
+ target = getFixture();
+ serverData = {
+ models: {
+ partner: {
+ fields: {
+ display_name: { string: "Displayed name", type: "char" },
+ stage_id: { string: "Stage", type: "many2one", relation: "stage_model" },
+ duration_tracking: {
+ string: "Time per stage",
+ type: "char",
+ default: "{}",
+ },
+ },
+ records: [
+ {
+ id: 1,
+ display_name: "first record",
+ stage_id: 30,
+ // 7 days, 30 minutes - 3 hours - 2 days, 5 hours
+ duration_tracking: {
+ 10: 7 * 24 * 60 * 60 + 30 * 60,
+ 20: 3 * 60 * 60,
+ 40: 24 * 2 * 60 * 60 + 5 * 60 * 60,
+ },
+ },
+ ],
+ },
+ stage_model: {
+ fields: {
+ name: { string: "Stage Name", type: "char" },
+ },
+ records: [
+ {
+ id: 10,
+ display_name: "New",
+ },
+ {
+ id: 20,
+ display_name: "Qualified",
+ },
+ {
+ id: 30,
+ display_name: "Proposition",
+ },
+ {
+ id: 40,
+ display_name: "Won",
+ },
+ ],
+ },
+ },
+ };
+
+ setupViewRegistries();
+ });
+
+ QUnit.module("StatusBarDurationField");
+
+ QUnit.test("StatusBarDurationField in a form view", async function (assert) {
+ await makeView({
+ serverData,
+ type: "form",
+ resModel: "partner",
+ arch: `
+
`,
+ resId: 1,
+ });
+
+ // Check that time is put in the stages
+ const firstButton = target.querySelector("button[data-value='10'");
+ assert.strictEqual(firstButton.innerText, "New7d");
+ assert.strictEqual(firstButton.querySelectorAll("span")[1].title, "7 days, 30 minutes");
+
+ const secondButton = target.querySelector("button[data-value='20']");
+ assert.strictEqual(secondButton.innerText, "Qualified3h");
+ assert.strictEqual(secondButton.querySelectorAll("span")[1].title, "3 hours");
+
+ const thirdButton = target.querySelector("button[data-value='30']");
+ assert.strictEqual(thirdButton.innerText, "Proposition");
+ assert.strictEqual(thirdButton.title, "");
+
+ const fourthButton = target.querySelector("button[data-value='40']");
+ assert.strictEqual(fourthButton.innerText, "Won2d");
+ assert.strictEqual(fourthButton.querySelectorAll("span")[1].title, "2 days, 5 hours");
+ });
+});
diff --git a/addons/web/static/src/views/fields/formatters.js b/addons/web/static/src/views/fields/formatters.js
index b16e1e3b33a..197fe8fb485 100644
--- a/addons/web/static/src/views/fields/formatters.js
+++ b/addons/web/static/src/views/fields/formatters.js
@@ -154,6 +154,51 @@ export function formatChar(value, options) {
return value;
}
+/**
+ * Converts a given duration in seconds into a human-readable format.
+ *
+ * The function takes a duration in seconds and converts it into a human-readable form,
+ * such as "1h" or "1 hour, 30 minutes", depending on the value of the `showFullDuration` parameter.
+ * If the `showFullDuration` is set to true, the function will display up to two non-zero duration
+ * components in long form (e.g: hours, minutes).
+ * Otherwise, it will show just the largest non-zero duration component in narrow form (e.g: y or h).
+ * Luxon takes care of translations given the current locale.
+ *
+ * @param {number} seconds - The duration in seconds to be converted.
+ * @param {boolean} showFullDuration - If true, the output will have two components in long form.
+ * Otherwise, just one component will be displayed in narrow form.
+ *
+ * @returns {string} A human-readable string representation of the duration.
+ *
+ * @example
+ * // Sample usage
+ * const durationInSeconds = 7320; // 2 hours and 2 minutes (2 * 3600 + 2 * 60)
+ * const fullDuration = humanizeDuration(durationInSeconds, true);
+ * console.log(fullDuration); // Output: "2 hours, 2 minutes"
+ *
+ * const shortDuration = humanizeDuration(durationInSeconds, false);
+ * console.log(shortDuration); // Output: "2h"
+ */
+export function formatDuration(seconds, showFullDuration) {
+ const displayStyle = showFullDuration ? "long" : "narrow";
+ const numberOfValuesToDisplay = showFullDuration ? 2 : 1;
+ const durationKeys = ["years", "months", "days", "hours", "minutes"];
+
+ if (seconds < 60) {
+ seconds = 60;
+ }
+ seconds -= seconds % 60;
+
+ let duration = luxon.Duration.fromObject({ seconds: seconds }).shiftTo(...durationKeys);
+ duration = duration.shiftTo(...durationKeys.filter((key) => duration.get(key)));
+ const durationSplit = duration.toHuman({ unitDisplay: displayStyle }).split(",");
+
+ if (!showFullDuration && duration.loc.locale.includes("en") && duration.months > 0) {
+ durationSplit[0] = durationSplit[0].replace("m", "M");
+ }
+ return durationSplit.slice(0, numberOfValuesToDisplay).join(",");
+}
+
/**
* Returns a string representing a float. The result takes into account the
* user settings (to display the correct decimal separator).
diff --git a/addons/web/static/src/views/fields/statusbar/statusbar_field.scss b/addons/web/static/src/views/fields/statusbar/statusbar_field.scss
index 75816704612..dab8fd862c6 100644
--- a/addons/web/static/src/views/fields/statusbar/statusbar_field.scss
+++ b/addons/web/static/src/views/fields/statusbar/statusbar_field.scss
@@ -50,7 +50,8 @@
&.disabled {
opacity: 1;
- pointer-events: none;
+ pointer-events: auto;
+ cursor: default;
&:not(.o_arrow_button_current) {
&, &:hover, &:focus {
@@ -76,4 +77,4 @@
}
}
}
-}
\ No newline at end of file
+}