From 239fbe893b7192d8bf49ac90623a38ffc0880f9c Mon Sep 17 00:00:00 2001 From: Luis Felipe Mileo Date: Mon, 2 Feb 2026 23:52:08 -0300 Subject: [PATCH 1/2] [ADD] customer_engagement: Omnichannel Customer Engagement Center New module providing a unified inbox for managing customer conversations across multiple channels with support teams, canned responses, labels, and real-time messaging capabilities. --- customer_engagement/README.rst | 90 +++ customer_engagement/__init__.py | 1 + customer_engagement/__manifest__.py | 80 +++ .../data/canned_response_data.xml | 208 ++++++ .../data/conversation_stage_data.xml | 44 ++ .../data/support_folder_data.xml | 97 +++ customer_engagement/demo/demo_data.xml | 124 ++++ customer_engagement/models/__init__.py | 8 + customer_engagement/models/canned_response.py | 127 ++++ customer_engagement/models/conversation.py | 359 +++++++++++ .../models/conversation_history.py | 30 + .../models/conversation_label.py | 41 ++ .../models/conversation_note.py | 69 ++ .../models/conversation_stage.py | 39 ++ customer_engagement/models/support_folder.py | 89 +++ customer_engagement/models/support_team.py | 139 ++++ customer_engagement/pyproject.toml | 3 + .../security/ir.model.access.csv | 17 + .../security/security_groups.xml | 18 + .../chat_panel/attachment_uploader.js | 135 ++++ .../chat_panel/canned_response_picker.js | 114 ++++ .../js/components/chat_panel/chat_header.js | 97 +++ .../js/components/chat_panel/chat_panel.js | 120 ++++ .../js/components/chat_panel/emoji_picker.js | 348 ++++++++++ .../components/chat_panel/message_bubble.js | 74 +++ .../js/components/chat_panel/message_list.js | 62 ++ .../js/components/chat_panel/note_bubble.js | 58 ++ .../js/components/chat_panel/rich_composer.js | 174 +++++ .../components/chat_panel/timeline_event.js | 35 + .../components/contact_panel/contact_panel.js | 276 ++++++++ .../conversation_list/channel_badge.js | 48 ++ .../conversation_list/conversation_card.js | 88 +++ .../conversation_list/conversation_list.js | 74 +++ .../conversation_list/label_pills.js | 44 ++ .../conversation_list/priority_indicator.js | 29 + .../conversation_list/relative_time.js | 79 +++ .../src/js/components/sidebar/sidebar_item.js | 48 ++ .../js/components/sidebar/sidebar_section.js | 18 + .../js/components/sidebar/support_sidebar.js | 143 +++++ .../static/src/js/inbox_action.js | 552 ++++++++++++++++ .../src/scss/components/_chat_panel.scss | 90 +++ .../static/src/scss/components/_composer.scss | 149 +++++ .../src/scss/components/_contact_panel.scss | 130 ++++ .../scss/components/_conversation_list.scss | 179 ++++++ .../src/scss/components/_message_bubbles.scss | 129 ++++ .../src/scss/components/_responsive.scss | 275 ++++++++ .../static/src/scss/components/_sidebar.scss | 151 +++++ .../static/src/scss/inbox.scss | 133 ++++ .../xml/components/chat_panel_templates.xml | 600 ++++++++++++++++++ .../components/contact_panel_templates.xml | 312 +++++++++ .../conversation_list_templates.xml | 202 ++++++ .../src/xml/components/sidebar_templates.xml | 353 +++++++++++ .../static/src/xml/inbox_templates.xml | 62 ++ customer_engagement/tests/__init__.py | 1 + .../tests/test_conversation.py | 365 +++++++++++ .../views/canned_response_views.xml | 206 ++++++ .../views/conversation_label_views.xml | 98 +++ .../views/conversation_stage_views.xml | 48 ++ .../views/conversation_views.xml | 295 +++++++++ customer_engagement/views/menu_views.xml | 88 +++ .../views/support_folder_views.xml | 137 ++++ .../views/support_team_views.xml | 156 +++++ eslint.config.cjs | 27 + .../odoo/addons/customer_engagement | 1 + setup/customer_engagement/setup.py | 6 + 65 files changed, 8392 insertions(+) create mode 100644 customer_engagement/README.rst create mode 100644 customer_engagement/__init__.py create mode 100644 customer_engagement/__manifest__.py create mode 100644 customer_engagement/data/canned_response_data.xml create mode 100644 customer_engagement/data/conversation_stage_data.xml create mode 100644 customer_engagement/data/support_folder_data.xml create mode 100644 customer_engagement/demo/demo_data.xml create mode 100644 customer_engagement/models/__init__.py create mode 100644 customer_engagement/models/canned_response.py create mode 100644 customer_engagement/models/conversation.py create mode 100644 customer_engagement/models/conversation_history.py create mode 100644 customer_engagement/models/conversation_label.py create mode 100644 customer_engagement/models/conversation_note.py create mode 100644 customer_engagement/models/conversation_stage.py create mode 100644 customer_engagement/models/support_folder.py create mode 100644 customer_engagement/models/support_team.py create mode 100644 customer_engagement/pyproject.toml create mode 100644 customer_engagement/security/ir.model.access.csv create mode 100644 customer_engagement/security/security_groups.xml create mode 100644 customer_engagement/static/src/js/components/chat_panel/attachment_uploader.js create mode 100644 customer_engagement/static/src/js/components/chat_panel/canned_response_picker.js create mode 100644 customer_engagement/static/src/js/components/chat_panel/chat_header.js create mode 100644 customer_engagement/static/src/js/components/chat_panel/chat_panel.js create mode 100644 customer_engagement/static/src/js/components/chat_panel/emoji_picker.js create mode 100644 customer_engagement/static/src/js/components/chat_panel/message_bubble.js create mode 100644 customer_engagement/static/src/js/components/chat_panel/message_list.js create mode 100644 customer_engagement/static/src/js/components/chat_panel/note_bubble.js create mode 100644 customer_engagement/static/src/js/components/chat_panel/rich_composer.js create mode 100644 customer_engagement/static/src/js/components/chat_panel/timeline_event.js create mode 100644 customer_engagement/static/src/js/components/contact_panel/contact_panel.js create mode 100644 customer_engagement/static/src/js/components/conversation_list/channel_badge.js create mode 100644 customer_engagement/static/src/js/components/conversation_list/conversation_card.js create mode 100644 customer_engagement/static/src/js/components/conversation_list/conversation_list.js create mode 100644 customer_engagement/static/src/js/components/conversation_list/label_pills.js create mode 100644 customer_engagement/static/src/js/components/conversation_list/priority_indicator.js create mode 100644 customer_engagement/static/src/js/components/conversation_list/relative_time.js create mode 100644 customer_engagement/static/src/js/components/sidebar/sidebar_item.js create mode 100644 customer_engagement/static/src/js/components/sidebar/sidebar_section.js create mode 100644 customer_engagement/static/src/js/components/sidebar/support_sidebar.js create mode 100644 customer_engagement/static/src/js/inbox_action.js create mode 100644 customer_engagement/static/src/scss/components/_chat_panel.scss create mode 100644 customer_engagement/static/src/scss/components/_composer.scss create mode 100644 customer_engagement/static/src/scss/components/_contact_panel.scss create mode 100644 customer_engagement/static/src/scss/components/_conversation_list.scss create mode 100644 customer_engagement/static/src/scss/components/_message_bubbles.scss create mode 100644 customer_engagement/static/src/scss/components/_responsive.scss create mode 100644 customer_engagement/static/src/scss/components/_sidebar.scss create mode 100644 customer_engagement/static/src/scss/inbox.scss create mode 100644 customer_engagement/static/src/xml/components/chat_panel_templates.xml create mode 100644 customer_engagement/static/src/xml/components/contact_panel_templates.xml create mode 100644 customer_engagement/static/src/xml/components/conversation_list_templates.xml create mode 100644 customer_engagement/static/src/xml/components/sidebar_templates.xml create mode 100644 customer_engagement/static/src/xml/inbox_templates.xml create mode 100644 customer_engagement/tests/__init__.py create mode 100644 customer_engagement/tests/test_conversation.py create mode 100644 customer_engagement/views/canned_response_views.xml create mode 100644 customer_engagement/views/conversation_label_views.xml create mode 100644 customer_engagement/views/conversation_stage_views.xml create mode 100644 customer_engagement/views/conversation_views.xml create mode 100644 customer_engagement/views/menu_views.xml create mode 100644 customer_engagement/views/support_folder_views.xml create mode 100644 customer_engagement/views/support_team_views.xml create mode 120000 setup/customer_engagement/odoo/addons/customer_engagement create mode 100644 setup/customer_engagement/setup.py diff --git a/customer_engagement/README.rst b/customer_engagement/README.rst new file mode 100644 index 0000000000..a0c056fa90 --- /dev/null +++ b/customer_engagement/README.rst @@ -0,0 +1,90 @@ +=================== +Customer Engagement +=================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:placeholder !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsocial-lightgray.png?logo=github + :target: https://github.com/OCA/social/tree/18.0/customer_engagement + :alt: OCA/social + +|badge1| |badge2| |badge3| + +Omnichannel Customer Engagement Center for managing customer conversations +across multiple channels (email, WhatsApp, Instagram, Messenger, Telegram, +Live Chat, and API). + +**Table of contents** + +.. contents:: + :local: + +Features +======== + +* Unified inbox for all customer conversations +* Support for multiple channels +* Team-based conversation routing +* Canned responses for quick replies +* Conversation labels and folders +* Private internal notes +* Real-time messaging with bus integration +* SLA tracking with timestamps + +Usage +===== + +1. Install the module +2. Configure support teams and assign members +3. Create canned responses for common replies +4. Access the Customer Engagement app from the main menu + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Credits +======= + +Authors +~~~~~~~ + +* KMEE + +Contributors +~~~~~~~~~~~~ + +* KMEE + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/social `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/customer_engagement/__init__.py b/customer_engagement/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/customer_engagement/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/customer_engagement/__manifest__.py b/customer_engagement/__manifest__.py new file mode 100644 index 0000000000..4fc6465834 --- /dev/null +++ b/customer_engagement/__manifest__.py @@ -0,0 +1,80 @@ +{ + "name": "Customer Engagement", + "version": "18.0.1.0.0", + "category": "Services", + "summary": "Omnichannel Customer Engagement Center", + "author": "KMEE, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/social", + "license": "AGPL-3", + "depends": [ + "mail", + "contacts", + "bus", + ], + "data": [ + "security/security_groups.xml", + "security/ir.model.access.csv", + "data/conversation_stage_data.xml", + "data/support_folder_data.xml", + "data/canned_response_data.xml", + "views/conversation_stage_views.xml", + "views/conversation_label_views.xml", + "views/support_team_views.xml", + "views/support_folder_views.xml", + "views/canned_response_views.xml", + "views/conversation_views.xml", + "views/menu_views.xml", + ], + "assets": { + "web.assets_backend": [ + # SCSS - Component styles (self-contained, no imports) + "customer_engagement/static/src/scss/components/_sidebar.scss", + "customer_engagement/static/src/scss/components/_conversation_list.scss", + "customer_engagement/static/src/scss/components/_chat_panel.scss", + "customer_engagement/static/src/scss/components/_message_bubbles.scss", + "customer_engagement/static/src/scss/components/_composer.scss", + "customer_engagement/static/src/scss/components/_contact_panel.scss", + "customer_engagement/static/src/scss/components/_responsive.scss", + # SCSS - Main styles + "customer_engagement/static/src/scss/inbox.scss", + # JavaScript - Sidebar components + "customer_engagement/static/src/js/components/sidebar/sidebar_item.js", + "customer_engagement/static/src/js/components/sidebar/sidebar_section.js", + "customer_engagement/static/src/js/components/sidebar/support_sidebar.js", + # JavaScript - Conversation List components + "customer_engagement/static/src/js/components/conversation_list/channel_badge.js", + "customer_engagement/static/src/js/components/conversation_list/label_pills.js", + "customer_engagement/static/src/js/components/conversation_list/priority_indicator.js", # noqa: B950 + "customer_engagement/static/src/js/components/conversation_list/relative_time.js", + "customer_engagement/static/src/js/components/conversation_list/conversation_card.js", # noqa: B950 + "customer_engagement/static/src/js/components/conversation_list/conversation_list.js", # noqa: B950 + # JavaScript - Chat Panel components + "customer_engagement/static/src/js/components/chat_panel/timeline_event.js", + "customer_engagement/static/src/js/components/chat_panel/message_bubble.js", + "customer_engagement/static/src/js/components/chat_panel/note_bubble.js", + "customer_engagement/static/src/js/components/chat_panel/message_list.js", + "customer_engagement/static/src/js/components/chat_panel/emoji_picker.js", + "customer_engagement/static/src/js/components/chat_panel/canned_response_picker.js", + "customer_engagement/static/src/js/components/chat_panel/attachment_uploader.js", + "customer_engagement/static/src/js/components/chat_panel/rich_composer.js", + "customer_engagement/static/src/js/components/chat_panel/chat_header.js", + "customer_engagement/static/src/js/components/chat_panel/chat_panel.js", + # JavaScript - Contact Panel components + "customer_engagement/static/src/js/components/contact_panel/contact_panel.js", + # JavaScript - Main inbox action + "customer_engagement/static/src/js/inbox_action.js", + # XML Templates + "customer_engagement/static/src/xml/components/sidebar_templates.xml", + "customer_engagement/static/src/xml/components/conversation_list_templates.xml", + "customer_engagement/static/src/xml/components/chat_panel_templates.xml", + "customer_engagement/static/src/xml/components/contact_panel_templates.xml", + "customer_engagement/static/src/xml/inbox_templates.xml", + ], + }, + "demo": [ + "demo/demo_data.xml", + ], + "installable": True, + "application": True, + "auto_install": False, +} diff --git a/customer_engagement/data/canned_response_data.xml b/customer_engagement/data/canned_response_data.xml new file mode 100644 index 0000000000..c01c20961b --- /dev/null +++ b/customer_engagement/data/canned_response_data.xml @@ -0,0 +1,208 @@ + + + + + + Hello Greeting + hello + greeting + 1 + Olá! 👋

+

Obrigado por entrar em contato conosco. Como posso ajudá-lo hoje?

+ ]]>
+
+ + + Welcome Back + welcome + greeting + 2 + Olá, seja bem-vindo de volta!

+

Fico feliz em atendê-lo novamente. Em que posso ajudar?

+ ]]>
+
+ + + Good Morning + bomdia + greeting + 3 + Bom dia! ☀️

+

Obrigado por entrar em contato. Como posso ajudá-lo?

+ ]]>
+
+ + + + + Thank You Closing + thanks + closing + 10 + Obrigado pelo contato!

+

Se tiver mais alguma dúvida, estamos à disposição.

+

Tenha um ótimo dia! 😊

+ ]]>
+
+ + + Issue Resolved + resolved + closing + 11 + Fico feliz em saber que o problema foi resolvido! ✅

+

Caso precise de mais alguma coisa, não hesite em nos contatar.

+

Obrigado pela preferência!

+ ]]>
+
+ + + Request Feedback + feedback + closing + 12 + Espero que tenha conseguido ajudá-lo!

+

Se puder, nos dê um feedback sobre o atendimento. Sua opinião é muito importante para melhorarmos nossos serviços.

+

Obrigado!

+ ]]>
+
+ + + + + Please Wait + wait + info + 20 + Um momento, por favor. ⏳

+

Estou verificando as informações para você.

+ ]]>
+
+ + + Checking Information + checking + info + 21 + Estou consultando nosso sistema para obter as informações necessárias.

+

Retorno em breve com uma resposta. 🔍

+ ]]>
+
+ + + Business Hours + horario + info + 22 + Nosso horário de atendimento é:

+
    +
  • Segunda a Sexta: 8h às 18h
  • +
  • Sábado: 8h às 12h
  • +
+

Fora deste horário, deixe sua mensagem que retornaremos assim que possível.

+ ]]>
+
+ + + + + Apology for Delay + sorry + apology + 30 + Pedimos desculpas pela demora no retorno. 🙏

+

Estamos trabalhando para resolver sua solicitação o mais rápido possível.

+ ]]>
+
+ + + Apology for Inconvenience + desculpa + apology + 31 + Lamentamos pelo transtorno causado.

+

Estamos empenhados em resolver essa situação para você.

+

Agradecemos sua compreensão.

+ ]]>
+
+ + + + + Status Update + update + followup + 40 + Olá! Passando aqui para dar um retorno sobre sua solicitação.

+

[Adicione o status atual aqui]

+

Se tiver alguma dúvida, é só responder esta mensagem.

+ ]]>
+
+ + + Need More Information + needinfo + followup + 41 + Para prosseguir com seu atendimento, precisamos de algumas informações adicionais:

+
    +
  • [Liste as informações necessárias]
  • +
+

Aguardamos seu retorno.

