Public release from ruodoo-project: 19.0 - 2026-07-26 21:17:35 UTC

This commit is contained in:
CI Publish Bot
2026-07-26 21:17:45 +00:00
commit 4f7b594ec8
1335 changed files with 191620 additions and 0 deletions

View File

@ -0,0 +1,82 @@
/** @odoo-module **/
import { discussSidebarItemsRegistry } from "@mail/core/public_web/discuss_sidebar";
import { Component, useState, onWillStart } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { markEventHandled } from "@web/core/utils/misc";
/**
* Renders each tier.mailbox as a collapsible section with conversation list.
*
* Under each mailbox header — list of authors (grouped conversations).
* Clicking an author opens a DM (discuss.channel chat) with that partner.
*/
export class TierDiscussSidebarMailboxes extends Component {
static template = "tier_communications.DiscussSidebarMailboxes";
static props = {};
setup() {
this.store = useService("mail.store");
this.orm = useService("orm");
this.state = useState({
openIds: {},
conversationsByMailbox: {}, // { mailbox_id: [{partner_id, partner_name, ...}] }
});
onWillStart(async () => {
await this._loadConversations();
});
}
get mailboxThreads() {
return this.store.tierMailboxThreads || [];
}
isOpen(threadId) {
return this.state.openIds[threadId] !== false;
}
toggle(threadId) {
this.state.openIds[threadId] = !this.isOpen(threadId);
}
getConversations(mailboxId) {
return this.state.conversationsByMailbox[mailboxId] || [];
}
async _loadConversations() {
try {
const mailboxes = await this.orm.call(
"tier.mailbox", "get_accessible_mailboxes", [], {}
);
for (const mailbox of (mailboxes || [])) {
const conversations = await this.orm.call(
"tier.mailbox", "get_mailbox_conversations", [mailbox.id], {}
);
this.state.conversationsByMailbox[mailbox.id] = conversations || [];
}
} catch (error) {
console.warn("[tier_communications] _loadConversations error:", error);
}
}
/**
* Open a DM (personal chat) with the given partner.
* Uses store.getChat() which finds or creates the DM channel.
*/
async openConversation(ev, partnerId) {
markEventHandled(ev, "sidebar.openThread");
if (!partnerId) {
return;
}
const chat = await this.store.getChat({ partnerId });
if (chat) {
chat.setAsDiscussThread();
}
}
}
discussSidebarItemsRegistry.add(
"tier_mailboxes",
TierDiscussSidebarMailboxes,
{ sequence: 40 }
);

View File

@ -0,0 +1,40 @@
/** @odoo-module **/
import { Message } from "@mail/core/common/message_model";
import { patch } from "@web/core/utils/patch";
/**
* Patch Message to display channel tags (Email, Matrix, etc.) next to the message.
*
* channel_tag_ids arrives from the backend as a plain JSON array:
* [{id: 1, name: "Email", code: "email"}, ...]
* via Store.Attr in _to_store_defaults.
*
* We intercept it in update() and store it as _tierChannelTags.
*/
patch(Message.prototype, {
/** @type {Array<{id: number, name: string, code: string}>|undefined} */
_tierChannelTags: undefined,
update(data) {
super.update(data);
if (data && Array.isArray(data.channel_tag_ids)) {
this._tierChannelTags = data.channel_tag_ids.filter(
(tag) => tag && typeof tag === 'object' && tag.name
);
}
},
/**
* Returns display names of all channel tags on this message.
* Used by message_channel_tag.xml to render badges.
* @returns {string[]}
*/
get tierChannelTagNames() {
const tags = this._tierChannelTags;
if (!tags || tags.length === 0) {
return [];
}
return tags.map((tag) => tag.name).filter(Boolean);
},
});

View File

