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

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

No messages yet

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