+ ]]>
+
+ + + Friendly Reminder + reminder + followup + 42 + Olá! 👋

+

Gostaríamos de verificar se ainda precisa de ajuda com sua solicitação.

+

Caso já tenha sido resolvido, por favor nos avise para que possamos fechar este atendimento.

+

Estamos à disposição!

+ ]]>
+
+
diff --git a/customer_engagement/data/conversation_stage_data.xml b/customer_engagement/data/conversation_stage_data.xml new file mode 100644 index 0000000000..fe654a3e52 --- /dev/null +++ b/customer_engagement/data/conversation_stage_data.xml @@ -0,0 +1,44 @@ + + + + + + + + + New + new + 10 + False + + + + Open + open + 20 + False + + + + Pending + pending + 30 + False + + + + Resolved + resolved + 40 + False + True + + + + Closed + closed + 50 + True + True + + diff --git a/customer_engagement/data/support_folder_data.xml b/customer_engagement/data/support_folder_data.xml new file mode 100644 index 0000000000..91d3178c6c --- /dev/null +++ b/customer_engagement/data/support_folder_data.xml @@ -0,0 +1,97 @@ + + + + + + + Inbox + inbox + fa-inbox + 1 + True + smart + [('closed', '=', False)] + + + + + Assigned to me + mine + fa-user + 2 + True + smart + [('user_id', '=', uid), ('closed', '=', False)] + + + + + Unassigned + unassigned + fa-question-circle + 3 + True + smart + [('user_id', '=', False), ('closed', '=', False)] + + + + + Urgent + urgent + fa-exclamation-triangle + 4 + 1 + True + smart + [('priority', '=', '3'), ('closed', '=', False)] + + + + + Starred + starred + fa-star + 5 + 3 + True + smart + [('priority', 'in', ['2', '3']), ('closed', '=', False)] + + + + + Pending + pending + fa-clock-o + 6 + True + smart + [('stage_id.code', '=', 'pending')] + + + + + Resolved + resolved + fa-check + 7 + 10 + True + smart + [('stage_id.code', '=', 'resolved')] + + + + + All Conversations + all + fa-comments + 99 + True + smart + [] + + diff --git a/customer_engagement/demo/demo_data.xml b/customer_engagement/demo/demo_data.xml new file mode 100644 index 0000000000..dce58e8984 --- /dev/null +++ b/customer_engagement/demo/demo_data.xml @@ -0,0 +1,124 @@ + + + + + João Silva + joao.silva@email.com + +55 11 98765-4321 + + + + Maria Santos + maria.santos@email.com + +55 21 99876-5432 + + + + Pedro Oliveira + pedro.oliveira@email.com + +55 31 97654-3210 + + + + Ana Costa + ana.costa@email.com + +55 41 96543-2109 + + + + Carlos Ferreira + carlos.ferreira@email.com + +55 51 95432-1098 + + + + + Problema com pedido #12345 + whatsapp + + 3 + + + + + Dúvida sobre prazo de entrega + email + + 1 + + + + + + Solicitação de troca de produto + instagram + + 2 + + + + + + Não recebi o código de rastreio + whatsapp + + 1 + + + + + + + Aguardando envio de documentos + email + + 1 + + + + + + Problema técnico - aguardando retorno + telegram + + 2 + + + + + + + Informações sobre garantia + livechat + + 1 + + + + + Cancelamento de pedido + messenger + + 3 + + + + + + Dúvida sobre formas de pagamento + whatsapp + + 0 + + + + + + Atualização de cadastro + email + + 1 + + + + diff --git a/customer_engagement/models/__init__.py b/customer_engagement/models/__init__.py new file mode 100644 index 0000000000..d4cac00e63 --- /dev/null +++ b/customer_engagement/models/__init__.py @@ -0,0 +1,8 @@ +from . import conversation_stage +from . import conversation_label +from . import support_team +from . import support_folder +from . import canned_response +from . import conversation_note +from . import conversation +from . import conversation_history diff --git a/customer_engagement/models/canned_response.py b/customer_engagement/models/canned_response.py new file mode 100644 index 0000000000..3aa9250421 --- /dev/null +++ b/customer_engagement/models/canned_response.py @@ -0,0 +1,127 @@ +"""Canned Response Model.""" + +from odoo import fields, models + + +class CannedResponse(models.Model): + """Pre-defined responses for quick replies.""" + + _name = "support.canned.response" + _description = "Canned Response" + _order = "sequence, shortcut" + + name = fields.Char( + string="Title", + required=True, + translate=True, + help="Descriptive name for the response", + ) + shortcut = fields.Char( + required=True, + help="Type / followed by this shortcut to insert (e.g., /greeting)", + ) + content = fields.Html( + required=True, + translate=True, + sanitize=True, + help="The response text to insert", + ) + plain_content = fields.Text( + compute="_compute_plain_content", + store=True, + help="Plain text version of content for preview", + ) + sequence = fields.Integer( + default=10, + ) + active = fields.Boolean( + default=True, + ) + category = fields.Selection( + selection=[ + ("greeting", "Greetings"), + ("closing", "Closings"), + ("info", "Information"), + ("apology", "Apologies"), + ("followup", "Follow-ups"), + ("other", "Other"), + ], + default="other", + ) + channel_types = fields.Char( + string="Channels", + help="Comma-separated channel types, empty = all channels", + ) + team_ids = fields.Many2many( + comodel_name="support.team", + relation="canned_response_team_rel", + column1="response_id", + column2="team_id", + string="Teams", + help="Restrict to specific teams, empty = all teams", + ) + user_id = fields.Many2one( + comodel_name="res.users", + string="Owner", + help="If set, only this user can use this response", + ) + usage_count = fields.Integer( + default=0, + help="Number of times this response has been used", + ) + + _sql_constraints = [ + ( + "shortcut_unique", + "UNIQUE(shortcut)", + "Shortcut must be unique!", + ), + ] + + def _compute_plain_content(self): + """Extract plain text from HTML content.""" + from odoo.tools import html2plaintext + + for response in self: + if response.content: + response.plain_content = html2plaintext(response.content)[:200] + else: + response.plain_content = "" + + def increment_usage(self): + """Increment usage count when response is used.""" + for response in self: + response.sudo().write({"usage_count": response.usage_count + 1}) + + def search_by_shortcut( + self, shortcut, channel_type=None, team_id=None, user_id=None + ): + """Search for canned responses matching a shortcut prefix.""" + domain = [ + ("active", "=", True), + ("shortcut", "ilike", shortcut), + ] + + responses = self.search(domain, order="sequence, shortcut", limit=10) + + # Filter by channel type if specified + if channel_type: + responses = responses.filtered( + lambda r: not r.channel_types + or channel_type in r.channel_types.split(",") + ) + + # Filter by team if specified + if team_id: + team = self.env["support.team"].browse(team_id) + responses = responses.filtered( + lambda r: not r.team_ids or team in r.team_ids + ) + + # Filter by user (personal responses) + if user_id: + responses = responses.filtered( + lambda r: not r.user_id or r.user_id.id == user_id + ) + + return responses diff --git a/customer_engagement/models/conversation.py b/customer_engagement/models/conversation.py new file mode 100644 index 0000000000..9b2174393c --- /dev/null +++ b/customer_engagement/models/conversation.py @@ -0,0 +1,359 @@ +import uuid as uuid_lib + +from odoo import api, fields, models +from odoo.exceptions import UserError + +# Valid transitions mapping +VALID_TRANSITIONS = { + "new": ["open"], + "open": ["pending", "resolved"], + "pending": ["open", "resolved"], + "resolved": ["open", "closed"], + "closed": ["open"], +} + + +class Conversation(models.Model): + _name = "support.conversation" + _description = "Support Conversation" + _inherit = ["mail.thread", "mail.activity.mixin"] + _order = "priority desc, create_date desc" + _rec_name = "display_name" + + # Identification + uuid = fields.Char( + string="UUID", + default=lambda self: str(uuid_lib.uuid4()), + readonly=True, + index=True, + copy=False, + ) + subject = fields.Char() + display_name = fields.Char(compute="_compute_display_name", store=True) + + # Channel + channel_type = fields.Selection( + [ + ("email", "Email"), + ("whatsapp", "WhatsApp"), + ("instagram", "Instagram"), + ("messenger", "Messenger"), + ("telegram", "Telegram"), + ("livechat", "Live Chat"), + ("api", "API"), + ], + string="Channel", + required=True, + index=True, + tracking=True, + ) + + # Stage (state machine) + stage_id = fields.Many2one( + comodel_name="support.conversation.stage", + string="Stage", + tracking=True, + index=True, + group_expand="_read_group_stage_ids", + default=lambda self: self._default_stage(), + copy=False, + ) + closed = fields.Boolean(related="stage_id.closed", store=True) + + # Priority + priority = fields.Selection( + [ + ("0", "Low"), + ("1", "Normal"), + ("2", "High"), + ("3", "Urgent"), + ], + default="1", + index=True, + tracking=True, + ) + + # Relationships + partner_id = fields.Many2one( + comodel_name="res.partner", + string="Contact", + index=True, + tracking=True, + ) + user_id = fields.Many2one( + comodel_name="res.users", + string="Assigned Agent", + index=True, + tracking=True, + ) + + # Timestamps + first_response_at = fields.Datetime(string="First Response", readonly=True) + resolved_at = fields.Datetime(readonly=True) + + # Color for Kanban + color = fields.Integer(string="Color Index") + + # History + history_ids = fields.One2many( + comodel_name="support.conversation.history", + inverse_name="conversation_id", + string="History", + ) + + # Labels (tags) + label_ids = fields.Many2many( + comodel_name="support.conversation.label", + relation="support_conversation_label_rel", + column1="conversation_id", + column2="label_id", + string="Labels", + ) + + # Team assignment + team_id = fields.Many2one( + comodel_name="support.team", + string="Team", + index=True, + tracking=True, + ) + + # Folders + folder_ids = fields.Many2many( + comodel_name="support.folder", + relation="support_conversation_folder_rel", + column1="conversation_id", + column2="folder_id", + string="Folders", + ) + + # Private notes + note_ids = fields.One2many( + comodel_name="support.conversation.note", + inverse_name="conversation_id", + string="Notes", + ) + + # Computed fields for UI + unread_message_count = fields.Integer( + string="Unread Messages", + compute="_compute_unread_count", + store=False, + ) + last_message_preview = fields.Char( + string="Last Message", + compute="_compute_last_message", + store=False, + ) + last_message_date = fields.Datetime( + compute="_compute_last_message", + store=False, + ) + note_count = fields.Integer( + string="Notes Count", + compute="_compute_note_count", + ) + + # Computed methods for UI fields + def _compute_unread_count(self): + """Compute unread messages count.""" + for rec in self: + # Count messages not authored by internal users + # author_id is res.partner, check if it has no linked user + messages = self.env["mail.message"].search_count( + [ + ("model", "=", "support.conversation"), + ("res_id", "=", rec.id), + ("message_type", "in", ["comment", "email"]), + ( + "author_id.user_ids", + "=", + False, + ), # External author (no linked user) + ] + ) + rec.unread_message_count = messages + + def _compute_last_message(self): + """Compute last message preview and date.""" + for rec in self: + last_message = self.env["mail.message"].search( + [ + ("model", "=", "support.conversation"), + ("res_id", "=", rec.id), + ("message_type", "in", ["comment", "email"]), + ], + order="date desc", + limit=1, + ) + if last_message: + # Strip HTML and truncate + from odoo.tools import html2plaintext + + plain_text = html2plaintext(last_message.body or "") + rec.last_message_preview = plain_text[:100] if plain_text else "" + rec.last_message_date = last_message.date + else: + rec.last_message_preview = "" + rec.last_message_date = False + + @api.depends("note_ids") + def _compute_note_count(self): + """Compute notes count.""" + for rec in self: + rec.note_count = len(rec.note_ids) + + # Computed + @api.depends("uuid", "subject", "partner_id") + def _compute_display_name(self): + for rec in self: + if rec.subject: + rec.display_name = f"[{rec.uuid[:8]}] {rec.subject}" + elif rec.partner_id: + rec.display_name = f"[{rec.uuid[:8]}] {rec.partner_id.name}" + else: + rec.display_name = f"[{rec.uuid[:8]}]" + + def _default_stage(self): + return self.env["support.conversation.stage"].search( + [("code", "=", "new")], limit=1 + ) + + @api.model + def _read_group_stage_ids(self, stages, domain): + return self.env["support.conversation.stage"].search([]) + + def write(self, vals): + if "stage_id" in vals: + new_stage = self.env["support.conversation.stage"].browse(vals["stage_id"]) + for rec in self: + if rec.stage_id: + rec._validate_transition(rec.stage_id.code, new_stage.code) + rec._record_transition(new_stage) + rec._update_timestamps(new_stage) + return super().write(vals) + + def _validate_transition(self, from_code, to_code): + """Validate that the state transition is allowed.""" + if from_code == to_code: + return + valid_targets = VALID_TRANSITIONS.get(from_code, []) + if to_code not in valid_targets: + raise UserError( + f"Invalid transition: {from_code} → {to_code}. " + f"Allowed: {', '.join(valid_targets)}" + ) + + def _record_transition(self, new_stage): + """Record transition in history.""" + self.env["support.conversation.history"].create( + { + "conversation_id": self.id, + "from_stage_id": self.stage_id.id if self.stage_id else False, + "to_stage_id": new_stage.id, + "user_id": self.env.uid, + } + ) + + def _update_timestamps(self, new_stage): + """Update relevant timestamps on transition.""" + vals = {} + if new_stage.code == "open" and not self.first_response_at: + vals["first_response_at"] = fields.Datetime.now() + if new_stage.code == "resolved" and not self.resolved_at: + vals["resolved_at"] = fields.Datetime.now() + if new_stage.code == "open" and self.resolved_at: + # Reopening - clear resolved timestamp + vals["resolved_at"] = False + if vals: + return super().write(vals) + return True + + # Action buttons + def action_open(self): + """Move to open stage.""" + stage = self.env["support.conversation.stage"].search( + [("code", "=", "open")], limit=1 + ) + self.write({"stage_id": stage.id}) + + def action_resolve(self): + """Move to resolved stage.""" + stage = self.env["support.conversation.stage"].search( + [("code", "=", "resolved")], limit=1 + ) + self.write({"stage_id": stage.id}) + + def action_close(self): + """Move to closed stage.""" + stage = self.env["support.conversation.stage"].search( + [("code", "=", "closed")], limit=1 + ) + self.write({"stage_id": stage.id}) + + def action_pending(self): + """Move to pending stage.""" + stage = self.env["support.conversation.stage"].search( + [("code", "=", "pending")], limit=1 + ) + self.write({"stage_id": stage.id}) + + def action_assign_to_me(self): + """Assign conversation to current user.""" + self.write({"user_id": self.env.uid}) + + def action_add_note(self, content): + """Add a private note to the conversation.""" + self.env["support.conversation.note"].create( + { + "conversation_id": self.id, + "author_id": self.env.uid, + "content": content, + } + ) + + def action_add_label(self, label_id): + """Add a label to the conversation.""" + self.write({"label_ids": [(4, label_id)]}) + + def action_remove_label(self, label_id): + """Remove a label from the conversation.""" + self.write({"label_ids": [(3, label_id)]}) + + def action_assign_team(self, team_id): + """Assign conversation to a team.""" + team = self.env["support.team"].browse(team_id) + vals = {"team_id": team_id} + if team.auto_assign: + agent = team.get_available_agent() + if agent: + vals["user_id"] = agent.id + self.write(vals) + + def action_add_to_folder(self, folder_id): + """Add conversation to a folder.""" + self.write({"folder_ids": [(4, folder_id)]}) + + def action_remove_from_folder(self, folder_id): + """Remove conversation from a folder.""" + self.write({"folder_ids": [(3, folder_id)]}) + + def init(self): + """Create database indexes for performance.""" + self.env.cr.execute( + """ + CREATE INDEX IF NOT EXISTS support_conversation_stage_user_idx + ON support_conversation (stage_id, user_id) + WHERE user_id IS NOT NULL; + + CREATE INDEX IF NOT EXISTS support_conversation_channel_stage_idx + ON support_conversation (channel_type, stage_id); + + CREATE INDEX IF NOT EXISTS support_conversation_partner_idx + ON support_conversation (partner_id) + WHERE partner_id IS NOT NULL; + + CREATE INDEX IF NOT EXISTS support_conversation_create_date_idx + ON support_conversation (create_date DESC); + """ + ) diff --git a/customer_engagement/models/conversation_history.py b/customer_engagement/models/conversation_history.py new file mode 100644 index 0000000000..f7f431fd9a --- /dev/null +++ b/customer_engagement/models/conversation_history.py @@ -0,0 +1,30 @@ +from odoo import fields, models + + +class ConversationHistory(models.Model): + _name = "support.conversation.history" + _description = "Conversation Transition History" + _order = "create_date desc" + + conversation_id = fields.Many2one( + comodel_name="support.conversation", + string="Conversation", + required=True, + ondelete="cascade", + index=True, + ) + from_stage_id = fields.Many2one( + comodel_name="support.conversation.stage", + string="From Stage", + ) + to_stage_id = fields.Many2one( + comodel_name="support.conversation.stage", + string="To Stage", + required=True, + ) + user_id = fields.Many2one( + comodel_name="res.users", + string="Changed By", + default=lambda self: self.env.uid, + ) + notes = fields.Text() diff --git a/customer_engagement/models/conversation_label.py b/customer_engagement/models/conversation_label.py new file mode 100644 index 0000000000..e78fdb564e --- /dev/null +++ b/customer_engagement/models/conversation_label.py @@ -0,0 +1,41 @@ +"""Support Conversation Label Model.""" + +from odoo import fields, models + + +class ConversationLabel(models.Model): + """Labels/tags for categorizing support conversations.""" + + _name = "support.conversation.label" + _description = "Conversation Label" + _order = "sequence, name" + + name = fields.Char( + required=True, + translate=True, + ) + sequence = fields.Integer( + default=10, + ) + color = fields.Integer( + string="Color Index", + default=0, + help="Color index for kanban display (0-11)", + ) + description = fields.Text( + translate=True, + ) + active = fields.Boolean( + default=True, + ) + conversation_count = fields.Integer( + string="Conversations", + compute="_compute_conversation_count", + ) + + def _compute_conversation_count(self): + """Count conversations using this label.""" + for label in self: + label.conversation_count = self.env["support.conversation"].search_count( + [("label_ids", "in", label.id)] + ) diff --git a/customer_engagement/models/conversation_note.py b/customer_engagement/models/conversation_note.py new file mode 100644 index 0000000000..8807ed5efa --- /dev/null +++ b/customer_engagement/models/conversation_note.py @@ -0,0 +1,69 @@ +"""Conversation Note Model.""" + +from odoo import fields, models + + +class ConversationNote(models.Model): + """Private notes attached to conversations (not visible to customers).""" + + _name = "support.conversation.note" + _description = "Conversation Note" + _order = "create_date desc" + + conversation_id = fields.Many2one( + comodel_name="support.conversation", + string="Conversation", + required=True, + ondelete="cascade", + index=True, + ) + author_id = fields.Many2one( + comodel_name="res.users", + string="Author", + required=True, + default=lambda self: self.env.user, + index=True, + ) + content = fields.Html( + required=True, + sanitize=True, + ) + plain_content = fields.Text( + compute="_compute_plain_content", + store=True, + ) + attachment_ids = fields.Many2many( + comodel_name="ir.attachment", + relation="conversation_note_attachment_rel", + column1="note_id", + column2="attachment_id", + string="Attachments", + ) + mentioned_user_ids = fields.Many2many( + comodel_name="res.users", + relation="conversation_note_mention_rel", + column1="note_id", + column2="user_id", + string="Mentioned Users", + help="Users mentioned in this note using @", + ) + is_pinned = fields.Boolean( + string="Pinned", + default=False, + help="Pinned notes appear at the top", + ) + + def _compute_plain_content(self): + """Extract plain text from HTML content.""" + from odoo.tools import html2plaintext + + for note in self: + if note.content: + note.plain_content = html2plaintext(note.content)[:500] + else: + note.plain_content = "" + + def toggle_pin(self): + """Toggle the pinned status of a note.""" + for note in self: + note.is_pinned = not note.is_pinned diff --git a/customer_engagement/models/conversation_stage.py b/customer_engagement/models/conversation_stage.py new file mode 100644 index 0000000000..1203ee0c3a --- /dev/null +++ b/customer_engagement/models/conversation_stage.py @@ -0,0 +1,39 @@ +from odoo import fields, models + + +class ConversationStage(models.Model): + _name = "support.conversation.stage" + _description = "Conversation Stage" + _order = "sequence, id" + + name = fields.Char(required=True, translate=True) + sequence = fields.Integer(default=10) + code = fields.Selection( + [ + ("new", "New"), + ("open", "Open"), + ("pending", "Pending"), + ("resolved", "Resolved"), + ("closed", "Closed"), + ], + required=True, + ) + + closed = fields.Boolean( + help="Conversation is considered closed in this stage", + ) + fold = fields.Boolean( + string="Folded in Kanban", + help="Fold this stage in kanban view when empty", + ) + + mail_template_id = fields.Many2one( + comodel_name="mail.template", + string="Email Template", + domain="[('model', '=', 'support.conversation')]", + help="Email sent when conversation enters this stage", + ) + + _sql_constraints = [ + ("code_unique", "UNIQUE(code)", "Stage code must be unique"), + ] diff --git a/customer_engagement/models/support_folder.py b/customer_engagement/models/support_folder.py new file mode 100644 index 0000000000..5bd68f17e0 --- /dev/null +++ b/customer_engagement/models/support_folder.py @@ -0,0 +1,89 @@ +"""Support Folder Model.""" + +from odoo import api, fields, models + + +class SupportFolder(models.Model): + """Custom folders for organizing conversations.""" + + _name = "support.folder" + _description = "Support Folder" + _order = "sequence, name" + + name = fields.Char( + required=True, + translate=True, + ) + sequence = fields.Integer( + default=10, + ) + code = fields.Char( + help="Internal code for programmatic access", + ) + icon = fields.Char( + default="fa-folder", + help="Font Awesome icon class (e.g., fa-folder, fa-star)", + ) + color = fields.Integer( + string="Color Index", + default=0, + ) + description = fields.Text( + translate=True, + ) + active = fields.Boolean( + default=True, + ) + is_system = fields.Boolean( + string="System Folder", + default=False, + help="System folders cannot be deleted", + ) + folder_type = fields.Selection( + selection=[ + ("static", "Static (Manual)"), + ("smart", "Smart (Auto-filter)"), + ], + default="static", + required=True, + ) + domain = fields.Char( + string="Filter Domain", + help="Domain filter for smart folders (JSON format)", + ) + user_id = fields.Many2one( + comodel_name="res.users", + string="Owner", + help="If set, folder is private to this user", + ) + conversation_count = fields.Integer( + string="Conversations", + compute="_compute_conversation_count", + ) + + @api.depends("folder_type", "domain") + def _compute_conversation_count(self): + """Count conversations in this folder.""" + Conversation = self.env["support.conversation"] + for folder in self: + if folder.folder_type == "smart" and folder.domain: + try: + import ast + + domain = ast.literal_eval(folder.domain) + folder.conversation_count = Conversation.search_count(domain) + except (ValueError, SyntaxError): + folder.conversation_count = 0 + else: + folder.conversation_count = Conversation.search_count( + [("folder_ids", "in", folder.id)] + ) + + def unlink(self): + """Prevent deletion of system folders.""" + for folder in self: + if folder.is_system: + raise models.ValidationError( + f"Cannot delete system folder: {folder.name}" + ) + return super().unlink() diff --git a/customer_engagement/models/support_team.py b/customer_engagement/models/support_team.py new file mode 100644 index 0000000000..dd4773436c --- /dev/null +++ b/customer_engagement/models/support_team.py @@ -0,0 +1,139 @@ +"""Support Team Model.""" + +from odoo import api, fields, models + + +class SupportTeam(models.Model): + """Support teams for organizing agents and routing conversations.""" + + _name = "support.team" + _description = "Support Team" + _order = "sequence, name" + + name = fields.Char( + required=True, + translate=True, + ) + sequence = fields.Integer( + default=10, + ) + description = fields.Text( + translate=True, + ) + active = fields.Boolean( + default=True, + ) + color = fields.Integer( + string="Color Index", + default=0, + ) + member_ids = fields.Many2many( + comodel_name="res.users", + relation="support_team_user_rel", + column1="team_id", + column2="user_id", + string="Team Members", + ) + leader_id = fields.Many2one( + comodel_name="res.users", + string="Team Leader", + domain="[('id', 'in', member_ids)]", + ) + channel_types = fields.Selection( + selection=[ + ("all", "All Channels"), + ("selected", "Selected Channels"), + ], + string="Channel Assignment", + default="all", + help="Define which channels this team handles", + ) + allowed_channel_ids = fields.Char( + string="Allowed Channels", + help="Comma-separated list of channel types (e.g., 'whatsapp,email')", + ) + auto_assign = fields.Boolean( + string="Auto-assign Conversations", + default=False, + help="Automatically assign new conversations to team members", + ) + assignment_method = fields.Selection( + selection=[ + ("round_robin", "Round Robin"), + ("least_loaded", "Least Loaded"), + ("manual", "Manual"), + ], + default="manual", + ) + max_conversations_per_agent = fields.Integer( + string="Max Conversations per Agent", + default=0, + help="Maximum open conversations per agent (0 = unlimited)", + ) + conversation_count = fields.Integer( + string="Open Conversations", + compute="_compute_conversation_count", + ) + member_count = fields.Integer( + string="Members", + compute="_compute_member_count", + ) + + def _compute_conversation_count(self): + """Count open conversations assigned to this team.""" + for team in self: + team.conversation_count = self.env["support.conversation"].search_count( + [("team_id", "=", team.id), ("closed", "=", False)] + ) + + @api.depends("member_ids") + def _compute_member_count(self): + """Count team members.""" + for team in self: + team.member_count = len(team.member_ids) + + def get_available_agent(self): + """Get the next available agent for assignment based on method.""" + self.ensure_one() + if not self.member_ids: + return False + + available_members = self.member_ids.filtered(lambda u: u.active and not u.share) + if not available_members: + return False + + if self.assignment_method == "round_robin": + # Get last assigned agent and rotate + last_conv = self.env["support.conversation"].search( + [("team_id", "=", self.id), ("user_id", "!=", False)], + order="create_date desc", + limit=1, + ) + if last_conv and last_conv.user_id in available_members: + idx = list(available_members).index(last_conv.user_id) + next_idx = (idx + 1) % len(available_members) + return available_members[next_idx] + return available_members[0] + + elif self.assignment_method == "least_loaded": + # Find agent with least open conversations + min_count = float("inf") + best_agent = False + for member in available_members: + count = self.env["support.conversation"].search_count( + [ + ("user_id", "=", member.id), + ("closed", "=", False), + ] + ) + if ( + self.max_conversations_per_agent + and count >= self.max_conversations_per_agent + ): + continue + if count < min_count: + min_count = count + best_agent = member + return best_agent + + return False diff --git a/customer_engagement/pyproject.toml b/customer_engagement/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/customer_engagement/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/customer_engagement/security/ir.model.access.csv b/customer_engagement/security/ir.model.access.csv new file mode 100644 index 0000000000..2f633b4237 --- /dev/null +++ b/customer_engagement/security/ir.model.access.csv @@ -0,0 +1,17 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_conversation_user,support.conversation.user,model_support_conversation,group_support_user,1,1,1,0 +access_conversation_manager,support.conversation.manager,model_support_conversation,group_support_manager,1,1,1,1 +access_conversation_stage_all,support.conversation.stage.all,model_support_conversation_stage,base.group_user,1,0,0,0 +access_conversation_stage_manager,support.conversation.stage.manager,model_support_conversation_stage,group_support_manager,1,1,1,1 +access_conversation_history_user,support.conversation.history.user,model_support_conversation_history,group_support_user,1,0,0,0 +access_conversation_history_manager,support.conversation.history.manager,model_support_conversation_history,group_support_manager,1,1,1,1 +access_conversation_label_user,support.conversation.label.user,model_support_conversation_label,group_support_user,1,0,0,0 +access_conversation_label_manager,support.conversation.label.manager,model_support_conversation_label,group_support_manager,1,1,1,1 +access_support_team_user,support.team.user,model_support_team,group_support_user,1,0,0,0 +access_support_team_manager,support.team.manager,model_support_team,group_support_manager,1,1,1,1 +access_support_folder_user,support.folder.user,model_support_folder,group_support_user,1,0,0,0 +access_support_folder_manager,support.folder.manager,model_support_folder,group_support_manager,1,1,1,1 +access_canned_response_user,support.canned.response.user,model_support_canned_response,group_support_user,1,0,0,0 +access_canned_response_manager,support.canned.response.manager,model_support_canned_response,group_support_manager,1,1,1,1 +access_conversation_note_user,support.conversation.note.user,model_support_conversation_note,group_support_user,1,1,1,0 +access_conversation_note_manager,support.conversation.note.manager,model_support_conversation_note,group_support_manager,1,1,1,1 diff --git a/customer_engagement/security/security_groups.xml b/customer_engagement/security/security_groups.xml new file mode 100644 index 0000000000..b65cb346f9 --- /dev/null +++ b/customer_engagement/security/security_groups.xml @@ -0,0 +1,18 @@ + + + + Support Center + 10 + + + + Agent + + + + + Manager + + + + diff --git a/customer_engagement/static/src/js/components/chat_panel/attachment_uploader.js b/customer_engagement/static/src/js/components/chat_panel/attachment_uploader.js new file mode 100644 index 0000000000..c0150c8c83 --- /dev/null +++ b/customer_engagement/static/src/js/components/chat_panel/attachment_uploader.js @@ -0,0 +1,135 @@ +/** @odoo-module **/ + +import {Component, useRef, useState} from "@odoo/owl"; +import {useService} from "@web/core/utils/hooks"; + +export class AttachmentUploader extends Component { + static template = "customer_engagement.AttachmentUploader"; + static props = { + attachments: {type: Array}, + onAdd: {type: Function}, + onRemove: {type: Function}, + maxSize: {type: Number, optional: true}, // In MB + acceptedTypes: {type: String, optional: true}, + }; + + setup() { + this.notification = useService("notification"); + this.state = useState({ + isUploading: false, + dragOver: false, + }); + this.fileInputRef = useRef("fileInput"); + } + + get maxSizeBytes() { + return (this.props.maxSize || 10) * 1024 * 1024; // Default 10MB + } + + get acceptTypes() { + return ( + this.props.acceptedTypes || "image/*,application/pdf,.doc,.docx,.xls,.xlsx" + ); + } + + onFileInputClick() { + this.fileInputRef.el.click(); + } + + async onFileChange(ev) { + const files = ev.target.files; + await this.processFiles(files); + ev.target.value = ""; // Reset input + } + + onDragOver(ev) { + ev.preventDefault(); + this.state.dragOver = true; + } + + onDragLeave(ev) { + ev.preventDefault(); + this.state.dragOver = false; + } + + async onDrop(ev) { + ev.preventDefault(); + this.state.dragOver = false; + const files = ev.dataTransfer.files; + await this.processFiles(files); + } + + async processFiles(files) { + for (const file of files) { + if (file.size > this.maxSizeBytes) { + this.notification.add( + `File "${file.name}" is too large. Maximum size is ${ + this.props.maxSize || 10 + }MB.`, + {type: "warning"} + ); + continue; + } + + this.state.isUploading = true; + + try { + const attachment = await this.uploadFile(file); + this.props.onAdd(attachment); + } catch (error) { + this.notification.add( + `Failed to upload "${file.name}": ${error.message}`, + {type: "danger"} + ); + } + + this.state.isUploading = false; + } + } + + async uploadFile(file) { + // Create a base64 representation for preview + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + resolve({ + name: file.name, + size: file.size, + mimetype: file.type, + data: reader.result.split(",")[1], // Base64 data + url: URL.createObjectURL(file), // For preview + file: file, // Keep reference to file + }); + }; + reader.onerror = () => reject(new Error("Failed to read file")); + reader.readAsDataURL(file); + }); + } + + onRemoveClick(attachment) { + this.props.onRemove(attachment); + } + + getFileIcon(attachment) { + const type = attachment.mimetype || ""; + if (type.startsWith("image/")) return "fa-image"; + if (type.startsWith("video/")) return "fa-video-camera"; + if (type.startsWith("audio/")) return "fa-music"; + if (type.includes("pdf")) return "fa-file-pdf-o"; + if (type.includes("word")) return "fa-file-word-o"; + if (type.includes("excel") || type.includes("spreadsheet")) + return "fa-file-excel-o"; + if (type.includes("zip") || type.includes("rar")) return "fa-file-archive-o"; + return "fa-file-o"; + } + + formatSize(bytes) { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; + return (bytes / (1024 * 1024)).toFixed(1) + " MB"; + } + + isImage(attachment) { + return attachment.mimetype && attachment.mimetype.startsWith("image/"); + } +} diff --git a/customer_engagement/static/src/js/components/chat_panel/canned_response_picker.js b/customer_engagement/static/src/js/components/chat_panel/canned_response_picker.js new file mode 100644 index 0000000000..7c3b024ebd --- /dev/null +++ b/customer_engagement/static/src/js/components/chat_panel/canned_response_picker.js @@ -0,0 +1,114 @@ +/** @odoo-module **/ + +import {Component, onWillStart, useState} from "@odoo/owl"; +import {useService} from "@web/core/utils/hooks"; + +export class CannedResponsePicker extends Component { + static template = "customer_engagement.CannedResponsePicker"; + static props = { + channelType: {type: String, optional: true}, + teamId: {type: Number, optional: true}, + onSelect: {type: Function}, + onClose: {type: Function}, + }; + + setup() { + this.orm = useService("orm"); + this.state = useState({ + responses: [], + filteredResponses: [], + searchQuery: "", + activeCategory: "all", + isLoading: true, + }); + + onWillStart(async () => { + await this.loadResponses(); + }); + } + + get categories() { + return [ + {key: "all", label: "All"}, + {key: "greeting", label: "Greetings"}, + {key: "closing", label: "Closings"}, + {key: "info", label: "Information"}, + {key: "apology", label: "Apologies"}, + {key: "followup", label: "Follow-ups"}, + {key: "other", label: "Other"}, + ]; + } + + async loadResponses() { + this.state.isLoading = true; + + const domain = [["active", "=", true]]; + + const responses = await this.orm.searchRead( + "support.canned.response", + domain, + ["shortcut", "name", "content", "plain_content", "category", "usage_count"], + {order: "usage_count desc, sequence"} + ); + + // Filter by channel and team if needed + this.state.responses = responses.filter((r) => { + if (r.channel_types && this.props.channelType) { + const channels = r.channel_types.split(","); + if (!channels.includes(this.props.channelType)) { + return false; + } + } + return true; + }); + + this.filterResponses(); + this.state.isLoading = false; + } + + filterResponses() { + let filtered = this.state.responses; + + // Filter by category + if (this.state.activeCategory !== "all") { + filtered = filtered.filter((r) => r.category === this.state.activeCategory); + } + + // Filter by search query + if (this.state.searchQuery) { + const query = this.state.searchQuery.toLowerCase(); + filtered = filtered.filter( + (r) => + r.shortcut.toLowerCase().includes(query) || + r.name.toLowerCase().includes(query) || + (r.plain_content && r.plain_content.toLowerCase().includes(query)) + ); + } + + this.state.filteredResponses = filtered; + } + + setCategory(category) { + this.state.activeCategory = category; + this.filterResponses(); + } + + onSearchInput(ev) { + this.state.searchQuery = ev.target.value; + this.filterResponses(); + } + + onResponseClick(response) { + this.props.onSelect(response); + } + + onClose() { + this.props.onClose(); + } + + truncateContent(content, maxLength = 80) { + if (!content) return ""; + if (content.length <= maxLength) return content; + return content.substring(0, maxLength) + "..."; + } +} diff --git a/customer_engagement/static/src/js/components/chat_panel/chat_header.js b/customer_engagement/static/src/js/components/chat_panel/chat_header.js new file mode 100644 index 0000000000..4312f47991 --- /dev/null +++ b/customer_engagement/static/src/js/components/chat_panel/chat_header.js @@ -0,0 +1,97 @@ +/** @odoo-module **/ + +import {Component, useState} from "@odoo/owl"; + +export class ChatHeader extends Component { + static template = "customer_engagement.ChatHeader"; + static props = { + conversation: {type: Object}, + isAssignedToMe: {type: Boolean}, + isUnassigned: {type: Boolean}, + onAssign: {type: Function}, + onStatusChange: {type: Function}, + onOpenForm: {type: Function}, + onAddLabel: {type: Function, optional: true}, + }; + + setup() { + this.state = useState({ + showStatusMenu: false, + showMoreMenu: false, + }); + } + + get partnerName() { + const conv = this.props.conversation; + if (conv.partner_id && conv.partner_id[1]) { + return conv.partner_id[1]; + } + return "Unknown Contact"; + } + + get channelType() { + return this.props.conversation.channel_type; + } + + get channelIcon() { + const icons = { + whatsapp: "fa-whatsapp text-success", + email: "fa-envelope text-primary", + instagram: "fa-instagram text-danger", + messenger: "fa-facebook-messenger text-info", + telegram: "fa-telegram text-info", + livechat: "fa-comments text-warning", + api: "fa-code text-secondary", + }; + return icons[this.channelType] || "fa-question text-muted"; + } + + get stageCode() { + const conv = this.props.conversation; + return conv.stage_code || ""; + } + + get statusOptions() { + return [ + {code: "open", label: "Open", icon: "fa-folder-open", color: "primary"}, + {code: "pending", label: "Pending", icon: "fa-clock-o", color: "warning"}, + {code: "resolved", label: "Resolved", icon: "fa-check", color: "success"}, + {code: "closed", label: "Closed", icon: "fa-times", color: "secondary"}, + ]; + } + + toggleStatusMenu() { + this.state.showStatusMenu = !this.state.showStatusMenu; + this.state.showMoreMenu = false; + } + + toggleMoreMenu() { + this.state.showMoreMenu = !this.state.showMoreMenu; + this.state.showStatusMenu = false; + } + + closeMenus() { + this.state.showStatusMenu = false; + this.state.showMoreMenu = false; + } + + onAssignClick() { + this.props.onAssign(); + } + + onStatusClick(statusCode) { + this.props.onStatusChange(statusCode); + this.closeMenus(); + } + + onOpenFormClick() { + this.props.onOpenForm(); + } + + onAddLabelClick() { + if (this.props.onAddLabel) { + this.props.onAddLabel(); + } + this.closeMenus(); + } +} diff --git a/customer_engagement/static/src/js/components/chat_panel/chat_panel.js b/customer_engagement/static/src/js/components/chat_panel/chat_panel.js new file mode 100644 index 0000000000..2d8f977bbc --- /dev/null +++ b/customer_engagement/static/src/js/components/chat_panel/chat_panel.js @@ -0,0 +1,120 @@ +/** @odoo-module **/ + +import {Component, onWillUpdateProps, useRef, useState} from "@odoo/owl"; +import {ChatHeader} from "./chat_header"; +import {MessageList} from "./message_list"; +import {RichComposer} from "./rich_composer"; + +export class ChatPanel extends Component { + static template = "customer_engagement.ChatPanel"; + static components = {ChatHeader, MessageList, RichComposer}; + static props = { + conversation: {type: Object, optional: true}, + messages: {type: Array}, + notes: {type: Array, optional: true}, + isLoading: {type: Boolean}, + currentUserId: {type: Number}, + onSendMessage: {type: Function}, + onSendNote: {type: Function}, + onAssign: {type: Function}, + onStatusChange: {type: Function}, + onOpenForm: {type: Function}, + onAddLabel: {type: Function, optional: true}, + onRemoveLabel: {type: Function, optional: true}, + }; + + setup() { + this.state = useState({ + composerMode: "message", // "message" or "note" + showEmojiPicker: false, + showCannedPicker: false, + }); + this.messageListRef = useRef("messageList"); + + onWillUpdateProps((nextProps) => { + // Scroll to bottom when messages change + if (nextProps.messages !== this.props.messages) { + this.scrollToBottom(); + } + }); + } + + scrollToBottom() { + setTimeout(() => { + const el = this.messageListRef.el; + if (el) { + el.scrollTop = el.scrollHeight; + } + }, 100); + } + + get hasConversation() { + return this.props.conversation && this.props.conversation.id; + } + + get isAssignedToMe() { + if (!this.props.conversation) return false; + const userId = this.props.conversation.user_id; + return userId && userId[0] === this.props.currentUserId; + } + + get isUnassigned() { + if (!this.props.conversation) return false; + return !this.props.conversation.user_id; + } + + get combinedTimeline() { + // Combine messages and notes into a single timeline + const timeline = []; + + for (const msg of this.props.messages) { + timeline.push({ + type: "message", + date: msg.date, + data: msg, + }); + } + + if (this.props.notes) { + for (const note of this.props.notes) { + timeline.push({ + type: "note", + date: note.create_date, + data: note, + }); + } + } + + // Sort by date + timeline.sort((a, b) => new Date(a.date) - new Date(b.date)); + return timeline; + } + + setComposerMode(mode) { + this.state.composerMode = mode; + } + + async onSend(content, attachments) { + if (this.state.composerMode === "note") { + await this.props.onSendNote(content); + } else { + await this.props.onSendMessage(content, attachments); + } + this.scrollToBottom(); + } + + toggleEmojiPicker() { + this.state.showEmojiPicker = !this.state.showEmojiPicker; + this.state.showCannedPicker = false; + } + + toggleCannedPicker() { + this.state.showCannedPicker = !this.state.showCannedPicker; + this.state.showEmojiPicker = false; + } + + closePickers() { + this.state.showEmojiPicker = false; + this.state.showCannedPicker = false; + } +} diff --git a/customer_engagement/static/src/js/components/chat_panel/emoji_picker.js b/customer_engagement/static/src/js/components/chat_panel/emoji_picker.js new file mode 100644 index 0000000000..6f16859d2b --- /dev/null +++ b/customer_engagement/static/src/js/components/chat_panel/emoji_picker.js @@ -0,0 +1,348 @@ +/** @odoo-module **/ + +import {Component, useState} from "@odoo/owl"; + +export class EmojiPicker extends Component { + static template = "customer_engagement.EmojiPicker"; + static props = { + onSelect: {type: Function}, + onClose: {type: Function}, + }; + + setup() { + this.state = useState({ + activeCategory: "smileys", + searchQuery: "", + }); + } + + get categories() { + return [ + {key: "smileys", icon: "😀", label: "Smileys"}, + {key: "people", icon: "👋", label: "People"}, + {key: "nature", icon: "🐶", label: "Nature"}, + {key: "food", icon: "🍕", label: "Food"}, + {key: "activities", icon: "⚽", label: "Activities"}, + {key: "travel", icon: "🚗", label: "Travel"}, + {key: "objects", icon: "💡", label: "Objects"}, + {key: "symbols", icon: "❤️", label: "Symbols"}, + ]; + } + + get emojis() { + const emojiMap = { + smileys: [ + "😀", + "😃", + "😄", + "😁", + "😆", + "😅", + "🤣", + "😂", + "🙂", + "🙃", + "😉", + "😊", + "😇", + "🥰", + "😍", + "🤩", + "😘", + "😗", + "😚", + "😙", + "🥲", + "😋", + "😛", + "😜", + "🤪", + "😝", + "🤑", + "🤗", + "🤭", + "🤫", + "🤔", + "🤐", + "🤨", + "😐", + "😑", + "😶", + "😏", + "😒", + "🙄", + "😬", + "🤥", + "😌", + "😔", + "😪", + "🤤", + "😴", + "😷", + ], + people: [ + "👋", + "🤚", + "🖐️", + "✋", + "🖖", + "👌", + "🤌", + "🤏", + "✌️", + "🤞", + "🤟", + "🤘", + "🤙", + "👈", + "👉", + "👆", + "🖕", + "👇", + "☝️", + "👍", + "👎", + "✊", + "👊", + "🤛", + "🤜", + "👏", + "🙌", + "👐", + "🤲", + "🤝", + "🙏", + ], + nature: [ + "🐶", + "🐱", + "🐭", + "🐹", + "🐰", + "🦊", + "🐻", + "🐼", + "🐨", + "🐯", + "🦁", + "🐮", + "🐷", + "🐸", + "🐵", + "🐔", + "🐧", + "🐦", + "🐤", + "🦆", + "🦅", + "🦉", + "🦇", + "🐺", + "🐗", + "🐴", + "🦄", + "🐝", + "🐛", + "🦋", + "🐌", + "🐞", + ], + food: [ + "🍕", + "🍔", + "🍟", + "🌭", + "🍿", + "🧂", + "🥓", + "🥚", + "🍳", + "🧇", + "🥞", + "🧈", + "🍞", + "🥐", + "🥖", + "🥨", + "🧀", + "🥗", + "🥙", + "🥪", + "🌮", + "🌯", + "🫔", + "🥫", + "🍝", + "🍜", + "🍲", + "🍛", + "🍣", + "🍱", + "🥟", + "🦪", + ], + activities: [ + "⚽", + "🏀", + "🏈", + "⚾", + "🥎", + "🎾", + "🏐", + "🏉", + "🥏", + "🎱", + "🪀", + "🏓", + "🏸", + "🏒", + "🏑", + "🥍", + "🏏", + "🪃", + "🥅", + "⛳", + "🪁", + "🏹", + "🎣", + "🤿", + "🥊", + "🥋", + "🎽", + "🛹", + "🛼", + "🛷", + "⛸️", + "🥌", + ], + travel: [ + "🚗", + "🚕", + "🚙", + "🚌", + "🚎", + "🏎️", + "🚓", + "🚑", + "🚒", + "🚐", + "🛻", + "🚚", + "🚛", + "🚜", + "🏍️", + "🛵", + "🚲", + "🛴", + "🚏", + "🛣️", + "🛤️", + "🛢️", + "⛽", + "🚨", + "🚥", + "🚦", + "🛑", + "🚧", + "⚓", + "⛵", + "🛶", + "🚤", + ], + objects: [ + "💡", + "🔦", + "🏮", + "🪔", + "📱", + "📲", + "💻", + "🖥️", + "🖨️", + "⌨️", + "🖱️", + "🖲️", + "💽", + "💾", + "💿", + "📀", + "🧮", + "🎥", + "🎞️", + "📽️", + "🎬", + "📺", + "📷", + "📸", + "📹", + "📼", + "🔍", + "🔎", + "🕯️", + "💰", + "💳", + "💎", + ], + symbols: [ + "❤️", + "🧡", + "💛", + "💚", + "💙", + "💜", + "🖤", + "🤍", + "🤎", + "💔", + "❣️", + "💕", + "💞", + "💓", + "💗", + "💖", + "💘", + "💝", + "💟", + "☮️", + "✝️", + "☪️", + "🕉️", + "☸️", + "✡️", + "🔯", + "🕎", + "☯️", + "☦️", + "🛐", + "⛎", + "♈", + ], + }; + + let emojis = emojiMap[this.state.activeCategory] || []; + + if (this.state.searchQuery) { + // Simple search - just filter the current category + // In production, you'd want a proper emoji search library + emojis = Object.values(emojiMap).flat(); + } + + return emojis; + } + + setCategory(category) { + this.state.activeCategory = category; + } + + onSearchInput(ev) { + this.state.searchQuery = ev.target.value; + } + + onEmojiClick(emoji) { + this.props.onSelect(emoji); + } + + onClose() { + this.props.onClose(); + } +} diff --git a/customer_engagement/static/src/js/components/chat_panel/message_bubble.js b/customer_engagement/static/src/js/components/chat_panel/message_bubble.js new file mode 100644 index 0000000000..065b8a1927 --- /dev/null +++ b/customer_engagement/static/src/js/components/chat_panel/message_bubble.js @@ -0,0 +1,74 @@ +/** @odoo-module **/ + +import {Component} from "@odoo/owl"; + +export class MessageBubble extends Component { + static template = "customer_engagement.MessageBubble"; + static props = { + message: {type: Object}, + isOutgoing: {type: Boolean}, + }; + + get authorName() { + const msg = this.props.message; + if (msg.author_id && msg.author_id[1]) { + return msg.author_id[1]; + } + return "Unknown"; + } + + get authorInitials() { + return this.authorName + .split(" ") + .map((word) => word[0]) + .slice(0, 2) + .join("") + .toUpperCase(); + } + + get formattedTime() { + const date = new Date(this.props.message.date); + return date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + }); + } + + get bubbleClass() { + const classes = ["o_message_bubble"]; + if (this.props.isOutgoing) { + classes.push("outgoing"); + } else { + classes.push("incoming"); + } + return classes.join(" "); + } + + get hasAttachments() { + return ( + this.props.message.attachment_ids && + this.props.message.attachment_ids.length > 0 + ); + } + + get attachments() { + return this.props.message.attachment_ids || []; + } + + getAttachmentIcon(attachment) { + const mimetype = attachment.mimetype || ""; + if (mimetype.startsWith("image/")) return "fa-image"; + if (mimetype.startsWith("video/")) return "fa-video-camera"; + if (mimetype.startsWith("audio/")) return "fa-music"; + if (mimetype.includes("pdf")) return "fa-file-pdf-o"; + if (mimetype.includes("word")) return "fa-file-word-o"; + if (mimetype.includes("excel") || mimetype.includes("spreadsheet")) + return "fa-file-excel-o"; + return "fa-file-o"; + } + + isImageAttachment(attachment) { + const mimetype = attachment.mimetype || ""; + return mimetype.startsWith("image/"); + } +} diff --git a/customer_engagement/static/src/js/components/chat_panel/message_list.js b/customer_engagement/static/src/js/components/chat_panel/message_list.js new file mode 100644 index 0000000000..5046efb611 --- /dev/null +++ b/customer_engagement/static/src/js/components/chat_panel/message_list.js @@ -0,0 +1,62 @@ +/** @odoo-module **/ + +import {Component, onMounted, onPatched, useRef} from "@odoo/owl"; +import {MessageBubble} from "./message_bubble"; +import {NoteBubble} from "./note_bubble"; +import {TimelineEvent} from "./timeline_event"; + +export class MessageList extends Component { + static template = "customer_engagement.MessageList"; + static components = {MessageBubble, NoteBubble, TimelineEvent}; + static props = { + timeline: {type: Array}, + currentUserId: {type: Number}, + isLoading: {type: Boolean}, + }; + + setup() { + this.containerRef = useRef("container"); + + onMounted(() => { + this.scrollToBottom(); + }); + + onPatched(() => { + this.scrollToBottom(); + }); + } + + scrollToBottom() { + const el = this.containerRef.el; + if (el) { + el.scrollTop = el.scrollHeight; + } + } + + isOutgoing(message) { + // Check if the message was sent by an internal user + if (!message.author_id) return false; + + // If author is linked to a user (internal), it's outgoing + // This is a simplification - in real implementation you'd check user relation + return message.author_id[0] && message.is_internal_user; + } + + formatDate(dateStr) { + if (!dateStr) return ""; + const date = new Date(dateStr); + return date.toLocaleDateString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + }); + } + + shouldShowDateSeparator(item, index) { + if (index === 0) return true; + const prevItem = this.props.timeline[index - 1]; + const prevDate = new Date(prevItem.date).toDateString(); + const currentDate = new Date(item.date).toDateString(); + return prevDate !== currentDate; + } +} diff --git a/customer_engagement/static/src/js/components/chat_panel/note_bubble.js b/customer_engagement/static/src/js/components/chat_panel/note_bubble.js new file mode 100644 index 0000000000..1742d61155 --- /dev/null +++ b/customer_engagement/static/src/js/components/chat_panel/note_bubble.js @@ -0,0 +1,58 @@ +/** @odoo-module **/ + +import {Component} from "@odoo/owl"; + +export class NoteBubble extends Component { + static template = "customer_engagement.NoteBubble"; + static props = { + note: {type: Object}, + onPin: {type: Function, optional: true}, + onDelete: {type: Function, optional: true}, + }; + + get authorName() { + const note = this.props.note; + if (note.author_id && note.author_id[1]) { + return note.author_id[1]; + } + return "Unknown"; + } + + get authorInitials() { + return this.authorName + .split(" ") + .map((word) => word[0]) + .slice(0, 2) + .join("") + .toUpperCase(); + } + + get formattedTime() { + const date = new Date(this.props.note.create_date); + return date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + }); + } + + get formattedDate() { + const date = new Date(this.props.note.create_date); + return date.toLocaleDateString(); + } + + get isPinned() { + return this.props.note.is_pinned; + } + + onPinClick() { + if (this.props.onPin) { + this.props.onPin(this.props.note); + } + } + + onDeleteClick() { + if (this.props.onDelete) { + this.props.onDelete(this.props.note); + } + } +} diff --git a/customer_engagement/static/src/js/components/chat_panel/rich_composer.js b/customer_engagement/static/src/js/components/chat_panel/rich_composer.js new file mode 100644 index 0000000000..456733aa4a --- /dev/null +++ b/customer_engagement/static/src/js/components/chat_panel/rich_composer.js @@ -0,0 +1,174 @@ +/** @odoo-module **/ + +import {Component, onMounted, useRef, useState} from "@odoo/owl"; +import {useService} from "@web/core/utils/hooks"; +import {EmojiPicker} from "./emoji_picker"; +import {CannedResponsePicker} from "./canned_response_picker"; +import {AttachmentUploader} from "./attachment_uploader"; + +export class RichComposer extends Component { + static template = "customer_engagement.RichComposer"; + static components = {EmojiPicker, CannedResponsePicker, AttachmentUploader}; + static props = { + mode: {type: String}, // "message" or "note" + conversationId: {type: Number, optional: true}, + channelType: {type: String, optional: true}, + onSend: {type: Function}, + onModeChange: {type: Function}, + showEmojiPicker: {type: Boolean}, + showCannedPicker: {type: Boolean}, + onToggleEmoji: {type: Function}, + onToggleCanned: {type: Function}, + onClosePickers: {type: Function}, + }; + + setup() { + this.orm = useService("orm"); + this.state = useState({ + content: "", + attachments: [], + cannedSuggestions: [], + isUploading: false, + }); + this.textareaRef = useRef("textarea"); + + onMounted(() => { + this.focusTextarea(); + }); + } + + focusTextarea() { + if (this.textareaRef.el) { + this.textareaRef.el.focus(); + } + } + + get isNote() { + return this.props.mode === "note"; + } + + get placeholder() { + return this.isNote + ? "Add a private note... (visible only to your team)" + : "Type a message... (use / for quick replies)"; + } + + get sendButtonClass() { + return this.isNote ? "btn-warning" : "btn-primary"; + } + + get canSend() { + return ( + this.state.content.trim().length > 0 || this.state.attachments.length > 0 + ); + } + + onInput(ev) { + this.state.content = ev.target.value; + + // Check for canned response trigger + if (this.state.content.startsWith("/")) { + this.searchCannedResponses(this.state.content.slice(1)); + } else { + this.state.cannedSuggestions = []; + this.props.onClosePickers(); + } + } + + onKeyDown(ev) { + // Send on Enter (without Shift) + if (ev.key === "Enter" && !ev.shiftKey) { + ev.preventDefault(); + this.send(); + } + // Escape to close pickers + if (ev.key === "Escape") { + this.props.onClosePickers(); + } + } + + async searchCannedResponses(query) { + if (query.length < 1) { + this.state.cannedSuggestions = []; + return; + } + + const responses = await this.orm.searchRead( + "support.canned.response", + [ + ["shortcut", "ilike", query], + ["active", "=", true], + ], + ["shortcut", "name", "content", "plain_content"], + {limit: 5, order: "usage_count desc, sequence"} + ); + + this.state.cannedSuggestions = responses; + if (responses.length > 0) { + // Show inline suggestions instead of picker + } + } + + onCannedSelect(response) { + // Replace the /shortcut with the response content + // Strip HTML for plain text channels + let content = response.plain_content || response.content; + + // Remove HTML tags for non-email channels + if (this.props.channelType !== "email") { + const temp = document.createElement("div"); + temp.innerHTML = content; + content = temp.textContent || temp.innerText || ""; + } + + this.state.content = content; + this.state.cannedSuggestions = []; + this.props.onClosePickers(); + + // Increment usage count + this.orm.call("support.canned.response", "increment_usage", [[response.id]]); + + this.focusTextarea(); + } + + onEmojiSelect(emoji) { + const textarea = this.textareaRef.el; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const before = this.state.content.substring(0, start); + const after = this.state.content.substring(end); + + this.state.content = before + emoji + after; + this.props.onClosePickers(); + + // Set cursor position after emoji + setTimeout(() => { + textarea.selectionStart = textarea.selectionEnd = start + emoji.length; + textarea.focus(); + }, 0); + } + + onAttachmentAdd(attachment) { + this.state.attachments.push(attachment); + } + + onAttachmentRemove(attachment) { + const index = this.state.attachments.indexOf(attachment); + if (index > -1) { + this.state.attachments.splice(index, 1); + } + } + + setMode(mode) { + this.props.onModeChange(mode); + } + + async send() { + if (!this.canSend) return; + + await this.props.onSend(this.state.content, this.state.attachments); + this.state.content = ""; + this.state.attachments = []; + this.focusTextarea(); + } +} diff --git a/customer_engagement/static/src/js/components/chat_panel/timeline_event.js b/customer_engagement/static/src/js/components/chat_panel/timeline_event.js new file mode 100644 index 0000000000..4501c207fc --- /dev/null +++ b/customer_engagement/static/src/js/components/chat_panel/timeline_event.js @@ -0,0 +1,35 @@ +/** @odoo-module **/ + +import {Component} from "@odoo/owl"; + +export class TimelineEvent extends Component { + static template = "customer_engagement.TimelineEvent"; + static props = { + event: {type: Object}, + }; + + get eventIcon() { + const type = this.props.event.event_type; + const icons = { + created: "fa-plus-circle text-success", + assigned: "fa-user-plus text-primary", + unassigned: "fa-user-times text-warning", + status_change: "fa-exchange text-info", + label_added: "fa-tag text-primary", + label_removed: "fa-tag text-muted", + team_assigned: "fa-users text-primary", + resolved: "fa-check-circle text-success", + reopened: "fa-undo text-warning", + closed: "fa-times-circle text-secondary", + }; + return icons[type] || "fa-info-circle text-muted"; + } + + get formattedTime() { + const date = new Date(this.props.event.date); + return date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + }); + } +} diff --git a/customer_engagement/static/src/js/components/contact_panel/contact_panel.js b/customer_engagement/static/src/js/components/contact_panel/contact_panel.js new file mode 100644 index 0000000000..85d7ba221d --- /dev/null +++ b/customer_engagement/static/src/js/components/contact_panel/contact_panel.js @@ -0,0 +1,276 @@ +/** @odoo-module **/ + +import {Component, onWillStart, onWillUpdateProps, useState} from "@odoo/owl"; +import {useService} from "@web/core/utils/hooks"; + +export class ContactPanel extends Component { + static template = "customer_engagement.ContactPanel"; + static props = { + conversation: {type: Object, optional: true}, + collapsed: {type: Boolean, optional: true}, + onToggleCollapse: {type: Function, optional: true}, + onOpenPartner: {type: Function, optional: true}, + }; + + setup() { + this.orm = useService("orm"); + this.action = useService("action"); + this.state = useState({ + partner: null, + conversationHistory: [], + isLoading: false, + expandedSections: { + contact: true, + channels: true, + history: false, + notes: false, + }, + }); + + onWillStart(async () => { + await this.loadPartnerData(); + }); + + onWillUpdateProps(async (nextProps) => { + if ( + nextProps.conversation?.partner_id?.[0] !== + this.props.conversation?.partner_id?.[0] + ) { + await this.loadPartnerData(nextProps.conversation); + } + }); + } + + async loadPartnerData(conversation = this.props.conversation) { + if (!conversation?.partner_id?.[0]) { + this.state.partner = null; + this.state.conversationHistory = []; + return; + } + + this.state.isLoading = true; + + const partnerId = conversation.partner_id[0]; + + // Load partner details + const [partner] = await this.orm.read( + "res.partner", + [partnerId], + [ + "name", + "email", + "phone", + "mobile", + "street", + "city", + "state_id", + "country_id", + "image_128", + "company_name", + "function", + "website", + "comment", + ] + ); + + // Load conversation history with this partner + const history = await this.orm.searchRead( + "support.conversation", + [ + ["partner_id", "=", partnerId], + ["id", "!=", conversation.id], + ], + ["display_name", "channel_type", "stage_id", "create_date", "resolved_at"], + {order: "create_date desc", limit: 10} + ); + + this.state.partner = partner; + this.state.conversationHistory = history; + this.state.isLoading = false; + } + + get hasPartner() { + return this.state.partner !== null; + } + + get partnerName() { + return this.state.partner?.name || "Unknown Contact"; + } + + get partnerInitials() { + return this.partnerName + .split(" ") + .map((word) => word[0]) + .slice(0, 2) + .join("") + .toUpperCase(); + } + + get partnerImage() { + if (this.state.partner?.image_128) { + return `data:image/png;base64,${this.state.partner.image_128}`; + } + return null; + } + + get contactInfo() { + const partner = this.state.partner; + if (!partner) return []; + + const info = []; + + if (partner.email) { + info.push({ + icon: "fa-envelope", + label: "Email", + value: partner.email, + type: "email", + }); + } + if (partner.phone) { + info.push({ + icon: "fa-phone", + label: "Phone", + value: partner.phone, + type: "phone", + }); + } + if (partner.mobile) { + info.push({ + icon: "fa-mobile", + label: "Mobile", + value: partner.mobile, + type: "phone", + }); + } + if (partner.company_name) { + info.push({ + icon: "fa-building", + label: "Company", + value: partner.company_name, + }); + } + if (partner.function) { + info.push({ + icon: "fa-briefcase", + label: "Position", + value: partner.function, + }); + } + if (partner.website) { + info.push({ + icon: "fa-globe", + label: "Website", + value: partner.website, + type: "url", + }); + } + + return info; + } + + get address() { + const partner = this.state.partner; + if (!partner) return null; + + const parts = []; + if (partner.street) parts.push(partner.street); + if (partner.city) parts.push(partner.city); + if (partner.state_id) parts.push(partner.state_id[1]); + if (partner.country_id) parts.push(partner.country_id[1]); + + return parts.length > 0 ? parts.join(", ") : null; + } + + get channelInfo() { + const conv = this.props.conversation; + if (!conv) return []; + + const channels = []; + const channelType = conv.channel_type; + + if (channelType === "whatsapp" && this.state.partner?.mobile) { + channels.push({ + icon: "fa-whatsapp", + color: "success", + label: "WhatsApp", + value: this.state.partner.mobile, + }); + } + if (channelType === "email" && this.state.partner?.email) { + channels.push({ + icon: "fa-envelope", + color: "primary", + label: "Email", + value: this.state.partner.email, + }); + } + if (channelType === "instagram") { + channels.push({ + icon: "fa-instagram", + color: "danger", + label: "Instagram", + value: + "@" + + (this.state.partner?.name || "").toLowerCase().replace(/\s+/g, ""), + }); + } + if (channelType === "telegram") { + channels.push({ + icon: "fa-telegram", + color: "info", + label: "Telegram", + value: this.state.partner?.mobile || "", + }); + } + + return channels; + } + + toggleSection(section) { + this.state.expandedSections[section] = !this.state.expandedSections[section]; + } + + formatDate(dateStr) { + if (!dateStr) return ""; + const date = new Date(dateStr); + return date.toLocaleDateString(); + } + + getChannelIcon(channelType) { + const icons = { + whatsapp: "fa-whatsapp text-success", + email: "fa-envelope text-primary", + instagram: "fa-instagram text-danger", + messenger: "fa-facebook-messenger text-info", + telegram: "fa-telegram text-info", + livechat: "fa-comments text-warning", + api: "fa-code text-secondary", + }; + return icons[channelType] || "fa-question text-muted"; + } + + onOpenPartnerClick() { + if (this.props.onOpenPartner && this.state.partner) { + this.props.onOpenPartner(this.state.partner.id); + } else if (this.state.partner) { + this.action.doAction({ + type: "ir.actions.act_window", + res_model: "res.partner", + res_id: this.state.partner.id, + views: [[false, "form"]], + target: "current", + }); + } + } + + onConversationClick(conversation) { + this.action.doAction({ + type: "ir.actions.act_window", + res_model: "support.conversation", + res_id: conversation.id, + views: [[false, "form"]], + target: "current", + }); + } +} diff --git a/customer_engagement/static/src/js/components/conversation_list/channel_badge.js b/customer_engagement/static/src/js/components/conversation_list/channel_badge.js new file mode 100644 index 0000000000..bdffd7a2e7 --- /dev/null +++ b/customer_engagement/static/src/js/components/conversation_list/channel_badge.js @@ -0,0 +1,48 @@ +/** @odoo-module **/ + +import {Component} from "@odoo/owl"; + +export class ChannelBadge extends Component { + static template = "customer_engagement.ChannelBadge"; + static props = { + channel: {type: String, optional: true}, + size: {type: String, optional: true}, // "sm", "md", "lg" + }; + + get channelConfig() { + const configs = { + whatsapp: {icon: "fa-whatsapp", color: "success", label: "WhatsApp"}, + email: {icon: "fa-envelope", color: "primary", label: "Email"}, + instagram: {icon: "fa-instagram", color: "danger", label: "Instagram"}, + messenger: { + icon: "fa-facebook-messenger", + color: "info", + label: "Messenger", + }, + telegram: {icon: "fa-telegram", color: "info", label: "Telegram"}, + livechat: {icon: "fa-comments", color: "warning", label: "Live Chat"}, + api: {icon: "fa-code", color: "secondary", label: "API"}, + }; + return ( + configs[this.props.channel] || { + icon: "fa-question", + color: "secondary", + label: this.props.channel || "Unknown", + } + ); + } + + get iconClass() { + return `fa ${this.channelConfig.icon}`; + } + + get badgeClass() { + const size = this.props.size || "sm"; + const sizeClass = size === "sm" ? "badge-sm" : size === "lg" ? "badge-lg" : ""; + return `o_channel_badge text-${this.channelConfig.color} ${sizeClass}`; + } + + get tooltip() { + return this.channelConfig.label; + } +} diff --git a/customer_engagement/static/src/js/components/conversation_list/conversation_card.js b/customer_engagement/static/src/js/components/conversation_list/conversation_card.js new file mode 100644 index 0000000000..0bacd2a876 --- /dev/null +++ b/customer_engagement/static/src/js/components/conversation_list/conversation_card.js @@ -0,0 +1,88 @@ +/** @odoo-module **/ + +import {Component} from "@odoo/owl"; +import {ChannelBadge} from "./channel_badge"; +import {LabelPills} from "./label_pills"; +import {PriorityIndicator} from "./priority_indicator"; +import {RelativeTime} from "./relative_time"; + +export class ConversationCard extends Component { + static template = "customer_engagement.ConversationCard"; + static components = {ChannelBadge, LabelPills, PriorityIndicator, RelativeTime}; + static props = { + conversation: {type: Object}, + selected: {type: Boolean, optional: true}, + onClick: {type: Function}, + }; + + get partnerName() { + const conv = this.props.conversation; + if (conv.partner_id && conv.partner_id[1]) { + return conv.partner_id[1]; + } + return "Unknown Contact"; + } + + get partnerInitials() { + const name = this.partnerName; + return name + .split(" ") + .map((word) => word[0]) + .slice(0, 2) + .join("") + .toUpperCase(); + } + + get subject() { + return this.props.conversation.subject || "No subject"; + } + + get stageName() { + const conv = this.props.conversation; + if (conv.stage_id && conv.stage_id[1]) { + return conv.stage_id[1]; + } + return ""; + } + + get stageCode() { + // We need stage code for styling - this should be passed from parent + const conv = this.props.conversation; + return conv.stage_code || ""; + } + + get assignedAgent() { + const conv = this.props.conversation; + if (conv.user_id && conv.user_id[1]) { + return conv.user_id[1]; + } + return null; + } + + get cardClass() { + const classes = ["o_support_conversation_card"]; + if (this.props.selected) { + classes.push("selected"); + } + if (this.props.conversation.unread_message_count > 0) { + classes.push("unread"); + } + return classes.join(" "); + } + + get priorityClass() { + const priority = this.props.conversation.priority; + switch (priority) { + case "3": + return "priority-urgent"; + case "2": + return "priority-high"; + default: + return ""; + } + } + + onClick() { + this.props.onClick(this.props.conversation); + } +} diff --git a/customer_engagement/static/src/js/components/conversation_list/conversation_list.js b/customer_engagement/static/src/js/components/conversation_list/conversation_list.js new file mode 100644 index 0000000000..838546aa62 --- /dev/null +++ b/customer_engagement/static/src/js/components/conversation_list/conversation_list.js @@ -0,0 +1,74 @@ +/** @odoo-module **/ + +import {Component, useState} from "@odoo/owl"; +import {ConversationCard} from "./conversation_card"; + +export class ConversationList extends Component { + static template = "customer_engagement.ConversationList"; + static components = {ConversationCard}; + static props = { + conversations: {type: Array}, + selectedId: {type: [Number, Boolean], optional: true}, + isLoading: {type: Boolean}, + filter: {type: String, optional: true}, + onSelect: {type: Function}, + onFilterChange: {type: Function}, + onSearch: {type: Function, optional: true}, + }; + + setup() { + this.state = useState({ + searchQuery: "", + activeTab: this.props.filter || "all", + }); + } + + get tabs() { + return [ + {key: "mine", label: "Mine"}, + {key: "unassigned", label: "Unassigned"}, + {key: "all", label: "All"}, + ]; + } + + get filteredConversations() { + let conversations = this.props.conversations; + if (this.state.searchQuery) { + const query = this.state.searchQuery.toLowerCase(); + conversations = conversations.filter( + (conv) => + (conv.display_name && + conv.display_name.toLowerCase().includes(query)) || + (conv.subject && conv.subject.toLowerCase().includes(query)) || + (conv.partner_id && + conv.partner_id[1] && + conv.partner_id[1].toLowerCase().includes(query)) + ); + } + return conversations; + } + + onSearchInput(ev) { + this.state.searchQuery = ev.target.value; + if (this.props.onSearch) { + this.props.onSearch(this.state.searchQuery); + } + } + + onTabClick(tab) { + this.state.activeTab = tab; + this.props.onFilterChange(tab); + } + + onConversationSelect(conversation) { + this.props.onSelect(conversation); + } + + isSelected(conversation) { + return this.props.selectedId === conversation.id; + } + + isActiveTab(tab) { + return this.state.activeTab === tab; + } +} diff --git a/customer_engagement/static/src/js/components/conversation_list/label_pills.js b/customer_engagement/static/src/js/components/conversation_list/label_pills.js new file mode 100644 index 0000000000..d568edb810 --- /dev/null +++ b/customer_engagement/static/src/js/components/conversation_list/label_pills.js @@ -0,0 +1,44 @@ +/** @odoo-module **/ + +import {Component} from "@odoo/owl"; + +export class LabelPills extends Component { + static template = "customer_engagement.LabelPills"; + static props = { + labels: {type: Array, optional: true}, + maxVisible: {type: Number, optional: true}, + onRemove: {type: Function, optional: true}, + }; + + get hasLabels() { + return ( + this.props.labels && + this.props.labels.length > 0 && + typeof this.props.labels[0] === "object" + ); + } + + get maxLabels() { + return this.props.maxVisible || 3; + } + + get visibleLabels() { + return this.props.labels.slice(0, this.maxLabels); + } + + get hiddenCount() { + return Math.max(0, this.props.labels.length - this.maxLabels); + } + + getLabelClass(label) { + const colorIndex = label.color || 0; + return `o_tag o_tag_color_${colorIndex}`; + } + + onRemoveClick(label, ev) { + ev.stopPropagation(); + if (this.props.onRemove) { + this.props.onRemove(label); + } + } +} diff --git a/customer_engagement/static/src/js/components/conversation_list/priority_indicator.js b/customer_engagement/static/src/js/components/conversation_list/priority_indicator.js new file mode 100644 index 0000000000..afc5074485 --- /dev/null +++ b/customer_engagement/static/src/js/components/conversation_list/priority_indicator.js @@ -0,0 +1,29 @@ +/** @odoo-module **/ + +import {Component} from "@odoo/owl"; + +export class PriorityIndicator extends Component { + static template = "customer_engagement.PriorityIndicator"; + static props = { + priority: {type: String}, + showLabel: {type: Boolean, optional: true}, + }; + + get priorityConfig() { + const configs = { + 0: {icon: "fa-arrow-down", color: "secondary", label: "Low"}, + 1: {icon: "fa-minus", color: "info", label: "Normal"}, + 2: {icon: "fa-arrow-up", color: "warning", label: "High"}, + 3: {icon: "fa-exclamation", color: "danger", label: "Urgent"}, + }; + return configs[this.props.priority] || configs["1"]; + } + + get iconClass() { + return `fa ${this.priorityConfig.icon} text-${this.priorityConfig.color}`; + } + + get indicatorClass() { + return `o_priority_indicator priority-${this.props.priority}`; + } +} diff --git a/customer_engagement/static/src/js/components/conversation_list/relative_time.js b/customer_engagement/static/src/js/components/conversation_list/relative_time.js new file mode 100644 index 0000000000..e6a90f5a20 --- /dev/null +++ b/customer_engagement/static/src/js/components/conversation_list/relative_time.js @@ -0,0 +1,79 @@ +/** @odoo-module **/ + +import {Component, onMounted, onWillUnmount, useState} from "@odoo/owl"; + +export class RelativeTime extends Component { + static template = "customer_engagement.RelativeTime"; + static props = { + datetime: {type: [String, Boolean], optional: true}, + format: {type: String, optional: true}, // "short", "long" + }; + + setup() { + this.state = useState({ + displayText: this.computeRelativeTime(), + }); + + this.intervalId = null; + + onMounted(() => { + // Update every minute + this.intervalId = setInterval(() => { + this.state.displayText = this.computeRelativeTime(); + }, 60000); + }); + + onWillUnmount(() => { + if (this.intervalId) { + clearInterval(this.intervalId); + } + }); + } + + computeRelativeTime() { + if (!this.props.datetime) { + return ""; + } + + const date = new Date(this.props.datetime); + const now = new Date(); + const diff = now - date; + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const weeks = Math.floor(days / 7); + + const isShort = this.props.format === "short"; + + if (seconds < 60) { + return isShort ? "now" : "just now"; + } + if (minutes < 60) { + return isShort + ? `${minutes}m` + : `${minutes} minute${minutes !== 1 ? "s" : ""} ago`; + } + if (hours < 24) { + return isShort ? `${hours}h` : `${hours} hour${hours !== 1 ? "s" : ""} ago`; + } + if (days < 7) { + return isShort ? `${days}d` : `${days} day${days !== 1 ? "s" : ""} ago`; + } + if (weeks < 4) { + return isShort ? `${weeks}w` : `${weeks} week${weeks !== 1 ? "s" : ""} ago`; + } + + // For older dates, show the actual date + return date.toLocaleDateString(); + } + + get fullDateTime() { + if (!this.props.datetime) { + return ""; + } + const date = new Date(this.props.datetime); + return date.toLocaleString(); + } +} diff --git a/customer_engagement/static/src/js/components/sidebar/sidebar_item.js b/customer_engagement/static/src/js/components/sidebar/sidebar_item.js new file mode 100644 index 0000000000..f6f07836f5 --- /dev/null +++ b/customer_engagement/static/src/js/components/sidebar/sidebar_item.js @@ -0,0 +1,48 @@ +/** @odoo-module **/ + +import {Component} from "@odoo/owl"; + +export class SidebarItem extends Component { + static template = "customer_engagement.SidebarItem"; + static props = { + label: {type: String}, + icon: {type: String, optional: true}, + count: {type: Number, optional: true}, + color: {type: [Number, String], optional: true}, + active: {type: Boolean, optional: true}, + onClick: {type: Function}, + badge: {type: String, optional: true}, + badgeClass: {type: String, optional: true}, + }; + + get iconClass() { + return this.props.icon ? `fa ${this.props.icon}` : "fa fa-circle"; + } + + get colorClass() { + if (typeof this.props.color === "number") { + return `o_tag_color_${this.props.color}`; + } + return this.props.color ? `text-${this.props.color}` : ""; + } + + get itemClass() { + const classes = [ + "o_support_sidebar_item", + "d-flex", + "align-items-center", + "gap-2", + "px-3", + "py-2", + "cursor-pointer", + ]; + if (this.props.active) { + classes.push("active"); + } + return classes.join(" "); + } + + onClick() { + this.props.onClick(); + } +} diff --git a/customer_engagement/static/src/js/components/sidebar/sidebar_section.js b/customer_engagement/static/src/js/components/sidebar/sidebar_section.js new file mode 100644 index 0000000000..b47ad75817 --- /dev/null +++ b/customer_engagement/static/src/js/components/sidebar/sidebar_section.js @@ -0,0 +1,18 @@ +/** @odoo-module **/ + +import {Component} from "@odoo/owl"; + +export class SidebarSection extends Component { + static template = "customer_engagement.SidebarSection"; + static props = { + title: {type: String}, + icon: {type: String, optional: true}, + expanded: {type: Boolean}, + onToggle: {type: Function}, + slots: {type: Object, optional: true}, + }; + + toggle() { + this.props.onToggle(); + } +} diff --git a/customer_engagement/static/src/js/components/sidebar/support_sidebar.js b/customer_engagement/static/src/js/components/sidebar/support_sidebar.js new file mode 100644 index 0000000000..490fbb0065 --- /dev/null +++ b/customer_engagement/static/src/js/components/sidebar/support_sidebar.js @@ -0,0 +1,143 @@ +/** @odoo-module **/ + +import {Component, onWillStart, useState} from "@odoo/owl"; +import {useService} from "@web/core/utils/hooks"; +import {SidebarSection} from "./sidebar_section"; +import {SidebarItem} from "./sidebar_item"; + +export class SupportSidebar extends Component { + static template = "customer_engagement.SupportSidebar"; + static components = {SidebarSection, SidebarItem}; + static props = { + collapsed: {type: Boolean, optional: true}, + activeFilter: {type: String, optional: true}, + activeFolder: {type: Object, optional: true}, + counts: {type: Object}, + onFilterChange: {type: Function}, + onFolderSelect: {type: Function}, + onToggleCollapse: {type: Function, optional: true}, + }; + + setup() { + this.orm = useService("orm"); + this.state = useState({ + folders: [], + teams: [], + labels: [], + channels: [], + expandedSections: { + conversations: true, + folders: true, + teams: false, + channels: false, + labels: false, + }, + }); + + onWillStart(async () => { + await this.loadSidebarData(); + }); + } + + async loadSidebarData() { + const [folders, teams, labels] = await Promise.all([ + this.orm.searchRead( + "support.folder", + [["active", "=", true]], + [ + "name", + "code", + "icon", + "color", + "folder_type", + "is_system", + "conversation_count", + ], + {order: "sequence, name"} + ), + this.orm.searchRead( + "support.team", + [["active", "=", true]], + ["name", "color", "conversation_count", "member_count"], + {order: "sequence, name"} + ), + this.orm.searchRead( + "support.conversation.label", + [["active", "=", true]], + ["name", "color", "conversation_count"], + {order: "sequence, name"} + ), + ]); + + this.state.folders = folders; + this.state.teams = teams; + this.state.labels = labels; + this.state.channels = this.getChannelOptions(); + } + + getChannelOptions() { + return [ + {code: "whatsapp", name: "WhatsApp", icon: "fa-whatsapp", color: "success"}, + {code: "email", name: "Email", icon: "fa-envelope", color: "primary"}, + { + code: "instagram", + name: "Instagram", + icon: "fa-instagram", + color: "danger", + }, + { + code: "messenger", + name: "Messenger", + icon: "fa-facebook-messenger", + color: "info", + }, + {code: "telegram", name: "Telegram", icon: "fa-telegram", color: "info"}, + { + code: "livechat", + name: "Live Chat", + icon: "fa-comments", + color: "warning", + }, + ]; + } + + toggleSection(section) { + this.state.expandedSections[section] = !this.state.expandedSections[section]; + } + + onMainFilterClick(filter) { + this.props.onFilterChange(filter); + } + + onFolderClick(folder) { + this.props.onFolderSelect(folder); + } + + onTeamClick(team) { + this.props.onFilterChange("team", team.id); + } + + onChannelClick(channel) { + this.props.onFilterChange("channel", channel.code); + } + + onLabelClick(label) { + this.props.onFilterChange("label", label.id); + } + + get systemFolders() { + return this.state.folders.filter((f) => f.is_system); + } + + get customFolders() { + return this.state.folders.filter((f) => !f.is_system); + } + + isActiveFilter(filter) { + return this.props.activeFilter === filter; + } + + isActiveFolder(folder) { + return this.props.activeFolder && this.props.activeFolder.id === folder.id; + } +} diff --git a/customer_engagement/static/src/js/inbox_action.js b/customer_engagement/static/src/js/inbox_action.js new file mode 100644 index 0000000000..6dc248707d --- /dev/null +++ b/customer_engagement/static/src/js/inbox_action.js @@ -0,0 +1,552 @@ +/** @odoo-module **/ + +import {Component, onMounted, onWillStart, onWillUnmount, useState} from "@odoo/owl"; +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; +import {Layout} from "@web/search/layout"; +import {session} from "@web/session"; + +// Import new components +import {SupportSidebar} from "./components/sidebar/support_sidebar"; +import {ConversationList} from "./components/conversation_list/conversation_list"; +import {ChatPanel} from "./components/chat_panel/chat_panel"; +import {ContactPanel} from "./components/contact_panel/contact_panel"; + +class SupportInbox extends Component { + static template = "customer_engagement.SupportInbox"; + static components = { + Layout, + SupportSidebar, + ConversationList, + ChatPanel, + ContactPanel, + }; + static props = ["*"]; + + setup() { + this.orm = useService("orm"); + this.action = useService("action"); + this.notification = useService("notification"); + this.bus = useService("bus_service"); + + this.userId = session.uid; + this.userName = session.name; + + // Determine initial collapsed states based on screen size + const initialWidth = window.innerWidth; + const isMobile = initialWidth < 768; + const isTablet = initialWidth >= 768 && initialWidth < 992; + + this.state = useState({ + // Data + conversations: [], + selectedConversation: null, + messages: [], + notes: [], + + // Filters + filter: "all", + activeFolder: null, + statusFilter: null, + channelFilter: null, + teamFilter: null, + labelFilter: null, + + // UI State + isLoading: true, + isLoadingMessages: false, + sidebarCollapsed: isMobile || isTablet, + contactPanelCollapsed: isMobile, + mobileView: "list", // "list" | "chat" | "contact" + + // Counts + counts: { + all: 0, + mine: 0, + unassigned: 0, + }, + }); + + this.display = { + controlPanel: {}, + }; + + onWillStart(async () => { + await Promise.all([this.loadConversations(), this.loadCounts()]); + }); + + onMounted(() => { + // Poll for new conversations every 30 seconds + this.pollInterval = setInterval(() => { + this.loadConversations(); + this.loadCounts(); + }, 30000); + + // Subscribe to bus notifications + this.subscribeToBusNotifications(); + + // Handle window resize for responsive + this.handleResize(); + window.addEventListener("resize", this.handleResize.bind(this)); + }); + + onWillUnmount(() => { + if (this.pollInterval) { + clearInterval(this.pollInterval); + } + window.removeEventListener("resize", this.handleResize.bind(this)); + }); + } + + handleResize() { + const width = window.innerWidth; + if (width < 768) { + this.state.sidebarCollapsed = true; + this.state.contactPanelCollapsed = true; + } else if (width < 992) { + this.state.sidebarCollapsed = true; + } + } + + subscribeToBusNotifications() { + // Subscribe to conversation updates + try { + this.bus.subscribe("support.conversation", (payload) => { + if ( + payload.type === "new_message" && + this.state.selectedConversation?.id === payload.conversation_id + ) { + this.loadMessages(payload.conversation_id); + } + this.loadConversations(); + this.loadCounts(); + }); + } catch (e) { + // Bus service may not be available + console.debug("Bus notifications not available:", e); + } + } + + // ==================== Data Loading ==================== + + async loadConversations() { + this.state.isLoading = true; + const domain = this.buildDomain(); + + try { + this.state.conversations = await this.orm.searchRead( + "support.conversation", + domain, + [ + "uuid", + "subject", + "display_name", + "partner_id", + "user_id", + "channel_type", + "stage_id", + "priority", + "create_date", + "write_date", + "label_ids", + "team_id", + "unread_message_count", + "last_message_preview", + "last_message_date", + ], + {order: "write_date desc", limit: 100} + ); + + // Load stage codes for filtering + await this.enrichConversationsWithStageCode(); + } catch (e) { + console.error("Error loading conversations:", e); + this.notification.add("Failed to load conversations", {type: "danger"}); + } + + this.state.isLoading = false; + } + + async enrichConversationsWithStageCode() { + const stageIds = [ + ...new Set( + this.state.conversations.map((c) => c.stage_id?.[0]).filter(Boolean) + ), + ]; + if (stageIds.length === 0) return; + + const stages = await this.orm.searchRead( + "support.conversation.stage", + [["id", "in", stageIds]], + ["id", "code"] + ); + + const stageCodeMap = {}; + for (const stage of stages) { + stageCodeMap[stage.id] = stage.code; + } + + for (const conv of this.state.conversations) { + if (conv.stage_id) { + conv.stage_code = stageCodeMap[conv.stage_id[0]] || ""; + } + } + } + + async loadCounts() { + try { + const userId = this.userId; + const [allCount, mineCount, unassignedCount] = await Promise.all([ + this.orm.searchCount("support.conversation", [["closed", "=", false]]), + this.orm.searchCount("support.conversation", [ + ["closed", "=", false], + ["user_id", "=", userId], + ]), + this.orm.searchCount("support.conversation", [ + ["closed", "=", false], + ["user_id", "=", false], + ]), + ]); + this.state.counts = { + all: allCount, + mine: mineCount, + unassigned: unassignedCount, + }; + } catch (e) { + console.error("Error loading counts:", e); + } + } + + buildDomain() { + let domain = [["closed", "=", false]]; + + // Main filter + if (this.state.filter === "mine") { + domain.push(["user_id", "=", this.userId]); + } else if (this.state.filter === "unassigned") { + domain.push(["user_id", "=", false]); + } + + // Folder filter (smart folders have their own domain) + if ( + this.state.activeFolder?.folder_type === "smart" && + this.state.activeFolder?.domain + ) { + try { + const folderDomain = JSON.parse( + this.state.activeFolder.domain.replace(/'/g, '"') + ); + domain = [...domain, ...folderDomain]; + } catch (e) { + console.warn("Invalid folder domain:", e); + } + } + + // Status filter + if (this.state.statusFilter) { + domain.push(["stage_id.code", "=", this.state.statusFilter]); + } + + // Channel filter + if (this.state.channelFilter) { + domain.push(["channel_type", "=", this.state.channelFilter]); + } + + // Team filter + if (this.state.teamFilter) { + domain.push(["team_id", "=", this.state.teamFilter]); + } + + // Label filter + if (this.state.labelFilter) { + domain.push(["label_ids", "in", this.state.labelFilter]); + } + + return domain; + } + + async loadMessages(conversationId) { + this.state.isLoadingMessages = true; + try { + const [messages, notes] = await Promise.all([ + this.orm.searchRead( + "mail.message", + [ + ["res_id", "=", conversationId], + ["model", "=", "support.conversation"], + ], + [ + "body", + "author_id", + "date", + "message_type", + "attachment_ids", + "subtype_id", + ], + {order: "date asc"} + ), + this.orm.searchRead( + "support.conversation.note", + [["conversation_id", "=", conversationId]], + [ + "content", + "author_id", + "create_date", + "is_pinned", + "attachment_ids", + ], + {order: "create_date asc"} + ), + ]); + + // Batch check for internal users (avoid N+1 queries) + const partnerIds = [ + ...new Set(messages.map((m) => m.author_id?.[0]).filter(Boolean)), + ]; + const internalPartnerIds = await this.getInternalPartnerIds(partnerIds); + + // Mark messages as internal/external + for (const msg of messages) { + msg.is_internal_user = internalPartnerIds.has(msg.author_id?.[0]); + } + + this.state.messages = messages; + this.state.notes = notes; + } catch (e) { + console.error("Error loading messages:", e); + this.state.messages = []; + this.state.notes = []; + } + this.state.isLoadingMessages = false; + } + + async getInternalPartnerIds(partnerIds) { + /** + * Batch query to check which partners are internal users. + * Returns a Set of partner IDs that have linked users. + */ + if (!partnerIds.length) return new Set(); + try { + const users = await this.orm.searchRead( + "res.users", + [["partner_id", "in", partnerIds]], + ["partner_id"], + {limit: partnerIds.length} + ); + return new Set(users.map((u) => u.partner_id[0])); + } catch { + return new Set(); + } + } + + // ==================== Event Handlers ==================== + + // Sidebar handlers + onFilterChange(filter, value) { + if (filter === "team") { + this.state.teamFilter = value; + } else if (filter === "channel") { + this.state.channelFilter = + this.state.channelFilter === value ? null : value; + } else if (filter === "label") { + this.state.labelFilter = value; + } else { + this.state.filter = filter; + this.state.activeFolder = null; + } + this.state.selectedConversation = null; + this.state.messages = []; + this.state.notes = []; + this.loadConversations(); + } + + onFolderSelect(folder) { + this.state.activeFolder = folder; + this.state.filter = null; + this.state.selectedConversation = null; + this.state.messages = []; + this.state.notes = []; + this.loadConversations(); + } + + toggleSidebarCollapse() { + this.state.sidebarCollapsed = !this.state.sidebarCollapsed; + } + + // Conversation list handlers + onConversationSelect(conversation) { + this.state.selectedConversation = conversation; + this.loadMessages(conversation.id); + // On mobile, switch to chat view + if (window.innerWidth < 768) { + this.state.mobileView = "chat"; + } + } + + onListFilterChange(filter) { + this.state.filter = filter; + this.state.selectedConversation = null; + this.state.messages = []; + this.loadConversations(); + } + + // eslint-disable-next-line no-unused-vars + onSearch(query) { + // Search is handled client-side in ConversationList component + // Could add server-side search here for large datasets + } + + // Chat panel handlers + async onSendMessage(content, attachments = []) { + if (!this.state.selectedConversation || !content.trim()) return; + + try { + const attachmentIds = []; + // Upload attachments first + for (const att of attachments) { + const result = await this.orm.create("ir.attachment", [ + { + name: att.name, + datas: att.data, + res_model: "support.conversation", + res_id: this.state.selectedConversation.id, + }, + ]); + attachmentIds.push(result[0]); + } + + await this.orm.call( + "support.conversation", + "message_post", + [this.state.selectedConversation.id], + { + body: content, + message_type: "comment", + attachment_ids: attachmentIds, + } + ); + + await this.loadMessages(this.state.selectedConversation.id); + await this.loadConversations(); + } catch (e) { + console.error("Error sending message:", e); + this.notification.add("Failed to send message", {type: "danger"}); + } + } + + async onSendNote(content) { + if (!this.state.selectedConversation || !content.trim()) return; + + try { + await this.orm.call("support.conversation", "action_add_note", [ + this.state.selectedConversation.id, + content, + ]); + await this.loadMessages(this.state.selectedConversation.id); + } catch (e) { + console.error("Error sending note:", e); + this.notification.add("Failed to add note", {type: "danger"}); + } + } + + async onAssign() { + if (!this.state.selectedConversation) return; + + try { + await this.orm.call("support.conversation", "action_assign_to_me", [ + this.state.selectedConversation.id, + ]); + this.state.selectedConversation.user_id = [this.userId, this.userName]; + await this.loadConversations(); + await this.loadCounts(); + this.notification.add("Conversation assigned to you", {type: "success"}); + } catch (e) { + console.error("Error assigning conversation:", e); + this.notification.add("Failed to assign conversation", {type: "danger"}); + } + } + + async onStatusChange(stageCode) { + if (!this.state.selectedConversation) return; + + try { + const actionMap = { + open: "action_open", + pending: "action_pending", + resolved: "action_resolve", + closed: "action_close", + }; + + const method = actionMap[stageCode]; + if (method) { + await this.orm.call("support.conversation", method, [ + this.state.selectedConversation.id, + ]); + await this.loadConversations(); + await this.loadCounts(); + this.notification.add(`Conversation marked as ${stageCode}`, { + type: "success", + }); + } + } catch (e) { + console.error("Error changing status:", e); + this.notification.add( + "Failed to change status: " + (e.message || "Invalid transition"), + {type: "danger"} + ); + } + } + + onOpenForm() { + if (!this.state.selectedConversation) return; + this.action.doAction({ + type: "ir.actions.act_window", + res_model: "support.conversation", + res_id: this.state.selectedConversation.id, + views: [[false, "form"]], + target: "current", + }); + } + + async onAddLabel() { + // This would open a label selection dialog + // For now, we'll just show a notification + this.notification.add("Label management coming soon", {type: "info"}); + } + + // Contact panel handlers + onOpenPartner(partnerId) { + this.action.doAction({ + type: "ir.actions.act_window", + res_model: "res.partner", + res_id: partnerId, + views: [[false, "form"]], + target: "current", + }); + } + + toggleContactPanelCollapse() { + this.state.contactPanelCollapsed = !this.state.contactPanelCollapsed; + } + + // Mobile navigation + onMobileBack() { + this.state.mobileView = "list"; + this.state.selectedConversation = null; + } + + // ==================== Computed Properties ==================== + + get inboxClass() { + const classes = ["o_support_inbox", "d-flex"]; + if (this.state.selectedConversation) { + classes.push("conversation-selected"); + } + if (this.state.mobileView === "chat") { + classes.push("mobile-chat-view"); + } + return classes.join(" "); + } +} + +registry.category("actions").add("support_inbox", SupportInbox); diff --git a/customer_engagement/static/src/scss/components/_chat_panel.scss b/customer_engagement/static/src/scss/components/_chat_panel.scss new file mode 100644 index 0000000000..76802aecff --- /dev/null +++ b/customer_engagement/static/src/scss/components/_chat_panel.scss @@ -0,0 +1,90 @@ +// Chat Panel Component Styles + +.o_support_chat_panel { + flex: 1; + min-width: 0; + background-color: #f8f9fa; + display: flex; + flex-direction: column; + color: #212529 !important; // Ensure text is dark + + // Chat header + .o_support_chat_header { + flex-shrink: 0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + z-index: 10; + background-color: white; + color: #212529; + + .dropdown-menu { + min-width: 150px; + color: #212529; + + &.show { + display: block; + } + } + + .dropdown-item { + cursor: pointer; + color: #212529; + + &:hover { + background-color: #f8f9fa; + } + } + } + + // Messages area + .o_support_messages { + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: #adb5bd #e9ecef; + + &::-webkit-scrollbar { + width: 8px; + } + &::-webkit-scrollbar-track { + background: #e9ecef; + } + &::-webkit-scrollbar-thumb { + background: #adb5bd; + border-radius: 4px; + } + } + + // Composer container + .o_support_composer_container { + flex-shrink: 0; + box-shadow: 0 -1px 3px rgba(0, 0, 0, 0.1); + background-color: white; + } + + // Placeholder text + .text-muted { + color: #6c757d !important; + } +} + +// Message List +.o_support_message_list { + padding: 0.5rem 0; + color: #212529; + + // Date separator + .o_date_separator { + hr { + flex: 1; + border: none; + border-top: 1px solid #dee2e6; + margin: 0; + } + + span { + background-color: #f8f9fa; + padding: 0 0.5rem; + color: #6c757d; + } + } +} diff --git a/customer_engagement/static/src/scss/components/_composer.scss b/customer_engagement/static/src/scss/components/_composer.scss new file mode 100644 index 0000000000..39221b0ad5 --- /dev/null +++ b/customer_engagement/static/src/scss/components/_composer.scss @@ -0,0 +1,149 @@ +// Composer Component Styles + +.o_support_rich_composer { + position: relative; + + // Mode toggle buttons + .btn-group-toggle { + .btn { + font-size: 0.85rem; + } + } + + // Textarea + textarea.form-control { + resize: none; + border-radius: 0.5rem; + transition: + background-color 0.15s ease, + border-color 0.15s ease; + + &:focus { + border-color: #0066cc; + box-shadow: 0 0 0 0.2rem rgba(0, 102, 204, 0.25); + } + + // Note mode styling + &[style*="background-color: #fff3cd"] { + border-color: var(--bs-warning); + + &:focus { + box-shadow: 0 0 0 0.2rem rgba(var(--bs-warning-rgb), 0.25); + } + } + } + + // Action buttons + .btn-outline-secondary { + border-color: var(--bs-border-color); + + &:hover { + background-color: var(--bs-gray-100); + border-color: var(--bs-gray-400); + } + } +} + +// Canned Response Suggestions (inline) +.o_canned_suggestions { + background-color: white; + max-height: 200px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.2) transparent; + + &::-webkit-scrollbar { + width: 6px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; + } + + .o_canned_suggestion { + transition: background-color 0.15s ease; + + &:hover { + background-color: var(--bs-gray-100); + } + + &:last-child { + border-bottom: none !important; + } + } +} + +// Emoji Picker +.o_emoji_picker { + animation: fadeIn 0.15s ease-out; + + .btn { + transition: transform 0.1s ease; + + &:hover { + transform: scale(1.2); + } + + &:active { + transform: scale(1.1); + } + } +} + +// Canned Response Picker +.o_canned_picker { + animation: fadeIn 0.15s ease-out; + + .o_canned_item { + transition: background-color 0.15s ease; + + &:hover { + background-color: var(--bs-gray-100); + } + + &:last-child { + border-bottom: none !important; + } + } +} + +// Attachment Uploader +.o_attachment_uploader { + .o_attachment_preview { + max-width: 200px; + animation: fadeIn 0.15s ease-out; + + img { + object-fit: cover; + } + + .btn-link { + opacity: 0.5; + transition: opacity 0.15s ease; + + &:hover { + opacity: 1; + } + } + } + + // Drag and drop zone + &.drag-over { + background-color: rgba(0, 102, 204, 0.1); + border: 2px dashed #0066cc; + border-radius: 0.5rem; + } +} + +// Animation +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/customer_engagement/static/src/scss/components/_contact_panel.scss b/customer_engagement/static/src/scss/components/_contact_panel.scss new file mode 100644 index 0000000000..6105dbb748 --- /dev/null +++ b/customer_engagement/static/src/scss/components/_contact_panel.scss @@ -0,0 +1,130 @@ +// Contact Panel Component Styles + +.o_support_contact_panel { + width: 300px; + min-width: 300px; + background-color: white; + display: flex; + flex-direction: column; + transition: + width 0.15s ease, + min-width 0.15s ease; + color: #212529 !important; // Ensure text is dark + + // Collapsed state + &.collapsed { + width: 60px; + min-width: 60px; + + .o_contact_header, + .o_contact_content { + display: none; + } + } + + // Header + .o_contact_header { + flex-shrink: 0; + color: #212529; + + .o_contact_avatar { + img { + border: 2px solid #dee2e6; + } + } + + h6 { + color: #212529; + } + } + + // Content + .o_contact_content { + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.2) transparent; + + &::-webkit-scrollbar { + width: 6px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; + } + } + + // Sections + .o_contact_section { + .o_section_header { + user-select: none; + transition: background-color 0.15s ease; + color: #212529; + + &:hover { + background-color: #f8f9fa; + } + + span { + color: #212529; + } + + i { + color: #6c757d; + } + } + + .o_section_content { + color: #212529; + // Animation disabled + // animation: slideDown 0.15s ease-out; + } + } + + // Contact info items + .o_contact_info_item { + color: #212529; + + a { + color: #0066cc; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + // Conversation history items + .o_history_item { + transition: + background-color 0.15s ease, + border-color 0.15s ease; + color: #212529; + + &:hover { + background-color: #f8f9fa; + border-color: #adb5bd; + } + } + + // Text muted override + .text-muted { + color: #6c757d !important; + } +} + +// Animation for section expand (shared with sidebar) +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/customer_engagement/static/src/scss/components/_conversation_list.scss b/customer_engagement/static/src/scss/components/_conversation_list.scss new file mode 100644 index 0000000000..35a57da52e --- /dev/null +++ b/customer_engagement/static/src/scss/components/_conversation_list.scss @@ -0,0 +1,179 @@ +// Conversation List Component Styles + +.o_support_conversation_list { + width: 320px; + min-width: 320px; + background-color: white; + border-right: 1px solid #dee2e6; + color: #212529 !important; // Ensure text is dark + + // Search input + .o_support_conversation_search { + .input-group { + .input-group-text { + border-color: #dee2e6; + background-color: #f8f9fa; + color: #6c757d; + } + + .form-control { + border-color: #dee2e6; + color: #212529; + + &:focus { + border-color: #0066cc; + box-shadow: 0 0 0 0.2rem rgba(0, 102, 204, 0.25); + } + + &::placeholder { + color: #6c757d; + } + } + } + } + + // Tab buttons + .o_support_conversation_tabs { + background-color: #f8f9fa; + + button { + font-size: 0.85rem; + font-weight: 500; + color: #6c757d; + transition: + color 0.15s ease, + border-color 0.15s ease; + + &.active { + background-color: white; + font-weight: 600; + color: #0066cc; + } + + &:hover:not(.active) { + background-color: rgba(0, 0, 0, 0.03); + } + } + } + + // Conversation items container + .o_support_conversation_items { + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.2) transparent; + + &::-webkit-scrollbar { + width: 6px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; + } + } +} + +// Conversation Card +.o_support_conversation_card { + border-bottom: 1px solid #dee2e6; + cursor: pointer; + transition: background-color 0.15s ease; + color: #212529 !important; // Ensure text is dark + + &:hover { + background-color: #f8f9fa; + } + + &.selected { + background-color: rgba(0, 102, 204, 0.1); + border-left: 3px solid #0066cc; + + .o_conversation_card_content { + padding-left: calc(1rem - 3px); + } + } + + &.unread { + background-color: rgba(0, 102, 204, 0.05); + + .fw-semibold { + font-weight: 700 !important; + } + } + + // Priority indicators via border + &.priority-urgent { + border-left: 3px solid #dc3545 !important; + } + + &.priority-high { + border-left: 3px solid #ffc107 !important; + } + + // Avatar + .o_avatar { + width: 40px; + height: 40px; + font-size: 14px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + flex-shrink: 0; + background-color: #0066cc; + color: white; + } + + // Content area + .o_conversation_info { + min-width: 0; // Allow text truncation in flex + color: #212529; + + .text-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .text-muted { + color: #6c757d !important; + } + } + + // Channel badge + .o_channel_badge { + font-size: 0.85rem; + + &.badge-sm { + font-size: 0.75rem; + } + } +} + +// Label Pills +.o_label_pills { + .o_tag { + font-size: 0.65rem; + padding: 2px 6px; + border-radius: 0.25rem; + } +} + +// Priority Indicator +.o_priority_indicator { + display: inline-flex; + align-items: center; + + i { + font-size: 0.75rem; + } +} + +// Relative Time +.o_relative_time { + white-space: nowrap; + font-size: 0.75rem; + color: #6c757d; +} diff --git a/customer_engagement/static/src/scss/components/_message_bubbles.scss b/customer_engagement/static/src/scss/components/_message_bubbles.scss new file mode 100644 index 0000000000..fb8a3e65b8 --- /dev/null +++ b/customer_engagement/static/src/scss/components/_message_bubbles.scss @@ -0,0 +1,129 @@ +// Message Bubbles Component Styles + +// Message bubble base +.o_message_bubble { + animation: fadeInUp 0.2s ease-out; + + .o_message_avatar { + width: 32px; + height: 32px; + font-size: 12px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + flex-shrink: 0; + } + + .o_message_content { + max-width: 70%; + padding: 0.5rem 1rem; + border-radius: 18px; + word-wrap: break-word; + position: relative; + + .o_message_body { + word-wrap: break-word; + word-break: break-word; + + // Reset some HTML styles + p { + margin-bottom: 0.25rem; + + &:last-child { + margin-bottom: 0; + } + } + + a { + color: inherit; + text-decoration: underline; + } + + img { + max-width: 100%; + border-radius: 0.5rem; + } + } + } + + // Outgoing messages (from agent) + &.outgoing { + .o_message_content { + background-color: #0066cc; + color: white; + border-radius: 18px 18px 4px 18px; + + a { + color: rgba(255, 255, 255, 0.9); + } + } + } + + // Incoming messages (from customer) + &.incoming { + .o_message_content { + background-color: #e8e8e8; + border-radius: 18px 18px 18px 4px; + } + } +} + +// Note bubble +.o_note_bubble { + animation: fadeInUp 0.2s ease-out; + + .o_note_content { + background-color: #fff3cd !important; + border-color: var(--bs-warning) !important; + + .o_note_body { + word-wrap: break-word; + + p { + margin-bottom: 0.25rem; + + &:last-child { + margin-bottom: 0; + } + } + } + } +} + +// Timeline event +.o_timeline_event { + opacity: 0.8; + + i { + font-size: 0.85rem; + } +} + +// Attachments in messages +.o_message_attachments { + .o_attachment { + transition: background-color 0.15s ease; + + &:hover { + background-color: rgba(255, 255, 255, 0.3) !important; + } + + img { + cursor: pointer; + } + } +} + +// Animation +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/customer_engagement/static/src/scss/components/_responsive.scss b/customer_engagement/static/src/scss/components/_responsive.scss new file mode 100644 index 0000000000..24a561c9f7 --- /dev/null +++ b/customer_engagement/static/src/scss/components/_responsive.scss @@ -0,0 +1,275 @@ +// Responsive Styles for Omnichannel Support + +// Large screens (default desktop) +@media (min-width: 1200px) { + .o_support_inbox { + // Full 4-column layout on large screens + .o_support_sidebar { + width: 220px; + } + + .o_support_conversation_list { + width: 350px; + } + + .o_support_contact_panel { + width: 300px; + } + } +} + +// Medium-large screens (small desktop / large tablet) +@media (min-width: 992px) and (max-width: 1199.98px) { + .o_support_inbox { + .o_support_sidebar { + width: 200px; + min-width: 200px; + } + + .o_support_conversation_list { + width: 280px; + min-width: 280px; + } + + .o_support_contact_panel { + width: 260px; + min-width: 260px; + } + } +} + +// Medium screens (tablet landscape) +@media (min-width: 768px) and (max-width: 991.98px) { + .o_support_inbox { + // Collapse sidebar to icons only + .o_support_sidebar { + width: 60px; + min-width: 60px; + + .o_support_sidebar_header h5, + .o_support_sidebar_section_header span, + .o_support_sidebar_item span:not(.badge) { + display: none; + } + + .o_support_sidebar_item { + justify-content: center; + padding: 0.75rem; + } + } + + .o_support_conversation_list { + width: 300px; + min-width: 300px; + } + + // Hide contact panel by default, show on demand + .o_support_contact_panel { + position: absolute; + right: 0; + top: 0; + bottom: 0; + z-index: 100; + box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1); + transform: translateX(100%); + transition: transform 0.3s ease; + + &.visible { + transform: translateX(0); + } + } + } +} + +// Small screens (tablet portrait / large phone) +@media (min-width: 576px) and (max-width: 767.98px) { + .o_support_inbox { + // Hide sidebar completely + .o_support_sidebar { + position: fixed; + left: 0; + top: 0; + bottom: 0; + z-index: 1000; + transform: translateX(-100%); + transition: transform 0.3s ease; + + &.visible { + transform: translateX(0); + box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2); + } + } + + // Full width conversation list when no conversation selected + .o_support_conversation_list { + width: 100%; + min-width: 100%; + flex: 1; + + &.hidden { + display: none; + } + } + + // Full width chat panel + .o_support_chat_panel { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + z-index: 50; + display: none; + + &.visible { + display: flex; + } + } + + // Hide contact panel + .o_support_contact_panel { + display: none; + } + } +} + +// Extra small screens (phone) +@media (max-width: 575.98px) { + .o_support_inbox { + // Mobile-first: single panel at a time + position: relative; + + .o_support_sidebar { + position: fixed; + left: 0; + top: 0; + bottom: 0; + z-index: 1000; + width: 280px; + transform: translateX(-100%); + transition: transform 0.3s ease; + + &.visible { + transform: translateX(0); + box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2); + } + } + + .o_support_conversation_list { + width: 100%; + min-width: 100%; + flex: 1; + + &.hidden { + display: none; + } + } + + .o_support_chat_panel { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + z-index: 50; + display: none; + + &.visible { + display: flex; + } + + // Add back button for mobile + .o_support_chat_header { + .o_mobile_back_btn { + display: block; + } + } + } + + .o_support_contact_panel { + position: fixed; + right: 0; + top: 0; + bottom: 0; + z-index: 1000; + width: 100%; + transform: translateX(100%); + transition: transform 0.3s ease; + + &.visible { + transform: translateX(0); + } + } + } + + // Mobile-specific adjustments + .o_support_conversation_card { + .o_conversation_card_content { + padding: 0.75rem; + } + } + + .o_support_chat_header { + padding: 0.75rem !important; + + .btn { + padding: 0.25rem 0.5rem; + } + } + + .o_support_rich_composer { + padding: 0.75rem !important; + } +} + +// Overlay for mobile sidebar/panels +// Only visible on small screens (< 768px) +.o_mobile_overlay { + display: none; // Hidden by default on desktop + + @media (max-width: 767.98px) { + display: block; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 999; + opacity: 0; + visibility: hidden; + transition: + opacity 0.3s ease, + visibility 0.3s ease; + pointer-events: none; + + &.visible { + opacity: 1; + visibility: visible; + pointer-events: auto; + } + } +} + +// Touch-friendly adjustments +@media (hover: none) and (pointer: coarse) { + .o_support_sidebar_item, + .o_support_conversation_card, + .o_canned_item, + .o_history_item { + // Larger touch targets + min-height: 44px; + } + + .btn { + min-height: 44px; + min-width: 44px; + } + + // Disable hover effects on touch devices + .o_support_sidebar_item:hover, + .o_support_conversation_card:hover, + .o_canned_item:hover { + background-color: transparent; + } +} diff --git a/customer_engagement/static/src/scss/components/_sidebar.scss b/customer_engagement/static/src/scss/components/_sidebar.scss new file mode 100644 index 0000000000..b585e62afa --- /dev/null +++ b/customer_engagement/static/src/scss/components/_sidebar.scss @@ -0,0 +1,151 @@ +// Sidebar Component Styles + +.o_support_sidebar { + width: 220px; + min-width: 220px; + background-color: #f8f9fa; + border-right: 1px solid #dee2e6; // Fallback for --bs-border-color + display: flex; + flex-direction: column; + height: 100%; + transition: + width 0.15s ease, + min-width 0.15s ease; + color: #212529 !important; // Ensure text is dark + + // Collapsed state + &.collapsed { + width: 60px; + min-width: 60px; + + .o_support_sidebar_header h5, + .o_support_sidebar_section_header span, + .o_support_sidebar_item span:not(.badge), + .o_sidebar_item_count { + display: none; + } + + .o_support_sidebar_item { + justify-content: center; + padding: 0.75rem; + + i { + margin: 0; + } + } + } + + // Header + .o_support_sidebar_header { + flex-shrink: 0; + border-bottom: 1px solid #dee2e6; + + h5 { + color: #212529; + font-size: 1rem; + } + } + + // Content area with scrolling + .o_support_sidebar_content { + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.2) transparent; + + &::-webkit-scrollbar { + width: 6px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; + } + } + + // Section styling + .o_support_sidebar_section { + margin-bottom: 0.5rem; + + .o_support_sidebar_section_header { + user-select: none; + color: #6c757d; // Muted text color for headers + + &:hover { + background-color: rgba(0, 0, 0, 0.03); + } + + span { + font-size: 0.7rem; + letter-spacing: 0.05em; + color: #6c757d; // Explicit muted color + } + + i { + color: #6c757d; // Explicit muted color for icons + } + } + + .o_support_sidebar_section_content { + // Animation disabled to prevent potential rendering issues + // animation: slideDown 0.15s ease-out; + } + } + + // Item styling + .o_support_sidebar_item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + margin: 0 0.5rem; + cursor: pointer; + border-radius: 0.25rem; + transition: background-color 0.15s ease; + color: #212529; // Ensure text is dark + + &:hover { + background-color: rgba(0, 0, 0, 0.05); + } + + &.active { + background-color: var(--bs-primary, #0d6efd); + color: white !important; + + i, + span { + color: white !important; + } + + .badge { + background-color: white !important; + color: var(--bs-primary, #0d6efd) !important; + } + } + + i { + width: 16px; + text-align: center; + color: #6c757d; // Muted icon color + } + + .badge { + font-size: 0.65rem; + font-weight: 500; + } + } +} + +// Animation for section expand +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/customer_engagement/static/src/scss/inbox.scss b/customer_engagement/static/src/scss/inbox.scss new file mode 100644 index 0000000000..707ed071e7 --- /dev/null +++ b/customer_engagement/static/src/scss/inbox.scss @@ -0,0 +1,133 @@ +// Support Inbox Styles (Chatwoot/Zendesk-inspired) +// Main entry point - component styles are loaded via manifest + +// Main Inbox Container +.o_support_inbox { + height: calc(100vh - 46px); + background: var(--o-view-background-color, #f8f9fa); + display: flex; + overflow: hidden; + position: relative; + color: #212529; // Ensure text is dark by default +} + +// Global Utilities +.min-width-0 { + min-width: 0; +} + +.cursor-pointer { + cursor: pointer; +} + +// Tag Colors (matching Odoo's color palette) +.o_tag_color_0 { + background-color: #f0f0f0; + color: #4c4c4c; +} +.o_tag_color_1 { + background-color: #f57b7b; + color: white; +} +.o_tag_color_2 { + background-color: #f4a460; + color: white; +} +.o_tag_color_3 { + background-color: #f0e68c; + color: #4c4c4c; +} +.o_tag_color_4 { + background-color: #90ee90; + color: #4c4c4c; +} +.o_tag_color_5 { + background-color: #87ceeb; + color: #4c4c4c; +} +.o_tag_color_6 { + background-color: #add8e6; + color: #4c4c4c; +} +.o_tag_color_7 { + background-color: #9370db; + color: white; +} +.o_tag_color_8 { + background-color: #dda0dd; + color: #4c4c4c; +} +.o_tag_color_9 { + background-color: #ffc0cb; + color: #4c4c4c; +} +.o_tag_color_10 { + background-color: #28a745; + color: white; +} +.o_tag_color_11 { + background-color: #6c757d; + color: white; +} + +// Global scrollbar styling for the inbox +.o_support_inbox { + * { + // Firefox scrollbar + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.2) transparent; + } + + *::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + *::-webkit-scrollbar-track { + background: transparent; + } + + *::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; + + &:hover { + background: rgba(0, 0, 0, 0.3); + } + } +} + +// Focus states for accessibility +.o_support_inbox { + .btn:focus, + .form-control:focus, + textarea:focus { + outline: none; + box-shadow: 0 0 0 0.2rem rgba(#0066cc, 0.25); + } + + // Focus visible only for keyboard navigation + .btn:focus:not(:focus-visible) { + box-shadow: none; + } +} + +// Print styles +@media print { + .o_support_inbox { + .o_support_sidebar, + .o_support_conversation_list, + .o_support_composer_container { + display: none !important; + } + + .o_support_chat_panel { + width: 100% !important; + } + + .o_support_messages { + overflow: visible !important; + height: auto !important; + } + } +} diff --git a/customer_engagement/static/src/xml/components/chat_panel_templates.xml b/customer_engagement/static/src/xml/components/chat_panel_templates.xml new file mode 100644 index 0000000000..94b3c4f2f7 --- /dev/null +++ b/customer_engagement/static/src/xml/components/chat_panel_templates.xml @@ -0,0 +1,600 @@ + + + + + +
+ +
+ +
Select a conversation
+

Choose a conversation from the list to start chatting

+
+
+ + + + + +
+ +
+ + +
+ +
+
+
+
+ + + +
+
+ + + + +
+
+
+ +
+
+
+ +
+ + + + + + + + + + + +
+
+ + + + +
+ +
+
+ Loading... +
+
+
+ +
+ +

No messages yet

+
+
+ + + +
+
+ +
+
+ + + + + + + + + + + +
+
+
+
+ + + +
+ +
+
+ +
+
+ + +
+
+
+ + +
+ +
+ + + + + + + +
+
+
+ +
+
+
+ + + + +
+
+
+
+ + Private Note + by +
+
+ + +
+
+
+
+
+ + + + +
+ + + +
+
+ + + +
+ +
+ + +
+ + +
+ +
+
+ / + - + +
+
+
+ +
+ + + + + +
+
+