@ -0,0 +1,235 @@
/** @odoo-module **/
import { Composer } from "@mail/core/common/composer";
import { Store } from "@mail/core/common/store_service";
import { patch } from "@web/core/utils/patch";
import { useState, onWillStart } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { user } from "@web/core/user";
import { rpc } from "@web/core/network/rpc";
/**
* Patch Store.getMessagePostParams to inject channel_tag_ids into post_data.
* This is the correct way to add custom fields — they end up in post_data
* which is filtered by _get_allowed_message_params on the Python side.
*/
patch(Store.prototype, {
async getMessagePostParams({ body, postData, thread }) {
const params = await super.getMessagePostParams({ body, postData, thread });
if (postData.tierChannelTagIds !== undefined) {
if (postData.tierChannelTagIds) {
params.post_data.channel_tag_ids = postData.tierChannelTagIds;
}
}
return params;
},
async doMessagePost(params, tmpMessage) {
const result = await super.doMessagePost(params, tmpMessage);
// Store the last posted message_id so Composer can use it for channel notification
if (result?.message_id) {
this._lastPostedMessageId = result.message_id;
}
return result;
},
});
/**
* Patch Composer to add channel/mailbox selector UI.
*
* Логика выбора канала:
* - По умолчанию канал не выбран (только Odoo)
* - Если последнее сообщение в треде пришло через email → автовыбор email
* - Если через matrix → автовыбор matrix
* - Если без канала → ничего не выбираем
* - Пользователь может вручную переключить или снять выбор
*/
patch(Composer.prototype, {
setup() {
super.setup();
this.orm = useService("orm");
this.notification = useService("notification");
this.store = useService("mail.store");
this.tierState = useState({
selectedChannelId: null,
selectedMailboxIds: [],
availableChannels: [],
availableMailboxes: [],
});
onWillStart(async () => {
await this._loadTierData();
});
},
async _loadTierData() {
try {
const [isCompanySender, channelTags, mailboxes] = await Promise.all([
user.hasGroup("tier_communications.group_company_sender"),
this.orm.searchRead(
"tier.channel.tag",
[["active", "=", true]],
["id", "name", "code", "is_company_reply"]
),
this.orm.call("tier.mailbox", "get_accessible_mailboxes", [], {}),
]);
this.tierState.availableChannels = (channelTags || []).filter(
(channelTag) => !channelTag.is_company_reply || isCompanySender
);
this.tierState.availableMailboxes = mailboxes || [];
await this._autoSelectChannel();
} catch (error) {
console.warn("[tier_communications] _loadTierData error:", error);
this.tierState.availableChannels = [];
this.tierState.availableMailboxes = [];
}
},
/**
* Auto-select channel based on the last message in the thread.
* email → select email, matrix → select matrix, none → no selection.
*/
async _autoSelectChannel() {
const composerThread = this.props?.composer?.thread;
if (!composerThread || composerThread.model === "mail.box" || !composerThread.id) {
this.tierState.selectedChannelId = null;
return;
}
try {
const lastChannelCode = await this.orm.call(
"mail.message",
"get_last_channel_code",
[composerThread.model, composerThread.id],
{}
);
if (!lastChannelCode) {
this.tierState.selectedChannelId = null;
return;
}
const matchedChannel = this.tierState.availableChannels.find(
(channelTag) => channelTag.code === lastChannelCode
);
this.tierState.selectedChannelId = matchedChannel ? matchedChannel.id : null;
} catch {
this.tierState.selectedChannelId = null;
}
},
/** Toggle channel: click selected → deselect, click other → select */
selectChannel(channelId) {
this.tierState.selectedChannelId =
this.tierState.selectedChannelId === channelId ? null : channelId;
},
toggleMailbox(mailboxId) {
const existingIndex = this.tierState.selectedMailboxIds.indexOf(mailboxId);
if (existingIndex >= 0) {
this.tierState.selectedMailboxIds.splice(existingIndex, 1);
} else {
this.tierState.selectedMailboxIds.push(mailboxId);
}
},
get postData() {
const basePostData = super.postData;
// Inject tierChannelTagIds into postData so Store.getMessagePostParams can pick it up
if (this.tierState.selectedChannelId) {
basePostData.tierChannelTagIds = [[6, 0, [this.tierState.selectedChannelId]]];
} else {
basePostData.tierChannelTagIds = null;
}
return basePostData;
},
async _sendMessage(value, postData, extraData) {
const sentMessage = await super._sendMessage(value, postData, extraData);
if (!this.tierState.selectedChannelId) {
return sentMessage;
}
const selectedChannel = this.tierState.availableChannels.find(
(channelTag) => channelTag.id === this.tierState.selectedChannelId
);
if (!selectedChannel) {
return sentMessage;
}
// For discuss.channel, sentMessage may be undefined (optimistic send).
// Fall back to the message_id captured by doMessagePost patch.
const messageId = sentMessage?.id ?? this.store._lastPostedMessageId;
if (!messageId) {
return sentMessage;
}
const composerThread = this.props?.composer?.thread;
// Fire-and-forget: show toast after send
this._tierNotifyChannelSend(
messageId,
selectedChannel.code,
composerThread?.model,
composerThread?.id
);
return sentMessage;
},
/**
* Call the backend to send via external channel and show a toast notification.
* Runs async — does not block message posting.
*/
async _tierNotifyChannelSend(messageId, channelCode, threadModel, threadId) {
try {
const result = await rpc('/tier_communications/send_via_channel', {
message_id: messageId,
channel_code: channelCode,
thread_model: threadModel || null,
thread_id: threadId || null,
});
const notificationType = result.status === 'success' ? 'success'
: result.status === 'no_data' ? 'warning'
: 'danger';
this.notification.add(result.message, { type: notificationType });
} catch (error) {
this.notification.add(
`Ошибка при отправке через ${channelCode}: ${error.message || error}`,
{ type: 'danger' }
);
}
},
/**
* Assign the current thread's conversation to a mailbox.
* Called from the chatter toolbar buttons "Входящие лиды" / "Входящие тикеты".
*/
async assignToMailbox(mailboxId, mailboxName) {
const composerThread = this.props?.composer?.thread;
if (!composerThread || composerThread.model === "mail.box" || !composerThread.id) {
return;
}
try {
await this.orm.call(
composerThread.model,
"assign_mailbox",
[[composerThread.id], mailboxId],
{}
);
this.notification.add(
`Переписка перенесена в "${mailboxName}"`,
{ type: "success" }
);
} catch (error) {
console.error("[tier_communications] assignToMailbox error:", error);
this.notification.add(
`Ошибка при переносе в "${mailboxName}"`,
{ type: "danger" }
);
}
},
});

