diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0a3b9ef..bd4c6e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import Cumpleanos from './pages/Cumpleanos'; import Inventario from './pages/Inventario'; import WacrmMessages from './pages/WacrmMessages'; import WacrmLeads from './pages/WacrmLeads'; +import Chat from './pages/Chat'; import Login from './pages/Login'; import './App.css'; @@ -81,6 +82,7 @@ const App: FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/ClasicoLayout.tsx b/frontend/src/components/ClasicoLayout.tsx index 9ed9668..503bc2f 100644 --- a/frontend/src/components/ClasicoLayout.tsx +++ b/frontend/src/components/ClasicoLayout.tsx @@ -35,6 +35,7 @@ import GlobalSearch from './GlobalSearch'; import ThemePicker from './ThemePicker'; import NotificationBell from './NotificationBell'; import { useAuth, ROLE_LABELS } from '../lib/auth'; +import { useChatUnread } from '../lib/chat'; import { useQuickLinks } from '../lib/quicklinks'; import { odooApi } from '../services/odoo'; @@ -114,6 +115,7 @@ const menuTree: MenuEntry[] = [ { label: 'Comunicación', icon: MessageSquare, children: [ + { label: 'Chat', href: '/chat' }, { label: 'Cumpleañeros', href: '/cumpleanos' }, { label: 'Mensajes', href: '/wacrm/messages' }, ], @@ -143,6 +145,7 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit const navigate = useNavigate(); const { user, canSee, hasRole, logout } = useAuth(); const [sidebarOpen, setSidebarOpen] = useState(false); + const chatUnread = useChatUnread(); const visibleLeaves = useMemo( () => menuLeaves.filter((it) => canSee(it.href)), @@ -383,7 +386,12 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit > {entry.label} - + {entry.children?.some((c) => c.href === '/chat') && chatUnread > 0 && ( + + {chatUnread} + + )} + c.href === '/chat') && chatUnread > 0 ? '' : 'ml-auto'} transition-transform ${open ? '' : '-rotate-90'}`} /> {open && ( @@ -405,6 +413,11 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit > {child.label} + {child.href === '/chat' && chatUnread > 0 && ( + + {chatUnread} + + )} = ({ children, title: _title, subti const { user, canSee, hasRole, logout } = useAuth(); const [menuOpen, setMenuOpen] = useState(false); const [expanded, setExpanded] = useState(['Clínica']); + const chatUnread = useChatUnread(); const visibleSections = useMemo( () => @@ -287,6 +291,11 @@ export const HomeNestLayout: FC = ({ children, title: _title, subti onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} > {item.label} + {item.href === '/chat' && chatUnread > 0 && ( + + {chatUnread} + + )} ); })} @@ -458,6 +467,11 @@ export const HomeNestLayout: FC = ({ children, title: _title, subti > {item.label} + {item.href === '/chat' && chatUnread > 0 && ( + + {chatUnread} + + )} = ({ children, title: _title, subti }} > {item.label} + {item.href === '/chat' && chatUnread > 0 && ( + + {chatUnread} + + )} ); })} diff --git a/frontend/src/components/SkeenLayout.tsx b/frontend/src/components/SkeenLayout.tsx index 4348de1..013081c 100644 --- a/frontend/src/components/SkeenLayout.tsx +++ b/frontend/src/components/SkeenLayout.tsx @@ -31,6 +31,7 @@ import { Pencil, ChevronUp, ChevronDown, + MessageCircle, } from 'lucide-react'; import { Button, Modal, Input } from './ui'; import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock'; @@ -38,6 +39,7 @@ import GlobalSearch from './GlobalSearch'; import ThemePicker from './ThemePicker'; import NotificationBell from './NotificationBell'; import { useAuth, ROLE_LABELS } from '../lib/auth'; +import { useChatUnread } from '../lib/chat'; import { useQuickLinks } from '../lib/quicklinks'; import { odooApi } from '../services/odoo'; @@ -91,6 +93,7 @@ const menuGroups = [ { label: 'Comunicación', items: [ + { label: 'Chat', icon: MessageCircle, href: '/chat' }, { label: 'Cumpleañeros', icon: Cake, href: '/cumpleanos' }, ], }, @@ -108,6 +111,7 @@ export const SkeenLayout: FC = ({ children, title, subtitle }) => { const navigate = useNavigate(); const { user, canSee, hasRole, logout } = useAuth(); const [sidebarOpen, setSidebarOpen] = useState(false); + const chatUnread = useChatUnread(); const visibleGroups = useMemo( () => @@ -293,7 +297,12 @@ export const SkeenLayout: FC = ({ children, title, subtitle }) => { > {item.label} - {active && } + {item.href === '/chat' && chatUnread > 0 && ( + + {chatUnread} + + )} + {active && 0 ? '' : 'ml-auto'} />} { + const { user } = useAuth(); + const [unread, setUnread] = useState(0); + + useEffect(() => { + if (!user) { + setUnread(0); + return; + } + let alive = true; + const fetchCount = () => { + odooApi.getChatUnreadCount() + .then((res) => { if (alive && res.status === 'success') setUnread(res.unread); }) + .catch(() => {}); + }; + fetchCount(); + const id = setInterval(fetchCount, intervalMs); + return () => { alive = false; clearInterval(id); }; + }, [user, intervalMs]); + + return unread; +}; diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx new file mode 100644 index 0000000..a04571f --- /dev/null +++ b/frontend/src/pages/Chat.tsx @@ -0,0 +1,273 @@ +import type { FC, FormEvent, KeyboardEvent } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Send, Search, ArrowLeft } from 'lucide-react'; +import Layout from '../components/Layout'; +import { Card, Button, EmptyState, toast } from '../components/ui'; +import { ROLE_LABELS } from '../lib/auth';import { odooApi, type ChatUser, type ChatMessage } from '../services/odoo'; + +const POLL_MS = 5000; + +// Las fechas llegan en UTC ("YYYY-MM-DD HH:MM:SS"); se muestran en hora local +const fmtTime = (d: string | null): string => { + if (!d) return ''; + const dt = new Date(d.replace(' ', 'T') + 'Z'); + return dt.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' }); +}; + +const fmtDay = (d: string | null): string => { + if (!d) return ''; + const dt = new Date(d.replace(' ', 'T') + 'Z'); + const today = new Date(); + const sameDay = dt.toDateString() === today.toDateString(); + return sameDay + ? fmtTime(d) + : dt.toLocaleDateString('es-MX', { day: 'numeric', month: 'short' }); +}; + +const initials = (name: string) => + (name || '?').split(' ').filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase(); + +const Chat: FC = () => { + const [users, setUsers] = useState([]); + const [loadingUsers, setLoadingUsers] = useState(true); + const [activeId, setActiveId] = useState(null); + const [messages, setMessages] = useState([]); + const [loadingThread, setLoadingThread] = useState(false); + const [draft, setDraft] = useState(''); + const [sending, setSending] = useState(false); + const [search, setSearch] = useState(''); + const lastIdRef = useRef(0); + const bottomRef = useRef(null); + + const activeUser = users.find((u) => u.id === activeId) || null; + + const fetchUsers = useCallback(async () => { + try { + const res = await odooApi.getChatUsers(); + if (res.status === 'success') setUsers(res.users); + } catch { + // silencioso en polling + } finally { + setLoadingUsers(false); + } + }, []); + + const fetchThread = useCallback(async (otherId: number, incremental: boolean) => { + try { + const res = await odooApi.getChatMessages(otherId, incremental ? lastIdRef.current : undefined); + if (res.status !== 'success') return; + if (incremental) { + if (res.messages.length === 0) return; + setMessages((prev) => { + const known = new Set(prev.map((m) => m.id)); + return [...prev, ...res.messages.filter((m) => !known.has(m.id))]; + }); + } else { + setMessages(res.messages); + } + lastIdRef.current = Math.max(...res.messages.map((m) => m.id), lastIdRef.current); + setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50); + } catch { + // silencioso en polling + } + }, []); + + const openConversation = useCallback(async (id: number) => { + setActiveId(id); + setMessages([]); + lastIdRef.current = 0; + setLoadingThread(true); + await fetchThread(id, false); + setLoadingThread(false); + // El servidor ya marcó leídos: refrescar conteos de la lista + fetchUsers(); + }, [fetchThread, fetchUsers]); + + // Carga inicial + polling de lista y hilo abierto + useEffect(() => { + fetchUsers(); + const id = setInterval(() => { + fetchUsers(); + if (activeId) fetchThread(activeId, true); + }, POLL_MS); + return () => clearInterval(id); + }, [fetchUsers, fetchThread, activeId]); + + const send = async (e?: FormEvent) => { + e?.preventDefault(); + const body = draft.trim(); + if (!body || !activeId || sending) return; + try { + setSending(true); + const res = await odooApi.sendChatMessage(activeId, body); + if (res.status === 'success') { + setMessages((prev) => [...prev, res.message]); + lastIdRef.current = Math.max(lastIdRef.current, res.message.id); + setDraft(''); + setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50); + fetchUsers(); + } + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'No se pudo enviar el mensaje'); + } finally { + setSending(false); + } + }; + + const onDraftKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + send(); + } + }; + + const filteredUsers = users.filter((u) => + !search || u.name.toLowerCase().includes(search.toLowerCase())); + + return ( + + + + + {/* Columna izquierda: conversaciones */} + + + + + setSearch(e.target.value)} + className="w-full rounded-xl border border-theme-border-strong bg-theme-surface pl-9 pr-3 py-2 text-sm text-theme-heading focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent transition" + /> + + + + {loadingUsers ? ( + Cargando... + ) : filteredUsers.length === 0 ? ( + No hay usuarios para mostrar. + ) : ( + + {filteredUsers.map((u) => ( + + openConversation(u.id)} + className={`w-full flex items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-theme-accent-bg ${activeId === u.id ? 'bg-theme-accent-bg' : ''}`} + > + + {initials(u.name)} + + + + {u.name} + {u.last_date && ( + {fmtDay(u.last_date)} + )} + + + + {u.last_message + ? `${u.last_mine ? 'Tú: ' : ''}${u.last_message}` + : ROLE_LABELS[u.role] || u.role} + + {u.unread > 0 && ( + + {u.unread} + + )} + + + + + ))} + + )} + + + + {/* Columna derecha: hilo */} + + {!activeUser ? ( + + + + ) : ( + <> + + setActiveId(null)} + className="md:hidden p-1.5 rounded-lg text-theme-muted hover:bg-theme-accent-bg" + aria-label="Volver a la lista" + > + + + + {initials(activeUser.name)} + + + {activeUser.name} + {ROLE_LABELS[activeUser.role] || activeUser.role} + + + + + {loadingThread ? ( + Cargando mensajes... + ) : messages.length === 0 ? ( + + Aún no hay mensajes. Escribe el primero. + + ) : ( + messages.map((m) => ( + + + {m.body} + + {fmtTime(m.date)} + + + + )) + )} + + + + + setDraft(e.target.value)} + onKeyDown={onDraftKeyDown} + placeholder={`Mensaje para ${activeUser.name}... (Enter envía)`} + rows={1} + maxLength={2000} + className="flex-1 resize-none rounded-xl border border-theme-border-strong bg-theme-surface px-3 py-2.5 text-sm text-theme-heading focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent transition" + /> + + + + + > + )} + + + + + + ); +}; + +export default Chat; diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts index 4366d59..3e2f056 100644 --- a/frontend/src/services/odoo.ts +++ b/frontend/src/services/odoo.ts @@ -544,6 +544,27 @@ export interface Receta { contenido: string; } +// ---- Chat interno ---- +export interface ChatUser { + id: number; + name: string; + role: FrontendRole; + unread: number; + last_message: string | null; + last_date: string | null; + last_mine: boolean | null; +} + +export interface ChatMessage { + id: number; + sender_id: number; + recipient_id: number; + body: string; + is_read: boolean; + mine: boolean; + date: string | null; +} + export interface Diagnostico { id: number; name: string; @@ -894,6 +915,27 @@ export const odooApi = { return data; }, + // Chat interno entre usuarios de la plataforma + async getChatUsers(): Promise<{ status: string; users: ChatUser[] }> { + const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/chat/users`); + return data; + }, + + async getChatMessages(userId: number, sinceId?: number): Promise<{ status: string; messages: ChatMessage[] }> { + const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/chat/messages`, { user_id: userId, since_id: sinceId || undefined }); + return data; + }, + + async sendChatMessage(userId: number, message: string): Promise<{ status: string; message: ChatMessage }> { + const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/chat/send`, { user_id: userId, message }); + return data; + }, + + async getChatUnreadCount(): Promise<{ status: string; unread: number }> { + const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/chat/unread_count`); + return data; + }, + async getAvailableSlots(date: string, servicio_id: number): Promise<{ status: string; slots: { time: number; time_str: string }[] }> { const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/appointments/slots`, { params: { date, servicio_id } }); return data; diff --git a/odoo-addons/skeen_chat/__init__.py b/odoo-addons/skeen_chat/__init__.py new file mode 100644 index 0000000..c3d410e --- /dev/null +++ b/odoo-addons/skeen_chat/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import models +from . import controllers diff --git a/odoo-addons/skeen_chat/__manifest__.py b/odoo-addons/skeen_chat/__manifest__.py new file mode 100644 index 0000000..abdaa42 --- /dev/null +++ b/odoo-addons/skeen_chat/__manifest__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'SKEEN Chat Interno', + 'version': '1.0.0', + 'category': 'Healthcare', + 'summary': 'Chat interno 1-a-1 entre usuarios del frontend SKEEN', + 'description': """ + Mensajería directa entre usuarios de skeen.frontend.user. + Sin websockets: el frontend React consulta por polling. + """, + 'author': 'Consultoria Alcaraz Salazar, S.A.S.', + 'website': 'https://skeen.mx', + 'depends': ['skeen_whatsapp'], + 'data': [ + 'security/ir.model.access.csv', + ], + 'installable': True, + 'application': False, + 'auto_install': False, + 'license': 'LGPL-3', +} diff --git a/odoo-addons/skeen_chat/controllers/__init__.py b/odoo-addons/skeen_chat/controllers/__init__.py new file mode 100644 index 0000000..b7c05b8 --- /dev/null +++ b/odoo-addons/skeen_chat/controllers/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import chat diff --git a/odoo-addons/skeen_chat/controllers/chat.py b/odoo-addons/skeen_chat/controllers/chat.py new file mode 100644 index 0000000..a5897dc --- /dev/null +++ b/odoo-addons/skeen_chat/controllers/chat.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +"""Chat interno 1-a-1 entre usuarios del frontend React (skeen.frontend.user). + +Mismo esquema de auth HMAC que el resto de la API. Todos los endpoints están +restringidos al usuario autenticado: solo puede listar/leer conversaciones +donde él es sender o recipient, y solo puede enviar como él mismo. +""" + +from odoo import http +from odoo.http import request + +from odoo.addons.skeen_whatsapp.controllers.auth import require_role +from odoo.addons.skeen_whatsapp.controllers.frontend import json_response, _parse_json_body + +MAX_BODY = 2000 +HISTORY_LIMIT = 50 +LIST_SCAN_LIMIT = 500 + + +class SkeenChatController(http.Controller): + + def _message_to_dict(self, msg, me_id): + return { + 'id': msg.id, + 'sender_id': msg.sender_id.id, + 'recipient_id': msg.recipient_id.id, + 'body': msg.body, + 'is_read': msg.is_read, + 'mine': msg.sender_id.id == me_id, + 'date': msg.create_date.strftime('%Y-%m-%d %H:%M:%S') if msg.create_date else None, + } + + @http.route('/skeen/frontend/v1/chat/users', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) + @require_role('lectura') + def chat_users(self, **kw): + """Usuarios activos (menos yo) con conteo de no leídos y último mensaje. + + Orden: primero conversaciones con actividad (más reciente primero), + luego el resto alfabéticamente. + """ + try: + me = request.frontend_user + User = request.env['skeen.frontend.user'].sudo() + Msg = request.env['skeen.chat.message'].sudo() + users = User.search([('active', '=', True), ('id', '!=', me.id)], order='name') + + unread = {} + for g in Msg.read_group( + [('recipient_id', '=', me.id), ('is_read', '=', False)], + ['sender_id'], ['sender_id']): + unread[g['sender_id'][0]] = g['sender_id_count'] + + last = {} + recent = Msg.search([ + '|', ('sender_id', '=', me.id), ('recipient_id', '=', me.id), + ], order='id desc', limit=LIST_SCAN_LIMIT) + for m in recent: + other_id = m.recipient_id.id if m.sender_id.id == me.id else m.sender_id.id + if other_id not in last: + last[other_id] = { + 'body': m.body or '', + 'date': m.create_date.strftime('%Y-%m-%d %H:%M:%S') if m.create_date else None, + 'mine': m.sender_id.id == me.id, + } + + # Conversaciones con actividad primero (más reciente arriba) + conversations = sorted( + (u for u in users if u.id in last), + key=lambda u: last[u.id]['date'] or '', reverse=True) + rest = [u for u in users if u.id not in last] + + return json_response({ + 'status': 'success', + 'users': [{ + 'id': u.id, + 'name': u.name, + 'role': u.role, + 'unread': unread.get(u.id, 0), + 'last_message': last.get(u.id, {}).get('body'), + 'last_date': last.get(u.id, {}).get('date'), + 'last_mine': last.get(u.id, {}).get('mine'), + } for u in conversations + rest], + }) + except Exception as e: + return json_response({'status': 'error', 'message': str(e)}, 500) + + @http.route('/skeen/frontend/v1/chat/messages', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) + @require_role('lectura') + def chat_messages(self, **kw): + """Historial con otro usuario (últimos 50; since_id para incremental). + + Marca como leídos los mensajes recibidos de ese usuario. + """ + try: + me = request.frontend_user + data = _parse_json_body() + other_id = int(data.get('user_id') or 0) + if not other_id or other_id == me.id: + return json_response({'status': 'error', 'message': 'user_id inválido'}, 400) + User = request.env['skeen.frontend.user'].sudo() + if not User.search([('id', '=', other_id), ('active', '=', True)], limit=1): + return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404) + domain = [ + '|', '&', ('sender_id', '=', me.id), ('recipient_id', '=', other_id), + '&', ('sender_id', '=', other_id), ('recipient_id', '=', me.id), + ] + since_id = int(data.get('since_id') or 0) + if since_id: + domain.append(('id', '>', since_id)) + Msg = request.env['skeen.chat.message'].sudo() + msgs = Msg.search(domain, order='id desc', limit=HISTORY_LIMIT).sorted('id') + # Marcar leídos los recibidos en esta conversación + incoming = msgs.filtered(lambda m: m.recipient_id.id == me.id and not m.is_read) + if incoming: + incoming.write({'is_read': True}) + return json_response({ + 'status': 'success', + 'messages': [self._message_to_dict(m, me.id) for m in msgs], + }) + except Exception as e: + return json_response({'status': 'error', 'message': str(e)}, 500) + + @http.route('/skeen/frontend/v1/chat/send', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) + @require_role('lectura') + def chat_send(self, **kw): + """Enviar mensaje directo a otro usuario.""" + try: + me = request.frontend_user + data = _parse_json_body() + other_id = int(data.get('user_id') or 0) + body = (data.get('message') or '').strip() + if not other_id or other_id == me.id: + return json_response({'status': 'error', 'message': 'user_id inválido'}, 400) + if not body: + return json_response({'status': 'error', 'message': 'El mensaje no puede estar vacío'}, 400) + if len(body) > MAX_BODY: + return json_response( + {'status': 'error', 'message': f'El mensaje excede {MAX_BODY} caracteres'}, 400) + User = request.env['skeen.frontend.user'].sudo() + if not User.search([('id', '=', other_id), ('active', '=', True)], limit=1): + return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404) + msg = request.env['skeen.chat.message'].sudo().create({ + 'sender_id': me.id, + 'recipient_id': other_id, + 'body': body, + }) + return json_response({ + 'status': 'success', + 'message': self._message_to_dict(msg, me.id), + }, 201) + except Exception as e: + return json_response({'status': 'error', 'message': str(e)}, 500) + + @http.route('/skeen/frontend/v1/chat/unread_count', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) + @require_role('lectura') + def chat_unread_count(self, **kw): + """Total global de mensajes no leídos del usuario (badge del menú).""" + try: + me = request.frontend_user + total = request.env['skeen.chat.message'].sudo().search_count([ + ('recipient_id', '=', me.id), ('is_read', '=', False), + ]) + return json_response({'status': 'success', 'unread': total}) + except Exception as e: + return json_response({'status': 'error', 'message': str(e)}, 500) diff --git a/odoo-addons/skeen_chat/models/__init__.py b/odoo-addons/skeen_chat/models/__init__.py new file mode 100644 index 0000000..652f222 --- /dev/null +++ b/odoo-addons/skeen_chat/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import chat_message diff --git a/odoo-addons/skeen_chat/models/chat_message.py b/odoo-addons/skeen_chat/models/chat_message.py new file mode 100644 index 0000000..ef7dcd5 --- /dev/null +++ b/odoo-addons/skeen_chat/models/chat_message.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields + + +class SkeenChatMessage(models.Model): + """Mensaje directo 1-a-1 entre usuarios del frontend React. + + La seguridad real está en el controlador: un usuario solo puede leer o + crear mensajes donde él es sender_id o recipient_id. + """ + _name = 'skeen.chat.message' + _description = 'Mensaje de Chat Interno' + _order = 'id' + + sender_id = fields.Many2one('skeen.frontend.user', string='Remitente', + required=True, index=True, ondelete='cascade') + recipient_id = fields.Many2one('skeen.frontend.user', string='Destinatario', + required=True, index=True, ondelete='cascade') + body = fields.Text(string='Mensaje', required=True) + is_read = fields.Boolean(string='Leído', default=False, index=True) + # create_date (heredado) = fecha/hora de envío diff --git a/odoo-addons/skeen_chat/security/ir.model.access.csv b/odoo-addons/skeen_chat/security/ir.model.access.csv new file mode 100644 index 0000000..e94afc9 --- /dev/null +++ b/odoo-addons/skeen_chat/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_skeen_chat_message_user,Acceso a Mensajes de Chat SKEEN,model_skeen_chat_message,base.group_user,1,1,1,1
Cargando...
No hay usuarios para mostrar.
{activeUser.name}
{ROLE_LABELS[activeUser.role] || activeUser.role}
Cargando mensajes...
+ Aún no hay mensajes. Escribe el primero. +
{m.body}
+ {fmtTime(m.date)} +