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..e4f4917aea
--- /dev/null
+++ b/customer_engagement/__init__.py
@@ -0,0 +1,3 @@
+from . import controllers
+from . import models
+from . import wizard
diff --git a/customer_engagement/__manifest__.py b/customer_engagement/__manifest__.py
new file mode 100644
index 0000000000..cb80af321f
--- /dev/null
+++ b/customer_engagement/__manifest__.py
@@ -0,0 +1,92 @@
+{
+ "name": "Customer Engagement",
+ "version": "18.0.2.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/engage_folder_data.xml",
+ "data/canned_response_data.xml",
+ "data/ir_cron_data.xml",
+ "views/conversation_stage_views.xml",
+ "views/conversation_label_views.xml",
+ "views/engage_team_views.xml",
+ "views/engage_folder_views.xml",
+ "views/canned_response_views.xml",
+ "views/conversation_views.xml",
+ "views/api_key_views.xml",
+ "views/res_partner_views.xml",
+ "views/res_users_views.xml",
+ "views/sla_policy_views.xml",
+ "views/routing_rule_views.xml",
+ "views/automation_views.xml",
+ "views/metrics_views.xml",
+ "views/csat_views.xml",
+ "views/csat_templates.xml",
+ "views/integration_views.xml",
+ "wizard/transfer_wizard_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/engage_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/controllers/__init__.py b/customer_engagement/controllers/__init__.py
new file mode 100644
index 0000000000..342c012cbb
--- /dev/null
+++ b/customer_engagement/controllers/__init__.py
@@ -0,0 +1,2 @@
+from . import api
+from . import csat
diff --git a/customer_engagement/controllers/api.py b/customer_engagement/controllers/api.py
new file mode 100644
index 0000000000..0334f935d3
--- /dev/null
+++ b/customer_engagement/controllers/api.py
@@ -0,0 +1,603 @@
+import logging
+from functools import wraps
+
+from odoo import http
+from odoo.exceptions import AccessDenied, ValidationError
+from odoo.http import request
+
+_logger = logging.getLogger(__name__)
+
+
+def api_key_required(permission="read"):
+ """
+ Decorator to require API key authentication.
+
+ :param permission: Required permission ('read', 'write', 'webhook')
+ """
+
+ def decorator(func):
+ @wraps(func)
+ def wrapper(self, *args, **kwargs):
+ # Get API key from header
+ api_key = request.httprequest.headers.get("X-API-Key")
+ if not api_key:
+ api_key = request.httprequest.headers.get("Authorization", "").replace(
+ "Bearer ", ""
+ )
+
+ try:
+ # Authenticate
+ api_key_record = (
+ request.env["engage.api.key"].sudo()._authenticate(api_key)
+ )
+
+ # Check permission
+ channel_type = kwargs.get("channel_type") or kwargs.get("channel")
+ api_key_record.check_permission(permission, channel_type)
+
+ # Store API key in request for later use
+ request.engage_api_key = api_key_record
+
+ return func(self, *args, **kwargs)
+
+ except AccessDenied as e:
+ return self._error_response(str(e), 401)
+ except Exception:
+ _logger.exception("API authentication error")
+ return self._error_response("Authentication failed", 401)
+
+ return wrapper
+
+ return decorator
+
+
+class EngageAPI(http.Controller):
+ """REST API for Customer Engagement."""
+
+ # ==================== Helper Methods ====================
+
+ def _json_response(self, data, status=200):
+ """Return JSON response."""
+ return request.make_json_response(data, status=status)
+
+ def _error_response(self, message, status=400, code=None):
+ """Return error JSON response."""
+ return request.make_json_response(
+ {
+ "error": {
+ "code": code or status,
+ "message": message,
+ }
+ },
+ status=status,
+ )
+
+ def _success_response(self, data=None, message=None):
+ """Return success JSON response."""
+ response = {"success": True}
+ if message:
+ response["message"] = message
+ if data is not None:
+ response["data"] = data
+ return self._json_response(response)
+
+ def _get_json_body(self):
+ """Get JSON body from request."""
+ try:
+ return request.get_json_data()
+ except Exception:
+ return {}
+
+ # ==================== Webhook Endpoints ====================
+
+ @http.route(
+ "/api/v1/webhook/message",
+ type="http",
+ auth="public",
+ methods=["POST"],
+ csrf=False,
+ )
+ @api_key_required(permission="webhook")
+ def webhook_message(self, **kwargs):
+ """
+ Receive incoming message from external channel.
+
+ Expected payload:
+ {
+ "channel": "whatsapp",
+ "message_id": "external_msg_id",
+ "from": {
+ "id": "+5511999999999",
+ "name": "John Doe"
+ },
+ "type": "text",
+ "content": {
+ "text": "Hello!",
+ "media_url": "https://...",
+ "media_type": "image"
+ },
+ "timestamp": "2024-01-15T10:30:00Z",
+ "reply_to": "previous_msg_id",
+ "metadata": {}
+ }
+ """
+ try:
+ data = self._get_json_body()
+
+ channel_type = data.get("channel")
+ if not channel_type:
+ return self._error_response("Channel type is required", 400)
+
+ # Extract sender info
+ sender = data.get("from", {})
+ sender_id = sender.get("id")
+ sender_name = sender.get("name")
+
+ if not sender_id:
+ return self._error_response("Sender ID is required", 400)
+
+ # Find or create partner
+ Partner = request.env["res.partner"].sudo()
+ partner = Partner._find_or_create_by_channel(
+ channel_type, sender_id, sender_name
+ )
+
+ # Find or create conversation
+ Conversation = request.env["engage.conversation"].sudo()
+ conversation = Conversation.search(
+ [
+ ("partner_id", "=", partner.id),
+ ("channel_type", "=", channel_type),
+ ("closed", "=", False),
+ ],
+ limit=1,
+ order="create_date desc",
+ )
+
+ if not conversation:
+ # Create new conversation
+ conversation = Conversation.create(
+ {
+ "partner_id": partner.id,
+ "channel_type": channel_type,
+ "channel_identifier": sender_id,
+ "subject": f"Conversation with {partner.name}",
+ }
+ )
+
+ # Process content
+ content = data.get("content", {})
+ message_text = content.get("text", "")
+ media_url = content.get("media_url")
+ media_type = content.get("media_type")
+
+ # Build message body
+ body = message_text
+ if media_url and not message_text:
+ body = f"[{media_type or 'Media'}]"
+
+ # Create message
+ message_vals = {
+ "body": body,
+ "message_type": "comment",
+ "subtype_id": request.env.ref("mail.mt_comment").id,
+ "author_id": partner.id,
+ "model": "engage.conversation",
+ "res_id": conversation.id,
+ # Engage-specific fields
+ "channel_message_id": data.get("message_id"),
+ "engage_channel_type": channel_type,
+ "media_url": media_url,
+ "media_type": media_type,
+ "channel_metadata": data.get("metadata"),
+ }
+
+ # Handle reply-to
+ if data.get("reply_to"):
+ reply_msg = (
+ request.env["mail.message"]
+ .sudo()
+ ._get_message_by_channel_id(data["reply_to"], channel_type)
+ )
+ if reply_msg:
+ message_vals["reply_to_message_id"] = reply_msg.id
+
+ message = request.env["mail.message"].sudo().create(message_vals)
+
+ # Update conversation
+ conversation.write(
+ {
+ "write_date": message.date,
+ }
+ )
+
+ return self._success_response(
+ {
+ "conversation_id": conversation.id,
+ "conversation_uuid": conversation.uuid,
+ "message_id": message.id,
+ "partner_id": partner.id,
+ }
+ )
+
+ except ValidationError as e:
+ return self._error_response(str(e), 400)
+ except Exception:
+ _logger.exception("Error processing webhook message")
+ return self._error_response("Internal server error", 500)
+
+ @http.route(
+ "/api/v1/webhook/status",
+ type="http",
+ auth="public",
+ methods=["POST"],
+ csrf=False,
+ )
+ @api_key_required(permission="webhook")
+ def webhook_status(self, **kwargs):
+ """
+ Receive delivery status update for a message.
+
+ Expected payload:
+ {
+ "message_id": "external_msg_id",
+ "status": "delivered",
+ "timestamp": "2024-01-15T10:30:00Z",
+ "error": "optional error message"
+ }
+ """
+ try:
+ data = self._get_json_body()
+
+ message_id = data.get("message_id")
+ if not message_id:
+ return self._error_response("Message ID is required", 400)
+
+ status = data.get("status")
+ if status not in ("sent", "delivered", "read", "failed"):
+ return self._error_response("Invalid status", 400)
+
+ # Find message
+ message = (
+ request.env["mail.message"]
+ .sudo()
+ ._get_message_by_channel_id(message_id)
+ )
+ if not message:
+ return self._error_response("Message not found", 404)
+
+ # Update status
+ message._update_delivery_status(
+ status,
+ data.get("timestamp"),
+ data.get("error"),
+ )
+
+ return self._success_response({"message_id": message.id})
+
+ except Exception:
+ _logger.exception("Error processing status webhook")
+ return self._error_response("Internal server error", 500)
+
+ # ==================== Conversation Endpoints ====================
+
+ @http.route(
+ "/api/v1/conversations",
+ type="http",
+ auth="public",
+ methods=["GET"],
+ csrf=False,
+ )
+ @api_key_required(permission="read")
+ def list_conversations(self, **kwargs):
+ """
+ List conversations with optional filters.
+
+ Query params:
+ - channel: Filter by channel type
+ - status: Filter by status (open, closed)
+ - partner_id: Filter by partner
+ - limit: Max results (default 50)
+ - offset: Pagination offset
+ """
+ try:
+ domain = []
+
+ # Apply filters
+ if kwargs.get("channel"):
+ domain.append(("channel_type", "=", kwargs["channel"]))
+ if kwargs.get("status") == "open":
+ domain.append(("closed", "=", False))
+ elif kwargs.get("status") == "closed":
+ domain.append(("closed", "=", True))
+ if kwargs.get("partner_id"):
+ domain.append(("partner_id", "=", int(kwargs["partner_id"])))
+
+ limit = min(int(kwargs.get("limit", 50)), 100)
+ offset = int(kwargs.get("offset", 0))
+
+ conversations = (
+ request.env["engage.conversation"]
+ .sudo()
+ .search(domain, limit=limit, offset=offset, order="write_date desc")
+ )
+
+ data = []
+ for conv in conversations:
+ data.append(
+ {
+ "id": conv.id,
+ "uuid": conv.uuid,
+ "subject": conv.subject,
+ "channel_type": conv.channel_type,
+ "partner": {
+ "id": conv.partner_id.id,
+ "name": conv.partner_id.name,
+ }
+ if conv.partner_id
+ else None,
+ "stage": {
+ "id": conv.stage_id.id,
+ "code": conv.stage_id.code,
+ "name": conv.stage_id.name,
+ }
+ if conv.stage_id
+ else None,
+ "user_id": conv.user_id.id if conv.user_id else None,
+ "closed": conv.closed,
+ "create_date": conv.create_date.isoformat()
+ if conv.create_date
+ else None,
+ "write_date": conv.write_date.isoformat()
+ if conv.write_date
+ else None,
+ }
+ )
+
+ return self._success_response(data)
+
+ except Exception:
+ _logger.exception("Error listing conversations")
+ return self._error_response("Internal server error", 500)
+
+ @http.route(
+ "/api/v1/conversations/",
+ type="http",
+ auth="public",
+ methods=["GET"],
+ csrf=False,
+ )
+ @api_key_required(permission="read")
+ def get_conversation(self, conversation_id, **kwargs):
+ """Get conversation details with messages."""
+ try:
+ conversation = (
+ request.env["engage.conversation"].sudo().browse(conversation_id)
+ )
+ if not conversation.exists():
+ return self._error_response("Conversation not found", 404)
+
+ # Get messages
+ messages = (
+ request.env["mail.message"]
+ .sudo()
+ .search(
+ [
+ ("model", "=", "engage.conversation"),
+ ("res_id", "=", conversation_id),
+ ],
+ order="date asc",
+ )
+ )
+
+ message_data = []
+ for msg in messages:
+ message_data.append(
+ {
+ "id": msg.id,
+ "body": msg.body,
+ "author": {
+ "id": msg.author_id.id,
+ "name": msg.author_id.name,
+ }
+ if msg.author_id
+ else None,
+ "date": msg.date.isoformat() if msg.date else None,
+ "message_type": msg.message_type,
+ "channel_message_id": msg.channel_message_id,
+ "delivery_status": msg.delivery_status,
+ "media_url": msg.media_url,
+ "media_type": msg.media_type,
+ }
+ )
+
+ data = {
+ "id": conversation.id,
+ "uuid": conversation.uuid,
+ "subject": conversation.subject,
+ "channel_type": conversation.channel_type,
+ "channel_identifier": conversation.channel_identifier,
+ "partner": {
+ "id": conversation.partner_id.id,
+ "name": conversation.partner_id.name,
+ "email": conversation.partner_id.email,
+ "phone": conversation.partner_id.phone,
+ }
+ if conversation.partner_id
+ else None,
+ "stage": {
+ "id": conversation.stage_id.id,
+ "code": conversation.stage_id.code,
+ "name": conversation.stage_id.name,
+ }
+ if conversation.stage_id
+ else None,
+ "user": {
+ "id": conversation.user_id.id,
+ "name": conversation.user_id.name,
+ }
+ if conversation.user_id
+ else None,
+ "team": {
+ "id": conversation.team_id.id,
+ "name": conversation.team_id.name,
+ }
+ if conversation.team_id
+ else None,
+ "priority": conversation.priority,
+ "closed": conversation.closed,
+ "create_date": conversation.create_date.isoformat()
+ if conversation.create_date
+ else None,
+ "first_response_at": conversation.first_response_at.isoformat()
+ if conversation.first_response_at
+ else None,
+ "resolved_at": conversation.resolved_at.isoformat()
+ if conversation.resolved_at
+ else None,
+ "messages": message_data,
+ }
+
+ return self._success_response(data)
+
+ except Exception:
+ _logger.exception("Error getting conversation")
+ return self._error_response("Internal server error", 500)
+
+ @http.route(
+ "/api/v1/conversations//send",
+ type="http",
+ auth="public",
+ methods=["POST"],
+ csrf=False,
+ )
+ @api_key_required(permission="write")
+ def send_message(self, conversation_id, **kwargs):
+ """
+ Send a message to a conversation.
+
+ Expected payload:
+ {
+ "text": "Message content",
+ "media_url": "https://...",
+ "media_type": "image"
+ }
+ """
+ try:
+ conversation = (
+ request.env["engage.conversation"].sudo().browse(conversation_id)
+ )
+ if not conversation.exists():
+ return self._error_response("Conversation not found", 404)
+
+ data = self._get_json_body()
+ text = data.get("text", "")
+ media_url = data.get("media_url")
+ media_type = data.get("media_type")
+
+ if not text and not media_url:
+ return self._error_response("Message text or media is required", 400)
+
+ # Create message
+ body = text
+ if media_url and not text:
+ body = f"[{media_type or 'Media'}]"
+
+ message = conversation.message_post(
+ body=body,
+ message_type="comment",
+ )
+
+ # Update engage-specific fields
+ message.sudo().write(
+ {
+ "engage_channel_type": conversation.channel_type,
+ "media_url": media_url,
+ "media_type": media_type,
+ "delivery_status": "pending",
+ }
+ )
+
+ return self._success_response(
+ {
+ "message_id": message.id,
+ "conversation_id": conversation.id,
+ }
+ )
+
+ except Exception:
+ _logger.exception("Error sending message")
+ return self._error_response("Internal server error", 500)
+
+ # ==================== Partner Endpoints ====================
+
+ @http.route(
+ "/api/v1/partners/lookup",
+ type="http",
+ auth="public",
+ methods=["GET"],
+ csrf=False,
+ )
+ @api_key_required(permission="read")
+ def lookup_partner(self, **kwargs):
+ """
+ Look up a partner by channel identifier.
+
+ Query params:
+ - channel: Channel type (whatsapp, telegram, etc.)
+ - identifier: Channel-specific identifier
+ """
+ try:
+ channel = kwargs.get("channel")
+ identifier = kwargs.get("identifier")
+
+ if not channel or not identifier:
+ return self._error_response("Channel and identifier are required", 400)
+
+ partner = (
+ request.env["res.partner"]
+ .sudo()
+ ._find_by_channel_id(channel, identifier)
+ )
+
+ if not partner:
+ return self._success_response(None)
+
+ data = {
+ "id": partner.id,
+ "name": partner.name,
+ "email": partner.email,
+ "phone": partner.phone,
+ "mobile": partner.mobile,
+ "whatsapp_number": partner.whatsapp_number,
+ "telegram_id": partner.telegram_id,
+ "instagram_id": partner.instagram_id,
+ "vip_customer": partner.vip_customer,
+ "engage_blocked": partner.engage_blocked,
+ "conversation_count": partner.engage_conversation_count,
+ "open_conversations": partner.open_conversation_count,
+ }
+
+ return self._success_response(data)
+
+ except Exception:
+ _logger.exception("Error looking up partner")
+ return self._error_response("Internal server error", 500)
+
+ # ==================== Health Check ====================
+
+ @http.route(
+ "/api/v1/health",
+ type="http",
+ auth="public",
+ methods=["GET"],
+ csrf=False,
+ )
+ def health_check(self, **kwargs):
+ """Health check endpoint (no auth required)."""
+ return self._success_response(
+ {
+ "status": "healthy",
+ "service": "customer_engagement",
+ }
+ )
diff --git a/customer_engagement/controllers/csat.py b/customer_engagement/controllers/csat.py
new file mode 100644
index 0000000000..ddd181838b
--- /dev/null
+++ b/customer_engagement/controllers/csat.py
@@ -0,0 +1,138 @@
+"""CSAT Public Controller for Customer Engagement.
+
+This module provides public endpoints for CSAT survey submission.
+"""
+
+from odoo import http
+from odoo.http import request
+
+
+class CSATController(http.Controller):
+ """Controller for public CSAT survey access."""
+
+ @http.route(
+ "/csat/",
+ type="http",
+ auth="public",
+ website=True,
+ sitemap=False,
+ )
+ def csat_form(self, token, **kwargs):
+ """Display CSAT survey form."""
+ survey = (
+ request.env["engage.csat"].sudo().search([("token", "=", token)], limit=1)
+ )
+
+ if not survey:
+ return request.render(
+ "customer_engagement.csat_not_found",
+ {"error": "Survey not found"},
+ )
+
+ if survey.state == "answered":
+ return request.render(
+ "customer_engagement.csat_already_answered",
+ {"survey": survey},
+ )
+
+ if survey.state == "expired":
+ return request.render(
+ "customer_engagement.csat_expired",
+ {"survey": survey},
+ )
+
+ return request.render(
+ "customer_engagement.csat_form",
+ {
+ "survey": survey,
+ "conversation": survey.conversation_id,
+ "agent": survey.user_id,
+ },
+ )
+
+ @http.route(
+ "/csat//submit",
+ type="http",
+ auth="public",
+ methods=["POST"],
+ website=True,
+ sitemap=False,
+ csrf=True,
+ )
+ def csat_submit(self, token, **kwargs):
+ """Submit CSAT survey response."""
+ survey = (
+ request.env["engage.csat"].sudo().search([("token", "=", token)], limit=1)
+ )
+
+ if not survey:
+ return request.render(
+ "customer_engagement.csat_not_found",
+ {"error": "Survey not found"},
+ )
+
+ # Get rating from form
+ rating = kwargs.get("rating")
+ if not rating or rating not in ["1", "2", "3", "4", "5"]:
+ return request.render(
+ "customer_engagement.csat_form",
+ {
+ "survey": survey,
+ "conversation": survey.conversation_id,
+ "agent": survey.user_id,
+ "error": "Please select a rating",
+ },
+ )
+
+ # Get optional fields
+ feedback = kwargs.get("feedback", "").strip()
+ resolved = kwargs.get("resolved") == "on"
+ helpful = kwargs.get("helpful") == "on"
+ recommend = kwargs.get("recommend") == "on"
+
+ # Submit response
+ result = survey.action_submit_response(
+ rating=rating,
+ feedback=feedback or None,
+ resolved=resolved,
+ helpful=helpful,
+ recommend=recommend,
+ )
+
+ if result.get("success"):
+ return request.render(
+ "customer_engagement.csat_thank_you",
+ {"survey": survey, "rating": rating},
+ )
+ else:
+ return request.render(
+ "customer_engagement.csat_form",
+ {
+ "survey": survey,
+ "conversation": survey.conversation_id,
+ "agent": survey.user_id,
+ "error": result.get("error", "An error occurred"),
+ },
+ )
+
+ @http.route(
+ "/csat/api/",
+ type="json",
+ auth="public",
+ methods=["POST"],
+ csrf=False,
+ )
+ def csat_api_submit(self, token, rating, feedback=None, **kwargs):
+ """API endpoint for CSAT submission (for mobile apps, etc.)."""
+ survey = (
+ request.env["engage.csat"].sudo().search([("token", "=", token)], limit=1)
+ )
+
+ if not survey:
+ return {"success": False, "error": "Survey not found"}
+
+ return survey.action_submit_response(
+ rating=rating,
+ feedback=feedback,
+ **kwargs,
+ )
diff --git a/customer_engagement/data/canned_response_data.xml b/customer_engagement/data/canned_response_data.xml
new file mode 100644
index 0000000000..8d683759de
--- /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..3a7aa38684
--- /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/engage_folder_data.xml b/customer_engagement/data/engage_folder_data.xml
new file mode 100644
index 0000000000..1d965b7ca8
--- /dev/null
+++ b/customer_engagement/data/engage_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/data/ir_cron_data.xml b/customer_engagement/data/ir_cron_data.xml
new file mode 100644
index 0000000000..c4d4afe732
--- /dev/null
+++ b/customer_engagement/data/ir_cron_data.xml
@@ -0,0 +1,51 @@
+
+
+
+
+ Engage: Check Idle Conversations
+
+ code
+ model._cron_check_idle_conversations()
+ 5
+ minutes
+ True
+
+
+
+
+ Engage: Process Automation Queue
+
+ code
+ model._cron_process_queue()
+ 1
+ minutes
+ True
+
+
+
+
+ Engage: Update SLA Status
+
+ code
+
+# Recompute SLA status for open conversations
+conversations = model.search([('closed', '=', False), ('sla_policy_id', '!=', False)])
+for conv in conversations:
+ conv._compute_sla_status()
+
+ 5
+ minutes
+ True
+
+
+
+
+ Engage: Check Expired CSAT Surveys
+
+ code
+ model._cron_check_expired()
+ 1
+ days
+ True
+
+
diff --git a/customer_engagement/demo/demo_data.xml b/customer_engagement/demo/demo_data.xml
new file mode 100644
index 0000000000..4bf6b8f03e
--- /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..1d41bc8ce0
--- /dev/null
+++ b/customer_engagement/models/__init__.py
@@ -0,0 +1,19 @@
+from . import conversation_stage
+from . import conversation_label
+from . import engage_team
+from . import team_schedule
+from . import engage_folder
+from . import canned_response
+from . import conversation_note
+from . import conversation
+from . import conversation_history
+from . import mail_message
+from . import res_partner
+from . import api_key
+from . import res_users
+from . import sla_policy
+from . import routing_rule
+from . import automation
+from . import metrics
+from . import csat
+from . import integrations
diff --git a/customer_engagement/models/api_key.py b/customer_engagement/models/api_key.py
new file mode 100644
index 0000000000..007e57ae38
--- /dev/null
+++ b/customer_engagement/models/api_key.py
@@ -0,0 +1,267 @@
+import hashlib
+import secrets
+
+from odoo import api, fields, models
+from odoo.exceptions import AccessDenied
+
+
+class EngageApiKey(models.Model):
+ """API Key for external channel integrations."""
+
+ _name = "engage.api.key"
+ _description = "Engage API Key"
+ _order = "name"
+
+ name = fields.Char(
+ string="Name",
+ required=True,
+ help="Descriptive name for this API key",
+ )
+ key_hash = fields.Char(
+ string="Key Hash",
+ required=True,
+ copy=False,
+ help="SHA-256 hash of the API key",
+ )
+ key_prefix = fields.Char(
+ string="Key Prefix",
+ size=8,
+ copy=False,
+ help="First 8 characters of the key for identification",
+ )
+ active = fields.Boolean(default=True)
+
+ # Permissions
+ channel_types = fields.Selection(
+ selection=[
+ ("all", "All Channels"),
+ ("whatsapp", "WhatsApp Only"),
+ ("telegram", "Telegram Only"),
+ ("instagram", "Instagram Only"),
+ ("messenger", "Messenger Only"),
+ ("api", "API Only"),
+ ],
+ string="Allowed Channels",
+ default="all",
+ required=True,
+ )
+ can_read = fields.Boolean(
+ string="Can Read",
+ default=True,
+ help="Can query conversations and messages",
+ )
+ can_write = fields.Boolean(
+ string="Can Write",
+ default=True,
+ help="Can create/update conversations and send messages",
+ )
+ can_receive_webhooks = fields.Boolean(
+ string="Can Receive Webhooks",
+ default=True,
+ help="Can receive incoming message webhooks",
+ )
+
+ # Rate limiting
+ rate_limit_per_minute = fields.Integer(
+ string="Rate Limit (per minute)",
+ default=60,
+ help="Maximum API calls per minute (0 = unlimited)",
+ )
+ rate_limit_per_day = fields.Integer(
+ string="Rate Limit (per day)",
+ default=10000,
+ help="Maximum API calls per day (0 = unlimited)",
+ )
+
+ # Statistics
+ last_used_at = fields.Datetime(string="Last Used")
+ total_requests = fields.Integer(string="Total Requests", default=0)
+ requests_today = fields.Integer(
+ string="Requests Today",
+ compute="_compute_requests_today",
+ )
+
+ # Expiration
+ expires_at = fields.Datetime(
+ string="Expires At",
+ help="Leave empty for no expiration",
+ )
+
+ # Webhook configuration
+ webhook_url = fields.Char(
+ string="Webhook URL",
+ help="URL to send delivery status updates",
+ )
+ webhook_secret = fields.Char(
+ string="Webhook Secret",
+ help="Secret for webhook signature verification",
+ )
+
+ # Associated team (optional)
+ team_id = fields.Many2one(
+ comodel_name="engage.team",
+ string="Team",
+ help="Associate this key with a specific team",
+ )
+
+ _sql_constraints = [
+ (
+ "key_prefix_unique",
+ "UNIQUE(key_prefix)",
+ "API key prefix must be unique",
+ ),
+ ]
+
+ def _compute_requests_today(self):
+ """Compute requests made today."""
+ # This would typically query a log table
+ # For now, return 0 as placeholder
+ for key in self:
+ key.requests_today = 0
+
+ @api.model
+ def generate_key(self, name, **kwargs):
+ """
+ Generate a new API key.
+
+ :param name: Name for the API key
+ :param kwargs: Additional field values
+ :return: tuple (api_key_record, plain_key)
+ """
+ # Generate a secure random key
+ plain_key = secrets.token_urlsafe(32)
+ key_hash = hashlib.sha256(plain_key.encode()).hexdigest()
+ key_prefix = plain_key[:8]
+
+ vals = {
+ "name": name,
+ "key_hash": key_hash,
+ "key_prefix": key_prefix,
+ **kwargs,
+ }
+
+ record = self.create(vals)
+ return record, plain_key
+
+ @api.model
+ def _authenticate(self, api_key):
+ """
+ Authenticate an API key.
+
+ :param api_key: Plain text API key
+ :return: engage.api.key record
+ :raises: AccessDenied if invalid or expired
+ """
+ if not api_key:
+ raise AccessDenied("API key is required")
+
+ # Hash the provided key
+ key_hash = hashlib.sha256(api_key.encode()).hexdigest()
+ key_prefix = api_key[:8]
+
+ # Find matching key
+ api_key_record = self.search(
+ [
+ ("key_hash", "=", key_hash),
+ ("key_prefix", "=", key_prefix),
+ ("active", "=", True),
+ ],
+ limit=1,
+ )
+
+ if not api_key_record:
+ raise AccessDenied("Invalid API key")
+
+ # Check expiration
+ if (
+ api_key_record.expires_at
+ and api_key_record.expires_at < fields.Datetime.now()
+ ):
+ raise AccessDenied("API key has expired")
+
+ # Update last used
+ api_key_record.sudo().write(
+ {
+ "last_used_at": fields.Datetime.now(),
+ "total_requests": api_key_record.total_requests + 1,
+ }
+ )
+
+ return api_key_record
+
+ def check_permission(self, permission, channel_type=None):
+ """
+ Check if this API key has a specific permission.
+
+ :param permission: 'read', 'write', or 'webhook'
+ :param channel_type: Optional channel type to check
+ :return: True if permitted
+ :raises: AccessDenied if not permitted
+ """
+ self.ensure_one()
+
+ # Check channel permission
+ if channel_type and self.channel_types != "all":
+ if self.channel_types != channel_type:
+ raise AccessDenied(
+ f"API key not authorized for channel: {channel_type}"
+ )
+
+ # Check operation permission
+ if permission == "read" and not self.can_read:
+ raise AccessDenied("API key does not have read permission")
+ if permission == "write" and not self.can_write:
+ raise AccessDenied("API key does not have write permission")
+ if permission == "webhook" and not self.can_receive_webhooks:
+ raise AccessDenied("API key does not have webhook permission")
+
+ return True
+
+ def action_regenerate_key(self):
+ """Regenerate the API key (returns new plain key via wizard)."""
+ self.ensure_one()
+ # Generate new key
+ plain_key = secrets.token_urlsafe(32)
+ key_hash = hashlib.sha256(plain_key.encode()).hexdigest()
+ key_prefix = plain_key[:8]
+
+ self.write(
+ {
+ "key_hash": key_hash,
+ "key_prefix": key_prefix,
+ }
+ )
+
+ # Return action to show the new key
+ return {
+ "type": "ir.actions.act_window",
+ "name": "New API Key Generated",
+ "res_model": "engage.api.key.wizard",
+ "view_mode": "form",
+ "target": "new",
+ "context": {
+ "default_api_key_id": self.id,
+ "default_plain_key": plain_key,
+ },
+ }
+
+
+class EngageApiKeyWizard(models.TransientModel):
+ """Wizard to display newly generated API key."""
+
+ _name = "engage.api.key.wizard"
+ _description = "API Key Display Wizard"
+
+ api_key_id = fields.Many2one(
+ comodel_name="engage.api.key",
+ string="API Key Record",
+ readonly=True,
+ )
+ plain_key = fields.Char(
+ string="API Key",
+ readonly=True,
+ help="Copy this key now - it cannot be retrieved later!",
+ )
+
+ def action_close(self):
+ return {"type": "ir.actions.act_window_close"}
diff --git a/customer_engagement/models/automation.py b/customer_engagement/models/automation.py
new file mode 100644
index 0000000000..83777d38d3
--- /dev/null
+++ b/customer_engagement/models/automation.py
@@ -0,0 +1,624 @@
+import logging
+from datetime import timedelta
+
+from odoo import api, fields, models
+
+_logger = logging.getLogger(__name__)
+
+
+class EngageAutomation(models.Model):
+ """Automation rules triggered by conversation events."""
+
+ _name = "engage.automation"
+ _description = "Engage Automation"
+ _order = "sequence, id"
+
+ name = fields.Char(string="Name", required=True)
+ sequence = fields.Integer(default=10)
+ active = fields.Boolean(default=True)
+ description = fields.Text(string="Description")
+
+ # Trigger configuration
+ trigger_event = fields.Selection(
+ selection=[
+ ("conversation_created", "New Conversation"),
+ ("message_received", "Message Received"),
+ ("conversation_idle", "Conversation Idle"),
+ ("sla_warning", "SLA Warning"),
+ ("sla_breach", "SLA Breach"),
+ ("stage_changed", "Stage Changed"),
+ ("assigned", "Conversation Assigned"),
+ ("unassigned", "Conversation Unassigned"),
+ ],
+ string="Trigger Event",
+ required=True,
+ help="Event that triggers this automation",
+ )
+
+ # Delay settings
+ trigger_delay = fields.Integer(
+ string="Delay (minutes)",
+ default=0,
+ help="Minutes to wait before executing actions",
+ )
+ idle_minutes = fields.Integer(
+ string="Idle Time (minutes)",
+ default=30,
+ help="Minutes of inactivity before triggering (for idle trigger)",
+ )
+
+ # Conditions
+ channel_type = fields.Selection(
+ selection=[
+ ("email", "Email"),
+ ("whatsapp", "WhatsApp"),
+ ("instagram", "Instagram"),
+ ("messenger", "Messenger"),
+ ("telegram", "Telegram"),
+ ("livechat", "Live Chat"),
+ ("api", "API"),
+ ],
+ string="Channel",
+ help="Only trigger for this channel (empty = all)",
+ )
+ team_ids = fields.Many2many(
+ comodel_name="engage.team",
+ relation="engage_automation_team_rel",
+ column1="automation_id",
+ column2="team_id",
+ string="Teams",
+ help="Only trigger for conversations in these teams (empty = all)",
+ )
+ priority_filter = fields.Selection(
+ selection=[
+ ("0", "Low"),
+ ("1", "Normal"),
+ ("2", "High"),
+ ("3", "Urgent"),
+ ],
+ string="Priority",
+ help="Only trigger for this priority (empty = all)",
+ )
+ stage_ids = fields.Many2many(
+ comodel_name="engage.conversation.stage",
+ relation="engage_automation_stage_rel",
+ column1="automation_id",
+ column2="stage_id",
+ string="Stages",
+ help="Only trigger for conversations in these stages (empty = all)",
+ )
+
+ # Actions
+ action_ids = fields.One2many(
+ comodel_name="engage.automation.action",
+ inverse_name="automation_id",
+ string="Actions",
+ )
+
+ # Statistics
+ execution_count = fields.Integer(
+ string="Executions",
+ default=0,
+ readonly=True,
+ )
+ last_execution = fields.Datetime(
+ string="Last Execution",
+ readonly=True,
+ )
+ error_count = fields.Integer(
+ string="Errors",
+ default=0,
+ readonly=True,
+ )
+
+ def _check_conditions(self, conversation):
+ """
+ Check if automation conditions match the conversation.
+
+ :param conversation: engage.conversation record
+ :return: True if conditions match
+ """
+ self.ensure_one()
+
+ # Channel condition
+ if self.channel_type and conversation.channel_type != self.channel_type:
+ return False
+
+ # Team condition
+ if self.team_ids and conversation.team_id not in self.team_ids:
+ return False
+
+ # Priority condition
+ if self.priority_filter and conversation.priority != self.priority_filter:
+ return False
+
+ # Stage condition
+ if self.stage_ids and conversation.stage_id not in self.stage_ids:
+ return False
+
+ return True
+
+ def _execute(self, conversation):
+ """
+ Execute all actions for this automation.
+
+ :param conversation: engage.conversation record
+ """
+ self.ensure_one()
+
+ try:
+ for action in self.action_ids.sorted("sequence"):
+ action._execute(conversation)
+
+ # Update statistics
+ self.sudo().write(
+ {
+ "execution_count": self.execution_count + 1,
+ "last_execution": fields.Datetime.now(),
+ }
+ )
+
+ except Exception as e:
+ _logger.exception(
+ "Automation %s failed for conversation %s: %s",
+ self.name,
+ conversation.id,
+ str(e),
+ )
+ self.sudo().write(
+ {
+ "error_count": self.error_count + 1,
+ }
+ )
+
+ @api.model
+ def _trigger_automations(self, event, conversation, **kwargs):
+ """
+ Find and execute matching automations for an event.
+
+ :param event: Event type string
+ :param conversation: engage.conversation record
+ :param kwargs: Additional context
+ """
+ automations = self.search(
+ [
+ ("active", "=", True),
+ ("trigger_event", "=", event),
+ ],
+ order="sequence",
+ )
+
+ for automation in automations:
+ if automation._check_conditions(conversation):
+ if automation.trigger_delay > 0:
+ # Schedule delayed execution
+ self.env["engage.automation.queue"].create(
+ {
+ "automation_id": automation.id,
+ "conversation_id": conversation.id,
+ "scheduled_at": fields.Datetime.now()
+ + timedelta(minutes=automation.trigger_delay),
+ }
+ )
+ else:
+ automation._execute(conversation)
+
+ @api.model
+ def _cron_check_idle_conversations(self):
+ """Cron job to check for idle conversations."""
+ automations = self.search(
+ [
+ ("active", "=", True),
+ ("trigger_event", "=", "conversation_idle"),
+ ]
+ )
+
+ for automation in automations:
+ idle_threshold = fields.Datetime.now() - timedelta(
+ minutes=automation.idle_minutes
+ )
+
+ # Find conversations that are idle
+ domain = [
+ ("closed", "=", False),
+ ("write_date", "<", idle_threshold),
+ ]
+
+ # Add automation conditions
+ if automation.channel_type:
+ domain.append(("channel_type", "=", automation.channel_type))
+ if automation.team_ids:
+ domain.append(("team_id", "in", automation.team_ids.ids))
+ if automation.priority_filter:
+ domain.append(("priority", "=", automation.priority_filter))
+ if automation.stage_ids:
+ domain.append(("stage_id", "in", automation.stage_ids.ids))
+
+ conversations = self.env["engage.conversation"].search(domain)
+
+ for conv in conversations:
+ # Check if already processed recently
+ if not self._was_recently_triggered(automation, conv):
+ automation._execute(conv)
+
+ def _was_recently_triggered(self, automation, conversation):
+ """Check if this automation was recently triggered for this conversation."""
+ # Could implement using a log table
+ # For now, return False
+ return False
+
+ @api.model
+ def _cron_process_queue(self):
+ """Process delayed automation queue."""
+ now = fields.Datetime.now()
+ queue_items = self.env["engage.automation.queue"].search(
+ [
+ ("scheduled_at", "<=", now),
+ ("processed", "=", False),
+ ]
+ )
+
+ for item in queue_items:
+ if item.automation_id.active and item.conversation_id.exists():
+ item.automation_id._execute(item.conversation_id)
+ item.processed = True
+
+
+class EngageAutomationAction(models.Model):
+ """Actions to execute as part of an automation."""
+
+ _name = "engage.automation.action"
+ _description = "Engage Automation Action"
+ _order = "sequence, id"
+
+ automation_id = fields.Many2one(
+ comodel_name="engage.automation",
+ string="Automation",
+ required=True,
+ ondelete="cascade",
+ )
+ sequence = fields.Integer(default=10)
+
+ action_type = fields.Selection(
+ selection=[
+ ("send_message", "Send Message"),
+ ("add_label", "Add Label"),
+ ("remove_label", "Remove Label"),
+ ("assign_team", "Assign to Team"),
+ ("assign_user", "Assign to Agent"),
+ ("unassign", "Unassign"),
+ ("change_stage", "Change Stage"),
+ ("set_priority", "Set Priority"),
+ ("notify_user", "Notify User"),
+ ("notify_team", "Notify Team"),
+ ("webhook", "Call Webhook"),
+ ("add_note", "Add Internal Note"),
+ ],
+ string="Action Type",
+ required=True,
+ )
+
+ # Parameters for different action types
+ # Message
+ message_content = fields.Text(
+ string="Message Content",
+ help="Message to send (supports placeholders: {partner_name}, {agent_name})",
+ )
+ message_is_note = fields.Boolean(
+ string="As Internal Note",
+ default=False,
+ )
+
+ # Labels
+ label_ids = fields.Many2many(
+ comodel_name="engage.conversation.label",
+ relation="engage_automation_action_label_rel",
+ column1="action_id",
+ column2="label_id",
+ string="Labels",
+ )
+
+ # Assignment
+ team_id = fields.Many2one(
+ comodel_name="engage.team",
+ string="Team",
+ )
+ user_id = fields.Many2one(
+ comodel_name="res.users",
+ string="Agent",
+ )
+ use_team_assignment = fields.Boolean(
+ string="Use Team Assignment",
+ default=True,
+ help="Use team's assignment method to pick agent",
+ )
+
+ # Stage
+ stage_id = fields.Many2one(
+ comodel_name="engage.conversation.stage",
+ string="Stage",
+ )
+
+ # Priority
+ priority = fields.Selection(
+ selection=[
+ ("0", "Low"),
+ ("1", "Normal"),
+ ("2", "High"),
+ ("3", "Urgent"),
+ ],
+ string="Priority",
+ )
+
+ # Notification
+ notify_user_ids = fields.Many2many(
+ comodel_name="res.users",
+ relation="engage_automation_action_notify_rel",
+ column1="action_id",
+ column2="user_id",
+ string="Users to Notify",
+ )
+ notification_message = fields.Text(
+ string="Notification Message",
+ )
+
+ # Webhook
+ webhook_url = fields.Char(string="Webhook URL")
+ webhook_method = fields.Selection(
+ selection=[
+ ("POST", "POST"),
+ ("GET", "GET"),
+ ],
+ string="HTTP Method",
+ default="POST",
+ )
+
+ def _execute(self, conversation):
+ """
+ Execute this action on a conversation.
+
+ :param conversation: engage.conversation record
+ """
+ self.ensure_one()
+
+ method_name = f"_action_{self.action_type}"
+ if hasattr(self, method_name):
+ getattr(self, method_name)(conversation)
+ else:
+ _logger.warning("Unknown action type: %s", self.action_type)
+
+ def _format_message(self, template, conversation):
+ """Format message template with conversation data."""
+ return template.format(
+ partner_name=conversation.partner_id.name or "Customer",
+ agent_name=conversation.user_id.name or "Agent",
+ conversation_id=conversation.uuid[:8],
+ channel=conversation.channel_type,
+ subject=conversation.subject or "",
+ )
+
+ def _action_send_message(self, conversation):
+ """Send a message to the conversation."""
+ if not self.message_content:
+ return
+
+ body = self._format_message(self.message_content, conversation)
+
+ if self.message_is_note:
+ conversation.action_add_note(body)
+ else:
+ conversation.message_post(
+ body=body,
+ message_type="comment",
+ )
+
+ def _action_add_label(self, conversation):
+ """Add labels to the conversation."""
+ if self.label_ids:
+ conversation.write(
+ {"label_ids": [(4, label_id) for label_id in self.label_ids.ids]}
+ )
+
+ def _action_remove_label(self, conversation):
+ """Remove labels from the conversation."""
+ if self.label_ids:
+ conversation.write(
+ {"label_ids": [(3, label_id) for label_id in self.label_ids.ids]}
+ )
+
+ def _action_assign_team(self, conversation):
+ """Assign conversation to a team."""
+ if not self.team_id:
+ return
+
+ vals = {"team_id": self.team_id.id}
+
+ if self.use_team_assignment:
+ agent = self.team_id.get_available_agent(conversation)
+ if agent:
+ vals["user_id"] = agent.id
+
+ conversation.write(vals)
+
+ def _action_assign_user(self, conversation):
+ """Assign conversation to a specific agent."""
+ if self.user_id:
+ conversation.write({"user_id": self.user_id.id})
+
+ def _action_unassign(self, conversation):
+ """Unassign the conversation."""
+ conversation.write({"user_id": False})
+
+ def _action_change_stage(self, conversation):
+ """Change conversation stage."""
+ if self.stage_id:
+ try:
+ conversation.write({"stage_id": self.stage_id.id})
+ except Exception as e:
+ _logger.warning("Could not change stage: %s", e)
+
+ def _action_set_priority(self, conversation):
+ """Set conversation priority."""
+ if self.priority:
+ conversation.write({"priority": self.priority})
+
+ def _action_notify_user(self, conversation):
+ """Send notification to users."""
+ if not self.notify_user_ids:
+ return
+
+ message = self._format_message(
+ self.notification_message
+ or "Automation notification for conversation {conversation_id}",
+ conversation,
+ )
+
+ for user in self.notify_user_ids:
+ # Use Odoo's notification system
+ self.env["bus.bus"]._sendone(
+ user.partner_id,
+ "simple_notification",
+ {
+ "title": "Engagement Automation",
+ "message": message,
+ "sticky": False,
+ "type": "info",
+ },
+ )
+
+ def _action_notify_team(self, conversation):
+ """Send notification to team members."""
+ team = self.team_id or conversation.team_id
+ if not team:
+ return
+
+ message = self._format_message(
+ self.notification_message
+ or "Automation notification for conversation {conversation_id}",
+ conversation,
+ )
+
+ for member in team.member_ids:
+ self.env["bus.bus"]._sendone(
+ member.partner_id,
+ "simple_notification",
+ {
+ "title": "Engagement Automation",
+ "message": message,
+ "sticky": False,
+ "type": "info",
+ },
+ )
+
+ def _action_webhook(self, conversation):
+ """Call external webhook."""
+ if not self.webhook_url:
+ return
+
+ import requests
+
+ payload = {
+ "event": self.automation_id.trigger_event,
+ "conversation_id": conversation.id,
+ "conversation_uuid": conversation.uuid,
+ "partner_id": conversation.partner_id.id
+ if conversation.partner_id
+ else None,
+ "partner_name": conversation.partner_id.name
+ if conversation.partner_id
+ else None,
+ "channel_type": conversation.channel_type,
+ "stage": conversation.stage_id.code if conversation.stage_id else None,
+ }
+
+ try:
+ if self.webhook_method == "POST":
+ requests.post(self.webhook_url, json=payload, timeout=10)
+ else:
+ requests.get(self.webhook_url, params=payload, timeout=10)
+ except Exception as e:
+ _logger.error("Webhook call failed: %s", e)
+
+ def _action_add_note(self, conversation):
+ """Add an internal note."""
+ if self.message_content:
+ body = self._format_message(self.message_content, conversation)
+ conversation.action_add_note(body)
+
+
+class EngageAutomationQueue(models.Model):
+ """Queue for delayed automation execution."""
+
+ _name = "engage.automation.queue"
+ _description = "Engage Automation Queue"
+ _order = "scheduled_at"
+
+ automation_id = fields.Many2one(
+ comodel_name="engage.automation",
+ string="Automation",
+ required=True,
+ ondelete="cascade",
+ )
+ conversation_id = fields.Many2one(
+ comodel_name="engage.conversation",
+ string="Conversation",
+ required=True,
+ ondelete="cascade",
+ )
+ scheduled_at = fields.Datetime(
+ string="Scheduled At",
+ required=True,
+ )
+ processed = fields.Boolean(
+ string="Processed",
+ default=False,
+ )
+
+
+class ConversationAutomation(models.Model):
+ """Automation triggers integration for conversations."""
+
+ _inherit = "engage.conversation"
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """Trigger automations on conversation creation."""
+ records = super().create(vals_list)
+
+ for record in records:
+ self.env["engage.automation"]._trigger_automations(
+ "conversation_created", record
+ )
+
+ return records
+
+ def write(self, vals):
+ """Trigger automations on specific changes."""
+ # Track changes for automation triggers
+ old_stages = {r.id: r.stage_id for r in self}
+ old_users = {r.id: r.user_id for r in self}
+
+ result = super().write(vals)
+
+ # Trigger stage change automations
+ if "stage_id" in vals:
+ for record in self:
+ if old_stages.get(record.id) != record.stage_id:
+ self.env["engage.automation"]._trigger_automations(
+ "stage_changed", record
+ )
+
+ # Trigger assignment automations
+ if "user_id" in vals:
+ for record in self:
+ old_user = old_users.get(record.id)
+ if record.user_id and not old_user:
+ self.env["engage.automation"]._trigger_automations(
+ "assigned", record
+ )
+ elif not record.user_id and old_user:
+ self.env["engage.automation"]._trigger_automations(
+ "unassigned", record
+ )
+
+ return result
diff --git a/customer_engagement/models/canned_response.py b/customer_engagement/models/canned_response.py
new file mode 100644
index 0000000000..a9ff3a56ca
--- /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 = "engage.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="engage.team",
+ relation="engage_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["engage.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..2aea2c7729
--- /dev/null
+++ b/customer_engagement/models/conversation.py
@@ -0,0 +1,484 @@
+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 = "engage.conversation"
+ _description = "Engagement 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,
+ )
+ channel_identifier = fields.Char(
+ string="Channel Identifier",
+ index=True,
+ help="External identifier on the channel (phone number, email, user ID, etc.)",
+ )
+ channel_conversation_id = fields.Char(
+ string="External Conversation ID",
+ index=True,
+ help="Conversation ID from the external channel",
+ )
+ channel_metadata = fields.Json(
+ string="Channel Metadata",
+ help="Additional channel-specific metadata (JSON format)",
+ )
+
+ # Stage (state machine)
+ stage_id = fields.Many2one(
+ comodel_name="engage.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)
+ closed_date = fields.Datetime(string="Closed Date", readonly=True)
+
+ # Computed time metrics
+ response_time_seconds = fields.Integer(
+ compute="_compute_time_metrics",
+ store=True,
+ string="Response Time (seconds)",
+ help="Time from creation to first response",
+ )
+ resolution_time_seconds = fields.Integer(
+ compute="_compute_time_metrics",
+ store=True,
+ string="Resolution Time (seconds)",
+ help="Time from creation to resolution",
+ )
+
+ # Waiting status
+ is_waiting_customer = fields.Boolean(
+ compute="_compute_waiting_status",
+ store=True,
+ string="Waiting for Customer",
+ )
+ is_waiting_agent = fields.Boolean(
+ compute="_compute_waiting_status",
+ store=True,
+ string="Waiting for Agent",
+ )
+ last_customer_message_date = fields.Datetime(
+ compute="_compute_last_messages",
+ store=True,
+ string="Last Customer Message",
+ )
+ last_agent_message_date = fields.Datetime(
+ compute="_compute_last_messages",
+ store=True,
+ string="Last Agent Message",
+ )
+
+ # Color for Kanban
+ color = fields.Integer(string="Color Index")
+
+ # History
+ history_ids = fields.One2many(
+ comodel_name="engage.conversation.history",
+ inverse_name="conversation_id",
+ string="History",
+ )
+
+ # Labels (tags)
+ label_ids = fields.Many2many(
+ comodel_name="engage.conversation.label",
+ relation="engage_conversation_label_rel",
+ column1="conversation_id",
+ column2="label_id",
+ string="Labels",
+ )
+
+ # Team assignment
+ team_id = fields.Many2one(
+ comodel_name="engage.team",
+ string="Team",
+ index=True,
+ tracking=True,
+ )
+
+ # Folders
+ folder_ids = fields.Many2many(
+ comodel_name="engage.folder",
+ relation="engage_conversation_folder_rel",
+ column1="conversation_id",
+ column2="folder_id",
+ string="Folders",
+ )
+
+ # Private notes
+ note_ids = fields.One2many(
+ comodel_name="engage.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", "=", "engage.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", "=", "engage.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)
+
+ @api.depends("first_response_at", "resolved_at", "create_date")
+ def _compute_time_metrics(self):
+ """Compute response and resolution time in seconds."""
+ for rec in self:
+ if rec.first_response_at and rec.create_date:
+ delta = rec.first_response_at - rec.create_date
+ rec.response_time_seconds = int(delta.total_seconds())
+ else:
+ rec.response_time_seconds = 0
+
+ if rec.resolved_at and rec.create_date:
+ delta = rec.resolved_at - rec.create_date
+ rec.resolution_time_seconds = int(delta.total_seconds())
+ else:
+ rec.resolution_time_seconds = 0
+
+ def _compute_last_messages(self):
+ """Compute last message dates for customer and agent."""
+ for rec in self:
+ messages = self.env["mail.message"].search(
+ [
+ ("model", "=", "engage.conversation"),
+ ("res_id", "=", rec.id),
+ ("message_type", "in", ["comment", "email"]),
+ ],
+ order="date desc",
+ )
+
+ rec.last_customer_message_date = False
+ rec.last_agent_message_date = False
+
+ for msg in messages:
+ # Check if author is internal (has linked user)
+ is_internal = msg.author_id and msg.author_id.user_ids
+ if is_internal and not rec.last_agent_message_date:
+ rec.last_agent_message_date = msg.date
+ elif not is_internal and not rec.last_customer_message_date:
+ rec.last_customer_message_date = msg.date
+ if rec.last_customer_message_date and rec.last_agent_message_date:
+ break
+
+ @api.depends("last_customer_message_date", "last_agent_message_date", "closed")
+ def _compute_waiting_status(self):
+ """Compute who is waiting based on last messages."""
+ for rec in self:
+ if rec.closed:
+ rec.is_waiting_customer = False
+ rec.is_waiting_agent = False
+ elif not rec.last_customer_message_date and not rec.last_agent_message_date:
+ # New conversation, waiting for agent
+ rec.is_waiting_customer = False
+ rec.is_waiting_agent = True
+ elif not rec.last_agent_message_date:
+ # Only customer messages, waiting for agent
+ rec.is_waiting_customer = False
+ rec.is_waiting_agent = True
+ elif not rec.last_customer_message_date:
+ # Only agent messages, waiting for customer
+ rec.is_waiting_customer = True
+ rec.is_waiting_agent = False
+ elif rec.last_customer_message_date > rec.last_agent_message_date:
+ # Customer sent last, waiting for agent
+ rec.is_waiting_customer = False
+ rec.is_waiting_agent = True
+ else:
+ # Agent sent last, waiting for customer
+ rec.is_waiting_customer = True
+ rec.is_waiting_agent = False
+
+ # 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["engage.conversation.stage"].search(
+ [("code", "=", "new")], limit=1
+ )
+
+ @api.model
+ def _read_group_stage_ids(self, stages, domain):
+ return self.env["engage.conversation.stage"].search([])
+
+ def write(self, vals):
+ if "stage_id" in vals:
+ new_stage = self.env["engage.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["engage.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 == "closed" and not self.closed_date:
+ vals["closed_date"] = fields.Datetime.now()
+ if new_stage.code == "open":
+ # Reopening - clear resolved and closed timestamps
+ if self.resolved_at:
+ vals["resolved_at"] = False
+ if self.closed_date:
+ vals["closed_date"] = False
+ if vals:
+ return super().write(vals)
+ return True
+
+ # Action buttons
+ def action_open(self):
+ """Move to open stage."""
+ stage = self.env["engage.conversation.stage"].search(
+ [("code", "=", "open")], limit=1
+ )
+ self.write({"stage_id": stage.id})
+
+ def action_resolve(self):
+ """Move to resolved stage."""
+ stage = self.env["engage.conversation.stage"].search(
+ [("code", "=", "resolved")], limit=1
+ )
+ self.write({"stage_id": stage.id})
+
+ def action_close(self):
+ """Move to closed stage."""
+ stage = self.env["engage.conversation.stage"].search(
+ [("code", "=", "closed")], limit=1
+ )
+ self.write({"stage_id": stage.id})
+
+ def action_pending(self):
+ """Move to pending stage."""
+ stage = self.env["engage.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["engage.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["engage.team"].browse(team_id)
+ vals = {"team_id": team_id}
+ if team.auto_assign:
+ agent = team.get_available_agent(conversation=self)
+ 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 engage_conversation_stage_user_idx
+ ON engage_conversation (stage_id, user_id)
+ WHERE user_id IS NOT NULL;
+
+ CREATE INDEX IF NOT EXISTS engage_conversation_channel_stage_idx
+ ON engage_conversation (channel_type, stage_id);
+
+ CREATE INDEX IF NOT EXISTS engage_conversation_partner_idx
+ ON engage_conversation (partner_id)
+ WHERE partner_id IS NOT NULL;
+
+ CREATE INDEX IF NOT EXISTS engage_conversation_create_date_idx
+ ON engage_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..e1b6cfb077
--- /dev/null
+++ b/customer_engagement/models/conversation_history.py
@@ -0,0 +1,55 @@
+from odoo import fields, models
+
+
+class ConversationHistory(models.Model):
+ _name = "engage.conversation.history"
+ _description = "Conversation Transition History"
+ _order = "create_date desc"
+
+ conversation_id = fields.Many2one(
+ comodel_name="engage.conversation",
+ string="Conversation",
+ required=True,
+ ondelete="cascade",
+ index=True,
+ )
+
+ # Event type for different kinds of history entries
+ event_type = fields.Selection(
+ [
+ ("stage_change", "Stage Change"),
+ ("assignment", "Assignment"),
+ ("transfer", "Transfer"),
+ ("priority_change", "Priority Change"),
+ ("label_add", "Label Added"),
+ ("label_remove", "Label Removed"),
+ ("sla_pause", "SLA Paused"),
+ ("sla_resume", "SLA Resumed"),
+ ("integration", "Integration Action"),
+ ("csat_sent", "CSAT Sent"),
+ ("csat_received", "CSAT Received"),
+ ],
+ default="stage_change",
+ string="Event Type",
+ )
+
+ # Stage change fields
+ from_stage_id = fields.Many2one(
+ comodel_name="engage.conversation.stage",
+ string="From Stage",
+ )
+ to_stage_id = fields.Many2one(
+ comodel_name="engage.conversation.stage",
+ string="To Stage",
+ )
+
+ # Generic value fields for other event types
+ old_value = fields.Char(string="Old Value")
+ new_value = fields.Char(string="New Value")
+
+ 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..840c31cc0a
--- /dev/null
+++ b/customer_engagement/models/conversation_label.py
@@ -0,0 +1,41 @@
+"""Engage Conversation Label Model."""
+
+from odoo import fields, models
+
+
+class ConversationLabel(models.Model):
+ """Labels/tags for categorizing conversations."""
+
+ _name = "engage.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["engage.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..26c7e0c274
--- /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 = "engage.conversation.note"
+ _description = "Conversation Note"
+ _order = "create_date desc"
+
+ conversation_id = fields.Many2one(
+ comodel_name="engage.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="engage_conversation_note_attachment_rel",
+ column1="note_id",
+ column2="attachment_id",
+ string="Attachments",
+ )
+ mentioned_user_ids = fields.Many2many(
+ comodel_name="res.users",
+ relation="engage_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..9310066159
--- /dev/null
+++ b/customer_engagement/models/conversation_stage.py
@@ -0,0 +1,39 @@
+from odoo import fields, models
+
+
+class ConversationStage(models.Model):
+ _name = "engage.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', '=', 'engage.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/csat.py b/customer_engagement/models/csat.py
new file mode 100644
index 0000000000..ab09cbb46a
--- /dev/null
+++ b/customer_engagement/models/csat.py
@@ -0,0 +1,332 @@
+"""Customer Satisfaction Survey for Customer Engagement.
+
+This module provides CSAT (Customer Satisfaction) survey functionality
+for gathering feedback after conversations are resolved.
+"""
+
+import uuid
+from datetime import timedelta
+
+from odoo import api, fields, models
+
+
+class EngageCSAT(models.Model):
+ """Customer Satisfaction Survey."""
+
+ _name = "engage.csat"
+ _description = "Customer Satisfaction Survey"
+ _order = "create_date desc"
+
+ # Reference
+ conversation_id = fields.Many2one(
+ "engage.conversation",
+ required=True,
+ ondelete="cascade",
+ string="Conversation",
+ index=True,
+ )
+ partner_id = fields.Many2one(
+ "res.partner",
+ related="conversation_id.partner_id",
+ store=True,
+ string="Customer",
+ )
+ user_id = fields.Many2one(
+ "res.users",
+ related="conversation_id.user_id",
+ store=True,
+ string="Agent",
+ )
+ team_id = fields.Many2one(
+ "engage.team",
+ related="conversation_id.team_id",
+ store=True,
+ string="Team",
+ )
+
+ # Token for public access
+ token = fields.Char(
+ required=True,
+ default=lambda self: str(uuid.uuid4()),
+ copy=False,
+ index=True,
+ )
+ access_url = fields.Char(
+ compute="_compute_access_url",
+ string="Survey URL",
+ )
+
+ # Timing
+ sent_at = fields.Datetime(
+ default=fields.Datetime.now,
+ string="Sent At",
+ )
+ expires_at = fields.Datetime(
+ compute="_compute_expires_at",
+ store=True,
+ string="Expires At",
+ )
+ answered_at = fields.Datetime(
+ string="Answered At",
+ )
+ response_time = fields.Float(
+ compute="_compute_response_time",
+ store=True,
+ string="Response Time (hours)",
+ )
+
+ # Rating
+ rating = fields.Selection(
+ [
+ ("1", "1 - Very Unsatisfied"),
+ ("2", "2 - Unsatisfied"),
+ ("3", "3 - Neutral"),
+ ("4", "4 - Satisfied"),
+ ("5", "5 - Very Satisfied"),
+ ],
+ string="Rating",
+ )
+ rating_value = fields.Integer(
+ compute="_compute_rating_value",
+ store=True,
+ string="Rating Value",
+ )
+ feedback = fields.Text(
+ string="Feedback",
+ )
+
+ # Categories
+ resolved_satisfactorily = fields.Boolean(
+ string="Issue Resolved",
+ )
+ agent_helpful = fields.Boolean(
+ string="Agent Was Helpful",
+ )
+ would_recommend = fields.Boolean(
+ string="Would Recommend",
+ )
+
+ # State
+ state = fields.Selection(
+ [
+ ("pending", "Pending"),
+ ("sent", "Sent"),
+ ("answered", "Answered"),
+ ("expired", "Expired"),
+ ],
+ default="pending",
+ string="State",
+ index=True,
+ )
+
+ # Configuration
+ expiry_days = fields.Integer(
+ default=7,
+ string="Expiry Days",
+ )
+
+ @api.depends("token")
+ def _compute_access_url(self):
+ """Compute the public access URL for the survey."""
+ base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url")
+ for record in self:
+ record.access_url = f"{base_url}/csat/{record.token}"
+
+ @api.depends("sent_at", "expiry_days")
+ def _compute_expires_at(self):
+ """Compute expiration date."""
+ for record in self:
+ if record.sent_at:
+ record.expires_at = record.sent_at + timedelta(days=record.expiry_days)
+ else:
+ record.expires_at = False
+
+ @api.depends("rating")
+ def _compute_rating_value(self):
+ """Convert rating selection to integer for calculations."""
+ for record in self:
+ record.rating_value = int(record.rating) if record.rating else 0
+
+ @api.depends("sent_at", "answered_at")
+ def _compute_response_time(self):
+ """Compute time between sending and answering."""
+ for record in self:
+ if record.sent_at and record.answered_at:
+ delta = record.answered_at - record.sent_at
+ record.response_time = delta.total_seconds() / 3600
+ else:
+ record.response_time = 0.0
+
+ def action_send(self):
+ """Send the CSAT survey to the customer."""
+ self.ensure_one()
+ if not self.conversation_id.partner_id.email:
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "title": "Cannot Send Survey",
+ "message": "Customer has no email address.",
+ "type": "warning",
+ },
+ }
+
+ # Send email
+ template = self.env.ref(
+ "customer_engagement.email_template_csat_survey",
+ raise_if_not_found=False,
+ )
+ if template:
+ template.send_mail(self.id, force_send=True)
+
+ self.write(
+ {
+ "state": "sent",
+ "sent_at": fields.Datetime.now(),
+ }
+ )
+
+ return True
+
+ def action_submit_response(self, rating, feedback=None, **kwargs):
+ """Submit survey response (called from public controller)."""
+ self.ensure_one()
+
+ # Check if already answered or expired
+ if self.state == "answered":
+ return {"success": False, "error": "Survey already answered"}
+ if self.state == "expired" or (
+ self.expires_at and fields.Datetime.now() > self.expires_at
+ ):
+ self.state = "expired"
+ return {"success": False, "error": "Survey has expired"}
+
+ # Save response
+ vals = {
+ "rating": str(rating),
+ "feedback": feedback,
+ "answered_at": fields.Datetime.now(),
+ "state": "answered",
+ }
+
+ # Additional fields
+ if "resolved" in kwargs:
+ vals["resolved_satisfactorily"] = kwargs["resolved"]
+ if "helpful" in kwargs:
+ vals["agent_helpful"] = kwargs["helpful"]
+ if "recommend" in kwargs:
+ vals["would_recommend"] = kwargs["recommend"]
+
+ self.write(vals)
+
+ # Notify agent if low rating
+ if int(rating) <= 2 and self.user_id:
+ self._notify_low_rating()
+
+ return {"success": True}
+
+ def _notify_low_rating(self):
+ """Notify agent about low rating."""
+ self.ensure_one()
+ self.user_id.notify_warning(
+ title="Low CSAT Rating Received",
+ message=f"Conversation {self.conversation_id.uuid[:8]} received a {self.rating}-star rating.",
+ )
+
+ @api.model
+ def _cron_check_expired(self):
+ """Mark expired surveys."""
+ expired = self.search(
+ [
+ ("state", "in", ["pending", "sent"]),
+ ("expires_at", "<", fields.Datetime.now()),
+ ]
+ )
+ expired.write({"state": "expired"})
+
+ @api.model
+ def create_for_conversation(self, conversation, auto_send=False):
+ """Create a CSAT survey for a resolved conversation."""
+ # Check if survey already exists
+ existing = self.search(
+ [("conversation_id", "=", conversation.id)],
+ limit=1,
+ )
+ if existing:
+ return existing
+
+ survey = self.create({"conversation_id": conversation.id})
+
+ if auto_send:
+ survey.action_send()
+
+ return survey
+
+
+class EngageConversationCSAT(models.Model):
+ """Extend conversation with CSAT fields."""
+
+ _inherit = "engage.conversation"
+
+ csat_ids = fields.One2many(
+ "engage.csat",
+ "conversation_id",
+ string="Satisfaction Surveys",
+ )
+ csat_rating = fields.Integer(
+ compute="_compute_csat",
+ store=True,
+ string="CSAT Rating",
+ )
+ csat_feedback = fields.Text(
+ compute="_compute_csat",
+ store=True,
+ string="CSAT Feedback",
+ )
+ csat_sent = fields.Boolean(
+ compute="_compute_csat",
+ store=True,
+ string="CSAT Sent",
+ )
+
+ @api.depends("csat_ids.rating_value", "csat_ids.feedback", "csat_ids.state")
+ def _compute_csat(self):
+ """Compute CSAT fields from surveys."""
+ for conv in self:
+ answered = conv.csat_ids.filtered(lambda c: c.state == "answered")
+ if answered:
+ # Get latest answered survey
+ latest = answered.sorted("answered_at", reverse=True)[:1]
+ conv.csat_rating = latest.rating_value
+ conv.csat_feedback = latest.feedback
+ conv.csat_sent = True
+ else:
+ conv.csat_rating = 0
+ conv.csat_feedback = False
+ conv.csat_sent = bool(conv.csat_ids)
+
+ def action_send_csat(self):
+ """Send CSAT survey for this conversation."""
+ self.ensure_one()
+ survey = self.env["engage.csat"].create_for_conversation(self, auto_send=True)
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "title": "Survey Sent",
+ "message": f"CSAT survey sent to {self.partner_id.name}",
+ "type": "success",
+ },
+ }
+
+ def action_view_csat(self):
+ """View CSAT surveys for this conversation."""
+ self.ensure_one()
+ return {
+ "type": "ir.actions.act_window",
+ "name": "Satisfaction Surveys",
+ "res_model": "engage.csat",
+ "view_mode": "tree,form",
+ "domain": [("conversation_id", "=", self.id)],
+ "context": {"default_conversation_id": self.id},
+ }
diff --git a/customer_engagement/models/engage_folder.py b/customer_engagement/models/engage_folder.py
new file mode 100644
index 0000000000..459f4f8e20
--- /dev/null
+++ b/customer_engagement/models/engage_folder.py
@@ -0,0 +1,89 @@
+"""Engage Folder Model."""
+
+from odoo import api, fields, models
+
+
+class EngageFolder(models.Model):
+ """Custom folders for organizing conversations."""
+
+ _name = "engage.folder"
+ _description = "Engage 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["engage.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/engage_team.py b/customer_engagement/models/engage_team.py
new file mode 100644
index 0000000000..56055e9d42
--- /dev/null
+++ b/customer_engagement/models/engage_team.py
@@ -0,0 +1,262 @@
+"""Engage Team Model."""
+
+import pytz
+
+from odoo import api, fields, models
+
+
+def _tz_get(self):
+ """Get list of timezones."""
+ return [
+ (tz, tz)
+ for tz in sorted(
+ pytz.all_timezones, key=lambda tz: tz if not tz.startswith("Etc/") else "_"
+ )
+ ]
+
+
+class EngageTeam(models.Model):
+ """Teams for organizing agents and routing conversations."""
+
+ _name = "engage.team"
+ _description = "Engage 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="engage_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"),
+ ("preferred", "Preferred Agent"),
+ ("manual", "Manual"),
+ ],
+ default="manual",
+ help="Method for automatic agent assignment:\n"
+ "- Round Robin: Distribute evenly among agents\n"
+ "- Least Loaded: Assign to agent with fewest open conversations\n"
+ "- Preferred Agent: Try last agent who helped the customer\n"
+ "- Manual: No automatic assignment",
+ )
+ max_conversations_per_agent = fields.Integer(
+ string="Max Conversations per Agent",
+ default=0,
+ help="Maximum open conversations per agent (0 = unlimited)",
+ )
+
+ # Schedule fields
+ schedule_ids = fields.One2many(
+ "engage.team.schedule",
+ "team_id",
+ string="Working Hours",
+ )
+ timezone = fields.Selection(
+ _tz_get,
+ string="Timezone",
+ default=lambda self: self.env.user.tz or "UTC",
+ )
+ is_open = fields.Boolean(
+ compute="_compute_is_open",
+ string="Currently Open",
+ )
+
+ # Messages
+ welcome_message = fields.Text(
+ string="Welcome Message",
+ translate=True,
+ help="Message sent when a new conversation is assigned to this team",
+ )
+ out_of_hours_message = fields.Text(
+ string="Out of Hours Message",
+ translate=True,
+ help="Message sent when customer contacts outside working hours",
+ )
+
+ # Computed counts
+ 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["engage.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 _compute_is_open(self):
+ """Check if team is currently within working hours."""
+ for team in self:
+ team.is_open = team._check_is_open()
+
+ def _check_is_open(self):
+ """Check if team is currently within working hours."""
+ self.ensure_one()
+ if not self.schedule_ids:
+ # No schedule defined, always open
+ return True
+
+ # Get current time in team timezone
+ tz = pytz.timezone(self.timezone or "UTC")
+ now = fields.Datetime.context_timestamp(self, fields.Datetime.now())
+ if self.timezone:
+ now = now.astimezone(tz)
+
+ day = str(now.weekday())
+ current_time = now.hour + now.minute / 60.0
+
+ # Check if current time falls within any schedule for today
+ schedules = self.schedule_ids.filtered(lambda s: s.day_of_week == day)
+ for schedule in schedules:
+ if schedule.start_time <= current_time <= schedule.end_time:
+ return True
+ return False
+
+ def _get_available_members(self):
+ """Get list of available team members respecting capacity limits."""
+ self.ensure_one()
+ if not self.member_ids:
+ return self.env["res.users"]
+
+ available = self.member_ids.filtered(lambda u: u.active and not u.share)
+
+ # Filter by engage status if using res.users extension
+ if hasattr(available, "engage_is_available"):
+ available = available.filtered(lambda u: u.engage_is_available)
+ elif self.max_conversations_per_agent:
+ # Manual capacity check
+ result = self.env["res.users"]
+ for member in available:
+ count = self.env["engage.conversation"].search_count(
+ [("user_id", "=", member.id), ("closed", "=", False)]
+ )
+ if count < self.max_conversations_per_agent:
+ result |= member
+ available = result
+
+ return available
+
+ def get_available_agent(self, conversation=None):
+ """Get the next available agent for assignment based on method."""
+ self.ensure_one()
+ available_members = self._get_available_members()
+ if not available_members:
+ return False
+
+ if self.assignment_method == "preferred" and conversation:
+ agent = self._get_preferred_agent(conversation, available_members)
+ if agent:
+ return agent
+ # Fallback to least_loaded
+ return self._get_least_loaded_agent(available_members)
+
+ elif self.assignment_method == "round_robin":
+ return self._get_round_robin_agent(available_members)
+
+ elif self.assignment_method == "least_loaded":
+ return self._get_least_loaded_agent(available_members)
+
+ return False
+
+ def _get_preferred_agent(self, conversation, available_members):
+ """Try to get the last agent who helped this customer."""
+ if not conversation.partner_id:
+ return False
+
+ # Find last conversation with this customer that had an assigned agent
+ last_conv = self.env["engage.conversation"].search(
+ [
+ ("partner_id", "=", conversation.partner_id.id),
+ ("user_id", "!=", False),
+ ("id", "!=", conversation.id if conversation.id else 0),
+ ],
+ limit=1,
+ order="id desc",
+ )
+
+ if last_conv and last_conv.user_id in available_members:
+ return last_conv.user_id
+ return False
+
+ def _get_round_robin_agent(self, available_members):
+ """Get next agent in round-robin fashion."""
+ last_conv = self.env["engage.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:
+ members_list = list(available_members)
+ idx = members_list.index(last_conv.user_id)
+ next_idx = (idx + 1) % len(members_list)
+ return members_list[next_idx]
+ return available_members[0] if available_members else False
+
+ def _get_least_loaded_agent(self, available_members):
+ """Get agent with fewest open conversations."""
+ min_count = float("inf")
+ best_agent = False
+ for member in available_members:
+ count = self.env["engage.conversation"].search_count(
+ [("user_id", "=", member.id), ("closed", "=", False)]
+ )
+ if count < min_count:
+ min_count = count
+ best_agent = member
+ return best_agent
diff --git a/customer_engagement/models/integrations/__init__.py b/customer_engagement/models/integrations/__init__.py
new file mode 100644
index 0000000000..a5537d7d0d
--- /dev/null
+++ b/customer_engagement/models/integrations/__init__.py
@@ -0,0 +1,3 @@
+from . import sale_integration
+from . import helpdesk_integration
+from . import crm_integration
diff --git a/customer_engagement/models/integrations/crm_integration.py b/customer_engagement/models/integrations/crm_integration.py
new file mode 100644
index 0000000000..f891b49c34
--- /dev/null
+++ b/customer_engagement/models/integrations/crm_integration.py
@@ -0,0 +1,227 @@
+"""CRM Integration for Customer Engagement.
+
+This module provides integration with Odoo's CRM module,
+allowing agents to view and create leads/opportunities from conversations.
+"""
+
+from odoo import api, fields, models
+
+
+class ConversationCRMIntegration(models.Model):
+ """Extend conversation with CRM integration."""
+
+ _inherit = "engage.conversation"
+
+ # CRM lead/opportunity fields (computed to handle missing module)
+ crm_lead_ids = fields.One2many(
+ "crm.lead",
+ compute="_compute_crm_leads",
+ string="Leads/Opportunities",
+ )
+ crm_lead_count = fields.Integer(
+ compute="_compute_crm_leads",
+ string="Lead Count",
+ )
+ open_opportunity_count = fields.Integer(
+ compute="_compute_crm_leads",
+ string="Open Opportunities",
+ )
+ expected_revenue = fields.Monetary(
+ compute="_compute_crm_leads",
+ string="Expected Revenue",
+ currency_field="company_currency_id",
+ )
+
+ @api.depends("partner_id")
+ def _compute_crm_leads(self):
+ """Compute CRM leads for the conversation partner."""
+ # Check if CRM module is installed
+ if "crm.lead" not in self.env:
+ for conv in self:
+ conv.crm_lead_ids = False
+ conv.crm_lead_count = 0
+ conv.open_opportunity_count = 0
+ conv.expected_revenue = 0.0
+ return
+
+ for conv in self:
+ if conv.partner_id:
+ leads = self.env["crm.lead"].search(
+ [("partner_id", "=", conv.partner_id.id)],
+ order="create_date desc",
+ limit=20,
+ )
+ conv.crm_lead_ids = leads
+ conv.crm_lead_count = len(leads)
+ # Count open opportunities
+ open_leads = leads.filtered(
+ lambda l: not l.probability == 0 and l.active
+ )
+ conv.open_opportunity_count = len(open_leads)
+ conv.expected_revenue = sum(open_leads.mapped("expected_revenue"))
+ else:
+ conv.crm_lead_ids = False
+ conv.crm_lead_count = 0
+ conv.open_opportunity_count = 0
+ conv.expected_revenue = 0.0
+
+ def action_view_crm_leads(self):
+ """Open CRM leads/opportunities for this partner."""
+ self.ensure_one()
+ if "crm.lead" not in self.env:
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "title": "Module Not Installed",
+ "message": "The CRM module is not installed.",
+ "type": "warning",
+ },
+ }
+
+ return {
+ "type": "ir.actions.act_window",
+ "name": f"Opportunities - {self.partner_id.name}",
+ "res_model": "crm.lead",
+ "view_mode": "tree,kanban,form",
+ "domain": [("partner_id", "=", self.partner_id.id)],
+ "context": {
+ "default_partner_id": self.partner_id.id,
+ "search_default_partner_id": self.partner_id.id,
+ },
+ }
+
+ def action_create_lead(self):
+ """Create a new lead from this conversation."""
+ self.ensure_one()
+ if "crm.lead" not in self.env:
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "title": "Module Not Installed",
+ "message": "The CRM module is not installed.",
+ "type": "warning",
+ },
+ }
+
+ return {
+ "type": "ir.actions.act_window",
+ "name": "New Lead",
+ "res_model": "crm.lead",
+ "view_mode": "form",
+ "context": {
+ "default_partner_id": self.partner_id.id,
+ "default_name": f"Lead from {self.partner_id.name}",
+ "default_type": "lead",
+ "default_description": self._get_conversation_context(),
+ },
+ }
+
+ def action_create_opportunity(self):
+ """Create a new opportunity from this conversation."""
+ self.ensure_one()
+ if "crm.lead" not in self.env:
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "title": "Module Not Installed",
+ "message": "The CRM module is not installed.",
+ "type": "warning",
+ },
+ }
+
+ return {
+ "type": "ir.actions.act_window",
+ "name": "New Opportunity",
+ "res_model": "crm.lead",
+ "view_mode": "form",
+ "context": {
+ "default_partner_id": self.partner_id.id,
+ "default_name": f"Opportunity from {self.partner_id.name}",
+ "default_type": "opportunity",
+ "default_description": self._get_conversation_context(),
+ },
+ }
+
+ def action_convert_to_opportunity(self):
+ """Convert conversation to a CRM opportunity."""
+ self.ensure_one()
+ if "crm.lead" not in self.env:
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "title": "Module Not Installed",
+ "message": "The CRM module is not installed.",
+ "type": "warning",
+ },
+ }
+
+ # Get default sales team
+ default_team = self.env["crm.team"].search(
+ [("use_opportunities", "=", True)], limit=1
+ )
+
+ # Create the opportunity
+ lead = self.env["crm.lead"].create(
+ {
+ "name": self.subject
+ or f"From Conversation {self.uuid[:8] if self.uuid else self.id}",
+ "partner_id": self.partner_id.id,
+ "type": "opportunity",
+ "description": self._get_conversation_context(),
+ "team_id": default_team.id if default_team else False,
+ "user_id": self.user_id.id if self.user_id else self.env.uid,
+ }
+ )
+
+ # Log in conversation history
+ self.env["engage.conversation.history"].create(
+ {
+ "conversation_id": self.id,
+ "event_type": "integration",
+ "notes": f"Converted to opportunity {lead.name}",
+ }
+ )
+
+ return {
+ "type": "ir.actions.act_window",
+ "name": "Opportunity",
+ "res_model": "crm.lead",
+ "res_id": lead.id,
+ "view_mode": "form",
+ }
+
+ def _get_conversation_context(self):
+ """Get conversation context for CRM lead description."""
+ lines = []
+
+ # Add conversation metadata
+ lines.append(f"Channel: {self.channel_type or 'N/A'}")
+ if self.subject:
+ lines.append(f"Subject: {self.subject}")
+ lines.append("")
+
+ # Add recent messages
+ messages = self.message_ids.filtered(
+ lambda m: m.message_type in ("comment", "email")
+ ).sorted("date")[:5]
+
+ if messages:
+ lines.append("Recent Messages:")
+ lines.append("-" * 40)
+ for msg in messages:
+ author = msg.author_id.name if msg.author_id else "Unknown"
+ date_str = msg.date.strftime("%Y-%m-%d %H:%M") if msg.date else ""
+ body = msg.body or ""
+ # Strip HTML tags
+ import re
+
+ body = re.sub("<[^<]+?>", "", body)
+ lines.append(f"[{date_str}] {author}:")
+ lines.append(body[:300])
+ lines.append("")
+
+ return "\n".join(lines)
diff --git a/customer_engagement/models/integrations/helpdesk_integration.py b/customer_engagement/models/integrations/helpdesk_integration.py
new file mode 100644
index 0000000000..e4a4214045
--- /dev/null
+++ b/customer_engagement/models/integrations/helpdesk_integration.py
@@ -0,0 +1,176 @@
+"""Helpdesk Integration for Customer Engagement.
+
+This module provides integration with Odoo's helpdesk module,
+allowing agents to view and create tickets from conversations.
+"""
+
+from odoo import api, fields, models
+
+
+class ConversationHelpdeskIntegration(models.Model):
+ """Extend conversation with helpdesk integration."""
+
+ _inherit = "engage.conversation"
+
+ # Helpdesk ticket fields (computed to handle missing module)
+ helpdesk_ticket_ids = fields.One2many(
+ "helpdesk.ticket",
+ compute="_compute_helpdesk_tickets",
+ string="Support Tickets",
+ )
+ helpdesk_ticket_count = fields.Integer(
+ compute="_compute_helpdesk_tickets",
+ string="Ticket Count",
+ )
+ open_ticket_count = fields.Integer(
+ compute="_compute_helpdesk_tickets",
+ string="Open Tickets",
+ )
+
+ @api.depends("partner_id")
+ def _compute_helpdesk_tickets(self):
+ """Compute helpdesk tickets for the conversation partner."""
+ # Check if helpdesk module is installed
+ if "helpdesk.ticket" not in self.env:
+ for conv in self:
+ conv.helpdesk_ticket_ids = False
+ conv.helpdesk_ticket_count = 0
+ conv.open_ticket_count = 0
+ return
+
+ for conv in self:
+ if conv.partner_id:
+ tickets = self.env["helpdesk.ticket"].search(
+ [("partner_id", "=", conv.partner_id.id)],
+ order="create_date desc",
+ limit=20,
+ )
+ conv.helpdesk_ticket_ids = tickets
+ conv.helpdesk_ticket_count = len(tickets)
+ # Count open tickets (not in closed stage)
+ conv.open_ticket_count = len(
+ tickets.filtered(lambda t: not t.stage_id.is_close)
+ )
+ else:
+ conv.helpdesk_ticket_ids = False
+ conv.helpdesk_ticket_count = 0
+ conv.open_ticket_count = 0
+
+ def action_view_helpdesk_tickets(self):
+ """Open helpdesk tickets for this partner."""
+ self.ensure_one()
+ if "helpdesk.ticket" not in self.env:
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "title": "Module Not Installed",
+ "message": "The Helpdesk module is not installed.",
+ "type": "warning",
+ },
+ }
+
+ return {
+ "type": "ir.actions.act_window",
+ "name": f"Tickets - {self.partner_id.name}",
+ "res_model": "helpdesk.ticket",
+ "view_mode": "tree,form,kanban",
+ "domain": [("partner_id", "=", self.partner_id.id)],
+ "context": {
+ "default_partner_id": self.partner_id.id,
+ "search_default_partner_id": self.partner_id.id,
+ },
+ }
+
+ def action_create_helpdesk_ticket(self):
+ """Create a new helpdesk ticket from this conversation."""
+ self.ensure_one()
+ if "helpdesk.ticket" not in self.env:
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "title": "Module Not Installed",
+ "message": "The Helpdesk module is not installed.",
+ "type": "warning",
+ },
+ }
+
+ # Get conversation summary for ticket description
+ description = self._get_conversation_summary()
+
+ return {
+ "type": "ir.actions.act_window",
+ "name": "New Ticket",
+ "res_model": "helpdesk.ticket",
+ "view_mode": "form",
+ "context": {
+ "default_partner_id": self.partner_id.id,
+ "default_name": f"From Conversation {self.uuid[:8] if self.uuid else self.id}",
+ "default_description": description,
+ },
+ }
+
+ def action_convert_to_ticket(self):
+ """Convert conversation to a helpdesk ticket."""
+ self.ensure_one()
+ if "helpdesk.ticket" not in self.env:
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "title": "Module Not Installed",
+ "message": "The Helpdesk module is not installed.",
+ "type": "warning",
+ },
+ }
+
+ # Get default helpdesk team
+ default_team = self.env["helpdesk.team"].search([], limit=1)
+
+ # Create the ticket
+ ticket = self.env["helpdesk.ticket"].create(
+ {
+ "name": self.subject
+ or f"Conversation {self.uuid[:8] if self.uuid else self.id}",
+ "partner_id": self.partner_id.id,
+ "description": self._get_conversation_summary(),
+ "team_id": default_team.id if default_team else False,
+ }
+ )
+
+ # Log in conversation history
+ self.env["engage.conversation.history"].create(
+ {
+ "conversation_id": self.id,
+ "event_type": "integration",
+ "notes": f"Converted to ticket {ticket.name}",
+ }
+ )
+
+ return {
+ "type": "ir.actions.act_window",
+ "name": "Ticket",
+ "res_model": "helpdesk.ticket",
+ "res_id": ticket.id,
+ "view_mode": "form",
+ }
+
+ def _get_conversation_summary(self):
+ """Get a summary of the conversation for ticket description."""
+ messages = self.message_ids.filtered(
+ lambda m: m.message_type in ("comment", "email")
+ ).sorted("date")[:10]
+
+ lines = []
+ for msg in messages:
+ author = msg.author_id.name if msg.author_id else "Unknown"
+ date_str = msg.date.strftime("%Y-%m-%d %H:%M") if msg.date else ""
+ body = msg.body or ""
+ # Strip HTML tags for plain text
+ import re
+
+ body = re.sub("<[^<]+?>", "", body)
+ lines.append(f"[{date_str}] {author}: {body[:200]}")
+
+ return "\n".join(lines) if lines else "No messages"
diff --git a/customer_engagement/models/integrations/sale_integration.py b/customer_engagement/models/integrations/sale_integration.py
new file mode 100644
index 0000000000..fd4730c114
--- /dev/null
+++ b/customer_engagement/models/integrations/sale_integration.py
@@ -0,0 +1,162 @@
+"""Sale Order Integration for Customer Engagement.
+
+This module provides integration with Odoo's sale module,
+allowing agents to view and create sales orders from conversations.
+"""
+
+from odoo import api, fields, models
+
+
+class ConversationSaleIntegration(models.Model):
+ """Extend conversation with sale order integration."""
+
+ _inherit = "engage.conversation"
+
+ # Sale order fields (computed to handle missing sale module)
+ sale_order_ids = fields.One2many(
+ "sale.order",
+ compute="_compute_sale_orders",
+ string="Sales Orders",
+ )
+ sale_order_count = fields.Integer(
+ compute="_compute_sale_orders",
+ string="Order Count",
+ )
+ total_sales_amount = fields.Monetary(
+ compute="_compute_sale_orders",
+ string="Total Sales",
+ currency_field="company_currency_id",
+ )
+ company_currency_id = fields.Many2one(
+ "res.currency",
+ compute="_compute_company_currency",
+ )
+
+ def _compute_company_currency(self):
+ """Get company currency for monetary field."""
+ for record in self:
+ record.company_currency_id = self.env.company.currency_id
+
+ @api.depends("partner_id")
+ def _compute_sale_orders(self):
+ """Compute sale orders for the conversation partner."""
+ # Check if sale module is installed
+ if "sale.order" not in self.env:
+ for conv in self:
+ conv.sale_order_ids = False
+ conv.sale_order_count = 0
+ conv.total_sales_amount = 0.0
+ return
+
+ for conv in self:
+ if conv.partner_id:
+ orders = self.env["sale.order"].search(
+ [
+ "|",
+ ("partner_id", "=", conv.partner_id.id),
+ ("partner_id", "child_of", conv.partner_id.id),
+ ],
+ order="date_order desc",
+ limit=20,
+ )
+ conv.sale_order_ids = orders
+ conv.sale_order_count = len(orders)
+ conv.total_sales_amount = sum(orders.mapped("amount_total"))
+ else:
+ conv.sale_order_ids = False
+ conv.sale_order_count = 0
+ conv.total_sales_amount = 0.0
+
+ def action_view_sale_orders(self):
+ """Open sale orders for this partner."""
+ self.ensure_one()
+ if "sale.order" not in self.env:
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "title": "Module Not Installed",
+ "message": "The Sales module is not installed.",
+ "type": "warning",
+ },
+ }
+
+ return {
+ "type": "ir.actions.act_window",
+ "name": f"Orders - {self.partner_id.name}",
+ "res_model": "sale.order",
+ "view_mode": "tree,form",
+ "domain": [
+ "|",
+ ("partner_id", "=", self.partner_id.id),
+ ("partner_id", "child_of", self.partner_id.id),
+ ],
+ "context": {
+ "default_partner_id": self.partner_id.id,
+ "search_default_partner_id": self.partner_id.id,
+ },
+ }
+
+ def action_create_sale_order(self):
+ """Create a new sale order from this conversation."""
+ self.ensure_one()
+ if "sale.order" not in self.env:
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "title": "Module Not Installed",
+ "message": "The Sales module is not installed.",
+ "type": "warning",
+ },
+ }
+
+ return {
+ "type": "ir.actions.act_window",
+ "name": "New Sale Order",
+ "res_model": "sale.order",
+ "view_mode": "form",
+ "context": {
+ "default_partner_id": self.partner_id.id,
+ "default_origin": f"Conversation {self.uuid[:8] if self.uuid else self.id}",
+ },
+ }
+
+ def action_create_quotation(self):
+ """Create a quotation from conversation context."""
+ self.ensure_one()
+ if "sale.order" not in self.env:
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "title": "Module Not Installed",
+ "message": "The Sales module is not installed.",
+ "type": "warning",
+ },
+ }
+
+ # Create the quotation
+ order = self.env["sale.order"].create(
+ {
+ "partner_id": self.partner_id.id,
+ "origin": f"Conversation {self.uuid[:8] if self.uuid else self.id}",
+ }
+ )
+
+ # Log in conversation history
+ self.env["engage.conversation.history"].create(
+ {
+ "conversation_id": self.id,
+ "event_type": "integration",
+ "notes": f"Created quotation {order.name}",
+ }
+ )
+
+ return {
+ "type": "ir.actions.act_window",
+ "name": "Quotation",
+ "res_model": "sale.order",
+ "res_id": order.id,
+ "view_mode": "form",
+ }
diff --git a/customer_engagement/models/mail_message.py b/customer_engagement/models/mail_message.py
new file mode 100644
index 0000000000..c90e627278
--- /dev/null
+++ b/customer_engagement/models/mail_message.py
@@ -0,0 +1,137 @@
+from odoo import api, fields, models
+
+
+class MailMessage(models.Model):
+ """Extension of mail.message for omnichannel engagement."""
+
+ _inherit = "mail.message"
+
+ # Channel identification
+ channel_message_id = fields.Char(
+ string="Channel Message ID",
+ index=True,
+ help="Original message ID from the external channel (WhatsApp, Telegram, etc.)",
+ )
+ engage_channel_type = fields.Selection(
+ selection=[
+ ("whatsapp", "WhatsApp"),
+ ("telegram", "Telegram"),
+ ("instagram", "Instagram"),
+ ("messenger", "Messenger"),
+ ("email", "Email"),
+ ("livechat", "Live Chat"),
+ ("api", "API"),
+ ],
+ string="Channel Type",
+ help="The channel through which this message was sent/received",
+ )
+
+ # Delivery status (for outbound messages)
+ delivery_status = fields.Selection(
+ selection=[
+ ("pending", "Pending"),
+ ("sent", "Sent"),
+ ("delivered", "Delivered"),
+ ("read", "Read"),
+ ("failed", "Failed"),
+ ],
+ string="Delivery Status",
+ default="pending",
+ help="Current delivery status of the message",
+ )
+ sent_at = fields.Datetime(string="Sent At")
+ delivered_at = fields.Datetime(string="Delivered At")
+ read_at = fields.Datetime(string="Read At")
+ delivery_error = fields.Text(
+ string="Delivery Error",
+ help="Error message if delivery failed",
+ )
+
+ # Media attachments (beyond standard attachment_ids)
+ media_url = fields.Char(
+ string="Media URL",
+ help="URL of media content from external channel",
+ )
+ media_type = fields.Selection(
+ selection=[
+ ("image", "Image"),
+ ("audio", "Audio"),
+ ("video", "Video"),
+ ("document", "Document"),
+ ("sticker", "Sticker"),
+ ("location", "Location"),
+ ("contact", "Contact"),
+ ],
+ string="Media Type",
+ )
+ thumbnail_url = fields.Char(string="Thumbnail URL")
+
+ # Reply-to functionality
+ reply_to_message_id = fields.Many2one(
+ comodel_name="mail.message",
+ string="Reply To",
+ help="The message this is a reply to",
+ )
+
+ # Extra channel metadata
+ channel_metadata = fields.Json(
+ string="Channel Metadata",
+ help="Additional metadata from the channel (JSON format)",
+ )
+
+ # Computed fields
+ is_from_channel = fields.Boolean(
+ string="From External Channel",
+ compute="_compute_is_from_channel",
+ store=True,
+ )
+
+ @api.depends("engage_channel_type")
+ def _compute_is_from_channel(self):
+ for message in self:
+ message.is_from_channel = bool(message.engage_channel_type)
+
+ def _update_delivery_status(self, status, timestamp=None, error=None):
+ """
+ Update delivery status via webhook.
+
+ :param status: New status (sent, delivered, read, failed)
+ :param timestamp: Optional timestamp for the status change
+ :param error: Optional error message for failed status
+ """
+ self.ensure_one()
+ vals = {"delivery_status": status}
+
+ if not timestamp:
+ timestamp = fields.Datetime.now()
+
+ if status == "sent":
+ vals["sent_at"] = timestamp
+ elif status == "delivered":
+ vals["delivered_at"] = timestamp
+ if not self.sent_at:
+ vals["sent_at"] = timestamp
+ elif status == "read":
+ vals["read_at"] = timestamp
+ if not self.delivered_at:
+ vals["delivered_at"] = timestamp
+ if not self.sent_at:
+ vals["sent_at"] = timestamp
+ elif status == "failed":
+ vals["delivery_error"] = error or "Unknown error"
+
+ self.write(vals)
+
+ @api.model
+ def _get_message_by_channel_id(self, channel_message_id, channel_type=None):
+ """
+ Find a message by its external channel ID.
+
+ :param channel_message_id: The ID from the external channel
+ :param channel_type: Optional channel type filter
+ :return: mail.message record or empty recordset
+ """
+ domain = [("channel_message_id", "=", channel_message_id)]
+ if channel_type:
+ domain.append(("engage_channel_type", "=", channel_type))
+ return self.search(domain, limit=1)
diff --git a/customer_engagement/models/metrics.py b/customer_engagement/models/metrics.py
new file mode 100644
index 0000000000..b68515135e
--- /dev/null
+++ b/customer_engagement/models/metrics.py
@@ -0,0 +1,338 @@
+from odoo import fields, models, tools
+
+
+class EngageMetrics(models.Model):
+ """SQL View for engagement metrics and reporting."""
+
+ _name = "engage.metrics"
+ _description = "Engage Metrics"
+ _auto = False
+ _order = "date desc"
+
+ # Dimensions
+ date = fields.Date(string="Date", readonly=True)
+ channel_type = fields.Selection(
+ selection=[
+ ("email", "Email"),
+ ("whatsapp", "WhatsApp"),
+ ("instagram", "Instagram"),
+ ("messenger", "Messenger"),
+ ("telegram", "Telegram"),
+ ("livechat", "Live Chat"),
+ ("api", "API"),
+ ],
+ string="Channel",
+ readonly=True,
+ )
+ team_id = fields.Many2one(
+ comodel_name="engage.team",
+ string="Team",
+ readonly=True,
+ )
+ user_id = fields.Many2one(
+ comodel_name="res.users",
+ string="Agent",
+ readonly=True,
+ )
+
+ # Conversation metrics
+ conversation_count = fields.Integer(
+ string="Total Conversations",
+ readonly=True,
+ )
+ new_conversation_count = fields.Integer(
+ string="New Conversations",
+ readonly=True,
+ )
+ resolved_count = fields.Integer(
+ string="Resolved",
+ readonly=True,
+ )
+ closed_count = fields.Integer(
+ string="Closed",
+ readonly=True,
+ )
+
+ # Time metrics (in minutes)
+ avg_first_response = fields.Float(
+ string="Avg First Response (min)",
+ readonly=True,
+ group_operator="avg",
+ )
+ avg_resolution_time = fields.Float(
+ string="Avg Resolution Time (min)",
+ readonly=True,
+ group_operator="avg",
+ )
+
+ # SLA metrics
+ sla_achieved_count = fields.Integer(
+ string="SLA Achieved",
+ readonly=True,
+ )
+ sla_breached_count = fields.Integer(
+ string="SLA Breached",
+ readonly=True,
+ )
+ sla_rate = fields.Float(
+ string="SLA Rate (%)",
+ readonly=True,
+ group_operator="avg",
+ )
+
+ # Message metrics
+ message_count = fields.Integer(
+ string="Total Messages",
+ readonly=True,
+ )
+ inbound_message_count = fields.Integer(
+ string="Inbound Messages",
+ readonly=True,
+ )
+ outbound_message_count = fields.Integer(
+ string="Outbound Messages",
+ readonly=True,
+ )
+
+ def init(self):
+ """Create the SQL view for metrics."""
+ tools.drop_view_if_exists(self.env.cr, self._table)
+ self.env.cr.execute(f"""
+ CREATE OR REPLACE VIEW {self._table} AS (
+ SELECT
+ row_number() OVER () as id,
+ DATE(c.create_date) as date,
+ c.channel_type,
+ c.team_id,
+ c.user_id,
+
+ -- Conversation counts
+ COUNT(DISTINCT c.id) as conversation_count,
+ COUNT(DISTINCT c.id) FILTER (
+ WHERE DATE(c.create_date) = DATE(c.create_date)
+ ) as new_conversation_count,
+ COUNT(DISTINCT c.id) FILTER (
+ WHERE c.resolved_at IS NOT NULL
+ ) as resolved_count,
+ COUNT(DISTINCT c.id) FILTER (
+ WHERE cs.closed = TRUE
+ ) as closed_count,
+
+ -- Time metrics (in minutes)
+ AVG(
+ EXTRACT(EPOCH FROM (c.first_response_at - c.create_date)) / 60
+ ) FILTER (
+ WHERE c.first_response_at IS NOT NULL
+ ) as avg_first_response,
+ AVG(
+ EXTRACT(EPOCH FROM (c.resolved_at - c.create_date)) / 60
+ ) FILTER (
+ WHERE c.resolved_at IS NOT NULL
+ ) as avg_resolution_time,
+
+ -- SLA metrics
+ COUNT(DISTINCT c.id) FILTER (
+ WHERE c.sla_status = 'achieved'
+ ) as sla_achieved_count,
+ COUNT(DISTINCT c.id) FILTER (
+ WHERE c.sla_status = 'breached'
+ ) as sla_breached_count,
+ CASE
+ WHEN COUNT(DISTINCT c.id) FILTER (
+ WHERE c.sla_status IN ('achieved', 'breached')
+ ) > 0
+ THEN (
+ COUNT(DISTINCT c.id) FILTER (
+ WHERE c.sla_status = 'achieved'
+ )::float /
+ COUNT(DISTINCT c.id) FILTER (
+ WHERE c.sla_status IN ('achieved', 'breached')
+ ) * 100
+ )
+ ELSE 0
+ END as sla_rate,
+
+ -- Message counts (simplified - would need message table join)
+ 0 as message_count,
+ 0 as inbound_message_count,
+ 0 as outbound_message_count
+
+ FROM engage_conversation c
+ LEFT JOIN engage_conversation_stage cs ON c.stage_id = cs.id
+ GROUP BY
+ DATE(c.create_date),
+ c.channel_type,
+ c.team_id,
+ c.user_id
+ )
+ """)
+
+
+class EngageMetricsAgent(models.Model):
+ """SQL View for agent-level metrics."""
+
+ _name = "engage.metrics.agent"
+ _description = "Engage Agent Metrics"
+ _auto = False
+ _order = "date desc, user_id"
+
+ date = fields.Date(string="Date", readonly=True)
+ user_id = fields.Many2one(
+ comodel_name="res.users",
+ string="Agent",
+ readonly=True,
+ )
+
+ # Activity metrics
+ conversations_handled = fields.Integer(
+ string="Conversations Handled",
+ readonly=True,
+ )
+ conversations_resolved = fields.Integer(
+ string="Conversations Resolved",
+ readonly=True,
+ )
+
+ # Performance metrics
+ avg_first_response = fields.Float(
+ string="Avg First Response (min)",
+ readonly=True,
+ group_operator="avg",
+ )
+ avg_resolution_time = fields.Float(
+ string="Avg Resolution Time (min)",
+ readonly=True,
+ group_operator="avg",
+ )
+
+ # SLA performance
+ sla_achieved = fields.Integer(string="SLA Achieved", readonly=True)
+ sla_breached = fields.Integer(string="SLA Breached", readonly=True)
+ sla_rate = fields.Float(string="SLA Rate (%)", readonly=True)
+
+ def init(self):
+ """Create the SQL view for agent metrics."""
+ tools.drop_view_if_exists(self.env.cr, self._table)
+ self.env.cr.execute(f"""
+ CREATE OR REPLACE VIEW {self._table} AS (
+ SELECT
+ row_number() OVER () as id,
+ DATE(c.create_date) as date,
+ c.user_id,
+
+ COUNT(DISTINCT c.id) as conversations_handled,
+ COUNT(DISTINCT c.id) FILTER (
+ WHERE c.resolved_at IS NOT NULL
+ ) as conversations_resolved,
+
+ AVG(
+ EXTRACT(EPOCH FROM (c.first_response_at - c.create_date)) / 60
+ ) FILTER (
+ WHERE c.first_response_at IS NOT NULL
+ ) as avg_first_response,
+
+ AVG(
+ EXTRACT(EPOCH FROM (c.resolved_at - c.create_date)) / 60
+ ) FILTER (
+ WHERE c.resolved_at IS NOT NULL
+ ) as avg_resolution_time,
+
+ COUNT(DISTINCT c.id) FILTER (
+ WHERE c.sla_status = 'achieved'
+ ) as sla_achieved,
+ COUNT(DISTINCT c.id) FILTER (
+ WHERE c.sla_status = 'breached'
+ ) as sla_breached,
+ CASE
+ WHEN COUNT(DISTINCT c.id) FILTER (
+ WHERE c.sla_status IN ('achieved', 'breached')
+ ) > 0
+ THEN (
+ COUNT(DISTINCT c.id) FILTER (
+ WHERE c.sla_status = 'achieved'
+ )::float /
+ COUNT(DISTINCT c.id) FILTER (
+ WHERE c.sla_status IN ('achieved', 'breached')
+ ) * 100
+ )
+ ELSE 0
+ END as sla_rate
+
+ FROM engage_conversation c
+ WHERE c.user_id IS NOT NULL
+ GROUP BY DATE(c.create_date), c.user_id
+ )
+ """)
+
+
+class EngageMetricsChannel(models.Model):
+ """SQL View for channel-level metrics."""
+
+ _name = "engage.metrics.channel"
+ _description = "Engage Channel Metrics"
+ _auto = False
+ _order = "date desc, channel_type"
+
+ date = fields.Date(string="Date", readonly=True)
+ channel_type = fields.Selection(
+ selection=[
+ ("email", "Email"),
+ ("whatsapp", "WhatsApp"),
+ ("instagram", "Instagram"),
+ ("messenger", "Messenger"),
+ ("telegram", "Telegram"),
+ ("livechat", "Live Chat"),
+ ("api", "API"),
+ ],
+ string="Channel",
+ readonly=True,
+ )
+
+ conversation_count = fields.Integer(string="Conversations", readonly=True)
+ resolved_count = fields.Integer(string="Resolved", readonly=True)
+ avg_resolution_time = fields.Float(
+ string="Avg Resolution (min)",
+ readonly=True,
+ )
+ sla_rate = fields.Float(string="SLA Rate (%)", readonly=True)
+
+ def init(self):
+ """Create the SQL view for channel metrics."""
+ tools.drop_view_if_exists(self.env.cr, self._table)
+ self.env.cr.execute(f"""
+ CREATE OR REPLACE VIEW {self._table} AS (
+ SELECT
+ row_number() OVER () as id,
+ DATE(c.create_date) as date,
+ c.channel_type,
+
+ COUNT(DISTINCT c.id) as conversation_count,
+ COUNT(DISTINCT c.id) FILTER (
+ WHERE c.resolved_at IS NOT NULL
+ ) as resolved_count,
+
+ AVG(
+ EXTRACT(EPOCH FROM (c.resolved_at - c.create_date)) / 60
+ ) FILTER (
+ WHERE c.resolved_at IS NOT NULL
+ ) as avg_resolution_time,
+
+ CASE
+ WHEN COUNT(DISTINCT c.id) FILTER (
+ WHERE c.sla_status IN ('achieved', 'breached')
+ ) > 0
+ THEN (
+ COUNT(DISTINCT c.id) FILTER (
+ WHERE c.sla_status = 'achieved'
+ )::float /
+ COUNT(DISTINCT c.id) FILTER (
+ WHERE c.sla_status IN ('achieved', 'breached')
+ ) * 100
+ )
+ ELSE 0
+ END as sla_rate
+
+ FROM engage_conversation c
+ GROUP BY DATE(c.create_date), c.channel_type
+ )
+ """)
diff --git a/customer_engagement/models/res_partner.py b/customer_engagement/models/res_partner.py
new file mode 100644
index 0000000000..8983490cdb
--- /dev/null
+++ b/customer_engagement/models/res_partner.py
@@ -0,0 +1,212 @@
+from odoo import api, fields, models
+
+
+class ResPartner(models.Model):
+ """Extension of res.partner for omnichannel customer profile."""
+
+ _inherit = "res.partner"
+
+ # Channel identifiers
+ whatsapp_number = fields.Char(
+ string="WhatsApp Number",
+ index=True,
+ help="WhatsApp phone number with country code (e.g., +5511999999999)",
+ )
+ telegram_id = fields.Char(
+ string="Telegram ID",
+ index=True,
+ help="Telegram user ID or username",
+ )
+ instagram_id = fields.Char(
+ string="Instagram ID",
+ index=True,
+ help="Instagram user ID or handle",
+ )
+ messenger_id = fields.Char(
+ string="Messenger ID",
+ index=True,
+ help="Facebook Messenger PSID",
+ )
+
+ # Engagement conversations
+ engage_conversation_ids = fields.One2many(
+ comodel_name="engage.conversation",
+ inverse_name="partner_id",
+ string="Conversations",
+ )
+ engage_conversation_count = fields.Integer(
+ string="Total Conversations",
+ compute="_compute_conversation_stats",
+ store=True,
+ )
+ open_conversation_count = fields.Integer(
+ string="Open Conversations",
+ compute="_compute_conversation_stats",
+ store=True,
+ )
+ last_conversation_date = fields.Datetime(
+ string="Last Conversation",
+ compute="_compute_conversation_stats",
+ store=True,
+ )
+
+ # Customer preferences
+ preferred_channel = fields.Selection(
+ selection=[
+ ("whatsapp", "WhatsApp"),
+ ("email", "Email"),
+ ("telegram", "Telegram"),
+ ("instagram", "Instagram"),
+ ("messenger", "Messenger"),
+ ("phone", "Phone Call"),
+ ],
+ string="Preferred Channel",
+ help="Customer's preferred communication channel",
+ )
+ vip_customer = fields.Boolean(
+ string="VIP Customer",
+ default=False,
+ help="Mark as VIP for priority routing",
+ )
+ engage_blocked = fields.Boolean(
+ string="Blocked from Engagement",
+ default=False,
+ help="Block this contact from creating new conversations",
+ )
+ notes_for_agents = fields.Text(
+ string="Notes for Agents",
+ help="Internal notes visible to support agents",
+ )
+
+ # Engagement metrics
+ avg_resolution_time = fields.Float(
+ string="Avg Resolution Time (hours)",
+ compute="_compute_engagement_metrics",
+ help="Average time to resolve conversations with this customer",
+ )
+ satisfaction_score = fields.Float(
+ string="Satisfaction Score",
+ compute="_compute_engagement_metrics",
+ help="Average CSAT score from this customer",
+ )
+ total_messages_received = fields.Integer(
+ string="Messages Received",
+ compute="_compute_engagement_metrics",
+ help="Total messages received from this customer",
+ )
+
+ @api.depends("engage_conversation_ids", "engage_conversation_ids.closed")
+ def _compute_conversation_stats(self):
+ for partner in self:
+ conversations = partner.engage_conversation_ids
+ partner.engage_conversation_count = len(conversations)
+ partner.open_conversation_count = len(
+ conversations.filtered(lambda c: not c.closed)
+ )
+ if conversations:
+ partner.last_conversation_date = max(
+ conversations.mapped("create_date")
+ )
+ else:
+ partner.last_conversation_date = False
+
+ def _compute_engagement_metrics(self):
+ """Compute engagement metrics for the partner."""
+ for partner in self:
+ conversations = partner.engage_conversation_ids.filtered(
+ lambda c: c.resolved_at and c.create_date
+ )
+
+ # Average resolution time
+ if conversations:
+ total_time = sum(
+ (c.resolved_at - c.create_date).total_seconds() / 3600
+ for c in conversations
+ )
+ partner.avg_resolution_time = total_time / len(conversations)
+ else:
+ partner.avg_resolution_time = 0.0
+
+ # Satisfaction score (placeholder - will be computed from CSAT model)
+ partner.satisfaction_score = 0.0
+
+ # Total messages (count mail.messages for this partner's conversations)
+ partner.total_messages_received = self.env["mail.message"].search_count(
+ [
+ ("model", "=", "engage.conversation"),
+ ("res_id", "in", partner.engage_conversation_ids.ids),
+ ("message_type", "!=", "notification"),
+ ]
+ )
+
+ @api.model
+ def _find_by_channel_id(self, channel_type, channel_id):
+ """
+ Find partner by channel identifier.
+
+ :param channel_type: Type of channel (whatsapp, telegram, instagram, messenger)
+ :param channel_id: The identifier on that channel
+ :return: res.partner record or empty recordset
+ """
+ field_map = {
+ "whatsapp": "whatsapp_number",
+ "telegram": "telegram_id",
+ "instagram": "instagram_id",
+ "messenger": "messenger_id",
+ }
+ field = field_map.get(channel_type)
+ if field:
+ return self.search([(field, "=", channel_id)], limit=1)
+ # For email channel, search by email
+ if channel_type == "email":
+ return self.search([("email", "=ilike", channel_id)], limit=1)
+ return self.browse()
+
+ @api.model
+ def _find_or_create_by_channel(self, channel_type, channel_id, name=None):
+ """
+ Find or create partner by channel identifier.
+
+ :param channel_type: Type of channel
+ :param channel_id: The identifier on that channel
+ :param name: Optional name for new partner
+ :return: res.partner record
+ """
+ partner = self._find_by_channel_id(channel_type, channel_id)
+ if partner:
+ return partner
+
+ # Create new partner
+ field_map = {
+ "whatsapp": "whatsapp_number",
+ "telegram": "telegram_id",
+ "instagram": "instagram_id",
+ "messenger": "messenger_id",
+ "email": "email",
+ }
+ field = field_map.get(channel_type)
+ if not field:
+ return self.browse()
+
+ vals = {
+ field: channel_id,
+ "name": name or f"{channel_type.title()} User {channel_id[-4:]}",
+ }
+
+ # Set phone/mobile for WhatsApp
+ if channel_type == "whatsapp":
+ vals["mobile"] = channel_id
+
+ return self.create(vals)
+
+ def action_view_conversations(self):
+ """Open conversations for this partner."""
+ self.ensure_one()
+ return {
+ "type": "ir.actions.act_window",
+ "name": f"Conversations - {self.name}",
+ "res_model": "engage.conversation",
+ "view_mode": "tree,form",
+ "domain": [("partner_id", "=", self.id)],
+ "context": {"default_partner_id": self.id},
+ }
diff --git a/customer_engagement/models/res_users.py b/customer_engagement/models/res_users.py
new file mode 100644
index 0000000000..dcbeae3a60
--- /dev/null
+++ b/customer_engagement/models/res_users.py
@@ -0,0 +1,218 @@
+from odoo import api, fields, models
+
+
+class ResUsers(models.Model):
+ """Extension of res.users for agent profile and status."""
+
+ _inherit = "res.users"
+
+ # Agent status
+ engage_status = fields.Selection(
+ selection=[
+ ("online", "Online"),
+ ("busy", "Busy"),
+ ("away", "Away"),
+ ("offline", "Offline"),
+ ],
+ string="Engagement Status",
+ default="offline",
+ help="Current availability status for handling conversations",
+ )
+
+ # Capacity settings
+ engage_max_conversations = fields.Integer(
+ string="Max Conversations",
+ default=5,
+ help="Maximum number of simultaneous conversations this agent can handle",
+ )
+
+ # Computed fields
+ engage_active_count = fields.Integer(
+ string="Active Conversations",
+ compute="_compute_engage_stats",
+ help="Number of currently assigned open conversations",
+ )
+ engage_is_available = fields.Boolean(
+ string="Available",
+ compute="_compute_engage_stats",
+ help="Whether agent is available to receive new conversations",
+ )
+ engage_today_resolved = fields.Integer(
+ string="Resolved Today",
+ compute="_compute_engage_stats",
+ help="Conversations resolved today",
+ )
+
+ # Preferences
+ engage_signature = fields.Text(
+ string="Signature",
+ help="Signature to append to messages",
+ )
+ engage_sound_notifications = fields.Boolean(
+ string="Sound Notifications",
+ default=True,
+ help="Play sound for new messages",
+ )
+ engage_desktop_notifications = fields.Boolean(
+ string="Desktop Notifications",
+ default=True,
+ help="Show desktop notifications for new messages",
+ )
+ engage_auto_accept = fields.Boolean(
+ string="Auto-Accept Assignments",
+ default=True,
+ help="Automatically accept conversation assignments",
+ )
+
+ # Team membership
+ engage_team_ids = fields.Many2many(
+ comodel_name="engage.team",
+ relation="engage_team_user_rel",
+ column1="user_id",
+ column2="team_id",
+ string="Engage Teams",
+ )
+
+ # Skills/specializations for routing
+ engage_skill_ids = fields.Many2many(
+ comodel_name="engage.conversation.label",
+ relation="engage_user_skill_rel",
+ column1="user_id",
+ column2="label_id",
+ string="Skills",
+ help="Labels/topics this agent specializes in",
+ )
+
+ @api.depends("engage_status", "engage_max_conversations")
+ def _compute_engage_stats(self):
+ """Compute engagement statistics for the agent."""
+ Conversation = self.env["engage.conversation"]
+ today_start = fields.Datetime.today()
+
+ for user in self:
+ # Active conversations
+ active_count = Conversation.search_count(
+ [
+ ("user_id", "=", user.id),
+ ("closed", "=", False),
+ ]
+ )
+ user.engage_active_count = active_count
+
+ # Is available
+ user.engage_is_available = (
+ user.engage_status == "online"
+ and active_count < user.engage_max_conversations
+ )
+
+ # Resolved today
+ user.engage_today_resolved = Conversation.search_count(
+ [
+ ("user_id", "=", user.id),
+ ("resolved_at", ">=", today_start),
+ ]
+ )
+
+ def action_set_status(self, status):
+ """Set agent status."""
+ self.ensure_one()
+ if status in ("online", "busy", "away", "offline"):
+ self.engage_status = status
+
+ def action_go_online(self):
+ """Set status to online."""
+ self.action_set_status("online")
+
+ def action_go_offline(self):
+ """Set status to offline."""
+ self.action_set_status("offline")
+
+ def action_go_busy(self):
+ """Set status to busy."""
+ self.action_set_status("busy")
+
+ def action_go_away(self):
+ """Set status to away."""
+ self.action_set_status("away")
+
+ @api.model
+ def get_available_agents(self, team_id=None, skill_ids=None):
+ """
+ Get list of available agents.
+
+ :param team_id: Optional team filter
+ :param skill_ids: Optional skill/label IDs filter
+ :return: res.users recordset
+ """
+ domain = [
+ ("engage_status", "=", "online"),
+ ("share", "=", False), # Internal users only
+ ]
+
+ if team_id:
+ domain.append(("engage_team_ids", "in", [team_id]))
+
+ agents = self.search(domain)
+
+ # Filter by capacity
+ agents = agents.filtered(lambda u: u.engage_is_available)
+
+ # Filter by skills if specified
+ if skill_ids:
+ agents = agents.filtered(
+ lambda u: bool(set(skill_ids) & set(u.engage_skill_ids.ids))
+ )
+
+ return agents
+
+ @api.model
+ def _get_agent_stats(self, user_ids=None, date_from=None, date_to=None):
+ """
+ Get agent performance statistics.
+
+ :param user_ids: Optional list of user IDs
+ :param date_from: Start date for stats
+ :param date_to: End date for stats
+ :return: dict with stats per user
+ """
+ domain = [("user_id", "!=", False)]
+
+ if user_ids:
+ domain.append(("user_id", "in", user_ids))
+ if date_from:
+ domain.append(("create_date", ">=", date_from))
+ if date_to:
+ domain.append(("create_date", "<=", date_to))
+
+ conversations = self.env["engage.conversation"].search(domain)
+
+ stats = {}
+ for conv in conversations:
+ user_id = conv.user_id.id
+ if user_id not in stats:
+ stats[user_id] = {
+ "total": 0,
+ "resolved": 0,
+ "avg_response_time": 0,
+ "response_times": [],
+ }
+
+ stats[user_id]["total"] += 1
+ if conv.resolved_at:
+ stats[user_id]["resolved"] += 1
+
+ if conv.first_response_at and conv.create_date:
+ response_time = (
+ conv.first_response_at - conv.create_date
+ ).total_seconds()
+ stats[user_id]["response_times"].append(response_time)
+
+ # Calculate averages
+ for user_id, data in stats.items():
+ if data["response_times"]:
+ data["avg_response_time"] = sum(data["response_times"]) / len(
+ data["response_times"]
+ )
+ del data["response_times"]
+
+ return stats
diff --git a/customer_engagement/models/routing_rule.py b/customer_engagement/models/routing_rule.py
new file mode 100644
index 0000000000..1a4714a690
--- /dev/null
+++ b/customer_engagement/models/routing_rule.py
@@ -0,0 +1,324 @@
+from datetime import datetime
+
+from odoo import api, fields, models
+
+
+class EngageRoutingRule(models.Model):
+ """Routing rules for automatic conversation assignment."""
+
+ _name = "engage.routing.rule"
+ _description = "Engage Routing Rule"
+ _order = "sequence, id"
+
+ name = fields.Char(string="Rule Name", required=True)
+ sequence = fields.Integer(default=10)
+ active = fields.Boolean(default=True)
+ description = fields.Text(string="Description")
+
+ # ==================== Conditions ====================
+
+ # Channel condition
+ channel_type = fields.Selection(
+ selection=[
+ ("email", "Email"),
+ ("whatsapp", "WhatsApp"),
+ ("instagram", "Instagram"),
+ ("messenger", "Messenger"),
+ ("telegram", "Telegram"),
+ ("livechat", "Live Chat"),
+ ("api", "API"),
+ ],
+ string="Channel",
+ help="Match conversations from this channel",
+ )
+
+ # Partner conditions
+ partner_vip = fields.Boolean(
+ string="VIP Customers Only",
+ default=False,
+ help="Match only VIP customers",
+ )
+ partner_tag_ids = fields.Many2many(
+ comodel_name="res.partner.category",
+ relation="engage_routing_rule_partner_tag_rel",
+ column1="rule_id",
+ column2="tag_id",
+ string="Customer Tags",
+ help="Match customers with any of these tags",
+ )
+ partner_country_ids = fields.Many2many(
+ comodel_name="res.country",
+ relation="engage_routing_rule_country_rel",
+ column1="rule_id",
+ column2="country_id",
+ string="Countries",
+ help="Match customers from these countries",
+ )
+
+ # Content conditions
+ keyword_match = fields.Char(
+ string="Keywords",
+ help="Comma-separated keywords to match in message content (case-insensitive)",
+ )
+ keyword_match_mode = fields.Selection(
+ selection=[
+ ("any", "Any Keyword"),
+ ("all", "All Keywords"),
+ ],
+ string="Keyword Mode",
+ default="any",
+ )
+
+ # Time conditions
+ time_condition = fields.Selection(
+ selection=[
+ ("any", "Any Time"),
+ ("business", "Business Hours"),
+ ("outside", "Outside Business Hours"),
+ ],
+ string="Time Condition",
+ default="any",
+ )
+
+ # Source team (optional - for escalation rules)
+ source_team_id = fields.Many2one(
+ comodel_name="engage.team",
+ string="Source Team",
+ help="Match conversations from this team (for escalation)",
+ )
+
+ # ==================== Actions ====================
+
+ # Assignment actions
+ target_team_id = fields.Many2one(
+ comodel_name="engage.team",
+ string="Assign to Team",
+ help="Assign matching conversations to this team",
+ )
+ target_user_id = fields.Many2one(
+ comodel_name="res.users",
+ string="Assign to Agent",
+ help="Assign directly to this agent (overrides team)",
+ )
+ use_team_assignment = fields.Boolean(
+ string="Use Team Auto-Assignment",
+ default=True,
+ help="Use team's assignment method to pick agent",
+ )
+
+ # Property actions
+ set_priority = fields.Selection(
+ selection=[
+ ("0", "Low"),
+ ("1", "Normal"),
+ ("2", "High"),
+ ("3", "Urgent"),
+ ],
+ string="Set Priority",
+ )
+ add_label_ids = fields.Many2many(
+ comodel_name="engage.conversation.label",
+ relation="engage_routing_rule_label_rel",
+ column1="rule_id",
+ column2="label_id",
+ string="Add Labels",
+ )
+
+ # Auto-response
+ auto_message = fields.Text(
+ string="Auto-Response Message",
+ help="Send this message automatically when rule matches",
+ )
+ auto_message_delay = fields.Integer(
+ string="Auto-Response Delay (sec)",
+ default=0,
+ help="Delay before sending auto-response",
+ )
+
+ # Statistics
+ match_count = fields.Integer(
+ string="Times Matched",
+ default=0,
+ readonly=True,
+ )
+ last_match_date = fields.Datetime(
+ string="Last Match",
+ readonly=True,
+ )
+
+ def _check_conditions(self, conversation, message_content=None):
+ """
+ Check if this rule's conditions match the conversation.
+
+ :param conversation: engage.conversation record
+ :param message_content: Optional message text to check keywords
+ :return: True if all conditions match
+ """
+ self.ensure_one()
+
+ # Channel condition
+ if self.channel_type and conversation.channel_type != self.channel_type:
+ return False
+
+ # Partner conditions
+ partner = conversation.partner_id
+ if partner:
+ if self.partner_vip and not partner.vip_customer:
+ return False
+
+ if self.partner_tag_ids:
+ if not (set(self.partner_tag_ids.ids) & set(partner.category_id.ids)):
+ return False
+
+ if self.partner_country_ids:
+ if partner.country_id not in self.partner_country_ids:
+ return False
+ elif self.partner_vip or self.partner_tag_ids or self.partner_country_ids:
+ # Partner conditions specified but no partner
+ return False
+
+ # Keyword condition
+ if self.keyword_match and message_content:
+ keywords = [k.strip().lower() for k in self.keyword_match.split(",")]
+ content_lower = message_content.lower()
+
+ if self.keyword_match_mode == "all":
+ if not all(kw in content_lower for kw in keywords):
+ return False
+ else: # any
+ if not any(kw in content_lower for kw in keywords):
+ return False
+
+ # Time condition
+ if self.time_condition != "any":
+ is_business_hours = self._is_business_hours()
+ if self.time_condition == "business" and not is_business_hours:
+ return False
+ if self.time_condition == "outside" and is_business_hours:
+ return False
+
+ # Source team condition
+ if self.source_team_id and conversation.team_id != self.source_team_id:
+ return False
+
+ return True
+
+ def _is_business_hours(self):
+ """
+ Check if current time is within business hours.
+ TODO: Use team schedule or company calendar.
+ """
+ now = datetime.now()
+ # Simple check: Mon-Fri, 9:00-18:00
+ if now.weekday() >= 5: # Weekend
+ return False
+ if now.hour < 9 or now.hour >= 18:
+ return False
+ return True
+
+ def _apply_actions(self, conversation):
+ """
+ Apply this rule's actions to the conversation.
+
+ :param conversation: engage.conversation record
+ """
+ self.ensure_one()
+ vals = {}
+
+ # Team assignment
+ if self.target_team_id:
+ vals["team_id"] = self.target_team_id.id
+
+ if self.use_team_assignment:
+ # Let team's assignment method pick agent
+ agent = self.target_team_id.get_available_agent(conversation)
+ if agent:
+ vals["user_id"] = agent.id
+
+ # Direct agent assignment (overrides team assignment)
+ if self.target_user_id:
+ vals["user_id"] = self.target_user_id.id
+
+ # Priority
+ if self.set_priority:
+ vals["priority"] = self.set_priority
+
+ # Write conversation updates
+ if vals:
+ conversation.write(vals)
+
+ # Labels (separate write for M2M)
+ if self.add_label_ids:
+ conversation.write(
+ {"label_ids": [(4, label_id) for label_id in self.add_label_ids.ids]}
+ )
+
+ # Auto-response
+ if self.auto_message:
+ # TODO: Implement delayed message with queue
+ conversation.message_post(
+ body=self.auto_message,
+ message_type="comment",
+ )
+
+ # Update statistics
+ self.sudo().write(
+ {
+ "match_count": self.match_count + 1,
+ "last_match_date": fields.Datetime.now(),
+ }
+ )
+
+ @api.model
+ def apply_routing_rules(self, conversation, message_content=None):
+ """
+ Find and apply matching routing rules to a conversation.
+
+ :param conversation: engage.conversation record
+ :param message_content: Optional message content for keyword matching
+ :return: engage.routing.rule that matched, or empty recordset
+ """
+ rules = self.search([("active", "=", True)], order="sequence")
+
+ for rule in rules:
+ if rule._check_conditions(conversation, message_content):
+ rule._apply_actions(conversation)
+ return rule
+
+ return self.browse()
+
+
+class ConversationRouting(models.Model):
+ """Routing extension for engage.conversation."""
+
+ _inherit = "engage.conversation"
+
+ routing_rule_id = fields.Many2one(
+ comodel_name="engage.routing.rule",
+ string="Applied Routing Rule",
+ readonly=True,
+ help="The routing rule that was applied to this conversation",
+ )
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """Apply routing rules on conversation creation."""
+ records = super().create(vals_list)
+
+ for record in records:
+ # Skip if already assigned
+ if record.team_id or record.user_id:
+ continue
+
+ rule = self.env["engage.routing.rule"].apply_routing_rules(record)
+ if rule:
+ record.routing_rule_id = rule
+
+ return records
+
+ def action_apply_routing(self):
+ """Manually apply routing rules."""
+ for record in self:
+ rule = self.env["engage.routing.rule"].apply_routing_rules(record)
+ if rule:
+ record.routing_rule_id = rule
diff --git a/customer_engagement/models/sla_policy.py b/customer_engagement/models/sla_policy.py
new file mode 100644
index 0000000000..cace3f21e2
--- /dev/null
+++ b/customer_engagement/models/sla_policy.py
@@ -0,0 +1,334 @@
+from datetime import timedelta
+
+from odoo import api, fields, models
+
+
+class EngageSLAPolicy(models.Model):
+ """SLA Policy for conversation handling."""
+
+ _name = "engage.sla.policy"
+ _description = "Engage SLA Policy"
+ _order = "sequence, name"
+
+ name = fields.Char(string="Policy Name", required=True)
+ sequence = fields.Integer(default=10)
+ active = fields.Boolean(default=True)
+ description = fields.Text(string="Description")
+
+ # Time targets (in minutes)
+ first_response_time = fields.Integer(
+ string="First Response Time (min)",
+ required=True,
+ default=30,
+ help="Target time for first agent response",
+ )
+ resolution_time = fields.Integer(
+ string="Resolution Time (min)",
+ required=True,
+ default=480,
+ help="Target time for conversation resolution",
+ )
+
+ # Conditions for applying this policy
+ priority = fields.Selection(
+ selection=[
+ ("0", "Low"),
+ ("1", "Normal"),
+ ("2", "High"),
+ ("3", "Urgent"),
+ ],
+ string="Priority",
+ help="Apply to conversations with this priority (empty = all)",
+ )
+ channel_type = fields.Selection(
+ selection=[
+ ("email", "Email"),
+ ("whatsapp", "WhatsApp"),
+ ("instagram", "Instagram"),
+ ("messenger", "Messenger"),
+ ("telegram", "Telegram"),
+ ("livechat", "Live Chat"),
+ ("api", "API"),
+ ],
+ string="Channel",
+ help="Apply to conversations from this channel (empty = all)",
+ )
+ team_ids = fields.Many2many(
+ comodel_name="engage.team",
+ relation="engage_sla_policy_team_rel",
+ column1="policy_id",
+ column2="team_id",
+ string="Teams",
+ help="Apply to conversations assigned to these teams (empty = all)",
+ )
+ vip_only = fields.Boolean(
+ string="VIP Customers Only",
+ default=False,
+ help="Apply only to VIP customers",
+ )
+
+ # Thresholds for alerts
+ warning_threshold = fields.Integer(
+ string="Warning Threshold (%)",
+ default=80,
+ help="Show warning when this percentage of time has elapsed",
+ )
+ critical_threshold = fields.Integer(
+ string="Critical Threshold (%)",
+ default=95,
+ help="Show critical alert when this percentage of time has elapsed",
+ )
+
+ # Business hours
+ business_hours_only = fields.Boolean(
+ string="Business Hours Only",
+ default=True,
+ help="Only count business hours for SLA calculation",
+ )
+
+ # Statistics
+ conversation_count = fields.Integer(
+ string="Active Conversations",
+ compute="_compute_stats",
+ )
+ achieved_rate = fields.Float(
+ string="Achievement Rate (%)",
+ compute="_compute_stats",
+ )
+
+ def _compute_stats(self):
+ """Compute SLA statistics."""
+ for policy in self:
+ conversations = self.env["engage.conversation"].search(
+ [
+ ("sla_policy_id", "=", policy.id),
+ ]
+ )
+ policy.conversation_count = len(conversations)
+
+ achieved = conversations.filtered(lambda c: c.sla_status == "achieved")
+ total_closed = conversations.filtered(lambda c: c.closed)
+
+ if total_closed:
+ policy.achieved_rate = (len(achieved) / len(total_closed)) * 100
+ else:
+ policy.achieved_rate = 0.0
+
+ @api.model
+ def _get_matching_policy(self, conversation):
+ """
+ Find the best matching SLA policy for a conversation.
+
+ :param conversation: engage.conversation record
+ :return: engage.sla.policy record or empty recordset
+ """
+ domain = [("active", "=", True)]
+
+ # Find all active policies and filter
+ policies = self.search(domain, order="sequence")
+
+ for policy in policies:
+ # Check priority match
+ if policy.priority and policy.priority != conversation.priority:
+ continue
+
+ # Check channel match
+ if policy.channel_type and policy.channel_type != conversation.channel_type:
+ continue
+
+ # Check team match
+ if policy.team_ids and conversation.team_id not in policy.team_ids:
+ continue
+
+ # Check VIP
+ if policy.vip_only:
+ if (
+ not conversation.partner_id
+ or not conversation.partner_id.vip_customer
+ ):
+ continue
+
+ # All conditions match
+ return policy
+
+ return self.browse()
+
+ def calculate_deadline(self, start_time, minutes):
+ """
+ Calculate deadline from start time.
+
+ :param start_time: datetime
+ :param minutes: int
+ :return: datetime
+ """
+ # TODO: Implement business hours calculation
+ # For now, simple addition
+ return start_time + timedelta(minutes=minutes)
+
+
+class ConversationSLA(models.Model):
+ """SLA fields extension for engage.conversation."""
+
+ _inherit = "engage.conversation"
+
+ # SLA policy
+ sla_policy_id = fields.Many2one(
+ comodel_name="engage.sla.policy",
+ string="SLA Policy",
+ tracking=True,
+ )
+
+ # Deadlines (computed and stored)
+ sla_first_response_deadline = fields.Datetime(
+ string="First Response Deadline",
+ compute="_compute_sla_deadlines",
+ store=True,
+ )
+ sla_resolution_deadline = fields.Datetime(
+ string="Resolution Deadline",
+ compute="_compute_sla_deadlines",
+ store=True,
+ )
+
+ # Status
+ sla_status = fields.Selection(
+ selection=[
+ ("pending", "Pending"),
+ ("ok", "On Track"),
+ ("warning", "Warning"),
+ ("critical", "Critical"),
+ ("breached", "Breached"),
+ ("achieved", "Achieved"),
+ ],
+ string="SLA Status",
+ compute="_compute_sla_status",
+ store=True,
+ help="Current SLA status based on deadlines",
+ )
+
+ # Tracking
+ sla_first_response_breached = fields.Boolean(
+ string="First Response Breached",
+ compute="_compute_sla_status",
+ store=True,
+ )
+ sla_resolution_breached = fields.Boolean(
+ string="Resolution Breached",
+ compute="_compute_sla_status",
+ store=True,
+ )
+
+ @api.depends("sla_policy_id", "create_date")
+ def _compute_sla_deadlines(self):
+ """Compute SLA deadlines based on policy."""
+ for conv in self:
+ if conv.sla_policy_id and conv.create_date:
+ policy = conv.sla_policy_id
+
+ conv.sla_first_response_deadline = policy.calculate_deadline(
+ conv.create_date, policy.first_response_time
+ )
+ conv.sla_resolution_deadline = policy.calculate_deadline(
+ conv.create_date, policy.resolution_time
+ )
+ else:
+ conv.sla_first_response_deadline = False
+ conv.sla_resolution_deadline = False
+
+ @api.depends(
+ "sla_policy_id",
+ "sla_first_response_deadline",
+ "sla_resolution_deadline",
+ "first_response_at",
+ "resolved_at",
+ "closed",
+ )
+ def _compute_sla_status(self):
+ """Compute current SLA status."""
+ now = fields.Datetime.now()
+
+ for conv in self:
+ if not conv.sla_policy_id:
+ conv.sla_status = False
+ conv.sla_first_response_breached = False
+ conv.sla_resolution_breached = False
+ continue
+
+ policy = conv.sla_policy_id
+
+ # Check first response breach
+ if conv.first_response_at:
+ conv.sla_first_response_breached = (
+ conv.first_response_at > conv.sla_first_response_deadline
+ if conv.sla_first_response_deadline
+ else False
+ )
+ else:
+ conv.sla_first_response_breached = (
+ now > conv.sla_first_response_deadline
+ if conv.sla_first_response_deadline
+ else False
+ )
+
+ # Check resolution breach
+ if conv.resolved_at:
+ conv.sla_resolution_breached = (
+ conv.resolved_at > conv.sla_resolution_deadline
+ if conv.sla_resolution_deadline
+ else False
+ )
+ else:
+ conv.sla_resolution_breached = (
+ now > conv.sla_resolution_deadline
+ if conv.sla_resolution_deadline
+ else False
+ )
+
+ # Determine overall status
+ if conv.closed:
+ if conv.sla_first_response_breached or conv.sla_resolution_breached:
+ conv.sla_status = "breached"
+ else:
+ conv.sla_status = "achieved"
+ elif conv.sla_first_response_breached or conv.sla_resolution_breached:
+ conv.sla_status = "breached"
+ else:
+ # Check warning/critical thresholds
+ if conv.sla_resolution_deadline:
+ total_time = (
+ conv.sla_resolution_deadline - conv.create_date
+ ).total_seconds()
+ elapsed_time = (now - conv.create_date).total_seconds()
+
+ if total_time > 0:
+ percentage = (elapsed_time / total_time) * 100
+
+ if percentage >= policy.critical_threshold:
+ conv.sla_status = "critical"
+ elif percentage >= policy.warning_threshold:
+ conv.sla_status = "warning"
+ else:
+ conv.sla_status = "ok"
+ else:
+ conv.sla_status = "ok"
+ else:
+ conv.sla_status = "pending"
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """Auto-assign SLA policy on creation."""
+ records = super().create(vals_list)
+
+ for record in records:
+ if not record.sla_policy_id:
+ policy = self.env["engage.sla.policy"]._get_matching_policy(record)
+ if policy:
+ record.sla_policy_id = policy
+
+ return records
+
+ def action_update_sla(self):
+ """Manually reassign SLA policy."""
+ for record in self:
+ policy = self.env["engage.sla.policy"]._get_matching_policy(record)
+ record.sla_policy_id = policy if policy else False
diff --git a/customer_engagement/models/team_schedule.py b/customer_engagement/models/team_schedule.py
new file mode 100644
index 0000000000..f5191922e4
--- /dev/null
+++ b/customer_engagement/models/team_schedule.py
@@ -0,0 +1,111 @@
+"""Team Schedule Model for Customer Engagement.
+
+This module provides working hours configuration for engagement teams.
+"""
+
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+
+
+class EngageTeamSchedule(models.Model):
+ """Working hours schedule for engagement teams."""
+
+ _name = "engage.team.schedule"
+ _description = "Team Schedule"
+ _order = "team_id, day_of_week, start_time"
+
+ team_id = fields.Many2one(
+ "engage.team",
+ required=True,
+ ondelete="cascade",
+ string="Team",
+ index=True,
+ )
+ day_of_week = fields.Selection(
+ [
+ ("0", "Monday"),
+ ("1", "Tuesday"),
+ ("2", "Wednesday"),
+ ("3", "Thursday"),
+ ("4", "Friday"),
+ ("5", "Saturday"),
+ ("6", "Sunday"),
+ ],
+ required=True,
+ string="Day",
+ )
+ start_time = fields.Float(
+ required=True,
+ string="Start Time",
+ help="Start time in 24h format (e.g., 8.5 = 08:30)",
+ )
+ end_time = fields.Float(
+ required=True,
+ string="End Time",
+ help="End time in 24h format (e.g., 17.5 = 17:30)",
+ )
+ name = fields.Char(
+ compute="_compute_name",
+ store=True,
+ string="Name",
+ )
+
+ @api.depends("day_of_week", "start_time", "end_time")
+ def _compute_name(self):
+ """Compute display name."""
+ day_names = {
+ "0": "Monday",
+ "1": "Tuesday",
+ "2": "Wednesday",
+ "3": "Thursday",
+ "4": "Friday",
+ "5": "Saturday",
+ "6": "Sunday",
+ }
+ for rec in self:
+ day = day_names.get(rec.day_of_week, "")
+ start = self._float_to_time_str(rec.start_time)
+ end = self._float_to_time_str(rec.end_time)
+ rec.name = f"{day} {start} - {end}"
+
+ @staticmethod
+ def _float_to_time_str(float_time):
+ """Convert float time to HH:MM string."""
+ hours = int(float_time)
+ minutes = int((float_time - hours) * 60)
+ return f"{hours:02d}:{minutes:02d}"
+
+ @api.constrains("start_time", "end_time")
+ def _check_times(self):
+ """Validate time values."""
+ for rec in self:
+ if rec.start_time < 0 or rec.start_time >= 24:
+ raise ValidationError(_("Start time must be between 0 and 24"))
+ if rec.end_time < 0 or rec.end_time >= 24:
+ raise ValidationError(_("End time must be between 0 and 24"))
+ if rec.start_time >= rec.end_time:
+ raise ValidationError(_("Start time must be before end time"))
+
+ @api.constrains("team_id", "day_of_week", "start_time", "end_time")
+ def _check_overlap(self):
+ """Check for overlapping schedules on the same day."""
+ for rec in self:
+ overlapping = self.search(
+ [
+ ("team_id", "=", rec.team_id.id),
+ ("day_of_week", "=", rec.day_of_week),
+ ("id", "!=", rec.id),
+ "|",
+ "&",
+ ("start_time", "<=", rec.start_time),
+ ("end_time", ">", rec.start_time),
+ "&",
+ ("start_time", "<", rec.end_time),
+ ("end_time", ">=", rec.end_time),
+ ]
+ )
+ if overlapping:
+ raise ValidationError(
+ _("Schedule overlaps with existing schedule: %s")
+ % overlapping[0].name
+ )
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..9a5a747d95
--- /dev/null
+++ b/customer_engagement/security/ir.model.access.csv
@@ -0,0 +1,41 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_conversation_user,engage.conversation.user,model_engage_conversation,group_engage_user,1,1,1,0
+access_conversation_manager,engage.conversation.manager,model_engage_conversation,group_engage_manager,1,1,1,1
+access_conversation_stage_all,engage.conversation.stage.all,model_engage_conversation_stage,base.group_user,1,0,0,0
+access_conversation_stage_manager,engage.conversation.stage.manager,model_engage_conversation_stage,group_engage_manager,1,1,1,1
+access_conversation_history_user,engage.conversation.history.user,model_engage_conversation_history,group_engage_user,1,0,0,0
+access_conversation_history_manager,engage.conversation.history.manager,model_engage_conversation_history,group_engage_manager,1,1,1,1
+access_conversation_label_user,engage.conversation.label.user,model_engage_conversation_label,group_engage_user,1,0,0,0
+access_conversation_label_manager,engage.conversation.label.manager,model_engage_conversation_label,group_engage_manager,1,1,1,1
+access_engage_team_user,engage.team.user,model_engage_team,group_engage_user,1,0,0,0
+access_engage_team_manager,engage.team.manager,model_engage_team,group_engage_manager,1,1,1,1
+access_engage_folder_user,engage.folder.user,model_engage_folder,group_engage_user,1,0,0,0
+access_engage_folder_manager,engage.folder.manager,model_engage_folder,group_engage_manager,1,1,1,1
+access_canned_response_user,engage.canned.response.user,model_engage_canned_response,group_engage_user,1,0,0,0
+access_canned_response_manager,engage.canned.response.manager,model_engage_canned_response,group_engage_manager,1,1,1,1
+access_conversation_note_user,engage.conversation.note.user,model_engage_conversation_note,group_engage_user,1,1,1,0
+access_conversation_note_manager,engage.conversation.note.manager,model_engage_conversation_note,group_engage_manager,1,1,1,1
+access_api_key_manager,engage.api.key.manager,model_engage_api_key,group_engage_manager,1,1,1,1
+access_api_key_wizard_manager,engage.api.key.wizard.manager,model_engage_api_key_wizard,group_engage_manager,1,1,1,1
+access_sla_policy_user,engage.sla.policy.user,model_engage_sla_policy,group_engage_user,1,0,0,0
+access_sla_policy_manager,engage.sla.policy.manager,model_engage_sla_policy,group_engage_manager,1,1,1,1
+access_routing_rule_user,engage.routing.rule.user,model_engage_routing_rule,group_engage_user,1,0,0,0
+access_routing_rule_manager,engage.routing.rule.manager,model_engage_routing_rule,group_engage_manager,1,1,1,1
+access_automation_user,engage.automation.user,model_engage_automation,group_engage_user,1,0,0,0
+access_automation_manager,engage.automation.manager,model_engage_automation,group_engage_manager,1,1,1,1
+access_automation_action_user,engage.automation.action.user,model_engage_automation_action,group_engage_user,1,0,0,0
+access_automation_action_manager,engage.automation.action.manager,model_engage_automation_action,group_engage_manager,1,1,1,1
+access_automation_queue_user,engage.automation.queue.user,model_engage_automation_queue,group_engage_user,1,0,0,0
+access_automation_queue_manager,engage.automation.queue.manager,model_engage_automation_queue,group_engage_manager,1,1,1,1
+access_metrics_user,engage.metrics.user,model_engage_metrics,group_engage_user,1,0,0,0
+access_metrics_manager,engage.metrics.manager,model_engage_metrics,group_engage_manager,1,0,0,0
+access_metrics_agent_user,engage.metrics.agent.user,model_engage_metrics_agent,group_engage_user,1,0,0,0
+access_metrics_agent_manager,engage.metrics.agent.manager,model_engage_metrics_agent,group_engage_manager,1,0,0,0
+access_metrics_channel_user,engage.metrics.channel.user,model_engage_metrics_channel,group_engage_user,1,0,0,0
+access_metrics_channel_manager,engage.metrics.channel.manager,model_engage_metrics_channel,group_engage_manager,1,0,0,0
+access_csat_user,engage.csat.user,model_engage_csat,group_engage_user,1,0,0,0
+access_csat_manager,engage.csat.manager,model_engage_csat,group_engage_manager,1,1,1,1
+access_team_schedule_user,engage.team.schedule.user,model_engage_team_schedule,group_engage_user,1,0,0,0
+access_team_schedule_manager,engage.team.schedule.manager,model_engage_team_schedule,group_engage_manager,1,1,1,1
+access_transfer_wizard_user,engage.transfer.wizard.user,model_engage_transfer_wizard,group_engage_user,1,1,1,1
+access_transfer_wizard_manager,engage.transfer.wizard.manager,model_engage_transfer_wizard,group_engage_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..5840f0c001
--- /dev/null
+++ b/customer_engagement/security/security_groups.xml
@@ -0,0 +1,18 @@
+
+
+
+ Engagement 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..b8be02fd7d
--- /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(
+ "engage.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..c9922369c3
--- /dev/null
+++ b/customer_engagement/static/src/js/components/chat_panel/message_bubble.js
@@ -0,0 +1,155 @@
+/** @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 || [];
+ }
+
+ /**
+ * Get delivery status for outgoing messages
+ * Returns: pending, sent, delivered, read, failed
+ */
+ get deliveryStatus() {
+ if (!this.props.isOutgoing) {
+ return null;
+ }
+ return this.props.message.delivery_status || "sent";
+ }
+
+ /**
+ * Get delivery status icon class
+ */
+ get deliveryStatusIcon() {
+ const status = this.deliveryStatus;
+ switch (status) {
+ case "pending":
+ return "fa-clock-o"; // Clock for pending
+ case "sent":
+ return "fa-check"; // Single check for sent
+ case "delivered":
+ return "fa-check-double"; // Double check for delivered (using custom or fallback)
+ case "read":
+ return "fa-check-double text-info"; // Blue double check for read
+ case "failed":
+ return "fa-exclamation-circle text-danger"; // Error icon for failed
+ default:
+ return "fa-check";
+ }
+ }
+
+ /**
+ * Check if we should show double check (delivered or read)
+ */
+ get isDoubleCheck() {
+ return ["delivered", "read"].includes(this.deliveryStatus);
+ }
+
+ /**
+ * Check if message was read
+ */
+ get isRead() {
+ return this.deliveryStatus === "read";
+ }
+
+ /**
+ * Get delivery status tooltip text
+ */
+ get deliveryStatusTitle() {
+ const status = this.deliveryStatus;
+ const titles = {
+ pending: "Sending...",
+ sent: "Sent",
+ delivered: "Delivered",
+ read: "Read",
+ failed: "Failed to send",
+ };
+ return titles[status] || "Sent";
+ }
+
+ /**
+ * Check if message has a reply-to reference
+ */
+ get hasReplyTo() {
+ return Boolean(this.props.message.reply_to_message_id);
+ }
+
+ /**
+ * Get reply-to message preview
+ */
+ get replyToPreview() {
+ const replyTo = this.props.message.reply_to_message_id;
+ if (!replyTo) return null;
+ // If it's an array [id, name], extract the preview
+ if (Array.isArray(replyTo) && replyTo[1]) {
+ return replyTo[1].substring(0, 50) + (replyTo[1].length > 50 ? "..." : "");
+ }
+ return null;
+ }
+
+ 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..7036446432
--- /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(
+ "engage.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("engage.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..b1f795a30b
--- /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(
+ "engage.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: "engage.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..3c95c2ebc9
--- /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_engage_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/engage_sidebar.js b/customer_engagement/static/src/js/components/sidebar/engage_sidebar.js
new file mode 100644
index 0000000000..d86bf55a80
--- /dev/null
+++ b/customer_engagement/static/src/js/components/sidebar/engage_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 EngageSidebar extends Component {
+ static template = "customer_engagement.EngageSidebar";
+ 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(
+ "engage.folder",
+ [["active", "=", true]],
+ [
+ "name",
+ "code",
+ "icon",
+ "color",
+ "folder_type",
+ "is_system",
+ "conversation_count",
+ ],
+ {order: "sequence, name"}
+ ),
+ this.orm.searchRead(
+ "engage.team",
+ [["active", "=", true]],
+ ["name", "color", "conversation_count", "member_count"],
+ {order: "sequence, name"}
+ ),
+ this.orm.searchRead(
+ "engage.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/components/sidebar/sidebar_item.js b/customer_engagement/static/src/js/components/sidebar/sidebar_item.js
new file mode 100644
index 0000000000..9fd25fd759
--- /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_engage_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/inbox_action.js b/customer_engagement/static/src/js/inbox_action.js
new file mode 100644
index 0000000000..02566afa64
--- /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 {EngageSidebar} from "./components/sidebar/engage_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 EngageInbox extends Component {
+ static template = "customer_engagement.EngageInbox";
+ static components = {
+ Layout,
+ EngageSidebar,
+ 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("engage.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(
+ "engage.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(
+ "engage.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("engage.conversation", [["closed", "=", false]]),
+ this.orm.searchCount("engage.conversation", [
+ ["closed", "=", false],
+ ["user_id", "=", userId],
+ ]),
+ this.orm.searchCount("engage.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", "=", "engage.conversation"],
+ ],
+ [
+ "body",
+ "author_id",
+ "date",
+ "message_type",
+ "attachment_ids",
+ "subtype_id",
+ ],
+ {order: "date asc"}
+ ),
+ this.orm.searchRead(
+ "engage.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: "engage.conversation",
+ res_id: this.state.selectedConversation.id,
+ },
+ ]);
+ attachmentIds.push(result[0]);
+ }
+
+ await this.orm.call(
+ "engage.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("engage.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("engage.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("engage.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: "engage.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_engage_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("engage_inbox", EngageInbox);
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..e57a2d3620
--- /dev/null
+++ b/customer_engagement/static/src/scss/components/_chat_panel.scss
@@ -0,0 +1,90 @@
+// Chat Panel Component Styles
+
+.o_engage_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_engage_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_engage_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_engage_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_engage_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..0cd27cb9f1
--- /dev/null
+++ b/customer_engagement/static/src/scss/components/_composer.scss
@@ -0,0 +1,149 @@
+// Composer Component Styles
+
+.o_engage_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..2e328a1ffd
--- /dev/null
+++ b/customer_engagement/static/src/scss/components/_contact_panel.scss
@@ -0,0 +1,130 @@
+// Contact Panel Component Styles
+
+.o_engage_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..03d3fde617
--- /dev/null
+++ b/customer_engagement/static/src/scss/components/_conversation_list.scss
@@ -0,0 +1,179 @@
+// Conversation List Component Styles
+
+.o_engage_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_engage_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_engage_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_engage_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_engage_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..4b75fae029
--- /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_engage_inbox {
+ // Full 4-column layout on large screens
+ .o_engage_sidebar {
+ width: 220px;
+ }
+
+ .o_engage_conversation_list {
+ width: 350px;
+ }
+
+ .o_engage_contact_panel {
+ width: 300px;
+ }
+ }
+}
+
+// Medium-large screens (small desktop / large tablet)
+@media (min-width: 992px) and (max-width: 1199.98px) {
+ .o_engage_inbox {
+ .o_engage_sidebar {
+ width: 200px;
+ min-width: 200px;
+ }
+
+ .o_engage_conversation_list {
+ width: 280px;
+ min-width: 280px;
+ }
+
+ .o_engage_contact_panel {
+ width: 260px;
+ min-width: 260px;
+ }
+ }
+}
+
+// Medium screens (tablet landscape)
+@media (min-width: 768px) and (max-width: 991.98px) {
+ .o_engage_inbox {
+ // Collapse sidebar to icons only
+ .o_engage_sidebar {
+ width: 60px;
+ min-width: 60px;
+
+ .o_engage_sidebar_header h5,
+ .o_engage_sidebar_section_header span,
+ .o_engage_sidebar_item span:not(.badge) {
+ display: none;
+ }
+
+ .o_engage_sidebar_item {
+ justify-content: center;
+ padding: 0.75rem;
+ }
+ }
+
+ .o_engage_conversation_list {
+ width: 300px;
+ min-width: 300px;
+ }
+
+ // Hide contact panel by default, show on demand
+ .o_engage_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_engage_inbox {
+ // Hide sidebar completely
+ .o_engage_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_engage_conversation_list {
+ width: 100%;
+ min-width: 100%;
+ flex: 1;
+
+ &.hidden {
+ display: none;
+ }
+ }
+
+ // Full width chat panel
+ .o_engage_chat_panel {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ z-index: 50;
+ display: none;
+
+ &.visible {
+ display: flex;
+ }
+ }
+
+ // Hide contact panel
+ .o_engage_contact_panel {
+ display: none;
+ }
+ }
+}
+
+// Extra small screens (phone)
+@media (max-width: 575.98px) {
+ .o_engage_inbox {
+ // Mobile-first: single panel at a time
+ position: relative;
+
+ .o_engage_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_engage_conversation_list {
+ width: 100%;
+ min-width: 100%;
+ flex: 1;
+
+ &.hidden {
+ display: none;
+ }
+ }
+
+ .o_engage_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_engage_chat_header {
+ .o_mobile_back_btn {
+ display: block;
+ }
+ }
+ }
+
+ .o_engage_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_engage_conversation_card {
+ .o_conversation_card_content {
+ padding: 0.75rem;
+ }
+ }
+
+ .o_engage_chat_header {
+ padding: 0.75rem !important;
+
+ .btn {
+ padding: 0.25rem 0.5rem;
+ }
+ }
+
+ .o_engage_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_engage_sidebar_item,
+ .o_engage_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_engage_sidebar_item:hover,
+ .o_engage_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..070423df87
--- /dev/null
+++ b/customer_engagement/static/src/scss/components/_sidebar.scss
@@ -0,0 +1,151 @@
+// Sidebar Component Styles
+
+.o_engage_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_engage_sidebar_header h5,
+ .o_engage_sidebar_section_header span,
+ .o_engage_sidebar_item span:not(.badge),
+ .o_sidebar_item_count {
+ display: none;
+ }
+
+ .o_engage_sidebar_item {
+ justify-content: center;
+ padding: 0.75rem;
+
+ i {
+ margin: 0;
+ }
+ }
+ }
+
+ // Header
+ .o_engage_sidebar_header {
+ flex-shrink: 0;
+ border-bottom: 1px solid #dee2e6;
+
+ h5 {
+ color: #212529;
+ font-size: 1rem;
+ }
+ }
+
+ // Content area with scrolling
+ .o_engage_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_engage_sidebar_section {
+ margin-bottom: 0.5rem;
+
+ .o_engage_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_engage_sidebar_section_content {
+ // Animation disabled to prevent potential rendering issues
+ // animation: slideDown 0.15s ease-out;
+ }
+ }
+
+ // Item styling
+ .o_engage_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..c254086ad6
--- /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_engage_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_engage_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_engage_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_engage_inbox {
+ .o_engage_sidebar,
+ .o_engage_conversation_list,
+ .o_engage_composer_container {
+ display: none !important;
+ }
+
+ .o_engage_chat_panel {
+ width: 100% !important;
+ }
+
+ .o_engage_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..1990d00acd
--- /dev/null
+++ b/customer_engagement/static/src/xml/components/chat_panel_templates.xml
@@ -0,0 +1,640 @@
+
+
+
+
+
+
+
+
+
+
Select a conversation
+
Choose a conversation from the list to start chatting
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Private Note
+ by
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Quick Replies
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No responses found
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/customer_engagement/static/src/xml/components/contact_panel_templates.xml b/customer_engagement/static/src/xml/components/contact_panel_templates.xml
new file mode 100644
index 0000000000..2ae517c791
--- /dev/null
+++ b/customer_engagement/static/src/xml/components/contact_panel_templates.xml
@@ -0,0 +1,312 @@
+
+
+
+
+
+
+
+
+
+
Select a conversation to view contact details
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/customer_engagement/static/src/xml/components/conversation_list_templates.xml b/customer_engagement/static/src/xml/components/conversation_list_templates.xml
new file mode 100644
index 0000000000..8b83392b93
--- /dev/null
+++ b/customer_engagement/static/src/xml/components/conversation_list_templates.xml
@@ -0,0 +1,202 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No conversations found
+
Try adjusting your search
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/customer_engagement/static/src/xml/components/sidebar_templates.xml b/customer_engagement/static/src/xml/components/sidebar_templates.xml
new file mode 100644
index 0000000000..8f646b3965
--- /dev/null
+++ b/customer_engagement/static/src/xml/components/sidebar_templates.xml
@@ -0,0 +1,353 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/customer_engagement/static/src/xml/inbox_templates.xml b/customer_engagement/static/src/xml/inbox_templates.xml
new file mode 100644
index 0000000000..50ceb01a32
--- /dev/null
+++ b/customer_engagement/static/src/xml/inbox_templates.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/customer_engagement/tests/__init__.py b/customer_engagement/tests/__init__.py
new file mode 100644
index 0000000000..6302df6564
--- /dev/null
+++ b/customer_engagement/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_conversation
diff --git a/customer_engagement/tests/test_conversation.py b/customer_engagement/tests/test_conversation.py
new file mode 100644
index 0000000000..0393aade90
--- /dev/null
+++ b/customer_engagement/tests/test_conversation.py
@@ -0,0 +1,365 @@
+from odoo.exceptions import UserError
+from odoo.tests.common import TransactionCase
+
+
+class TestConversation(TransactionCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.stage_new = cls.env.ref("customer_engagement.stage_new")
+ cls.stage_open = cls.env.ref("customer_engagement.stage_open")
+ cls.stage_pending = cls.env.ref("customer_engagement.stage_pending")
+ cls.stage_resolved = cls.env.ref("customer_engagement.stage_resolved")
+ cls.stage_closed = cls.env.ref("customer_engagement.stage_closed")
+
+ def test_create_conversation(self):
+ """Test conversation creation with default stage."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "whatsapp",
+ "subject": "Test conversation",
+ }
+ )
+ self.assertEqual(conv.stage_id.code, "new")
+ self.assertFalse(conv.closed)
+ self.assertTrue(conv.uuid)
+ self.assertEqual(len(conv.uuid), 36) # UUID format
+
+ def test_display_name_with_subject(self):
+ """Test display name computation with subject."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ "subject": "Help needed",
+ }
+ )
+ self.assertIn("Help needed", conv.display_name)
+ self.assertIn(conv.uuid[:8], conv.display_name)
+
+ def test_display_name_with_partner(self):
+ """Test display name computation with partner."""
+ partner = self.env["res.partner"].create({"name": "John Doe"})
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ "partner_id": partner.id,
+ }
+ )
+ self.assertIn("John Doe", conv.display_name)
+
+ def test_display_name_without_subject_or_partner(self):
+ """Test display name computation without subject or partner."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "whatsapp",
+ }
+ )
+ self.assertEqual(conv.display_name, f"[{conv.uuid[:8]}]")
+
+ def test_valid_transition_new_to_open(self):
+ """Test valid transition from new to open."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ }
+ )
+ conv.write({"stage_id": self.stage_open.id})
+ self.assertEqual(conv.stage_id.code, "open")
+
+ def test_valid_transition_open_to_pending(self):
+ """Test valid transition from open to pending."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ }
+ )
+ conv.action_open()
+ conv.write({"stage_id": self.stage_pending.id})
+ self.assertEqual(conv.stage_id.code, "pending")
+
+ def test_valid_transition_open_to_resolved(self):
+ """Test valid transition from open to resolved."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ }
+ )
+ conv.action_open()
+ conv.action_resolve()
+ self.assertEqual(conv.stage_id.code, "resolved")
+
+ def test_valid_transition_pending_to_open(self):
+ """Test valid transition from pending back to open."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ }
+ )
+ conv.action_open()
+ conv.action_pending()
+ conv.action_open()
+ self.assertEqual(conv.stage_id.code, "open")
+
+ def test_valid_transition_resolved_to_closed(self):
+ """Test valid transition from resolved to closed."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ }
+ )
+ conv.action_open()
+ conv.action_resolve()
+ conv.action_close()
+ self.assertEqual(conv.stage_id.code, "closed")
+ self.assertTrue(conv.closed)
+
+ def test_valid_transition_closed_to_open_reopen(self):
+ """Test valid transition from closed to open (reopen)."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ }
+ )
+ conv.action_open()
+ conv.action_resolve()
+ conv.action_close()
+ conv.action_open()
+ self.assertEqual(conv.stage_id.code, "open")
+ self.assertFalse(conv.closed)
+
+ def test_invalid_transition_new_to_closed(self):
+ """Test invalid transition from new to closed."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ }
+ )
+ with self.assertRaises(UserError):
+ conv.write({"stage_id": self.stage_closed.id})
+
+ def test_invalid_transition_new_to_pending(self):
+ """Test invalid transition from new to pending."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ }
+ )
+ with self.assertRaises(UserError):
+ conv.write({"stage_id": self.stage_pending.id})
+
+ def test_invalid_transition_new_to_resolved(self):
+ """Test invalid transition from new to resolved."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ }
+ )
+ with self.assertRaises(UserError):
+ conv.write({"stage_id": self.stage_resolved.id})
+
+ def test_invalid_transition_open_to_closed(self):
+ """Test invalid transition from open to closed (must resolve first)."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ }
+ )
+ conv.action_open()
+ with self.assertRaises(UserError):
+ conv.write({"stage_id": self.stage_closed.id})
+
+ def test_transition_history(self):
+ """Test that transitions are recorded in history."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "whatsapp",
+ }
+ )
+ conv.action_open()
+ history = self.env["engage.conversation.history"].search(
+ [("conversation_id", "=", conv.id)]
+ )
+ self.assertEqual(len(history), 1)
+ self.assertEqual(history.to_stage_id.code, "open")
+ self.assertEqual(history.from_stage_id.code, "new")
+
+ def test_multiple_transitions_history(self):
+ """Test multiple transitions are recorded in history."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ }
+ )
+ conv.action_open()
+ conv.action_resolve()
+ conv.action_close()
+
+ history = self.env["engage.conversation.history"].search(
+ [("conversation_id", "=", conv.id)], order="create_date asc"
+ )
+
+ self.assertEqual(len(history), 3)
+ self.assertEqual(history[0].to_stage_id.code, "open")
+ self.assertEqual(history[1].to_stage_id.code, "resolved")
+ self.assertEqual(history[2].to_stage_id.code, "closed")
+
+ def test_first_response_timestamp(self):
+ """Test first_response_at is set on first open."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ }
+ )
+ self.assertFalse(conv.first_response_at)
+ conv.action_open()
+ self.assertTrue(conv.first_response_at)
+
+ def test_first_response_timestamp_not_updated_on_reopen(self):
+ """Test first_response_at is not updated on reopen."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ }
+ )
+ conv.action_open()
+ first_response = conv.first_response_at
+ conv.action_resolve()
+ conv.action_open()
+ self.assertEqual(conv.first_response_at, first_response)
+
+ def test_resolved_timestamp(self):
+ """Test resolved_at is set on resolve."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ }
+ )
+ conv.action_open()
+ self.assertFalse(conv.resolved_at)
+ conv.action_resolve()
+ self.assertTrue(conv.resolved_at)
+
+ def test_resolved_timestamp_cleared_on_reopen(self):
+ """Test resolved_at is cleared on reopen."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ }
+ )
+ conv.action_open()
+ conv.action_resolve()
+ self.assertTrue(conv.resolved_at)
+ conv.action_open()
+ self.assertFalse(conv.resolved_at)
+
+ def test_channel_types(self):
+ """Test all channel types can be created."""
+ channel_types = [
+ "email",
+ "whatsapp",
+ "instagram",
+ "messenger",
+ "telegram",
+ "livechat",
+ "api",
+ ]
+ for channel in channel_types:
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": channel,
+ }
+ )
+ self.assertEqual(conv.channel_type, channel)
+
+ def test_priority_levels(self):
+ """Test all priority levels."""
+ for priority in ["0", "1", "2", "3"]:
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ "priority": priority,
+ }
+ )
+ self.assertEqual(conv.priority, priority)
+
+ def test_default_priority(self):
+ """Test default priority is Normal (1)."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ }
+ )
+ self.assertEqual(conv.priority, "1")
+
+ def test_same_stage_transition_allowed(self):
+ """Test that transitioning to the same stage is allowed."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ }
+ )
+ conv.action_open()
+ # This should not raise an error
+ conv.write({"stage_id": self.stage_open.id})
+ self.assertEqual(conv.stage_id.code, "open")
+
+
+class TestConversationStage(TransactionCase):
+ def test_stage_sequence(self):
+ """Test that stages have correct sequence ordering."""
+ stages = self.env["engage.conversation.stage"].search([], order="sequence")
+ codes = [s.code for s in stages]
+ self.assertEqual(codes, ["new", "open", "pending", "resolved", "closed"])
+
+ def test_stage_closed_flag(self):
+ """Test that only 'closed' stage has closed=True."""
+ closed_stages = self.env["engage.conversation.stage"].search(
+ [("closed", "=", True)]
+ )
+ self.assertEqual(len(closed_stages), 1)
+ self.assertEqual(closed_stages.code, "closed")
+
+
+class TestConversationHistory(TransactionCase):
+ def test_history_creation(self):
+ """Test history record creation."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ }
+ )
+ stage_open = self.env.ref("customer_engagement.stage_open")
+
+ history = self.env["engage.conversation.history"].create(
+ {
+ "conversation_id": conv.id,
+ "from_stage_id": conv.stage_id.id,
+ "to_stage_id": stage_open.id,
+ }
+ )
+
+ self.assertEqual(history.conversation_id, conv)
+ self.assertEqual(history.to_stage_id, stage_open)
+ self.assertTrue(history.user_id)
+
+ def test_history_cascade_delete(self):
+ """Test history is deleted when conversation is deleted."""
+ conv = self.env["engage.conversation"].create(
+ {
+ "channel_type": "email",
+ }
+ )
+ conv.action_open()
+
+ history_count = self.env["engage.conversation.history"].search_count(
+ [("conversation_id", "=", conv.id)]
+ )
+ self.assertGreater(history_count, 0)
+
+ conv_id = conv.id
+ conv.unlink()
+
+ history_count = self.env["engage.conversation.history"].search_count(
+ [("conversation_id", "=", conv_id)]
+ )
+ self.assertEqual(history_count, 0)
diff --git a/customer_engagement/views/api_key_views.xml b/customer_engagement/views/api_key_views.xml
new file mode 100644
index 0000000000..f6039dcc9a
--- /dev/null
+++ b/customer_engagement/views/api_key_views.xml
@@ -0,0 +1,184 @@
+
+
+
+
+ engage.api.key.form
+ engage.api.key
+
+
+
+
+
+
+
+ engage.api.key.tree
+ engage.api.key
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.api.key.search
+ engage.api.key
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ API Keys
+ engage.api.key
+ list,form
+ {'search_default_active': 1}
+
+
+ Create your first API Key
+
+
+ API Keys are used to authenticate external integrations
+ like WhatsApp, Telegram, and other messaging platforms.
+
+
+
+
+
+
+ engage.api.key.wizard.form
+ engage.api.key.wizard
+
+
+
+
+
diff --git a/customer_engagement/views/automation_views.xml b/customer_engagement/views/automation_views.xml
new file mode 100644
index 0000000000..91bf9abd7b
--- /dev/null
+++ b/customer_engagement/views/automation_views.xml
@@ -0,0 +1,266 @@
+
+
+
+
+ engage.automation.form
+ engage.automation
+
+
+
+
+
+
+
+ engage.automation.tree
+ engage.automation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.automation.search
+ engage.automation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Automations
+ engage.automation
+ list,form
+ {'search_default_active': 1}
+
+
+ Create your first Automation
+
+
+ Automations let you automatically perform actions when
+ specific events occur, like sending welcome messages
+ or notifying agents.
+
+
+
+
diff --git a/customer_engagement/views/canned_response_views.xml b/customer_engagement/views/canned_response_views.xml
new file mode 100644
index 0000000000..d1dc40f498
--- /dev/null
+++ b/customer_engagement/views/canned_response_views.xml
@@ -0,0 +1,200 @@
+
+
+
+
+ engage.canned.response.view.search
+ engage.canned.response
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.canned.response.view.tree
+ engage.canned.response
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.canned.response.view.form
+ engage.canned.response
+
+
+
+
+
+
+
+ engage.canned.response.view.kanban
+ engage.canned.response
+
+
+
+
+
+ /
+
+ uses
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Canned Responses
+ engage.canned.response
+ kanban,list,form
+
+
+
+ Create your first canned response
+
+
+ Canned responses allow agents to quickly insert pre-written messages
+ by typing / followed by the shortcut.
+
+
+
+
diff --git a/customer_engagement/views/conversation_label_views.xml b/customer_engagement/views/conversation_label_views.xml
new file mode 100644
index 0000000000..002731e666
--- /dev/null
+++ b/customer_engagement/views/conversation_label_views.xml
@@ -0,0 +1,92 @@
+
+
+
+
+ engage.conversation.label.view.tree
+ engage.conversation.label
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.conversation.label.view.form
+ engage.conversation.label
+
+
+
+
+
+
+
+ engage.conversation.label.view.kanban
+ engage.conversation.label
+
+
+
+
+
+
+
+
+
+ conversations
+
+
+
+
+
+
+
+
+
+ Labels
+ engage.conversation.label
+ list,kanban,form
+
+
+ Create your first label
+
+
+ Labels help categorize and filter conversations.
+
+
+
+
diff --git a/customer_engagement/views/conversation_stage_views.xml b/customer_engagement/views/conversation_stage_views.xml
new file mode 100644
index 0000000000..1ff028283b
--- /dev/null
+++ b/customer_engagement/views/conversation_stage_views.xml
@@ -0,0 +1,48 @@
+
+
+
+
+ engage.conversation.stage.tree
+ engage.conversation.stage
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.conversation.stage.form
+ engage.conversation.stage
+
+
+
+
+
+
+
+ Stages
+ engage.conversation.stage
+ list,form
+
+
diff --git a/customer_engagement/views/conversation_views.xml b/customer_engagement/views/conversation_views.xml
new file mode 100644
index 0000000000..ac8845af71
--- /dev/null
+++ b/customer_engagement/views/conversation_views.xml
@@ -0,0 +1,354 @@
+
+
+
+
+ engage.conversation.search
+ engage.conversation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.conversation.tree
+ engage.conversation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.conversation.form
+ engage.conversation
+
+
+
+
+
+
+
+ engage.conversation.kanban
+ engage.conversation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Conversations
+ engage.conversation
+ kanban,list,form
+
+ {'search_default_not_closed': 1}
+
+
+ No conversations yet
+
+
+ Conversations will appear here when customers contact you through any channel.
+
+
+
+
diff --git a/customer_engagement/views/csat_templates.xml b/customer_engagement/views/csat_templates.xml
new file mode 100644
index 0000000000..ddd6a2c6fa
--- /dev/null
+++ b/customer_engagement/views/csat_templates.xml
@@ -0,0 +1,360 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ We'd love to hear your feedback about your recent conversation
+
+ with
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Thank You!
+
+ We appreciate your feedback. Your response helps us improve our service.
+
+
+
+ We're sorry to hear you had a less than satisfactory experience.
+ Our team will review your feedback and work to improve.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Already Submitted
+
+ You have already submitted your feedback for this conversation.
+ Thank you for your response!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Survey Expired
+
+ This survey has expired and is no longer accepting responses.
+ We apologize for any inconvenience.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Survey Not Found
+
+ The survey you're looking for could not be found.
+ Please check the link and try again.
+
+
+
+
+
+
+
+
+
+
+
+ CSAT Survey Request
+
+ How was your experience? - Your feedback matters
+ {{ (object.conversation_id.team_id.alias_id.display_name or object.env.company.email) }}
+ {{ object.partner_id.email }}
+
+
+
+
+
+
+
+
+ How was your experience?
+ |
+
+
+ |
+
+ Dear ,
+
+
+ Your recent conversation has been resolved. We'd love to hear your feedback!
+
+
+ Please take a moment to rate your experience:
+
+
+
+ Rate Your Experience
+
+
+
+ This survey will expire in days.
+
+ |
+
+
+ |
+
+ Thank you for choosing our service.
+
+ |
+
+
+ |
+
+
+
+
+
+
diff --git a/customer_engagement/views/csat_views.xml b/customer_engagement/views/csat_views.xml
new file mode 100644
index 0000000000..ae830c7381
--- /dev/null
+++ b/customer_engagement/views/csat_views.xml
@@ -0,0 +1,214 @@
+
+
+
+
+ engage.csat.tree
+ engage.csat
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.csat.form
+ engage.csat
+
+
+
+
+
+
+
+ engage.csat.search
+ engage.csat
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.csat.pivot
+ engage.csat
+
+
+
+
+
+
+
+
+
+
+ engage.csat.graph
+ engage.csat
+
+
+
+
+
+
+
+
+
+
+ Satisfaction Surveys
+ engage.csat
+ list,form,pivot,graph
+
+ {'search_default_filter_answered': 1}
+
+
+ No satisfaction surveys yet
+
+
+ CSAT surveys are automatically sent when conversations are resolved.
+
+
+
+
diff --git a/customer_engagement/views/engage_folder_views.xml b/customer_engagement/views/engage_folder_views.xml
new file mode 100644
index 0000000000..aa5dd719a1
--- /dev/null
+++ b/customer_engagement/views/engage_folder_views.xml
@@ -0,0 +1,129 @@
+
+
+
+
+ engage.folder.view.tree
+ engage.folder
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.folder.view.form
+ engage.folder
+
+
+
+
+
+
+
+ engage.folder.view.kanban
+ engage.folder
+
+
+
+
+
+
+
+
+
+
+
+
+
+ conversations
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Folders
+ engage.folder
+ kanban,list,form
+
+
+ Create your first folder
+
+
+ Folders help organize conversations by topic or project.
+ Smart folders automatically filter conversations based on criteria.
+
+
+
+
diff --git a/customer_engagement/views/engage_team_views.xml b/customer_engagement/views/engage_team_views.xml
new file mode 100644
index 0000000000..db2981ada9
--- /dev/null
+++ b/customer_engagement/views/engage_team_views.xml
@@ -0,0 +1,187 @@
+
+
+
+
+ engage.team.view.tree
+ engage.team
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.team.view.form
+ engage.team
+
+
+
+
+
+
+
+ engage.team.view.kanban
+ engage.team
+
+
+
+
+
+
+
+
+
+
+ Members
+
+
+
+
+
+ Open Conversations
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Teams
+ engage.team
+ kanban,list,form
+
+
+ Create your first team
+
+
+ Teams help organize agents and route conversations efficiently.
+
+
+
+
diff --git a/customer_engagement/views/integration_views.xml b/customer_engagement/views/integration_views.xml
new file mode 100644
index 0000000000..ce4ed1e121
--- /dev/null
+++ b/customer_engagement/views/integration_views.xml
@@ -0,0 +1,145 @@
+
+
+
+
+ engage.conversation.form.integrations
+ engage.conversation
+
+ 50
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.conversation.tree.integrations
+ engage.conversation
+
+ 50
+
+
+
+
+
+
+
+
+
+
+ res.partner.form.engage.integrations
+ res.partner
+
+ 50
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/customer_engagement/views/menu_views.xml b/customer_engagement/views/menu_views.xml
new file mode 100644
index 0000000000..055edc3bd1
--- /dev/null
+++ b/customer_engagement/views/menu_views.xml
@@ -0,0 +1,169 @@
+
+
+
+
+ Inbox
+ engage_inbox
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/customer_engagement/views/metrics_views.xml b/customer_engagement/views/metrics_views.xml
new file mode 100644
index 0000000000..092ed85841
--- /dev/null
+++ b/customer_engagement/views/metrics_views.xml
@@ -0,0 +1,295 @@
+
+
+
+
+
+
+ engage.metrics.pivot
+ engage.metrics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.metrics.graph
+ engage.metrics
+
+
+
+
+
+
+
+
+
+
+ engage.metrics.tree
+ engage.metrics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.metrics.search
+ engage.metrics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Metrics
+ engage.metrics
+ pivot,graph,list
+ {
+ 'search_default_this_month': 1,
+ 'search_default_group_date': 1,
+ }
+
+
+
+
+
+
+ engage.metrics.agent.pivot
+ engage.metrics.agent
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.metrics.agent.tree
+ engage.metrics.agent
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.metrics.agent.search
+ engage.metrics.agent
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Agent Performance
+ engage.metrics.agent
+ pivot,list
+ {
+ 'search_default_this_month': 1,
+ 'search_default_group_user': 1,
+ }
+
+
+
+
+
+
+ engage.metrics.channel.graph
+ engage.metrics.channel
+
+
+
+
+
+
+
+
+
+
+ engage.metrics.channel.tree
+ engage.metrics.channel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.metrics.channel.search
+ engage.metrics.channel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Channel Performance
+ engage.metrics.channel
+ graph,list
+ {
+ 'search_default_this_month': 1,
+ 'search_default_group_channel': 1,
+ }
+
+
diff --git a/customer_engagement/views/res_partner_views.xml b/customer_engagement/views/res_partner_views.xml
new file mode 100644
index 0000000000..f97ff5a196
--- /dev/null
+++ b/customer_engagement/views/res_partner_views.xml
@@ -0,0 +1,122 @@
+
+
+
+
+ res.partner.form.engage
+ res.partner
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ res.partner.tree.engage
+ res.partner
+
+
+
+
+
+
+
+
+
+
+
+
+ res.partner.search.engage
+ res.partner
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/customer_engagement/views/res_users_views.xml b/customer_engagement/views/res_users_views.xml
new file mode 100644
index 0000000000..ae77707c16
--- /dev/null
+++ b/customer_engagement/views/res_users_views.xml
@@ -0,0 +1,81 @@
+
+
+
+
+ res.users.form.engage
+ res.users
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Go Online
+
+
+ form
+ code
+
+ if record:
+ record.action_go_online()
+
+
+
+
+ Go Offline
+
+
+ form
+ code
+
+ if record:
+ record.action_go_offline()
+
+
+
diff --git a/customer_engagement/views/routing_rule_views.xml b/customer_engagement/views/routing_rule_views.xml
new file mode 100644
index 0000000000..05496a997b
--- /dev/null
+++ b/customer_engagement/views/routing_rule_views.xml
@@ -0,0 +1,208 @@
+
+
+
+
+ engage.routing.rule.form
+ engage.routing.rule
+
+
+
+
+
+
+
+ engage.routing.rule.tree
+ engage.routing.rule
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.routing.rule.search
+ engage.routing.rule
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Routing Rules
+ engage.routing.rule
+ list,form
+ {'search_default_active': 1}
+
+
+ Create your first Routing Rule
+
+
+ Routing rules automatically assign conversations to teams
+ or agents based on conditions like channel, customer type, and keywords.
+
+
+
+
+
+
+ engage.conversation.form.routing
+ engage.conversation
+
+
+
+
+
+
+
+
diff --git a/customer_engagement/views/sla_policy_views.xml b/customer_engagement/views/sla_policy_views.xml
new file mode 100644
index 0000000000..bda27d942f
--- /dev/null
+++ b/customer_engagement/views/sla_policy_views.xml
@@ -0,0 +1,190 @@
+
+
+
+
+ engage.sla.policy.form
+ engage.sla.policy
+
+
+
+
+
+
+
+ engage.sla.policy.tree
+ engage.sla.policy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ engage.sla.policy.search
+ engage.sla.policy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SLA Policies
+ engage.sla.policy
+ list,form
+ {'search_default_active': 1}
+
+
+ Create your first SLA Policy
+
+
+ SLA policies define response and resolution time targets
+ for different types of conversations.
+
+
+
+
+
+
+ engage.conversation.form.sla
+ engage.conversation
+
+
+
+
+
+
+
+
+
+
+
+ engage.conversation.tree.sla
+ engage.conversation
+
+
+
+
+
+
+
+
diff --git a/customer_engagement/wizard/__init__.py b/customer_engagement/wizard/__init__.py
new file mode 100644
index 0000000000..38f38175b4
--- /dev/null
+++ b/customer_engagement/wizard/__init__.py
@@ -0,0 +1 @@
+from . import transfer_wizard
diff --git a/customer_engagement/wizard/transfer_wizard.py b/customer_engagement/wizard/transfer_wizard.py
new file mode 100644
index 0000000000..c3890064d3
--- /dev/null
+++ b/customer_engagement/wizard/transfer_wizard.py
@@ -0,0 +1,167 @@
+"""Transfer Wizard for Customer Engagement.
+
+This module provides a wizard for transferring conversations
+between agents and teams with proper tracking.
+"""
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+
+class EngageTransferWizard(models.TransientModel):
+ """Wizard for transferring conversations."""
+
+ _name = "engage.transfer.wizard"
+ _description = "Transfer Conversation"
+
+ conversation_id = fields.Many2one(
+ "engage.conversation",
+ required=True,
+ string="Conversation",
+ default=lambda self: self.env.context.get("active_id"),
+ )
+ conversation_uuid = fields.Char(
+ related="conversation_id.uuid",
+ string="Conversation ID",
+ )
+ current_user_id = fields.Many2one(
+ "res.users",
+ related="conversation_id.user_id",
+ string="Current Agent",
+ )
+ current_team_id = fields.Many2one(
+ "engage.team",
+ related="conversation_id.team_id",
+ string="Current Team",
+ )
+
+ transfer_type = fields.Selection(
+ [
+ ("agent", "To Agent"),
+ ("team", "To Team"),
+ ],
+ required=True,
+ default="agent",
+ string="Transfer To",
+ )
+
+ user_id = fields.Many2one(
+ "res.users",
+ string="Agent",
+ domain="[('share', '=', False)]",
+ )
+ team_id = fields.Many2one(
+ "engage.team",
+ string="Team",
+ )
+
+ reason = fields.Text(
+ string="Reason",
+ required=True,
+ help="Reason for the transfer (will be recorded in history)",
+ )
+
+ add_internal_note = fields.Boolean(
+ string="Add Internal Note",
+ default=True,
+ help="Add a note visible to the new assignee",
+ )
+ note_content = fields.Text(
+ string="Note for New Assignee",
+ help="Context or instructions for the new assignee",
+ )
+
+ keep_in_team = fields.Boolean(
+ string="Keep in Current Team",
+ default=True,
+ help="When transferring to an agent, keep the conversation in the current team",
+ )
+
+ @api.onchange("transfer_type")
+ def _onchange_transfer_type(self):
+ """Clear fields when transfer type changes."""
+ if self.transfer_type == "agent":
+ self.team_id = False
+ else:
+ self.user_id = False
+
+ def action_transfer(self):
+ """Execute the transfer."""
+ self.ensure_one()
+
+ if self.transfer_type == "agent" and not self.user_id:
+ raise UserError(_("Please select an agent to transfer to."))
+ if self.transfer_type == "team" and not self.team_id:
+ raise UserError(_("Please select a team to transfer to."))
+
+ conv = self.conversation_id
+ old_user = conv.user_id
+ old_team = conv.team_id
+
+ # Prepare values for update
+ vals = {}
+
+ if self.transfer_type == "agent":
+ if self.user_id == old_user:
+ raise UserError(_("Conversation is already assigned to this agent."))
+ vals["user_id"] = self.user_id.id
+ if not self.keep_in_team:
+ # Find team the agent belongs to
+ agent_teams = self.env["engage.team"].search(
+ [("member_ids", "in", self.user_id.id)]
+ )
+ if agent_teams:
+ vals["team_id"] = agent_teams[0].id
+ else:
+ if self.team_id == old_team:
+ raise UserError(_("Conversation is already in this team."))
+ vals["team_id"] = self.team_id.id
+ vals["user_id"] = False # Return to team queue
+
+ # Auto-assign if team has auto_assign enabled
+ if self.team_id.auto_assign:
+ agent = self.team_id.get_available_agent(conversation=conv)
+ if agent:
+ vals["user_id"] = agent.id
+
+ # Update conversation
+ conv.write(vals)
+
+ # Record in history
+ if self.transfer_type == "agent":
+ old_value = old_user.name if old_user else _("Unassigned")
+ new_value = self.user_id.name
+ else:
+ old_value = old_team.name if old_team else _("No Team")
+ new_value = self.team_id.name
+
+ self.env["engage.conversation.history"].create(
+ {
+ "conversation_id": conv.id,
+ "event_type": "transfer",
+ "old_value": old_value,
+ "new_value": new_value,
+ "notes": self.reason,
+ }
+ )
+
+ # Add internal note if requested
+ if self.add_internal_note and self.note_content:
+ self.env["engage.conversation.note"].create(
+ {
+ "conversation_id": conv.id,
+ "author_id": self.env.uid,
+ "content": f"Transfer Note:
{self.note_content}
",
+ }
+ )
+
+ # Notify new assignee
+ if vals.get("user_id"):
+ new_user = self.env["res.users"].browse(vals["user_id"])
+ new_user.notify_info(
+ title=_("Conversation Transferred"),
+ message=_("Conversation %s has been transferred to you by %s.")
+ % (conv.uuid[:8], self.env.user.name),
+ )
+
+ return {"type": "ir.actions.act_window_close"}
diff --git a/customer_engagement/wizard/transfer_wizard_views.xml b/customer_engagement/wizard/transfer_wizard_views.xml
new file mode 100644
index 0000000000..40cd222f66
--- /dev/null
+++ b/customer_engagement/wizard/transfer_wizard_views.xml
@@ -0,0 +1,91 @@
+
+
+
+
+ engage.transfer.wizard.form
+ engage.transfer.wizard
+
+
+
+
+
+
+
+ Transfer Conversation
+ engage.transfer.wizard
+ form
+ new
+
+ form
+
+
+
+
+ engage.conversation.form.transfer.button
+ engage.conversation
+
+ 20
+
+
+
+
+
+
+
diff --git a/eslint.config.cjs b/eslint.config.cjs
index 0d5731f89a..13171ade0b 100644
--- a/eslint.config.cjs
+++ b/eslint.config.cjs
@@ -197,6 +197,33 @@ const config = [{
ecmaVersion: 2024,
sourceType: "module",
},
+}, {
+ files: ["**/static/src/js/**/*.js"],
+
+ languageOptions: {
+ ecmaVersion: 2024,
+ sourceType: "module",
+ globals: {
+ // Browser globals
+ document: "readonly",
+ window: "readonly",
+ setTimeout: "readonly",
+ clearTimeout: "readonly",
+ setInterval: "readonly",
+ clearInterval: "readonly",
+ console: "readonly",
+ FileReader: "readonly",
+ URL: "readonly",
+ Blob: "readonly",
+ FormData: "readonly",
+ fetch: "readonly",
+ XMLHttpRequest: "readonly",
+ Event: "readonly",
+ CustomEvent: "readonly",
+ localStorage: "readonly",
+ sessionStorage: "readonly",
+ },
+ },
}];
module.exports = config
diff --git a/setup/customer_engagement/odoo/addons/customer_engagement b/setup/customer_engagement/odoo/addons/customer_engagement
new file mode 120000
index 0000000000..d44e5e42ad
--- /dev/null
+++ b/setup/customer_engagement/odoo/addons/customer_engagement
@@ -0,0 +1 @@
+../../../../customer_engagement
\ No newline at end of file
diff --git a/setup/customer_engagement/setup.py b/setup/customer_engagement/setup.py
new file mode 100644
index 0000000000..28c57bb640
--- /dev/null
+++ b/setup/customer_engagement/setup.py
@@ -0,0 +1,6 @@
+import setuptools
+
+setuptools.setup(
+ setup_requires=['setuptools-odoo'],
+ odoo_addon=True,
+)