feat: Major WhatsApp integration update with Odoo and pause/resume

## Frontend
- Add media display (images, audio, video, docs) in Inbox
- Add pause/resume functionality for WhatsApp accounts
- Fix media URLs to use nginx proxy (relative URLs)

## API Gateway
- Add /accounts/:id/pause and /accounts/:id/resume endpoints
- Fix media URL handling for browser access

## WhatsApp Core
- Add pauseSession() - disconnect without logout
- Add resumeSession() - reconnect using saved credentials
- Add media download and storage for incoming messages
- Serve media files via /media/ static route

## Odoo Module (odoo_whatsapp_hub)
- Add Chat Hub interface with DOLLARS theme (dark, 3-column layout)
- Add WhatsApp/DRRR theme switcher for chat view
- Add "ABRIR CHAT" button in conversation form
- Add send_message_from_chat() method
- Add security/ir.model.access.csv
- Fix CSS scoping to avoid breaking Odoo UI
- Update webhook to handle message events properly

## Documentation
- Add docs/CONTEXTO_DESARROLLO.md with complete project context

## Infrastructure
- Add whatsapp_media Docker volume
- Configure nginx proxy for /media/ route
- Update .gitignore to track src/sessions/ source files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude AI
2026-01-30 20:48:56 +00:00
parent 1040debe2e
commit 5dd3499097
33 changed files with 3636 additions and 138 deletions

View File

@@ -16,9 +16,11 @@
'license': 'LGPL-3',
'depends': ['base', 'contacts', 'mail'],
'data': [
'security/ir.model.access.csv',
'views/whatsapp_account_views.xml',
'views/whatsapp_conversation_views.xml',
'views/res_partner_views.xml',
'views/dollars_action.xml',
'wizards/send_whatsapp_wizard.xml',
'views/whatsapp_menu.xml',
'data/whatsapp_data.xml',
@@ -27,8 +29,11 @@
'assets': {
'web.assets_backend': [
'odoo_whatsapp_hub/static/src/css/whatsapp.css',
'odoo_whatsapp_hub/static/src/js/chat_widget.js',
'odoo_whatsapp_hub/static/src/xml/chat_widget.xml',
'odoo_whatsapp_hub/static/src/css/dollars_theme.css',
'odoo_whatsapp_hub/static/src/js/chat_action.js',
'odoo_whatsapp_hub/static/src/js/dollars_chat.js',
'odoo_whatsapp_hub/static/src/xml/chat_template.xml',
'odoo_whatsapp_hub/static/src/xml/dollars_template.xml',
],
},
'installable': True,

View File

@@ -8,14 +8,15 @@ _logger = logging.getLogger(__name__)
class WhatsAppWebhookController(http.Controller):
@http.route('/whatsapp/webhook', type='json', auth='public', methods=['POST'], csrf=False)
@http.route('/whatsapp/webhook', type='http', auth='public', methods=['POST'], csrf=False)
def webhook(self, **kwargs):
"""
Receive webhooks from WhatsApp Central.
Events: message, status_update, conversation_update
"""
try:
data = request.jsonrequest
# Parse JSON from request body
data = json.loads(request.httprequest.data.decode('utf-8'))
event_type = data.get('type')
_logger.info(f'WhatsApp webhook received: {event_type}')
@@ -29,13 +30,14 @@ class WhatsAppWebhookController(http.Controller):
handler = handlers.get(event_type)
if handler:
return handler(data)
result = handler(data)
return request.make_json_response(result)
return {'status': 'ignored', 'reason': f'Unknown event type: {event_type}'}
return request.make_json_response({'status': 'ignored', 'reason': f'Unknown event type: {event_type}'})
except Exception as e:
_logger.error(f'WhatsApp webhook error: {e}')
return {'status': 'error', 'message': str(e)}
return request.make_json_response({'status': 'error', 'message': str(e)})
def _handle_message(self, data):
"""Handle incoming message"""
@@ -64,23 +66,25 @@ class WhatsAppWebhookController(http.Controller):
'status': 'bot',
})
# Try to find partner by phone
partner = request.env['res.partner'].sudo().search([
'|',
('phone', 'ilike', phone[-10:]),
('mobile', 'ilike', phone[-10:]),
], limit=1)
if partner:
conversation.partner_id = partner
# Get direction from webhook data (inbound or outbound)
direction = msg_data.get('direction', 'inbound')
request.env['whatsapp.message'].sudo().create({
'external_id': msg_data.get('id'),
'conversation_id': conversation.id,
'direction': 'inbound',
'direction': direction,
'message_type': msg_data.get('type', 'text'),
'content': msg_data.get('content'),
'media_url': msg_data.get('media_url'),
'status': 'delivered',
'status': 'delivered' if direction == 'inbound' else 'sent',
})
return {'status': 'ok'}
@@ -137,4 +141,4 @@ class WhatsAppWebhookController(http.Controller):
@http.route('/whatsapp/webhook/test', type='http', auth='public', methods=['GET'])
def webhook_test(self):
"""Test endpoint to verify webhook connectivity"""
return json.dumps({'status': 'ok', 'message': 'WhatsApp webhook is active'})
return request.make_json_response({'status': 'ok', 'message': 'WhatsApp webhook is active'})

View File

@@ -59,9 +59,9 @@ class WhatsAppAccount(models.Model):
raise UserError('Esta cuenta no está vinculada a WhatsApp Central')
try:
# Use internal endpoint (no auth required)
response = requests.get(
f'{self.api_url}/api/whatsapp/accounts/{self.external_id}',
headers=self._get_headers(),
f'{self.api_url}/api/whatsapp/internal/odoo/accounts/{self.external_id}',
timeout=10,
)
if response.status_code == 200:
@@ -71,7 +71,9 @@ class WhatsAppAccount(models.Model):
'phone_number': data.get('phone_number'),
'qr_code': data.get('qr_code'),
})
except Exception as e:
else:
raise UserError(f'Error del servidor: {response.status_code}')
except requests.exceptions.RequestException as e:
_logger.error(f'Error syncing WhatsApp account: {e}')
raise UserError(f'Error de conexión: {e}')

View File