View File

@ -0,0 +1,32 @@
/** @odoo-module **/
import { registerMessageAction } from "@mail/core/common/message_actions";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
/**
* Register "Move to Mailbox" action in the message action menu.
* Pункт 2: пользователь может вручную перенести сообщение в ящик.
*/
registerMessageAction("tier-move-to-mailbox", {
condition: ({ message }) => Boolean(message?.id && Number.isInteger(message.id)),
icon: "fa fa-inbox",
name: _t("Переместить в ящик"),
setup: ({ owner }) => {
owner.tierActionService = useService("action");
},
onSelected: async ({ message, owner }) => {
await owner.tierActionService.doAction({
type: "ir.actions.act_window",
name: _t("Переместить в ящик"),
res_model: "tier.move.to.mailbox.wizard",
view_mode: "form",
views: [[false, "form"]],
target: "new",
context: {
default_message_id: message.id,
},
});
},
sequence: 90,
});

View File

@ -0,0 +1,77 @@
/** @odoo-module **/
import { Store } from "@mail/core/common/store_service";
import { Thread } from "@mail/core/common/thread_model";
import { patch } from "@web/core/utils/patch";
/**
* Patch Thread to support tier mailbox fetch routes.
* Must be patched before Store so Thread.insert works correctly.
*/
patch(Thread.prototype, {
/** @type {number|undefined} */
tier_mailbox_id: undefined,
getFetchRoute() {
if (
this.model === "mail.box" &&
typeof this.id === "string" &&
this.id.startsWith("tier_mailbox_") &&
this.tier_mailbox_id
) {
return `/mail/tier_mailbox/${this.tier_mailbox_id}/messages`;
}
return super.getFetchRoute(...arguments);
},
getFetchParams() {
if (
this.model === "mail.box" &&
typeof this.id === "string" &&
this.id.startsWith("tier_mailbox_")
) {
return {};
}
return super.getFetchParams(...arguments);
},
});
/**
* Patch Store to load tier mailboxes as Thread objects on startup.
* Follows the same pattern as inbox/starred/history in store_service_patch.js.
*/
patch(Store.prototype, {
setup() {
super.setup(...arguments);
this.tierMailboxThreads = [];
},
onStarted() {
super.onStarted(...arguments);
// Load tier mailboxes asynchronously after store is ready
this._loadTierMailboxes().catch((error) => {
console.warn("[tier_communications] Failed to load tier mailboxes:", error);
});
},
async _loadTierMailboxes() {
const mailboxes = await this.env.services.orm.call(
"tier.mailbox",
"get_accessible_mailboxes",
[],
{}
);
this.tierMailboxThreads = (mailboxes || []).map((mailboxData) => {
const threadRecord = this.Thread.insert({
id: `tier_mailbox_${mailboxData.id}`,
model: "mail.box",
display_name: mailboxData.name,
counter: 0,
});
// Store the real DB id for the fetch route
threadRecord.tier_mailbox_id = mailboxData.id;
return threadRecord;
});
},
});

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="tier_communications.DiscussSidebarMailboxes">
<t t-foreach="mailboxThreads" t-as="mailboxThread" t-key="mailboxThread.id">
<!-- Section header with chevron -->
<div class="o-mail-DiscussSidebarCategory position-relative d-flex align-items-center rounded opacity-trigger-hover mt-1 ms-4 me-2 pe-1">
<button
class="o-mail-DiscussSidebarCategory-toggler btn btn-link text-reset flex-grow-1 d-flex p-0 text-start align-items-baseline o-gap-0_5"
t-on-click="() => this.toggle(mailboxThread.id)"
>
<i class="o-mail-DiscussSidebarCategory-icon o-xsmaller fa-fw position-absolute ms-n3 align-self-center"
t-att-class="isOpen(mailboxThread.id) ? 'oi oi-chevron-down' : 'oi oi-chevron-right'"/>
<span class="o-mail-DiscussSidebarCategory-title text-break smaller"
t-att-class="{ 'fw-bold': mailboxThread.counter > 0 }"
t-esc="mailboxThread.display_name"/>
</button>
<t t-if="mailboxThread.counter > 0">
<span class="o-mail-DiscussSidebar-badge shadow-sm badge rounded-pill o-discuss-badge fw-bold me-1"
t-esc="mailboxThread.counter"/>
</t>
</div>
<!-- Conversation list (grouped by author) when expanded -->
<t t-if="isOpen(mailboxThread.id)">
<t t-set="conversations" t-value="getConversations(mailboxThread.tier_mailbox_id)"/>
<t t-if="conversations.length > 0">
<t t-foreach="conversations" t-as="conversation" t-key="conversation.partner_id">
<div class="o-mail-DiscussSidebarChannel-container d-flex flex-column mx-2 bg-inherit">
<button
class="o-mail-DiscussSidebarChannel btn d-flex align-items-center px-0 mx-2 border-0 rounded fw-normal text-reset"
style="line-height:1.5; padding:2px 0;"
t-on-click="(ev) => this.openConversation(ev, conversation.partner_id)"
>
<div class="d-flex flex-column flex-grow-1 overflow-hidden ms-1">
<div class="d-flex align-items-center gap-1">
<span class="text-truncate smaller fw-bold" style="max-width:120px;"
t-esc="conversation.partner_name"/>
<t t-if="conversation.channel_code">
<span class="badge text-bg-secondary fw-normal"
style="font-size:0.6rem; padding:1px 4px;"
t-esc="conversation.channel_code"/>
</t>
<t t-if="conversation.message_count > 1">
<span class="badge rounded-pill text-bg-primary"
style="font-size:0.6rem; padding:1px 4px;"
t-esc="conversation.message_count"/>
</t>
</div>
<span class="text-truncate text-muted"
style="font-size:0.7rem; max-width:180px;"
t-esc="conversation.last_message_preview"/>
</div>
</button>
</div>
</t>
</t>
<t t-else="">
<div class="mx-4 my-1">
<span class="text-muted" style="font-size:0.72rem;">Нет сообщений</span>
</div>
</t>
</t>
</t>
</t>
</templates>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!--
Extends the mail.Message template to display channel tags
in the message header next to the date.
Requirements: 15.1, 15.2, 15.3, 15.4, 15.5
-->
<t t-name="mail.Message" t-inherit="mail.Message" t-inherit-mode="extension">
<xpath expr="//div[@name='header']" position="inside">
<t t-if="message.tierChannelTagNames and message.tierChannelTagNames.length > 0">
<span class="o-tier-Message-channelTags ms-1 d-inline-flex gap-1 align-items-baseline">
<t t-foreach="message.tierChannelTagNames" t-as="tagName" t-key="tagName_index">
<span class="badge text-bg-secondary fw-normal small" t-esc="tagName"/>
</t>
</span>
</t>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.Composer" t-inherit="mail.Composer" t-inherit-mode="extension">
<xpath expr="//div[hasclass('o-mail-Composer-footer')]" position="before">
<t t-if="tierState.availableChannels.length > 0 or tierState.availableMailboxes.length > 0">
<div style="display:flex; flex-direction:column; gap:4px; padding:4px 12px 2px; grid-column:1/-1;">
<!-- Channel row: horizontal pill buttons (radio) -->
<t t-if="tierState.availableChannels.length > 0">
<div style="display:flex; flex-direction:row; flex-wrap:wrap; align-items:center; gap:6px;">
<span style="font-size:0.78rem; color:#6c757d; white-space:nowrap; flex-shrink:0;">Канал:</span>
<t t-foreach="tierState.availableChannels" t-as="channel" t-key="channel.id">
<button
t-on-click="() => this.selectChannel(channel.id)"
t-att-style="tierState.selectedChannelId === channel.id
? 'display:inline-block; font-size:0.78rem; line-height:1.4; padding:1px 10px; border-radius:20px; border:1px solid #0d6efd; background:#0d6efd; color:#fff; cursor:pointer; white-space:nowrap;'
: 'display:inline-block; font-size:0.78rem; line-height:1.4; padding:1px 10px; border-radius:20px; border:1px solid #6c757d; background:transparent; color:#6c757d; cursor:pointer; white-space:nowrap;'"
><t t-esc="channel.name"/></button>
</t>
</div>
</t>
<!-- Assign conversation to mailbox: green arrow buttons -->
<t t-if="tierState.availableMailboxes.length > 0">
<div style="display:flex; flex-direction:row; flex-wrap:wrap; align-items:center; gap:6px;">
<span style="font-size:0.78rem; color:#6c757d; white-space:nowrap; flex-shrink:0;">Перенести в:</span>
<t t-foreach="tierState.availableMailboxes" t-as="mailbox" t-key="mailbox.id">
<button
t-on-click="() => this.assignToMailbox(mailbox.id, mailbox.name)"
style="display:inline-flex; align-items:center; gap:4px; font-size:0.78rem; line-height:1.4; padding:1px 10px; border-radius:20px; border:1px solid #198754; background:transparent; color:#198754; cursor:pointer; white-space:nowrap;"
>
<i class="fa fa-arrow-right" style="font-size:0.7rem;"/>
<t t-esc="mailbox.name"/>
</button>
</t>
</div>
</t>
</div>
</t>
</xpath>
</t>
</templates>