@@ -115,6 +115,79 @@ class WhatsAppConversation(models.Model):
'status': 'active',
})
def action_open_send_wizard(self):
"""Open wizard to send WhatsApp message"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Enviar WhatsApp',
'res_model': 'whatsapp.send.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_phone': self.phone_number,
'default_account_id': self.account_id.id,
'default_partner_id': self.partner_id.id if self.partner_id else False,
},
}
def action_open_chat(self):
"""Open fullscreen chat interface"""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'whatsapp_chat',
'name': self.display_name,
'context': {
'active_id': self.id,
},
}
def send_message_from_chat(self, message):
"""Send a message from the chat interface"""
self.ensure_one()
import requests
account = self.account_id
if not account or not account.api_url:
raise Exception("Cuenta WhatsApp no configurada")
# Send via API
api_url = account.api_url.rstrip('/')
response = requests.post(
f"{api_url}/whatsapp/send",
json={
'account_id': account.external_id,
'phone': self.phone_number,
'message': message,
},
timeout=30,
)
if response.status_code != 200:
raise Exception(f"Error al enviar: {response.text}")
result = response.json()
# Create local message record
self.env['whatsapp.message'].create({
'conversation_id': self.id,
'direction': 'outbound',
'message_type': 'text',
'content': message,
'status': 'sent',
'sent_by_id': self.env.user.id,
'external_id': result.get('message_id'),
})
# Update conversation
self.write({
'last_message_at': fields.Datetime.now(),
'status': 'active' if self.status == 'bot' else self.status,
})
return True
@api.model
def find_or_create_by_phone(self, phone, account_id, contact_name=None):
"""Find or create conversation by phone number"""

View File

@@ -68,15 +68,23 @@ class WhatsAppMessage(models.Model):
"""Send message via WhatsApp Central API"""
self.ensure_one()
account = self.conversation_id.account_id
phone_number = self.conversation_id.phone_number
if not account.external_id:
self.write({
'status': 'failed',
'error_message': 'La cuenta no está vinculada a WhatsApp Central',
})
return
try:
# Use internal endpoint (no auth required)
response = requests.post(
f'{account.api_url}/api/whatsapp/conversations/{self.conversation_id.external_id}/messages',
headers=account._get_headers(),
f'{account.api_url}/api/whatsapp/internal/odoo/send',
json={
'type': self.message_type,
'content': self.content,
'media_url': self.media_url,
'phone_number': phone_number,
'message': self.content,
'account_id': account.external_id,
},
timeout=30,
)
@@ -84,7 +92,7 @@ class WhatsAppMessage(models.Model):
if response.status_code == 200:
data = response.json()
self.write({
'external_id': data.get('id'),
'external_id': data.get('message_id'),
'status': 'sent',
'error_message': False,
})

View File

@@ -0,0 +1,8 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_whatsapp_account_user,whatsapp.account.user,model_whatsapp_account,base.group_user,1,0,0,0
access_whatsapp_account_admin,whatsapp.account.admin,model_whatsapp_account,base.group_system,1,1,1,1
access_whatsapp_conversation_user,whatsapp.conversation.user,model_whatsapp_conversation,base.group_user,1,1,1,0
access_whatsapp_conversation_admin,whatsapp.conversation.admin,model_whatsapp_conversation,base.group_system,1,1,1,1
access_whatsapp_message_user,whatsapp.message.user,model_whatsapp_message,base.group_user,1,1,1,0
access_whatsapp_message_admin,whatsapp.message.admin,model_whatsapp_message,base.group_system,1,1,1,1
access_whatsapp_send_wizard_user,whatsapp.send.wizard.user,model_whatsapp_send_wizard,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_whatsapp_account_user whatsapp.account.user model_whatsapp_account base.group_user 1 0 0 0
3 access_whatsapp_account_admin whatsapp.account.admin model_whatsapp_account base.group_system 1 1 1 1
4 access_whatsapp_conversation_user whatsapp.conversation.user model_whatsapp_conversation base.group_user 1 1 1 0
5 access_whatsapp_conversation_admin whatsapp.conversation.admin model_whatsapp_conversation base.group_system 1 1 1 1
6 access_whatsapp_message_user whatsapp.message.user model_whatsapp_message base.group_user 1 1 1 0
7 access_whatsapp_message_admin whatsapp.message.admin model_whatsapp_message base.group_system 1 1 1 1
8 access_whatsapp_send_wizard_user whatsapp.send.wizard.user model_whatsapp_send_wizard base.group_user 1 1 1 1

View File

@@ -0,0 +1,774 @@
/* DOLLARS WhatsApp Theme - Dark Mode with Amber Accents */
/* All styles are scoped to .o_dollars_chat to avoid affecting other Odoo views */
/* ============================================
MAIN CONTAINER - All styles scoped here
============================================ */
.o_dollars_chat {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: #1a1a24;
--bg-hover: #22222e;
--bg-active: #2a2a38;
--accent-primary: #f59e0b;
--accent-secondary: #fbbf24;
--accent-glow: rgba(245, 158, 11, 0.3);
--text-primary: #ffffff;
--text-secondary: #a1a1aa;
--text-muted: #71717a;
--border-color: #27272a;
--border-light: #3f3f46;
--success: #22c55e;
--danger: #ef4444;
--info: #3b82f6;
--msg-inbound-bg: #1e1e2a;
--msg-outbound-bg: #2d2a1f;
--msg-outbound-border: rgba(245, 158, 11, 0.2);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
--shadow-glow: 0 0 20px var(--accent-glow);
display: flex;
flex-direction: column;
height: 100vh;
background: var(--bg-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: var(--text-primary);
overflow: hidden;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
}
/* Header */
.o_dollars_chat .o_dollars_header {
background: var(--bg-secondary);
padding: 12px 24px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
position: relative;
}
.o_dollars_chat .o_dollars_header::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-primary), transparent);
animation: dollarsHeaderGlow 3s ease-in-out infinite;
}
@keyframes dollarsHeaderGlow {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
.o_dollars_chat .o_dollars_logo {
display: flex;
align-items: center;
gap: 12px;
}
.o_dollars_chat .o_dollars_logo_icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow: var(--shadow-glow);
color: #000;
}
.o_dollars_chat .o_dollars_logo_text {
font-size: 20px;
font-weight: 700;
letter-spacing: 2px;
color: var(--accent-primary);
}
.o_dollars_chat .o_dollars_status {
display: flex;
align-items: center;
gap: 16px;
}
.o_dollars_chat .o_dollars_status_item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
font-family: monospace;
}
.o_dollars_chat .o_dollars_status_dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
animation: dollarsPulse 2s infinite;
}
@keyframes dollarsPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
.o_dollars_chat .o_dollars_header_actions {
display: flex;
align-items: center;
gap: 8px;
}
.o_dollars_chat .o_dollars_header_btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 8px 16px;
border-radius: var(--radius-md);
font-size: 13px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 6px;
}
.o_dollars_chat .o_dollars_header_btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--accent-primary);
}
/* Main Layout */
.o_dollars_chat .o_dollars_main {
display: flex;
flex: 1;
overflow: hidden;
}
/* Sidebar */
.o_dollars_chat .o_dollars_sidebar {
width: 320px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.o_dollars_chat .o_dollars_sidebar_header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
}
.o_dollars_chat .o_dollars_search {
position: relative;
}
.o_dollars_chat .o_dollars_search input {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 10px 12px 10px 40px;
color: var(--text-primary);
font-size: 14px;
outline: none;
transition: all 0.15s ease;
}
.o_dollars_chat .o_dollars_search input:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.o_dollars_chat .o_dollars_search input::placeholder {
color: var(--text-muted);
}
.o_dollars_chat .o_dollars_search_icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
}
.o_dollars_chat .o_dollars_conversations {
flex: 1;
overflow-y: auto;
}
.o_dollars_chat .o_dollars_conv_item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: all 0.15s ease;
border-left: 3px solid transparent;
}
.o_dollars_chat .o_dollars_conv_item:hover {
background: var(--bg-hover);
}
.o_dollars_chat .o_dollars_conv_item.active {
background: var(--bg-active);
border-left-color: var(--accent-primary);
}
.o_dollars_chat .o_dollars_conv_avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
position: relative;
flex-shrink: 0;
color: var(--text-primary);
}
.o_dollars_chat .o_dollars_conv_avatar.online::after {
content: '';
position: absolute;
bottom: 2px;
right: 2px;
width: 12px;
height: 12px;
background: var(--success);
border-radius: 50%;
border: 2px solid var(--bg-secondary);
}
.o_dollars_chat .o_dollars_conv_info {
flex: 1;
min-width: 0;
}
.o_dollars_chat .o_dollars_conv_name {
font-weight: 600;
font-size: 14px;
margin-bottom: 2px;
color: var(--text-primary);
}
.o_dollars_chat .o_dollars_conv_preview {
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.o_dollars_chat .o_dollars_conv_meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.o_dollars_chat .o_dollars_conv_time {
font-size: 11px;
color: var(--text-muted);
font-family: monospace;
}
.o_dollars_chat .o_dollars_conv_badge {
background: var(--accent-primary);
color: #000;
font-size: 11px;
font-weight: 600;
padding: 2px 6px;
border-radius: var(--radius-full);
min-width: 20px;
text-align: center;
}
/* Chat Area */
.o_dollars_chat .o_dollars_chat_area {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-primary);
min-width: 0;
}
.o_dollars_chat .o_dollars_chat_header {
padding: 16px 24px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 16px;
}
.o_dollars_chat .o_dollars_chat_avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: #000;
}
.o_dollars_chat .o_dollars_chat_info {
flex: 1;
}
.o_dollars_chat .o_dollars_chat_name {
font-weight: 600;
font-size: 16px;
color: var(--text-primary);
margin-bottom: 2px;
}
.o_dollars_chat .o_dollars_chat_status {
font-size: 12px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.o_dollars_chat .o_dollars_chat_actions {
display: flex;
gap: 8px;
}
.o_dollars_chat .o_dollars_chat_btn {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
}
.o_dollars_chat .o_dollars_chat_btn:hover {
background: var(--bg-hover);
color: var(--accent-primary);
border-color: var(--accent-primary);
}
/* Messages */
.o_dollars_chat .o_dollars_messages {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.o_dollars_chat .o_dollars_messages::-webkit-scrollbar {
width: 6px;
}
.o_dollars_chat .o_dollars_messages::-webkit-scrollbar-track {
background: var(--bg-primary);
}
.o_dollars_chat .o_dollars_messages::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.o_dollars_chat .o_dollars_message {
display: flex;
gap: 12px;
max-width: 70%;
animation: dollarsMessageIn 0.2s ease-out;
}
@keyframes dollarsMessageIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.o_dollars_chat .o_dollars_message.inbound {
align-self: flex-start;
}
.o_dollars_chat .o_dollars_message.outbound {
align-self: flex-end;
flex-direction: row-reverse;
}
.o_dollars_chat .o_dollars_msg_avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
color: var(--text-primary);
}
.o_dollars_chat .o_dollars_message.outbound .o_dollars_msg_avatar {
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
color: #000;
}
.o_dollars_chat .o_dollars_msg_content {
display: flex;
flex-direction: column;
gap: 4px;
}
.o_dollars_chat .o_dollars_msg_header {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.o_dollars_chat .o_dollars_msg_sender {
font-weight: 600;
color: var(--accent-primary);
}
.o_dollars_chat .o_dollars_message.inbound .o_dollars_msg_sender {
color: var(--info);
}
.o_dollars_chat .o_dollars_msg_time {
color: var(--text-muted);
font-family: monospace;
font-size: 11px;
}
.o_dollars_chat .o_dollars_msg_bubble {
background: var(--msg-inbound-bg);
padding: 12px 16px;
border-radius: var(--radius-lg);
border-top-left-radius: var(--radius-sm);
line-height: 1.5;
font-size: 14px;
color: var(--text-primary);
}
.o_dollars_chat .o_dollars_message.outbound .o_dollars_msg_bubble {
background: var(--msg-outbound-bg);
border: 1px solid var(--msg-outbound-border);
border-top-left-radius: var(--radius-lg);
border-top-right-radius: var(--radius-sm);
}
.o_dollars_chat .o_dollars_msg_status {
font-size: 11px;
color: var(--text-muted);
text-align: right;
}
.o_dollars_chat .o_dollars_msg_status.read {
color: var(--info);
}
/* Media */
.o_dollars_chat .o_dollars_msg_image {
max-width: 280px;
max-height: 200px;
border-radius: var(--radius-md);
cursor: pointer;
display: block;
margin-bottom: 8px;
}
.o_dollars_chat .o_dollars_msg_audio {
width: 240px;
height: 40px;
}
.o_dollars_chat .o_dollars_msg_video {
max-width: 320px;
border-radius: var(--radius-md);
}
.o_dollars_chat .o_dollars_msg_doc {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg-tertiary);
border-radius: var(--radius-md);
color: var(--text-primary);
text-decoration: none;
margin-bottom: 8px;
}
.o_dollars_chat .o_dollars_msg_doc:hover {
background: var(--bg-hover);
}
.o_dollars_chat .o_dollars_msg_doc i {
font-size: 24px;
color: var(--accent-primary);
}
/* Input Area */
.o_dollars_chat .o_dollars_input_area {
padding: 16px 24px;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
display: flex;
align-items: flex-end;
gap: 12px;
}
.o_dollars_chat .o_dollars_input_wrapper {
flex: 1;
}
.o_dollars_chat .o_dollars_input_wrapper textarea {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 12px 16px;
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
resize: none;
outline: none;
min-height: 44px;
max-height: 120px;
transition: all 0.15s ease;
}
.o_dollars_chat .o_dollars_input_wrapper textarea:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.o_dollars_chat .o_dollars_input_wrapper textarea::placeholder {
color: var(--text-muted);
}
.o_dollars_chat .o_dollars_send_btn {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
border: none;
color: #000;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
font-size: 18px;
box-shadow: var(--shadow-glow);
}
.o_dollars_chat .o_dollars_send_btn:hover {
transform: scale(1.05);
}
.o_dollars_chat .o_dollars_send_btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* Details Panel */
.o_dollars_chat .o_dollars_details {
width: 280px;
background: var(--bg-secondary);
border-left: 1px solid var(--border-color);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.o_dollars_chat .o_dollars_details_header {
padding: 24px;
text-align: center;
border-bottom: 1px solid var(--border-color);
}
.o_dollars_chat .o_dollars_details_avatar {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
margin: 0 auto 16px;
box-shadow: var(--shadow-glow);
color: #000;
}
.o_dollars_chat .o_dollars_details_name {
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
color: var(--text-primary);
}
.o_dollars_chat .o_dollars_details_phone {
font-size: 14px;
color: var(--text-secondary);
font-family: monospace;
}
.o_dollars_chat .o_dollars_details_status {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 12px;
padding: 6px 12px;
background: var(--bg-tertiary);
border-radius: var(--radius-full);
font-size: 12px;
color: var(--text-secondary);
}
.o_dollars_chat .o_dollars_details_content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.o_dollars_chat .o_dollars_details_section {
margin-bottom: 24px;
}
.o_dollars_chat .o_dollars_details_section_title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
margin-bottom: 12px;
font-weight: 600;
}
.o_dollars_chat .o_dollars_details_item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--bg-tertiary);
border-radius: var(--radius-md);
margin-bottom: 8px;
cursor: pointer;
transition: all 0.15s ease;
color: var(--text-primary);
}
.o_dollars_chat .o_dollars_details_item:hover {
background: var(--bg-hover);
}
.o_dollars_chat .o_dollars_details_item i {
color: var(--accent-primary);
width: 20px;
text-align: center;
}
.o_dollars_chat .o_dollars_details_item span {
font-size: 13px;
}
/* Empty State */
.o_dollars_chat .o_dollars_empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px;
}
.o_dollars_chat .o_dollars_empty_icon {
width: 80px;
height: 80px;
background: var(--bg-tertiary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
margin-bottom: 24px;
color: var(--text-muted);
}
.o_dollars_chat .o_dollars_empty_title {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
color: var(--text-primary);
}
.o_dollars_chat .o_dollars_empty_text {
color: var(--text-secondary);
font-size: 14px;
}
/* Loading */
.o_dollars_chat .o_dollars_loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
.o_dollars_chat .o_dollars_spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-color);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: dollarsSpin 1s linear infinite;
}
@keyframes dollarsSpin {
to { transform: rotate(360deg); }
}
/* Responsive */
@media (max-width: 1200px) {
.o_dollars_chat .o_dollars_details {
display: none;
}
}
@media (max-width: 768px) {
.o_dollars_chat .o_dollars_sidebar {
width: 100%;
position: absolute;
z-index: 10;
}
}

View File

@@ -1,97 +1,175 @@
/* WhatsApp Hub Styles */
/* WhatsApp Hub - Scoped Styles */
/* All styles use specific class prefixes to avoid affecting other Odoo views */
/* WhatsApp Green */
:root {
/* ============================================
WHATSAPP CHAT FULLSCREEN (Theme selector)
============================================ */
.o_whatsapp_chat_fullscreen {
--whatsapp-green: #25D366;
--whatsapp-dark-green: #128C7E;
--whatsapp-light-green: #DCF8C6;
--whatsapp-bg: #E5DDD5;
}
/* Chat container */
.o_whatsapp_chat_container {
display: flex;
flex-direction: column;
height: 100%;
background: var(--whatsapp-bg);
border-radius: 8px;
overflow: hidden;
/* WhatsApp Theme */
.o_whatsapp_chat_fullscreen.theme-whatsapp {
--chat-bg: #E5DDD5;
--header-bg: #128C7E;
--header-text: #ffffff;
--input-bg: #F0F2F5;
--input-field-bg: #ffffff;
--input-text: #333333;
--input-border: #ddd;
--msg-inbound-bg: #ffffff;
--msg-inbound-text: #333333;
--msg-outbound-bg: #DCF8C6;
--msg-outbound-text: #333333;
--msg-meta-text: #667781;
--btn-primary-bg: #25D366;
--btn-primary-hover: #128C7E;
--status-read: #53bdeb;
--avatar-bg: rgba(255,255,255,0.2);
--avatar-text: #ffffff;
}
/* Chat header */
.o_whatsapp_chat_header {
background: var(--whatsapp-dark-green);
color: white;
padding: 12px 16px;
/* DRRR Theme */
.o_whatsapp_chat_fullscreen.theme-drrr {
--chat-bg: #0a0a0a;
--header-bg: #1a1a1a;
--header-text: #00ff88;
--input-bg: #1a1a1a;
--input-field-bg: #0d0d0d;
--input-text: #00ff88;
--input-border: rgba(0, 255, 136, 0.2);
--msg-inbound-bg: transparent;
--msg-inbound-text: #00ccff;
--msg-outbound-bg: transparent;
--msg-outbound-text: #00ff88;
--msg-meta-text: #666666;
--btn-primary-bg: #00ff88;
--btn-primary-hover: #00cc6a;
--status-read: #00ccff;
--avatar-bg: rgba(0, 255, 136, 0.13);
--avatar-text: #00ff88;
}
.o_whatsapp_chat_fullscreen {
display: flex;
flex-direction: column;
height: calc(100vh - 46px);
background: var(--chat-bg);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
transition: all 0.3s ease;
}
.o_whatsapp_chat_fullscreen.theme-drrr {
font-family: 'Courier New', monospace;
}
/* Header */
.o_whatsapp_chat_fullscreen .o_whatsapp_chat_header {
background: var(--header-bg);
color: var(--header-text);
padding: 10px 16px;
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.o_whatsapp_chat_header .avatar {
width: 40px;
height: 40px;
.o_whatsapp_chat_fullscreen .avatar {
width: 45px;
height: 45px;
border-radius: 50%;
background: white;
background: var(--avatar-bg);
color: var(--avatar-text);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: var(--whatsapp-dark-green);
font-size: 20px;
}
.o_whatsapp_chat_header .contact-info {
.o_whatsapp_chat_fullscreen .contact-info {
flex: 1;
}
.o_whatsapp_chat_header .contact-name {
.o_whatsapp_chat_fullscreen .contact-name {
font-weight: 600;
font-size: 16px;
font-size: 17px;
}
.o_whatsapp_chat_header .contact-status {
.o_whatsapp_chat_fullscreen .contact-status {
font-size: 13px;
opacity: 0.85;
}
/* Theme Toggle */
.o_whatsapp_chat_fullscreen .theme-toggle {
background: transparent;
border: 1px solid var(--header-text);
color: var(--header-text);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
opacity: 0.8;
transition: all 0.2s ease;
}
/* Messages container */
.o_whatsapp_messages {
.o_whatsapp_chat_fullscreen .theme-toggle:hover {
background: var(--header-text);
color: var(--header-bg);
}
/* Messages Container */
.o_whatsapp_chat_fullscreen .o_whatsapp_messages_container {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
gap: 4px;
}
/* Message bubble */
.o_whatsapp_message {
/* WhatsApp Theme Messages */
.o_whatsapp_chat_fullscreen.theme-whatsapp .o_whatsapp_message {
max-width: 65%;
padding: 8px 12px;
border-radius: 8px;
position: relative;
word-wrap: break-word;
box-shadow: 0 1px 1px rgba(0,0,0,0.1);
}
.o_whatsapp_message.inbound {
.o_whatsapp_chat_fullscreen.theme-whatsapp .o_whatsapp_message.inbound {
align-self: flex-start;
background: white;
background: var(--msg-inbound-bg);
color: var(--msg-inbound-text);
border-top-left-radius: 0;
}
.o_whatsapp_message.outbound {
.o_whatsapp_chat_fullscreen.theme-whatsapp .o_whatsapp_message.outbound {
align-self: flex-end;
background: var(--whatsapp-light-green);
background: var(--msg-outbound-bg);
color: var(--msg-outbound-text);
border-top-right-radius: 0;
}
.o_whatsapp_message .message-content {
margin-bottom: 4px;
.o_whatsapp_chat_fullscreen.theme-whatsapp .message-sender {
display: none;
}
.o_whatsapp_message .message-meta {
.o_whatsapp_chat_fullscreen.theme-whatsapp .message-content {
margin-bottom: 4px;
line-height: 1.4;
white-space: pre-wrap;
}
.o_whatsapp_chat_fullscreen.theme-whatsapp .message-meta {
font-size: 11px;
color: #667781;
color: var(--msg-meta-text);
text-align: right;
display: flex;
align-items: center;
@@ -99,63 +177,178 @@
gap: 4px;
}
.o_whatsapp_message .message-status {
color: #53bdeb;
/* DRRR Theme Messages */
.o_whatsapp_chat_fullscreen.theme-drrr .o_whatsapp_messages_container {
padding: 20px;
gap: 2px;
}
/* Input area */
.o_whatsapp_input_area {
background: #F0F2F5;
.o_whatsapp_chat_fullscreen.theme-drrr .o_whatsapp_message {
max-width: 100%;
padding: 4px 0;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
background: transparent;
box-shadow: none;
}
.o_whatsapp_chat_fullscreen.theme-drrr .o_whatsapp_message.inbound,
.o_whatsapp_chat_fullscreen.theme-drrr .o_whatsapp_message.outbound {
align-self: flex-start;
}
.o_whatsapp_chat_fullscreen.theme-drrr .o_whatsapp_message.inbound {
color: var(--msg-inbound-text);
}
.o_whatsapp_chat_fullscreen.theme-drrr .o_whatsapp_message.outbound {
color: var(--msg-outbound-text);
}
.o_whatsapp_chat_fullscreen.theme-drrr .message-sender {
display: inline;
font-weight: bold;
}
.o_whatsapp_chat_fullscreen.theme-drrr .message-sender::before {
content: '[';
}
.o_whatsapp_chat_fullscreen.theme-drrr .message-sender::after {
content: ']';
}
.o_whatsapp_chat_fullscreen.theme-drrr .message-content {
display: inline;
margin: 0;
}
.o_whatsapp_chat_fullscreen.theme-drrr .message-meta {
display: inline;
font-size: 10px;
color: var(--msg-meta-text);
margin-left: auto;
}
/* Input Area */
.o_whatsapp_chat_fullscreen .o_whatsapp_input_area {
background: var(--input-bg);
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
border-top: 1px solid var(--input-border);
}
.o_whatsapp_input_area input {
.o_whatsapp_chat_fullscreen .o_whatsapp_input_area input {
flex: 1;
border: none;
border-radius: 20px;
padding: 10px 16px;
border-radius: 24px;
padding: 12px 20px;
outline: none;
font-size: 15px;
background: var(--input-field-bg);
color: var(--input-text);
font-family: inherit;
}
.o_whatsapp_input_area button {
background: var(--whatsapp-green);
color: white;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
cursor: pointer;
.o_whatsapp_chat_fullscreen.theme-drrr .o_whatsapp_input_area input {
border-radius: 4px;
border: 1px solid var(--input-border);
}
.o_whatsapp_chat_fullscreen .o_whatsapp_input_area input:focus {
box-shadow: 0 0 0 2px var(--btn-primary-bg);
}
.o_whatsapp_chat_fullscreen .o_whatsapp_input_area .btn {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
background: var(--btn-primary-bg);
border-color: var(--btn-primary-bg);
color: white;
}
.o_whatsapp_input_area button:hover {
background: var(--whatsapp-dark-green);
.o_whatsapp_chat_fullscreen.theme-drrr .o_whatsapp_input_area .btn {
color: #0a0a0a;
}
/* Status badges */
.o_whatsapp_chat_fullscreen .o_whatsapp_input_area .btn:hover {
background: var(--btn-primary-hover);
}
.o_whatsapp_chat_fullscreen .o_whatsapp_input_area .btn:disabled {
opacity: 0.5;
}
/* Media */
.o_whatsapp_chat_fullscreen .o_whatsapp_media_image {
max-width: 100%;
max-height: 250px;
border-radius: 4px;
cursor: pointer;
}
.o_whatsapp_chat_fullscreen .o_whatsapp_media_audio {
max-width: 100%;
height: 40px;
}
.o_whatsapp_chat_fullscreen .o_whatsapp_media_video {
max-width: 100%;
max-height: 200px;
border-radius: 4px;
}
.o_whatsapp_chat_fullscreen .o_whatsapp_media_doc {
display: flex;
align-items: center;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
color: inherit;
text-decoration: none;
}
/* ============================================
FORM VIEW STYLES (for Odoo standard views)
============================================ */
.o_whatsapp_chat_wrapper {
background: #e5ddd5 !important;
border-radius: 8px;
padding: 16px;
min-height: 300px;
max-height: 500px;
overflow-y: auto;
}
/* Status badges in list/form views */
.o_whatsapp_status_connected {
color: var(--whatsapp-green);
color: #25D366;
}
.o_whatsapp_status_disconnected {
color: #dc3545;
}
/* Partner WhatsApp button */
/* WhatsApp button style */
.btn-whatsapp {
background-color: var(--whatsapp-green);
border-color: var(--whatsapp-green);
background-color: #25D366;
border-color: #25D366;
color: white;
}
.btn-whatsapp:hover {
background-color: var(--whatsapp-dark-green);
border-color: var(--whatsapp-dark-green);
background-color: #128C7E;
border-color: #128C7E;
color: white;
}

View File

@@ -0,0 +1,232 @@
/** @odoo-module **/
import { Component, useState, useRef, onMounted, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
const THEMES = {
whatsapp: {
name: "WhatsApp",
class: "theme-whatsapp",
icon: "fa-whatsapp",
},
drrr: {
name: "DRRR",
class: "theme-drrr",
icon: "fa-terminal",
},
};
export class WhatsAppChat extends Component {
static template = "odoo_whatsapp_hub.WhatsAppChat";
static props = {
action: { type: Object },
actionId: { type: [Number, Boolean], optional: true },
};
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.notification = useService("notification");
// Load saved theme or default to whatsapp
const savedTheme = localStorage.getItem("whatsapp_chat_theme") || "whatsapp";
this.state = useState({
messages: [],
newMessage: "",
conversation: null,
loading: true,
sending: false,
currentTheme: savedTheme,
});
this.messagesEndRef = useRef("messagesEnd");
this.inputRef = useRef("messageInput");
onWillStart(async () => {
await this.loadConversation();
});
onMounted(() => {
this.scrollToBottom();
if (this.inputRef.el) {
this.inputRef.el.focus();
}
});
}
get conversationId() {
return this.props.action.context?.active_id ||
this.props.action.params?.conversation_id;
}
get themeClass() {
return THEMES[this.state.currentTheme]?.class || "theme-whatsapp";
}
get currentThemeName() {
return THEMES[this.state.currentTheme]?.name || "WhatsApp";
}
get nextThemeName() {
const nextTheme = this.state.currentTheme === "whatsapp" ? "drrr" : "whatsapp";
return THEMES[nextTheme]?.name || "DRRR";
}
toggleTheme() {
const newTheme = this.state.currentTheme === "whatsapp" ? "drrr" : "whatsapp";
this.state.currentTheme = newTheme;
localStorage.setItem("whatsapp_chat_theme", newTheme);
}
async loadConversation() {
this.state.loading = true;
try {
const conversationId = this.conversationId;
if (!conversationId) {
this.state.loading = false;
return;
}
// Load conversation data
const [conversation] = await this.orm.read(
"whatsapp.conversation",
[conversationId],
["display_name", "phone_number", "account_id", "status", "partner_id"]
);
this.state.conversation = conversation;
// Load messages
const messages = await this.orm.searchRead(
"whatsapp.message",
[["conversation_id", "=", conversationId]],
["content", "direction", "create_date", "message_type", "media_url", "status", "sent_by_id"],
{ order: "create_date asc" }
);
this.state.messages = messages;
// Mark messages as read
const unreadIds = messages
.filter(m => m.direction === "inbound" && !m.is_read)
.map(m => m.id);
if (unreadIds.length > 0) {
await this.orm.write("whatsapp.message", unreadIds, { is_read: true });
}
} catch (error) {
console.error("Error loading conversation:", error);
this.notification.add(_t("Error loading conversation"), { type: "danger" });
}
this.state.loading = false;
setTimeout(() => this.scrollToBottom(), 100);
}
scrollToBottom() {
if (this.messagesEndRef.el) {
this.messagesEndRef.el.scrollIntoView({ behavior: "smooth" });
}
}
onInputChange(ev) {
this.state.newMessage = ev.target.value;
}
onKeyDown(ev) {
if (ev.key === "Enter" && !ev.shiftKey) {
ev.preventDefault();
this.sendMessage();
}
}
async sendMessage() {
const message = this.state.newMessage.trim();
if (!message || this.state.sending) return;
this.state.sending = true;
try {
// Call the send method on the conversation
await this.orm.call(
"whatsapp.conversation",
"send_message_from_chat",
[[this.conversationId], message]
);
this.state.newMessage = "";
await this.loadConversation();
this.notification.add(_t("Message sent"), { type: "success" });
} catch (error) {
console.error("Error sending message:", error);
this.notification.add(_t("Error sending message: ") + error.message, { type: "danger" });
}
this.state.sending = false;
if (this.inputRef.el) {
this.inputRef.el.focus();
}
}
async refreshMessages() {
await this.loadConversation();
}
formatTime(dateStr) {
if (!dateStr) return "";
const date = new Date(dateStr);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
formatDate(dateStr) {
if (!dateStr) return "";
const date = new Date(dateStr);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return _t("Today");
} else if (date.toDateString() === yesterday.toDateString()) {
return _t("Yesterday");
}
return date.toLocaleDateString();
}
getMessageClass(message) {
return message.direction === "outbound" ? "outbound" : "inbound";
}
getSenderName(message) {
if (message.direction === "outbound") {
// For DRRR theme, show "Tú" or agent name
if (message.sent_by_id && message.sent_by_id[1]) {
return message.sent_by_id[1];
}
return "Tú";
} else {
// For inbound, show contact name
return this.state.conversation?.display_name || "Cliente";
}
}
getStatusIcon(status) {
switch (status) {
case "sent": return "✓";
case "delivered": return "✓✓";
case "read": return "✓✓";
default: return "⏳";
}
}
goBack() {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "whatsapp.conversation",
res_id: this.conversationId,
views: [[false, "form"]],
target: "current",
});
}
}
registry.category("actions").add("whatsapp_chat", WhatsAppChat);

View File

@@ -55,7 +55,7 @@ export class WhatsAppChatWidget extends Component {
const messages = await this.orm.searchRead(
"whatsapp.message",
[["conversation_id", "=", this.state.conversation.id]],
["id", "direction", "content", "message_type", "status", "create_date"],
["id", "direction", "content", "message_type", "media_url", "status", "create_date"],
{ order: "create_date asc" }
);
this.state.messages = messages;

View File

@@ -0,0 +1,254 @@
/** @odoo-module **/
import { Component, useState, useRef, onMounted, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
export class DollarsChat extends Component {
static template = "odoo_whatsapp_hub.DollarsChat";
static props = {
action: { type: Object, optional: true },
actionId: { type: [Number, Boolean], optional: true },
};
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.notification = useService("notification");
this.state = useState({
conversations: [],
messages: [],
selectedConversation: null,
newMessage: "",
loading: true,
sending: false,
searchQuery: "",
});
this.messagesEndRef = useRef("messagesEnd");
this.inputRef = useRef("messageInput");
onWillStart(async () => {
await this.loadConversations();
});
onMounted(() => {
// Auto-refresh every 10 seconds
this.refreshInterval = setInterval(() => {
if (this.state.selectedConversation) {
this.loadMessages(this.state.selectedConversation.id, true);
}
this.loadConversations(true);
}, 10000);
});
}
willUnmount() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
get filteredConversations() {
if (!this.state.searchQuery) {
return this.state.conversations;
}
const query = this.state.searchQuery.toLowerCase();
return this.state.conversations.filter(conv =>
conv.display_name?.toLowerCase().includes(query) ||
conv.phone_number?.toLowerCase().includes(query)
);
}
async loadConversations(silent = false) {
if (!silent) {
this.state.loading = true;
}
try {
const conversations = await this.orm.searchRead(
"whatsapp.conversation",
[],
["display_name", "phone_number", "status", "last_message_at", "last_message_preview", "unread_count", "partner_id"],
{ order: "last_message_at desc", limit: 50 }
);
this.state.conversations = conversations;
} catch (error) {
console.error("Error loading conversations:", error);
}
if (!silent) {
this.state.loading = false;
}
}
async selectConversation(conv) {
this.state.selectedConversation = conv;
await this.loadMessages(conv.id);
}
async loadMessages(conversationId, silent = false) {
try {
const messages = await this.orm.searchRead(
"whatsapp.message",
[["conversation_id", "=", conversationId]],
["content", "direction", "create_date", "message_type", "media_url", "status", "sent_by_id", "is_read"],
{ order: "create_date asc" }
);
this.state.messages = messages;
// Mark as read
const unreadIds = messages
.filter(m => m.direction === "inbound" && !m.is_read)
.map(m => m.id);
if (unreadIds.length > 0) {
await this.orm.write("whatsapp.message", unreadIds, { is_read: true });
// Refresh conversation to update unread count
await this.loadConversations(true);
}
setTimeout(() => this.scrollToBottom(), 100);
} catch (error) {
console.error("Error loading messages:", error);
}
}
scrollToBottom() {
if (this.messagesEndRef.el) {
this.messagesEndRef.el.scrollIntoView({ behavior: "smooth" });
}
}
onSearchInput(ev) {
this.state.searchQuery = ev.target.value;
}
onMessageInput(ev) {
this.state.newMessage = ev.target.value;
// Auto-resize textarea
ev.target.style.height = 'auto';
ev.target.style.height = Math.min(ev.target.scrollHeight, 120) + 'px';
}
onKeyDown(ev) {
if (ev.key === "Enter" && !ev.shiftKey) {
ev.preventDefault();
this.sendMessage();
}
}
async sendMessage() {
const message = this.state.newMessage.trim();
if (!message || this.state.sending || !this.state.selectedConversation) return;
this.state.sending = true;
try {
await this.orm.call(
"whatsapp.conversation",
"send_message_from_chat",
[[this.state.selectedConversation.id], message]
);
this.state.newMessage = "";
if (this.inputRef.el) {
this.inputRef.el.style.height = 'auto';
}
await this.loadMessages(this.state.selectedConversation.id);
await this.loadConversations(true);
} catch (error) {
console.error("Error sending message:", error);
this.notification.add(_t("Error al enviar: ") + (error.message || error), { type: "danger" });
}
this.state.sending = false;
if (this.inputRef.el) {
this.inputRef.el.focus();
}
}
async refreshMessages() {
if (this.state.selectedConversation) {
await this.loadMessages(this.state.selectedConversation.id);
}
}
formatTime(dateStr) {
if (!dateStr) return "";
const date = new Date(dateStr);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === yesterday.toDateString()) {
return "Ayer";
}
return date.toLocaleDateString([], { day: "2-digit", month: "2-digit" });
}
formatMessageTime(dateStr) {
if (!dateStr) return "";
const date = new Date(dateStr);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
getInitial(name) {
if (!name) return "?";
return name.charAt(0).toUpperCase();
}
getStatusIcon(status) {
switch (status) {
case "sent": return "✓";
case "delivered": return "✓✓";
case "read": return "✓✓";
default: return "⏳";
}
}
getStatusClass(convStatus) {
const statusMap = {
'bot': 'Bot',
'waiting': 'En espera',
'active': 'Activa',
'resolved': 'Resuelta'
};
return statusMap[convStatus] || convStatus;
}
async markAsResolved() {
if (!this.state.selectedConversation) return;
try {
await this.orm.call(
"whatsapp.conversation",
"action_mark_resolved",
[[this.state.selectedConversation.id]]
);
await this.loadConversations(true);
this.state.selectedConversation.status = 'resolved';
this.notification.add(_t("Conversación marcada como resuelta"), { type: "success" });
} catch (error) {
console.error("Error:", error);
}
}
openInOdoo() {
if (!this.state.selectedConversation) return;
this.action.doAction({
type: "ir.actions.act_window",
res_model: "whatsapp.conversation",
res_id: this.state.selectedConversation.id,
views: [[false, "form"]],
target: "current",
});
}
}
// Register as the main WhatsApp action
registry.category("actions").add("dollars_whatsapp_chat", DollarsChat);

View File

@@ -0,0 +1,138 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="odoo_whatsapp_hub.WhatsAppChat">
<div t-attf-class="o_whatsapp_chat_fullscreen {{ themeClass }}">
<!-- Header -->
<div class="o_whatsapp_chat_header">
<button class="btn btn-link" style="color: inherit;" t-on-click="goBack">
<i class="fa fa-arrow-left fa-lg"/>
</button>
<div class="avatar">
<t t-if="state.conversation">
<t t-esc="(state.conversation.display_name || 'W')[0].toUpperCase()"/>
</t>
</div>
<div class="contact-info">
<div class="contact-name">
<t t-esc="state.conversation?.display_name || 'Cargando...'"/>
</div>
<div class="contact-status">
<t t-esc="state.conversation?.phone_number || ''"/>
</div>
</div>
<button class="theme-toggle" t-on-click="toggleTheme">
<i t-attf-class="fa {{ state.currentTheme === 'whatsapp' ? 'fa-terminal' : 'fa-whatsapp' }} me-1"/>
<t t-esc="nextThemeName"/>
</button>
<button class="btn btn-link" style="color: inherit;" t-on-click="refreshMessages">
<i class="fa fa-refresh fa-lg"/>
</button>
</div>
<!-- Messages Container -->
<div class="o_whatsapp_messages_container">
<t t-if="state.loading">
<div class="text-center py-5">
<i class="fa fa-spinner fa-spin fa-2x"/>
<p class="mt-2">
<t t-if="state.currentTheme === 'drrr'">
[SYSTEM] Conectando al servidor...
</t>
<t t-else="">
Cargando mensajes...
</t>
</p>
</div>
</t>
<t t-else="">
<t t-if="state.messages.length === 0">
<div class="text-center py-5" style="opacity: 0.6;">
<t t-if="state.currentTheme === 'drrr'">
<p>[SYSTEM] No hay mensajes en este chat</p>
<p>[SYSTEM] Escribe algo para comenzar...</p>
</t>
<t t-else="">
<i class="fa fa-comments fa-3x mb-3"/>
<p>No hay mensajes aún</p>
</t>
</div>
</t>
<t t-foreach="state.messages" t-as="message" t-key="message.id">
<div t-attf-class="o_whatsapp_message {{ getMessageClass(message) }}">
<!-- Sender name (visible in DRRR theme) -->
<span class="message-sender">
<t t-esc="getSenderName(message)"/>
</span>
<!-- Media content -->
<t t-if="message.media_url">
<t t-if="message.message_type === 'image'">
<img t-att-src="message.media_url" class="o_whatsapp_media_image"
t-on-click="() => window.open(message.media_url, '_blank')"/>
</t>
<t t-elif="message.message_type === 'audio'">
<audio controls="" class="o_whatsapp_media_audio">
<source t-att-src="message.media_url"/>
</audio>
</t>
<t t-elif="message.message_type === 'video'">
<video controls="" class="o_whatsapp_media_video">
<source t-att-src="message.media_url"/>
</video>
</t>
<t t-else="">
<a t-att-href="message.media_url" target="_blank" class="o_whatsapp_media_doc">
<i class="fa fa-file me-2"/>
Documento
</a>
</t>
</t>
<!-- Text content -->
<t t-if="message.content">
<span class="message-content">
<t t-esc="message.content"/>
</span>
</t>
<!-- Meta info -->
<span class="message-meta">
<span class="message-time">
<t t-esc="formatTime(message.create_date)"/>
</span>
<t t-if="message.direction === 'outbound'">
<span t-attf-class="message-status {{ message.status === 'read' ? 'text-info' : '' }}">
<t t-esc="getStatusIcon(message.status)"/>
</span>
</t>
</span>
</div>
</t>
<div t-ref="messagesEnd"/>
</t>
</div>
<!-- Input Area -->
<div class="o_whatsapp_input_area">
<input type="text"
t-ref="messageInput"
class="form-control"
t-att-placeholder="state.currentTheme === 'drrr' ? '> Escribe tu mensaje...' : 'Escribe un mensaje...'"
t-att-value="state.newMessage"
t-on-input="onInputChange"
t-on-keydown="onKeyDown"
t-att-disabled="state.sending"/>
<button class="btn btn-success rounded-circle"
t-on-click="sendMessage"
t-att-disabled="state.sending || !state.newMessage.trim()">
<t t-if="state.sending">
<i class="fa fa-spinner fa-spin"/>
</t>
<t t-else="">
<i class="fa fa-paper-plane"/>
</t>
</button>
</div>
</div>
</t>
</templates>

View File

@@ -23,7 +23,37 @@
<div class="o_whatsapp_messages">
<t t-foreach="state.messages" t-as="message" t-key="message.id">
<div t-attf-class="o_whatsapp_message #{message.direction}">
<div class="message-content" t-esc="message.content"/>
<div class="message-content">
<!-- Image message -->
<t t-if="message.message_type === 'image' and message.media_url">
<img t-att-src="message.media_url" class="o_whatsapp_media_image" t-att-alt="message.content"/>
<t t-if="message.content and message.content !== '[Image]'">
<div class="mt-1" t-esc="message.content"/>
</t>
</t>
<!-- Audio message -->
<t t-elif="message.message_type === 'audio' and message.media_url">
<audio controls="controls" class="o_whatsapp_media_audio">
<source t-att-src="message.media_url" type="audio/ogg"/>
</audio>
</t>
<!-- Video message -->
<t t-elif="message.message_type === 'video' and message.media_url">
<video controls="controls" class="o_whatsapp_media_video">
<source t-att-src="message.media_url" type="video/mp4"/>
</video>
</t>
<!-- Document message -->
<t t-elif="message.message_type === 'document' and message.media_url">
<a t-att-href="message.media_url" target="_blank" class="o_whatsapp_media_doc">
<i class="fa fa-file-o me-1"/> <t t-esc="message.content or 'Documento'"/>
</a>
</t>
<!-- Text message -->
<t t-else="">
<t t-esc="message.content"/>
</t>
</div>
<div class="message-meta">
<span t-esc="formatTime(message.create_date)"/>
<t t-if="message.direction === 'outbound'">

View File

@@ -0,0 +1,282 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="odoo_whatsapp_hub.DollarsChat">
<div class="o_dollars_chat">
<!-- Header -->
<div class="o_dollars_header">
<div class="o_dollars_logo">
<div class="o_dollars_logo_icon">
<i class="fa fa-whatsapp"/>
</div>
<span class="o_dollars_logo_text">WHATSAPP HUB</span>
</div>
<div class="o_dollars_status">
<div class="o_dollars_status_item">
<span class="o_dollars_status_dot"/>
<span>Conectado</span>
</div>
<div class="o_dollars_status_item">
<i class="fa fa-lock"/>
<span>Cifrado E2E</span>
</div>
<div class="o_dollars_status_item">
<i class="fa fa-comments"/>
<span><t t-esc="state.conversations.length"/> chats</span>
</div>
</div>
<div class="o_dollars_header_actions">
<button class="o_dollars_header_btn" t-on-click="() => this.loadConversations()">
<i class="fa fa-refresh"/>
<span>Actualizar</span>
</button>
</div>
</div>
<!-- Main 3-column layout -->
<div class="o_dollars_main">
<!-- Left Sidebar - Conversations -->
<div class="o_dollars_sidebar">
<div class="o_dollars_sidebar_header">
<div class="o_dollars_search">
<i class="fa fa-search o_dollars_search_icon"/>
<input type="text"
placeholder="Buscar conversación..."
t-att-value="state.searchQuery"
t-on-input="onSearchInput"/>
</div>
</div>
<div class="o_dollars_conversations">
<t t-if="state.loading">
<div class="o_dollars_loading">
<div class="o_dollars_spinner"/>
</div>
</t>
<t t-else="">
<t t-foreach="filteredConversations" t-as="conv" t-key="conv.id">
<div t-attf-class="o_dollars_conv_item {{ state.selectedConversation?.id === conv.id ? 'active' : '' }}"
t-on-click="() => this.selectConversation(conv)">
<div t-attf-class="o_dollars_conv_avatar {{ conv.status === 'active' ? 'online' : '' }}">
<t t-esc="getInitial(conv.display_name)"/>
</div>
<div class="o_dollars_conv_info">
<div class="o_dollars_conv_name">
<t t-esc="conv.display_name || conv.phone_number"/>
</div>
<div class="o_dollars_conv_preview">
<t t-esc="conv.last_message_preview || 'Sin mensajes'"/>
</div>
</div>
<div class="o_dollars_conv_meta">
<span class="o_dollars_conv_time">
<t t-esc="formatTime(conv.last_message_at)"/>
</span>
<t t-if="conv.unread_count > 0">
<span class="o_dollars_conv_badge">
<t t-esc="conv.unread_count"/>
</span>
</t>
</div>
</div>
</t>
<t t-if="filteredConversations.length === 0">
<div class="o_dollars_empty" style="padding: 40px 20px;">
<div class="o_dollars_empty_icon">
<i class="fa fa-inbox"/>
</div>
<div class="o_dollars_empty_text">No hay conversaciones</div>
</div>
</t>
</t>
</div>
</div>
<!-- Center - Chat Area -->
<div class="o_dollars_chat_area">
<t t-if="state.selectedConversation">
<!-- Chat Header -->
<div class="o_dollars_chat_header">
<div class="o_dollars_chat_avatar">
<t t-esc="getInitial(state.selectedConversation.display_name)"/>
</div>
<div class="o_dollars_chat_info">
<div class="o_dollars_chat_name">
<t t-esc="state.selectedConversation.display_name || state.selectedConversation.phone_number"/>
</div>
<div class="o_dollars_chat_status">
<span class="o_dollars_status_dot" style="width: 6px; height: 6px;"/>
<span><t t-esc="getStatusClass(state.selectedConversation.status)"/></span>
<span style="margin-left: 8px; opacity: 0.7;">
<t t-esc="state.selectedConversation.phone_number"/>
</span>
</div>
</div>
<div class="o_dollars_chat_actions">
<button class="o_dollars_chat_btn" t-on-click="refreshMessages" title="Actualizar">
<i class="fa fa-refresh"/>
</button>
<button class="o_dollars_chat_btn" t-on-click="markAsResolved" title="Marcar como resuelta">
<i class="fa fa-check"/>
</button>
<button class="o_dollars_chat_btn" t-on-click="openInOdoo" title="Abrir en Odoo">
<i class="fa fa-external-link"/>
</button>
</div>
</div>
<!-- Messages -->
<div class="o_dollars_messages">
<t t-foreach="state.messages" t-as="message" t-key="message.id">
<div t-attf-class="o_dollars_message {{ message.direction }}">
<div class="o_dollars_msg_avatar">
<t t-if="message.direction === 'outbound'">
<i class="fa fa-user"/>
</t>
<t t-else="">
<t t-esc="getInitial(state.selectedConversation.display_name)"/>
</t>
</div>
<div class="o_dollars_msg_content">
<div class="o_dollars_msg_header">
<span class="o_dollars_msg_sender">
<t t-if="message.direction === 'outbound'">
<t t-esc="message.sent_by_id?.[1] || 'Tú'"/>
</t>
<t t-else="">
<t t-esc="state.selectedConversation.display_name || 'Cliente'"/>
</t>
</span>
<span class="o_dollars_msg_time">
<t t-esc="formatMessageTime(message.create_date)"/>
</span>
</div>
<div class="o_dollars_msg_bubble">
<!-- Media -->
<t t-if="message.media_url">
<t t-if="message.message_type === 'image'">
<img t-att-src="message.media_url"
class="o_dollars_msg_image"
t-on-click="() => window.open(message.media_url, '_blank')"/>
</t>
<t t-elif="message.message_type === 'audio'">
<audio controls="" class="o_dollars_msg_audio">
<source t-att-src="message.media_url"/>
</audio>
</t>
<t t-elif="message.message_type === 'video'">
<video controls="" class="o_dollars_msg_video">
<source t-att-src="message.media_url"/>
</video>
</t>
<t t-else="">
<a t-att-href="message.media_url" target="_blank" class="o_dollars_msg_doc">
<i class="fa fa-file"/>
<span>Documento</span>
</a>
</t>
</t>
<!-- Text -->
<t t-if="message.content">
<t t-esc="message.content"/>
</t>
</div>
<t t-if="message.direction === 'outbound'">
<div t-attf-class="o_dollars_msg_status {{ message.status === 'read' ? 'read' : '' }}">
<t t-esc="getStatusIcon(message.status)"/>
</div>
</t>
</div>
</div>
</t>
<div t-ref="messagesEnd"/>
</div>
<!-- Input Area -->
<div class="o_dollars_input_area">
<div class="o_dollars_input_wrapper">
<textarea t-ref="messageInput"
rows="1"
placeholder="Escribe un mensaje..."
t-att-value="state.newMessage"
t-on-input="onMessageInput"
t-on-keydown="onKeyDown"
t-att-disabled="state.sending"/>
</div>
<button class="o_dollars_send_btn"
t-on-click="sendMessage"
t-att-disabled="state.sending || !state.newMessage.trim()">
<t t-if="state.sending">
<i class="fa fa-spinner fa-spin"/>
</t>
<t t-else="">
<i class="fa fa-paper-plane"/>
</t>
</button>
</div>
</t>
<t t-else="">
<!-- Empty state -->
<div class="o_dollars_empty">
<div class="o_dollars_empty_icon">
<i class="fa fa-comments"/>
</div>
<div class="o_dollars_empty_title">WhatsApp Hub</div>
<div class="o_dollars_empty_text">
Selecciona una conversación para comenzar
</div>
</div>
</t>
</div>
<!-- Right Panel - Contact Details -->
<t t-if="state.selectedConversation">
<div class="o_dollars_details">
<div class="o_dollars_details_header">
<div class="o_dollars_details_avatar">
<t t-esc="getInitial(state.selectedConversation.display_name)"/>
</div>
<div class="o_dollars_details_name">
<t t-esc="state.selectedConversation.display_name || 'Sin nombre'"/>
</div>
<div class="o_dollars_details_phone">
<t t-esc="state.selectedConversation.phone_number"/>
</div>
<div class="o_dollars_details_status">
<span class="o_dollars_status_dot"/>
<span><t t-esc="getStatusClass(state.selectedConversation.status)"/></span>
</div>
</div>
<div class="o_dollars_details_content">
<div class="o_dollars_details_section">
<div class="o_dollars_details_section_title">Acciones</div>
<div class="o_dollars_details_item" t-on-click="markAsResolved">
<i class="fa fa-check-circle"/>
<span>Marcar como resuelta</span>
</div>
<div class="o_dollars_details_item" t-on-click="openInOdoo">
<i class="fa fa-external-link"/>
<span>Ver en Odoo</span>
</div>
</div>
<div class="o_dollars_details_section">
<div class="o_dollars_details_section_title">Información</div>
<div class="o_dollars_details_item">
<i class="fa fa-phone"/>
<span><t t-esc="state.selectedConversation.phone_number"/></span>
</div>
<div class="o_dollars_details_item">
<i class="fa fa-comment"/>
<span><t t-esc="state.messages.length"/> mensajes</span>
</div>
</div>
</div>
</div>
</t>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Client Action for Dollars Chat Interface -->
<record id="action_dollars_whatsapp_chat" model="ir.actions.client">
<field name="name">WhatsApp Chat</field>
<field name="tag">dollars_whatsapp_chat</field>
<field name="target">fullscreen</field>
</record>
</odoo>

View File

@@ -5,55 +5,46 @@
<field name="name">whatsapp.conversation.tree</field>
<field name="model">whatsapp.conversation</field>
<field name="arch" type="xml">
<list decoration-bf="unread_count > 0">
<list>
<field name="display_name"/>
<field name="phone_number"/>
<field name="status" widget="badge" decoration-info="status == 'bot'" decoration-warning="status == 'waiting'" decoration-success="status == 'active'" decoration-muted="status == 'resolved'"/>
<field name="assigned_user_id" widget="many2one_avatar_user"/>
<field name="status"/>
<field name="last_message_at"/>
<field name="last_message_preview"/>
<field name="unread_count" widget="badge" decoration-danger="unread_count > 0"/>
</list>
</field>
</record>
<!-- Form View -->
<!-- Form View - Simplified -->
<record id="view_whatsapp_conversation_form" model="ir.ui.view">
<field name="name">whatsapp.conversation.form</field>
<field name="model">whatsapp.conversation</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_assign_to_me" string="Asignarme" type="object" class="btn-primary" invisible="assigned_user_id"/>
<button name="action_mark_resolved" string="Resolver" type="object" class="btn-secondary" invisible="status == 'resolved'"/>
<field name="status" widget="statusbar" statusbar_visible="bot,waiting,active,resolved"/>
<button name="action_open_chat" string="ABRIR CHAT" type="object" class="btn-primary"/>
<button name="action_mark_resolved" string="Resolver" type="object"/>
<field name="status" widget="statusbar"/>
</header>
<sheet>
<group>
<group>
<field name="display_name" readonly="1"/>
<field name="display_name"/>
<field name="phone_number"/>
<field name="partner_id"/>
</group>
<group>
<field name="account_id"/>
<field name="assigned_user_id"/>
<field name="last_message_at"/>
</group>
</group>
<notebook>
<page string="Mensajes" name="messages">
<field name="message_ids" mode="list" readonly="1">
<list>
<field name="create_date"/>
<field name="direction" widget="badge"/>
<field name="content"/>
<field name="status" widget="badge"/>
<field name="sent_by_id"/>
</list>
</field>
</page>
</notebook>
<field name="message_ids">
<list>
<field name="create_date"/>
<field name="direction"/>
<field name="content"/>
<field name="media_url" widget="url"/>
<field name="status"/>
</list>
</field>
</sheet>
</form>
</field>
@@ -66,7 +57,6 @@
<field name="arch" type="xml">
<search>
<field name="phone_number"/>
<field name="partner_id"/>
</search>
</field>
</record>

View File

@@ -8,10 +8,19 @@
sequence="50"
/>
<!-- Conversations Menu -->
<!-- Chat Hub (Dollars Interface) -->
<menuitem
id="menu_whatsapp_chat_hub"
name="Chat Hub"
parent="menu_whatsapp_root"
action="action_dollars_whatsapp_chat"
sequence="5"
/>
<!-- Conversations Menu (classic view) -->
<menuitem
id="menu_whatsapp_conversations"
name="Conversaciones"
name="Conversaciones (Lista)"
parent="menu_whatsapp_root"
action="action_whatsapp_conversation"
sequence="10"